chore: source formatting and spec allignment
This commit is contained in:
1 parent
a594b99e90
commit
16a210c0ad
118 files changed
+7756
-4138
No files matched your search
Generated
+10
@@ -805,6 +805,7 @@ dependencies = [
|
|||||||
"liquid-core",
|
"liquid-core",
|
||||||
"pagefind",
|
"pagefind",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
|
"quick-xml 0.41.0",
|
||||||
"rayon",
|
"rayon",
|
||||||
"refinery",
|
"refinery",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -5638,6 +5639,15 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-xml"
|
||||||
|
version = "0.41.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ open = "5"
|
|||||||
pulldown-cmark = "0.13"
|
pulldown-cmark = "0.13"
|
||||||
liquid = "0.26"
|
liquid = "0.26"
|
||||||
liquid-core = { version = "0.26", features = ["derive"] }
|
liquid-core = { version = "0.26", features = ["derive"] }
|
||||||
|
quick-xml = "0.41"
|
||||||
rayon = "1.10"
|
rayon = "1.10"
|
||||||
pagefind = "1.5.2"
|
pagefind = "1.5.2"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ Rules:
|
|||||||
|
|
||||||
### `bds-ui/views`
|
### `bds-ui/views`
|
||||||
|
|
||||||
- preview controls
|
- internal Markdown/Preview switch backed by Wry, plus external Open in Browser command
|
||||||
- generation progress display
|
- generation progress display
|
||||||
- render errors and diagnostics
|
- render errors and diagnostics
|
||||||
- AI operation triggers in post editor (analysis, taxonomy), translation editor (translate), and media editor (alt text, translate)
|
- AI operation triggers in post editor (analysis, taxonomy), translation editor (translate), and media editor (alt text, translate)
|
||||||
|
|||||||
+8
-6
@@ -25,7 +25,7 @@ This plan is split into multiple documents:
|
|||||||
## Non-Negotiable Constraints
|
## Non-Negotiable Constraints
|
||||||
|
|
||||||
1. **Content compatibility is exact.** Existing SQLite data, markdown/frontmatter, translation files, media sidecars, templates, menu documents, generated HTML structure, feeds, and sitemaps must remain readable and writable by the Rust app.
|
1. **Content compatibility is exact.** Existing SQLite data, markdown/frontmatter, translation files, media sidecars, templates, menu documents, generated HTML structure, feeds, and sitemaps must remain readable and writable by the Rust app.
|
||||||
2. **No JavaScript anywhere.** No npm, no webview, no JS runtime, no Electron. This is a supply-chain security constraint. The entire app is pure Rust plus native platform APIs. Pagefind is integrated as a Rust library dependency (`pagefind` crate), not as an external binary.
|
2. **No JavaScript application runtime.** No npm, Node.js, Electron, or remotely loaded application assets. The required in-editor and style preview panels use Wry to display the localhost-only Rust preview server in the operating system webview; the separate Open in Browser command opens the same preview in the system browser. Pagefind is integrated as a Rust library dependency (`pagefind` crate), not as an external binary.
|
||||||
3. **Script compatibility is intentionally broken.** Python/Pyodide is removed. Rust bDS is a green-field app and uses Lua for user-authored scripting. Existing Python scripts are not carried forward as compatible runtime artifacts. Built-in macros (gallery, youtube, vimeo, photo_archive, tag_cloud) are re-implemented as native Rust, not routed through Lua.
|
3. **Script compatibility is intentionally broken.** Python/Pyodide is removed. Rust bDS is a green-field app and uses Lua for user-authored scripting. Existing Python scripts are not carried forward as compatible runtime artifacts. Built-in macros (gallery, youtube, vimeo, photo_archive, tag_cloud) are re-implemented as native Rust, not routed through Lua.
|
||||||
4. **Core release ships as a native desktop app.** Primary target is macOS, but the UI stack (Iced + muda + rfd) is cross-platform from day one. Native menus, key handling, open-file/deep-link handling, and platform command routing are part of core scope, not follow-up polish.
|
4. **Core release ships as a native desktop app.** Primary target is macOS, but the UI stack (Iced + muda + rfd) is cross-platform from day one. Native menus, key handling, open-file/deep-link handling, and platform command routing are part of core scope, not follow-up polish.
|
||||||
5. **Template editing is core scope.** Template rendering without template management UI is not sufficient.
|
5. **Template editing is core scope.** Template rendering without template management UI is not sufficient.
|
||||||
@@ -42,12 +42,14 @@ The application uses the following UI and platform integration stack:
|
|||||||
| Text editing | **ropey** + **syntect** + **cosmic-text** | Custom syntax-highlighting editor widget for markdown, Liquid templates, and Lua scripts |
|
| Text editing | **ropey** + **syntect** + **cosmic-text** | Custom syntax-highlighting editor widget for markdown, Liquid templates, and Lua scripts |
|
||||||
| Native menus | **muda** | Cross-platform native menu bar (NSMenu on macOS, Win32 menus on Windows, GTK/dbus on Linux) |
|
| Native menus | **muda** | Cross-platform native menu bar (NSMenu on macOS, Win32 menus on Windows, GTK/dbus on Linux) |
|
||||||
| File dialogs | **rfd** | Cross-platform native file/folder dialogs (NSOpenPanel/NSSavePanel on macOS, equivalents elsewhere) |
|
| File dialogs | **rfd** | Cross-platform native file/folder dialogs (NSOpenPanel/NSSavePanel on macOS, equivalents elsewhere) |
|
||||||
|
| Internal preview | **wry** | Embedded post and style preview panels backed only by the localhost Rust preview server |
|
||||||
| Platform lifecycle | **objc2** (macOS only, cfg-gated) | Thin shim for `application:openFile:`, `application:openURLs:`, and other `NSApplicationDelegate` hooks |
|
| Platform lifecycle | **objc2** (macOS only, cfg-gated) | Thin shim for `application:openFile:`, `application:openURLs:`, and other `NSApplicationDelegate` hooks |
|
||||||
|
|
||||||
### Why this stack
|
### Why this stack
|
||||||
|
|
||||||
- **Iced** is a published crate with versioned releases, proper documentation, and a stable API. It uses wgpu for GPU-accelerated rendering and follows the Elm architecture (Message → update → view).
|
- **Iced** is a published crate with versioned releases, proper documentation, and a stable API. It uses wgpu for GPU-accelerated rendering and follows the Elm architecture (Message → update → view).
|
||||||
- **muda** and **rfd** render through real platform APIs (NSMenu, NSOpenPanel, etc.) with zero fidelity loss versus hand-rolled platform code, while providing cross-platform support from day one.
|
- **muda** and **rfd** render through real platform APIs (NSMenu, NSOpenPanel, etc.) with zero fidelity loss versus hand-rolled platform code, while providing cross-platform support from day one.
|
||||||
|
- **wry** preserves the baseline app's internal Markdown/Preview workflow using the operating system webview. It is a presentation surface for the loopback preview server, not the application runtime; external-browser preview remains available separately.
|
||||||
- **ropey + syntect + cosmic-text** gives full control over the editor experience: rope-based efficient text storage, Sublime Text syntax grammars (markdown, Liquid, Lua), and proper font shaping and layout via the same engine used by cosmic-DE.
|
- **ropey + syntect + cosmic-text** gives full control over the editor experience: rope-based efficient text storage, Sublime Text syntax grammars (markdown, Liquid, Lua), and proper font shaping and layout via the same engine used by cosmic-DE.
|
||||||
- The only platform-specific code is a small (~50 line) lifecycle shim for macOS app delegate hooks, conditionally compiled via `cfg(target_os = "macos")`. Linux and Windows equivalents are bounded and isolated to the same module.
|
- The only platform-specific code is a small (~50 line) lifecycle shim for macOS app delegate hooks, conditionally compiled via `cfg(target_os = "macos")`. Linux and Windows equivalents are bounded and isolated to the same module.
|
||||||
|
|
||||||
@@ -72,15 +74,15 @@ bds-rust/
|
|||||||
|
|
||||||
- **bds-core**: all engines, models, persistence, rendering, publishing — zero UI dependencies.
|
- **bds-core**: all engines, models, persistence, rendering, publishing — zero UI dependencies.
|
||||||
- **bds-editor**: reusable Iced custom widget for syntax-highlighting text editing. Depends on ropey, syntect, cosmic-text, and iced. Does not depend on bds-core. Can be extracted as a standalone crate.
|
- **bds-editor**: reusable Iced custom widget for syntax-highlighting text editing. Depends on ropey, syntect, cosmic-text, and iced. Does not depend on bds-core. Can be extracted as a standalone crate.
|
||||||
- **bds-ui**: application shell, Iced views and components, message routing, platform integration (muda for menus, rfd for dialogs, objc2 shim for macOS lifecycle). Depends on bds-core and bds-editor.
|
- **bds-ui**: application shell, Iced views and components, embedded localhost preview (wry), message routing, platform integration (muda for menus, rfd for dialogs, objc2 shim for macOS lifecycle). Depends on bds-core and bds-editor.
|
||||||
- **bds-cli**: headless automation surface. Depends on bds-core only.
|
- **bds-cli**: headless automation surface. Depends on bds-core only.
|
||||||
|
|
||||||
## Distribution Characteristics
|
## Distribution Characteristics
|
||||||
|
|
||||||
- **Single static binary.** No external runtime dependencies (no GTK, no Electron, no Node.js). Lua and SQLite are compiled into the binary.
|
- **Single application binary.** No Electron or Node.js runtime. Lua and SQLite are compiled into the binary; internal preview uses the operating system webview supplied by the target platform.
|
||||||
- **Binary size:** ~15–25 MB.
|
- **Binary size:** ~15–25 MB.
|
||||||
- **Memory usage / startup:** no BEAM VM, no browser engine — a single native process.
|
- **Memory usage / startup:** no BEAM VM or bundled browser engine — one native application process plus the operating system webview used while preview is visible.
|
||||||
- **Zero install dependencies:** users download one binary. No Homebrew, no system packages.
|
- **Platform prerequisites:** macOS uses the system WebKit runtime. Windows/Linux packaging must account for Wry's platform webview prerequisites without adding an application-managed JavaScript runtime.
|
||||||
|
|
||||||
## Split Rationale
|
## Split Rationale
|
||||||
|
|
||||||
@@ -98,7 +100,7 @@ The Rust rewrite is not considered successful until the core release can do all
|
|||||||
|
|
||||||
1. Open a real project created by the baseline app.
|
1. Open a real project created by the baseline app.
|
||||||
2. Create and edit posts, translations, media, tags, templates, and settings.
|
2. Create and edit posts, translations, media, tags, templates, and settings.
|
||||||
3. Preview drafts and published content locally.
|
3. Preview drafts and published content both in the post editor's internal preview panel and in the external system browser.
|
||||||
4. Generate a complete site whose output matches the current app modulo approved normalization differences.
|
4. Generate a complete site whose output matches the current app modulo approved normalization differences.
|
||||||
5. Publish the generated output to a remote target.
|
5. Publish the generated output to a remote target.
|
||||||
6. Rebuild the database from files and run metadata diff without losing information.
|
6. Rebuild the database from files and run metadata diff without losing information.
|
||||||
|
|||||||
+4
-1
@@ -757,6 +757,8 @@ By Wave 3, bds-editor must support the full feature set documented in the editor
|
|||||||
- preview server binds to `127.0.0.1:4123` (localhost only, fixed port)
|
- preview server binds to `127.0.0.1:4123` (localhost only, fixed port)
|
||||||
- preview and generated HTML use local assets only
|
- preview and generated HTML use local assets only
|
||||||
- preview must support drafts and language-prefixed routes
|
- preview must support drafts and language-prefixed routes
|
||||||
|
- each post editor can switch between Markdown and an internal Wry preview panel backed by the draft preview route
|
||||||
|
- Open in Browser starts or reuses the same preview server and opens its URL in the external system browser; it does not replace the internal panel
|
||||||
- rendered language is controlled by project settings, not by UI locale
|
- rendered language is controlled by project settings, not by UI locale
|
||||||
|
|
||||||
### One-shot AI operations
|
### One-shot AI operations
|
||||||
@@ -792,12 +794,13 @@ Wave 4 introduces the one-shot AI client in `bds-core`. This is a minimal `reqwe
|
|||||||
|
|
||||||
- fixture-based generation comparisons against current app output
|
- fixture-based generation comparisons against current app output
|
||||||
- preview route tests for posts, drafts, assets, media, and language routes
|
- preview route tests for posts, drafts, assets, media, and language routes
|
||||||
|
- UI tests for switching a post between Markdown and internal preview, plus the external Open in Browser command
|
||||||
- template compatibility tests using current `.liquid` templates
|
- template compatibility tests using current `.liquid` templates
|
||||||
|
|
||||||
### Done when
|
### Done when
|
||||||
|
|
||||||
- the same project can be generated by both apps with matching output
|
- the same project can be generated by both apps with matching output
|
||||||
- preview is accurate enough to be trusted as an authoring tool
|
- internal and external preview are accurate enough to be trusted as authoring tools
|
||||||
- milestone M4 acceptance review passes
|
- milestone M4 acceptance review passes
|
||||||
|
|
||||||
## Wave 5: Publishing And Operational Integrity
|
## Wave 5: Publishing And Operational Integrity
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ sys-locale = { workspace = true }
|
|||||||
pulldown-cmark = { workspace = true }
|
pulldown-cmark = { workspace = true }
|
||||||
liquid = { workspace = true }
|
liquid = { workspace = true }
|
||||||
liquid-core = { workspace = true }
|
liquid-core = { workspace = true }
|
||||||
|
quick-xml = { workspace = true }
|
||||||
rayon = { workspace = true }
|
rayon = { workspace = true }
|
||||||
pagefind = { workspace = true }
|
pagefind = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE chat_messages ADD COLUMN cache_read_tokens INTEGER;
|
||||||
|
ALTER TABLE chat_messages ADD COLUMN cache_write_tokens INTEGER;
|
||||||
|
|
||||||
|
ALTER TABLE ai_providers RENAME COLUMN npm TO package_ref;
|
||||||
|
ALTER TABLE ai_models RENAME COLUMN provider_npm TO provider_package_ref;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
use crate::db::migrations;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use crate::db::migrations;
|
|
||||||
|
|
||||||
/// Database wrapper managing a SQLite connection.
|
/// Database wrapper managing a SQLite connection.
|
||||||
pub struct Database {
|
pub struct Database {
|
||||||
@@ -11,7 +11,9 @@ impl Database {
|
|||||||
/// Open an existing bDS project database.
|
/// Open an existing bDS project database.
|
||||||
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
|
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
|
||||||
let conn = Connection::open(path)?;
|
let conn = Connection::open(path)?;
|
||||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;")?;
|
conn.execute_batch(
|
||||||
|
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;",
|
||||||
|
)?;
|
||||||
Ok(Self { conn })
|
Ok(Self { conn })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -162,11 +162,9 @@ pub const POST_TRANSLATION_COLUMNS: &str = "\
|
|||||||
id, project_id, translation_for, language, title, excerpt, content, \
|
id, project_id, translation_for, language, title, excerpt, content, \
|
||||||
status, file_path, checksum, created_at, updated_at, published_at";
|
status, file_path, checksum, created_at, updated_at, published_at";
|
||||||
|
|
||||||
pub const POST_LINK_COLUMNS: &str =
|
pub const POST_LINK_COLUMNS: &str = "id, source_post_id, target_post_id, link_text, created_at";
|
||||||
"id, source_post_id, target_post_id, link_text, created_at";
|
|
||||||
|
|
||||||
pub const POST_MEDIA_COLUMNS: &str =
|
pub const POST_MEDIA_COLUMNS: &str = "id, project_id, post_id, media_id, sort_order, created_at";
|
||||||
"id, project_id, post_id, media_id, sort_order, created_at";
|
|
||||||
|
|
||||||
pub const MEDIA_COLUMNS: &str = "\
|
pub const MEDIA_COLUMNS: &str = "\
|
||||||
id, project_id, filename, original_name, mime_type, size, \
|
id, project_id, filename, original_name, mime_type, size, \
|
||||||
@@ -189,8 +187,7 @@ pub const SCRIPT_COLUMNS: &str = "\
|
|||||||
|
|
||||||
pub const SETTING_COLUMNS: &str = "key, value, updated_at";
|
pub const SETTING_COLUMNS: &str = "key, value, updated_at";
|
||||||
|
|
||||||
pub const GENERATED_FILE_HASH_COLUMNS: &str =
|
pub const GENERATED_FILE_HASH_COLUMNS: &str = "project_id, relative_path, content_hash, updated_at";
|
||||||
"project_id, relative_path, content_hash, updated_at";
|
|
||||||
|
|
||||||
pub const DB_NOTIFICATION_COLUMNS: &str =
|
pub const DB_NOTIFICATION_COLUMNS: &str =
|
||||||
"id, entity_type, entity_id, action, from_cli, seen_at, created_at";
|
"id, entity_type, entity_id, action, from_cli, seen_at, created_at";
|
||||||
@@ -418,7 +415,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parse_post_status_valid() {
|
fn parse_post_status_valid() {
|
||||||
assert_eq!(parse_post_status("draft").unwrap(), PostStatus::Draft);
|
assert_eq!(parse_post_status("draft").unwrap(), PostStatus::Draft);
|
||||||
assert_eq!(parse_post_status("published").unwrap(), PostStatus::Published);
|
assert_eq!(
|
||||||
|
parse_post_status("published").unwrap(),
|
||||||
|
PostStatus::Published
|
||||||
|
);
|
||||||
assert_eq!(parse_post_status("archived").unwrap(), PostStatus::Archived);
|
assert_eq!(parse_post_status("archived").unwrap(), PostStatus::Archived);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,8 +431,14 @@ mod tests {
|
|||||||
fn parse_template_kind_valid() {
|
fn parse_template_kind_valid() {
|
||||||
assert_eq!(parse_template_kind("post").unwrap(), TemplateKind::Post);
|
assert_eq!(parse_template_kind("post").unwrap(), TemplateKind::Post);
|
||||||
assert_eq!(parse_template_kind("list").unwrap(), TemplateKind::List);
|
assert_eq!(parse_template_kind("list").unwrap(), TemplateKind::List);
|
||||||
assert_eq!(parse_template_kind("not_found").unwrap(), TemplateKind::NotFound);
|
assert_eq!(
|
||||||
assert_eq!(parse_template_kind("partial").unwrap(), TemplateKind::Partial);
|
parse_template_kind("not_found").unwrap(),
|
||||||
|
TemplateKind::NotFound
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_template_kind("partial").unwrap(),
|
||||||
|
TemplateKind::Partial
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -442,36 +448,69 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_template_status_valid() {
|
fn parse_template_status_valid() {
|
||||||
assert_eq!(parse_template_status("draft").unwrap(), TemplateStatus::Draft);
|
assert_eq!(
|
||||||
assert_eq!(parse_template_status("published").unwrap(), TemplateStatus::Published);
|
parse_template_status("draft").unwrap(),
|
||||||
|
TemplateStatus::Draft
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_template_status("published").unwrap(),
|
||||||
|
TemplateStatus::Published
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_script_kind_valid() {
|
fn parse_script_kind_valid() {
|
||||||
assert_eq!(parse_script_kind("macro").unwrap(), ScriptKind::Macro);
|
assert_eq!(parse_script_kind("macro").unwrap(), ScriptKind::Macro);
|
||||||
assert_eq!(parse_script_kind("utility").unwrap(), ScriptKind::Utility);
|
assert_eq!(parse_script_kind("utility").unwrap(), ScriptKind::Utility);
|
||||||
assert_eq!(parse_script_kind("transform").unwrap(), ScriptKind::Transform);
|
assert_eq!(
|
||||||
|
parse_script_kind("transform").unwrap(),
|
||||||
|
ScriptKind::Transform
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_script_status_valid() {
|
fn parse_script_status_valid() {
|
||||||
assert_eq!(parse_script_status("draft").unwrap(), ScriptStatus::Draft);
|
assert_eq!(parse_script_status("draft").unwrap(), ScriptStatus::Draft);
|
||||||
assert_eq!(parse_script_status("published").unwrap(), ScriptStatus::Published);
|
assert_eq!(
|
||||||
|
parse_script_status("published").unwrap(),
|
||||||
|
ScriptStatus::Published
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_notification_entity_valid() {
|
fn parse_notification_entity_valid() {
|
||||||
assert_eq!(parse_notification_entity("post").unwrap(), NotificationEntity::Post);
|
assert_eq!(
|
||||||
assert_eq!(parse_notification_entity("media").unwrap(), NotificationEntity::Media);
|
parse_notification_entity("post").unwrap(),
|
||||||
assert_eq!(parse_notification_entity("script").unwrap(), NotificationEntity::Script);
|
NotificationEntity::Post
|
||||||
assert_eq!(parse_notification_entity("template").unwrap(), NotificationEntity::Template);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_notification_entity("media").unwrap(),
|
||||||
|
NotificationEntity::Media
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_notification_entity("script").unwrap(),
|
||||||
|
NotificationEntity::Script
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_notification_entity("template").unwrap(),
|
||||||
|
NotificationEntity::Template
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_notification_action_valid() {
|
fn parse_notification_action_valid() {
|
||||||
assert_eq!(parse_notification_action("created").unwrap(), NotificationAction::Created);
|
assert_eq!(
|
||||||
assert_eq!(parse_notification_action("updated").unwrap(), NotificationAction::Updated);
|
parse_notification_action("created").unwrap(),
|
||||||
assert_eq!(parse_notification_action("deleted").unwrap(), NotificationAction::Deleted);
|
NotificationAction::Created
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_notification_action("updated").unwrap(),
|
||||||
|
NotificationAction::Updated
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_notification_action("deleted").unwrap(),
|
||||||
|
NotificationAction::Deleted
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── JSON helpers ─────────────────────────────────────────────────
|
// ── JSON helpers ─────────────────────────────────────────────────
|
||||||
@@ -504,11 +543,13 @@ mod tests {
|
|||||||
VALUES ('p1', 'Blog', 'blog', 'My blog', '/data', 1, 1000, 2000)",
|
VALUES ('p1', 'Blog', 'blog', 'My blog', '/data', 1, 1000, 2000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
let p = c.query_row(
|
let p = c
|
||||||
|
.query_row(
|
||||||
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = 'p1'"),
|
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = 'p1'"),
|
||||||
[],
|
[],
|
||||||
project_from_row,
|
project_from_row,
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(p.id, "p1");
|
assert_eq!(p.id, "p1");
|
||||||
assert_eq!(p.name, "Blog");
|
assert_eq!(p.name, "Blog");
|
||||||
assert_eq!(p.slug, "blog");
|
assert_eq!(p.slug, "blog");
|
||||||
@@ -527,7 +568,8 @@ mod tests {
|
|||||||
"INSERT INTO projects (id, name, slug, is_active, created_at, updated_at)
|
"INSERT INTO projects (id, name, slug, is_active, created_at, updated_at)
|
||||||
VALUES ('p1', 'B', 'b', 0, 1000, 1000)",
|
VALUES ('p1', 'B', 'b', 0, 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
c.execute(
|
c.execute(
|
||||||
"INSERT INTO posts (id, project_id, title, slug, excerpt, content, status, author,
|
"INSERT INTO posts (id, project_id, title, slug, excerpt, content, status, author,
|
||||||
language, do_not_translate, template_slug, file_path, checksum,
|
language, do_not_translate, template_slug, file_path, checksum,
|
||||||
@@ -541,11 +583,13 @@ mod tests {
|
|||||||
1000, 2000, NULL)",
|
1000, 2000, NULL)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
let p = c.query_row(
|
let p = c
|
||||||
|
.query_row(
|
||||||
&format!("SELECT {POST_COLUMNS} FROM posts WHERE id = 'x'"),
|
&format!("SELECT {POST_COLUMNS} FROM posts WHERE id = 'x'"),
|
||||||
[],
|
[],
|
||||||
post_from_row,
|
post_from_row,
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(p.id, "x");
|
assert_eq!(p.id, "x");
|
||||||
assert_eq!(p.project_id, "p1");
|
assert_eq!(p.project_id, "p1");
|
||||||
assert_eq!(p.title, "Hello");
|
assert_eq!(p.title, "Hello");
|
||||||
@@ -575,19 +619,23 @@ mod tests {
|
|||||||
"INSERT INTO projects (id, name, slug, is_active, created_at, updated_at)
|
"INSERT INTO projects (id, name, slug, is_active, created_at, updated_at)
|
||||||
VALUES ('p1', 'B', 'b', 0, 1000, 1000)",
|
VALUES ('p1', 'B', 'b', 0, 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
c.execute(
|
c.execute(
|
||||||
"INSERT INTO templates (id, project_id, slug, title, kind, enabled, version,
|
"INSERT INTO templates (id, project_id, slug, title, kind, enabled, version,
|
||||||
file_path, status, content, created_at, updated_at)
|
file_path, status, content, created_at, updated_at)
|
||||||
VALUES ('t1', 'p1', 'default', 'Default', 'not_found', 0, 3,
|
VALUES ('t1', 'p1', 'default', 'Default', 'not_found', 0, 3,
|
||||||
'templates/default.liquid', 'draft', 'html', 1000, 2000)",
|
'templates/default.liquid', 'draft', 'html', 1000, 2000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
let t = c.query_row(
|
.unwrap();
|
||||||
|
let t = c
|
||||||
|
.query_row(
|
||||||
&format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE id = 't1'"),
|
&format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE id = 't1'"),
|
||||||
[],
|
[],
|
||||||
template_from_row,
|
template_from_row,
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(t.kind, TemplateKind::NotFound);
|
assert_eq!(t.kind, TemplateKind::NotFound);
|
||||||
assert!(!t.enabled);
|
assert!(!t.enabled);
|
||||||
assert_eq!(t.version, 3);
|
assert_eq!(t.version, 3);
|
||||||
@@ -604,11 +652,15 @@ mod tests {
|
|||||||
VALUES ('media', 'm1', 'deleted', 1, 5000, 1000)",
|
VALUES ('media', 'm1', 'deleted', 1, 5000, 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
let n = c.query_row(
|
let n = c
|
||||||
&format!("SELECT {DB_NOTIFICATION_COLUMNS} FROM db_notifications WHERE entity_id = 'm1'"),
|
.query_row(
|
||||||
|
&format!(
|
||||||
|
"SELECT {DB_NOTIFICATION_COLUMNS} FROM db_notifications WHERE entity_id = 'm1'"
|
||||||
|
),
|
||||||
[],
|
[],
|
||||||
db_notification_from_row,
|
db_notification_from_row,
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(n.entity_type, NotificationEntity::Media);
|
assert_eq!(n.entity_type, NotificationEntity::Media);
|
||||||
assert_eq!(n.entity_id, "m1");
|
assert_eq!(n.entity_id, "m1");
|
||||||
assert_eq!(n.action, NotificationAction::Deleted);
|
assert_eq!(n.action, NotificationAction::Deleted);
|
||||||
|
|||||||
+179
-62
@@ -1,5 +1,7 @@
|
|||||||
use rust_stemmers::{Algorithm, Stemmer};
|
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
use rust_stemmers::{Algorithm, Stemmer};
|
||||||
|
|
||||||
|
use crate::util::calendar_range_unix_ms;
|
||||||
|
|
||||||
/// Create FTS5 virtual tables at runtime (not in migrations per spec).
|
/// Create FTS5 virtual tables at runtime (not in migrations per spec).
|
||||||
///
|
///
|
||||||
@@ -22,7 +24,7 @@ pub fn ensure_fts_tables(conn: &Connection) -> rusqlite::Result<()> {
|
|||||||
caption,
|
caption,
|
||||||
original_name,
|
original_name,
|
||||||
tags
|
tags
|
||||||
);"
|
);",
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -82,6 +84,10 @@ pub struct MediaTranslationFts {
|
|||||||
/// Index a post in the FTS table with separate columns per spec.
|
/// Index a post in the FTS table with separate columns per spec.
|
||||||
///
|
///
|
||||||
/// Translation titles go to the title column, excerpts to excerpt, content to content.
|
/// Translation titles go to the title column, excerpts to excerpt, content to content.
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_arguments,
|
||||||
|
reason = "FTS columns mirror the persisted post fields"
|
||||||
|
)]
|
||||||
pub fn index_post(
|
pub fn index_post(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
post_id: &str,
|
post_id: &str,
|
||||||
@@ -137,6 +143,10 @@ pub fn index_post(
|
|||||||
/// Index a media item in the FTS table with separate columns per spec.
|
/// Index a media item in the FTS table with separate columns per spec.
|
||||||
///
|
///
|
||||||
/// Translation titles go to the title column, alts to alt, captions to caption.
|
/// Translation titles go to the title column, alts to alt, captions to caption.
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_arguments,
|
||||||
|
reason = "FTS columns mirror the persisted media fields"
|
||||||
|
)]
|
||||||
pub fn index_media(
|
pub fn index_media(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
media_id: &str,
|
media_id: &str,
|
||||||
@@ -209,14 +219,15 @@ pub fn remove_media_from_index(conn: &Connection, media_id: &str) -> rusqlite::R
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Search posts by full-text query. Returns matching post IDs.
|
/// Search posts by full-text query. Returns matching post IDs.
|
||||||
pub fn search_posts(conn: &Connection, query: &str, language: &str) -> rusqlite::Result<Vec<String>> {
|
pub fn search_posts(
|
||||||
|
conn: &Connection,
|
||||||
|
query: &str,
|
||||||
|
language: &str,
|
||||||
|
) -> rusqlite::Result<Vec<String>> {
|
||||||
let stemmed = stem_text(query, language);
|
let stemmed = stem_text(query, language);
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt =
|
||||||
"SELECT post_id FROM posts_fts WHERE posts_fts MATCH ?1 ORDER BY rank"
|
conn.prepare("SELECT post_id FROM posts_fts WHERE posts_fts MATCH ?1 ORDER BY rank")?;
|
||||||
)?;
|
let rows = stmt.query_map(rusqlite::params![stemmed], |row| row.get::<_, String>(0))?;
|
||||||
let rows = stmt.query_map(rusqlite::params![stemmed], |row| {
|
|
||||||
row.get::<_, String>(0)
|
|
||||||
})?;
|
|
||||||
rows.collect()
|
rows.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,16 +266,28 @@ pub fn search_posts_filtered(
|
|||||||
// Get FTS matches first
|
// Get FTS matches first
|
||||||
let fts_ids = search_posts(conn, query, language)?;
|
let fts_ids = search_posts(conn, query, language)?;
|
||||||
if fts_ids.is_empty() {
|
if fts_ids.is_empty() {
|
||||||
return Ok(SearchResults { post_ids: vec![], total: 0, offset: filters.offset.unwrap_or(0), limit: filters.limit.unwrap_or(0) });
|
return Ok(SearchResults {
|
||||||
|
post_ids: vec![],
|
||||||
|
total: 0,
|
||||||
|
offset: filters.offset.unwrap_or(0),
|
||||||
|
limit: filters.limit.unwrap_or(0),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply filters by querying posts table
|
// Apply filters by querying posts table
|
||||||
let placeholders: Vec<String> = fts_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect();
|
let placeholders: Vec<String> = fts_ids
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, _)| format!("?{}", i + 1))
|
||||||
|
.collect();
|
||||||
let mut sql = format!(
|
let mut sql = format!(
|
||||||
"SELECT id FROM posts WHERE id IN ({}) ",
|
"SELECT id FROM posts WHERE id IN ({}) ",
|
||||||
placeholders.join(",")
|
placeholders.join(",")
|
||||||
);
|
);
|
||||||
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = fts_ids.iter().map(|id| Box::new(id.clone()) as Box<dyn rusqlite::types::ToSql>).collect();
|
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = fts_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| Box::new(id.clone()) as Box<dyn rusqlite::types::ToSql>)
|
||||||
|
.collect();
|
||||||
let mut param_idx = fts_ids.len() + 1;
|
let mut param_idx = fts_ids.len() + 1;
|
||||||
|
|
||||||
if let Some(status) = filters.status {
|
if let Some(status) = filters.status {
|
||||||
@@ -296,29 +319,21 @@ pub fn search_posts_filtered(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(year) = filters.year {
|
if let Some(year) = filters.year {
|
||||||
// Filter by year from created_at (unix ms)
|
let (start, end) =
|
||||||
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
|
calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?;
|
||||||
let end = chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
|
sql.push_str(&format!(
|
||||||
sql.push_str(&format!("AND created_at >= ?{param_idx} AND created_at < ?{} ", param_idx + 1));
|
"AND created_at >= ?{param_idx} AND created_at < ?{} ",
|
||||||
|
param_idx + 1
|
||||||
|
));
|
||||||
params.push(Box::new(start));
|
params.push(Box::new(start));
|
||||||
params.push(Box::new(end));
|
params.push(Box::new(end));
|
||||||
param_idx += 2;
|
param_idx += 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(month) = filters.month {
|
|
||||||
if let Some(year) = filters.year {
|
|
||||||
let (end_year, end_month) = if month == 12 { (year + 1, 1) } else { (year, month as i32 + 1) };
|
|
||||||
let start = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
|
|
||||||
let end = chrono::NaiveDate::from_ymd_opt(end_year, end_month as u32, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
|
|
||||||
sql.push_str(&format!("AND created_at >= ?{param_idx} AND created_at < ?{} ", param_idx + 1));
|
|
||||||
params.push(Box::new(start));
|
|
||||||
params.push(Box::new(end));
|
|
||||||
param_idx += 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(lang) = filters.language {
|
if let Some(lang) = filters.language {
|
||||||
sql.push_str(&format!("AND (language = ?{param_idx} OR language IS NULL) "));
|
sql.push_str(&format!(
|
||||||
|
"AND (language = ?{param_idx} OR language IS NULL) "
|
||||||
|
));
|
||||||
params.push(Box::new(lang.to_string()));
|
params.push(Box::new(lang.to_string()));
|
||||||
param_idx += 1;
|
param_idx += 1;
|
||||||
}
|
}
|
||||||
@@ -349,7 +364,8 @@ pub fn search_posts_filtered(
|
|||||||
let count_sql = sql.replace("SELECT id FROM posts", "SELECT COUNT(*) FROM posts");
|
let count_sql = sql.replace("SELECT id FROM posts", "SELECT COUNT(*) FROM posts");
|
||||||
let total: usize = {
|
let total: usize = {
|
||||||
let mut stmt = conn.prepare(&count_sql)?;
|
let mut stmt = conn.prepare(&count_sql)?;
|
||||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
||||||
|
params.iter().map(|p| p.as_ref()).collect();
|
||||||
stmt.query_row(params_refs.as_slice(), |row| row.get::<_, usize>(0))?
|
stmt.query_row(params_refs.as_slice(), |row| row.get::<_, usize>(0))?
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -365,22 +381,26 @@ pub fn search_posts_filtered(
|
|||||||
|
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||||
let rows = stmt.query_map(params_refs.as_slice(), |row| {
|
let rows = stmt.query_map(params_refs.as_slice(), |row| row.get::<_, String>(0))?;
|
||||||
row.get::<_, String>(0)
|
|
||||||
})?;
|
|
||||||
let post_ids: Vec<String> = rows.collect::<rusqlite::Result<Vec<_>>>()?;
|
let post_ids: Vec<String> = rows.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
Ok(SearchResults { post_ids, total, offset, limit })
|
Ok(SearchResults {
|
||||||
|
post_ids,
|
||||||
|
total,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search media by full-text query. Returns matching media IDs.
|
/// Search media by full-text query. Returns matching media IDs.
|
||||||
pub fn search_media(conn: &Connection, query: &str, language: &str) -> rusqlite::Result<Vec<String>> {
|
pub fn search_media(
|
||||||
|
conn: &Connection,
|
||||||
|
query: &str,
|
||||||
|
language: &str,
|
||||||
|
) -> rusqlite::Result<Vec<String>> {
|
||||||
let stemmed = stem_text(query, language);
|
let stemmed = stem_text(query, language);
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt =
|
||||||
"SELECT media_id FROM media_fts WHERE media_fts MATCH ?1 ORDER BY rank"
|
conn.prepare("SELECT media_id FROM media_fts WHERE media_fts MATCH ?1 ORDER BY rank")?;
|
||||||
)?;
|
let rows = stmt.query_map(rusqlite::params![stemmed], |row| row.get::<_, String>(0))?;
|
||||||
let rows = stmt.query_map(rusqlite::params![stemmed], |row| {
|
|
||||||
row.get::<_, String>(0)
|
|
||||||
})?;
|
|
||||||
rows.collect()
|
rows.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,7 +546,8 @@ mod tests {
|
|||||||
language: "en".into(),
|
language: "en".into(),
|
||||||
}],
|
}],
|
||||||
"en",
|
"en",
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let results = search_posts(db.conn(), "spider", "en").unwrap();
|
let results = search_posts(db.conn(), "spider", "en").unwrap();
|
||||||
assert_eq!(results, vec!["post-1"]);
|
assert_eq!(results, vec!["post-1"]);
|
||||||
@@ -545,7 +566,8 @@ mod tests {
|
|||||||
&["nature".into()],
|
&["nature".into()],
|
||||||
&[],
|
&[],
|
||||||
"en",
|
"en",
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let results = search_media(db.conn(), "sunset", "en").unwrap();
|
let results = search_media(db.conn(), "sunset", "en").unwrap();
|
||||||
assert_eq!(results, vec!["media-1"]);
|
assert_eq!(results, vec!["media-1"]);
|
||||||
@@ -561,10 +583,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn remove_from_index() {
|
fn remove_from_index() {
|
||||||
let db = setup();
|
let db = setup();
|
||||||
index_post(
|
index_post(db.conn(), "p1", "Test", None, None, &[], &[], &[], "en").unwrap();
|
||||||
db.conn(), "p1", "Test", None, None,
|
|
||||||
&[], &[], &[], "en",
|
|
||||||
).unwrap();
|
|
||||||
assert_eq!(search_posts(db.conn(), "test", "en").unwrap().len(), 1);
|
assert_eq!(search_posts(db.conn(), "test", "en").unwrap().len(), 1);
|
||||||
|
|
||||||
remove_post_from_index(db.conn(), "p1").unwrap();
|
remove_post_from_index(db.conn(), "p1").unwrap();
|
||||||
@@ -586,8 +605,13 @@ mod tests {
|
|||||||
let db = setup();
|
let db = setup();
|
||||||
// German post with English translation
|
// German post with English translation
|
||||||
index_post(
|
index_post(
|
||||||
db.conn(), "p1", "Programmierung", None, Some("Deutsche Entwicklung"),
|
db.conn(),
|
||||||
&[], &[],
|
"p1",
|
||||||
|
"Programmierung",
|
||||||
|
None,
|
||||||
|
Some("Deutsche Entwicklung"),
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
&[PostTranslationFts {
|
&[PostTranslationFts {
|
||||||
title: "English development programming".into(),
|
title: "English development programming".into(),
|
||||||
excerpt: None,
|
excerpt: None,
|
||||||
@@ -595,7 +619,8 @@ mod tests {
|
|||||||
language: "en".into(),
|
language: "en".into(),
|
||||||
}],
|
}],
|
||||||
"de",
|
"de",
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Search with English stemming should find via English translation
|
// Search with English stemming should find via English translation
|
||||||
let results = search_posts(db.conn(), "develop", "en").unwrap();
|
let results = search_posts(db.conn(), "develop", "en").unwrap();
|
||||||
@@ -605,7 +630,18 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn search_by_title_field() {
|
fn search_by_title_field() {
|
||||||
let db = setup();
|
let db = setup();
|
||||||
index_post(db.conn(), "p1", "Unique Title Here", None, Some("body text"), &[], &[], &[], "en").unwrap();
|
index_post(
|
||||||
|
db.conn(),
|
||||||
|
"p1",
|
||||||
|
"Unique Title Here",
|
||||||
|
None,
|
||||||
|
Some("body text"),
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
"en",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Search for title content
|
// Search for title content
|
||||||
let results = search_posts(db.conn(), "unique", "en").unwrap();
|
let results = search_posts(db.conn(), "unique", "en").unwrap();
|
||||||
@@ -615,7 +651,18 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn search_by_tags() {
|
fn search_by_tags() {
|
||||||
let db = setup();
|
let db = setup();
|
||||||
index_post(db.conn(), "p1", "Post", None, None, &["photography".into()], &[], &[], "en").unwrap();
|
index_post(
|
||||||
|
db.conn(),
|
||||||
|
"p1",
|
||||||
|
"Post",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&["photography".into()],
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
"en",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let results = search_posts(db.conn(), "photography", "en").unwrap();
|
let results = search_posts(db.conn(), "photography", "en").unwrap();
|
||||||
assert_eq!(results, vec!["p1"]);
|
assert_eq!(results, vec!["p1"]);
|
||||||
@@ -624,7 +671,18 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn search_by_categories() {
|
fn search_by_categories() {
|
||||||
let db = setup();
|
let db = setup();
|
||||||
index_post(db.conn(), "p1", "Post", None, None, &[], &["article".into()], &[], "en").unwrap();
|
index_post(
|
||||||
|
db.conn(),
|
||||||
|
"p1",
|
||||||
|
"Post",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
&["article".into()],
|
||||||
|
&[],
|
||||||
|
"en",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let results = search_posts(db.conn(), "article", "en").unwrap();
|
let results = search_posts(db.conn(), "article", "en").unwrap();
|
||||||
assert_eq!(results, vec!["p1"]);
|
assert_eq!(results, vec!["p1"]);
|
||||||
@@ -647,8 +705,30 @@ mod tests {
|
|||||||
[],
|
[],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
index_post(db.conn(), "post1", "Test Post", None, None, &[], &[], &[], "en").unwrap();
|
index_post(
|
||||||
index_post(db.conn(), "post2", "Draft Post", None, None, &[], &[], &[], "en").unwrap();
|
db.conn(),
|
||||||
|
"post1",
|
||||||
|
"Test Post",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
"en",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
index_post(
|
||||||
|
db.conn(),
|
||||||
|
"post2",
|
||||||
|
"Draft Post",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
"en",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let filters = PostSearchFilters {
|
let filters = PostSearchFilters {
|
||||||
status: Some("published"),
|
status: Some("published"),
|
||||||
@@ -670,19 +750,45 @@ mod tests {
|
|||||||
// 2023-06-15 in unix ms
|
// 2023-06-15 in unix ms
|
||||||
let ts_2023: i64 = 1686873600000;
|
let ts_2023: i64 = 1686873600000;
|
||||||
|
|
||||||
db.conn().execute(
|
db.conn()
|
||||||
|
.execute(
|
||||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||||
VALUES ('p2024', 'p1', 'Year 2024', 'y2024', 'draft', ?1, ?1)",
|
VALUES ('p2024', 'p1', 'Year 2024', 'y2024', 'draft', ?1, ?1)",
|
||||||
rusqlite::params![ts_2024],
|
rusqlite::params![ts_2024],
|
||||||
).unwrap();
|
)
|
||||||
db.conn().execute(
|
.unwrap();
|
||||||
|
db.conn()
|
||||||
|
.execute(
|
||||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||||
VALUES ('p2023', 'p1', 'Year 2023', 'y2023', 'draft', ?1, ?1)",
|
VALUES ('p2023', 'p1', 'Year 2023', 'y2023', 'draft', ?1, ?1)",
|
||||||
rusqlite::params![ts_2023],
|
rusqlite::params![ts_2023],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
index_post(db.conn(), "p2024", "Year 2024", None, None, &[], &[], &[], "en").unwrap();
|
index_post(
|
||||||
index_post(db.conn(), "p2023", "Year 2023", None, None, &[], &[], &[], "en").unwrap();
|
db.conn(),
|
||||||
|
"p2024",
|
||||||
|
"Year 2024",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
"en",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
index_post(
|
||||||
|
db.conn(),
|
||||||
|
"p2023",
|
||||||
|
"Year 2023",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
"en",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let filters = PostSearchFilters {
|
let filters = PostSearchFilters {
|
||||||
year: Some(2024),
|
year: Some(2024),
|
||||||
@@ -707,7 +813,18 @@ mod tests {
|
|||||||
VALUES (?1, 'p1', 'Searchable', ?2, 'draft', ?3, ?3)",
|
VALUES (?1, 'p1', 'Searchable', ?2, 'draft', ?3, ?3)",
|
||||||
rusqlite::params![id, slug, 1700000000000i64 - i as i64 * 1000],
|
rusqlite::params![id, slug, 1700000000000i64 - i as i64 * 1000],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
index_post(db.conn(), &id, "Searchable", None, None, &[], &[], &[], "en").unwrap();
|
index_post(
|
||||||
|
db.conn(),
|
||||||
|
&id,
|
||||||
|
"Searchable",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
"en",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
let filters = PostSearchFilters {
|
let filters = PostSearchFilters {
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ use rusqlite::Connection;
|
|||||||
|
|
||||||
mod embedded {
|
mod embedded {
|
||||||
use refinery::embed_migrations;
|
use refinery::embed_migrations;
|
||||||
embed_migrations!("migrations");
|
embed_migrations!("./migrations");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run all embedded migrations against the given connection using refinery.
|
/// Run all embedded migrations against the given connection using refinery.
|
||||||
///
|
///
|
||||||
/// Creates the full bDS schema as specified in specs/schema.allium.
|
/// Creates the full bDS schema as specified in specs/schema.allium.
|
||||||
/// Uses refinery for proper versioned migration tracking.
|
/// Uses refinery for proper versioned migration tracking.
|
||||||
pub fn run_migrations(conn: &mut Connection) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
pub fn run_migrations(
|
||||||
|
conn: &mut Connection,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
embedded::migrations::runner().run(conn)?;
|
embedded::migrations::runner().run(conn)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -63,19 +65,34 @@ mod tests {
|
|||||||
fn all_tables_exist() {
|
fn all_tables_exist() {
|
||||||
let conn = setup();
|
let conn = setup();
|
||||||
let expected = [
|
let expected = [
|
||||||
"projects", "posts", "post_translations", "media", "media_translations",
|
"projects",
|
||||||
"tags", "templates", "scripts", "post_links", "post_media", "settings",
|
"posts",
|
||||||
"generated_file_hashes", "chat_conversations", "chat_messages", "ai_providers",
|
"post_translations",
|
||||||
"ai_models", "ai_model_modalities", "ai_catalog_meta", "embedding_keys",
|
"media",
|
||||||
"dismissed_duplicate_pairs", "import_definitions", "db_notifications",
|
"media_translations",
|
||||||
|
"tags",
|
||||||
|
"templates",
|
||||||
|
"scripts",
|
||||||
|
"post_links",
|
||||||
|
"post_media",
|
||||||
|
"settings",
|
||||||
|
"generated_file_hashes",
|
||||||
|
"chat_conversations",
|
||||||
|
"chat_messages",
|
||||||
|
"ai_providers",
|
||||||
|
"ai_models",
|
||||||
|
"ai_model_modalities",
|
||||||
|
"ai_catalog_meta",
|
||||||
|
"embedding_keys",
|
||||||
|
"dismissed_duplicate_pairs",
|
||||||
|
"import_definitions",
|
||||||
|
"db_notifications",
|
||||||
];
|
];
|
||||||
for table in &expected {
|
for table in &expected {
|
||||||
let count: i64 = conn
|
let count: i64 = conn
|
||||||
.query_row(
|
.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| {
|
||||||
&format!("SELECT COUNT(*) FROM {table}"),
|
row.get(0)
|
||||||
[],
|
})
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap_or_else(|e| panic!("table '{table}' should be queryable: {e}"));
|
.unwrap_or_else(|e| panic!("table '{table}' should be queryable: {e}"));
|
||||||
assert_eq!(count, 0, "table '{table}' should start empty");
|
assert_eq!(count, 0, "table '{table}' should start empty");
|
||||||
}
|
}
|
||||||
@@ -89,11 +106,9 @@ mod tests {
|
|||||||
fn refinery_schema_history_exists() {
|
fn refinery_schema_history_exists() {
|
||||||
let conn = setup();
|
let conn = setup();
|
||||||
let count: i64 = conn
|
let count: i64 = conn
|
||||||
.query_row(
|
.query_row("SELECT COUNT(*) FROM refinery_schema_history", [], |row| {
|
||||||
"SELECT COUNT(*) FROM refinery_schema_history",
|
row.get(0)
|
||||||
[],
|
})
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(count >= 1, "refinery should track at least one migration");
|
assert!(count >= 1, "refinery should track at least one migration");
|
||||||
}
|
}
|
||||||
@@ -124,7 +139,10 @@ mod tests {
|
|||||||
VALUES ('post2', 'p1', 'Other', 'hello', 'draft', 1000, 1000)",
|
VALUES ('post2', 'p1', 'Other', 'hello', 'draft', 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
assert!(err.is_err(), "duplicate post slug within same project must be rejected");
|
assert!(
|
||||||
|
err.is_err(),
|
||||||
|
"duplicate post slug within same project must be rejected"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -156,7 +174,10 @@ mod tests {
|
|||||||
VALUES ('t2', 'p1', 'post1', 'de', 'Hallo2', 'draft', 1000, 1000)",
|
VALUES ('t2', 'p1', 'post1', 'de', 'Hallo2', 'draft', 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
assert!(err.is_err(), "duplicate (translation_for, language) must be rejected");
|
assert!(
|
||||||
|
err.is_err(),
|
||||||
|
"duplicate (translation_for, language) must be rejected"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -174,7 +195,10 @@ mod tests {
|
|||||||
VALUES ('mt2', 'p1', 'm1', 'de', 1000, 1000)",
|
VALUES ('mt2', 'p1', 'm1', 'de', 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
assert!(err.is_err(), "duplicate (media translation_for, language) must be rejected");
|
assert!(
|
||||||
|
err.is_err(),
|
||||||
|
"duplicate (media translation_for, language) must be rejected"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -185,13 +209,17 @@ mod tests {
|
|||||||
"INSERT INTO tags (id, project_id, name, created_at, updated_at)
|
"INSERT INTO tags (id, project_id, name, created_at, updated_at)
|
||||||
VALUES ('t1', 'p1', 'rust', 1000, 1000)",
|
VALUES ('t1', 'p1', 'rust', 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
let err = conn.execute(
|
let err = conn.execute(
|
||||||
"INSERT INTO tags (id, project_id, name, created_at, updated_at)
|
"INSERT INTO tags (id, project_id, name, created_at, updated_at)
|
||||||
VALUES ('t2', 'p1', 'rust', 1000, 1000)",
|
VALUES ('t2', 'p1', 'rust', 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
assert!(err.is_err(), "duplicate tag name within same project must be rejected");
|
assert!(
|
||||||
|
err.is_err(),
|
||||||
|
"duplicate tag name within same project must be rejected"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -208,7 +236,10 @@ mod tests {
|
|||||||
VALUES ('tpl2', 'p1', 'default', 'Default2', 'list', 'templates/default.liquid', 1000, 1000)",
|
VALUES ('tpl2', 'p1', 'default', 'Default2', 'list', 'templates/default.liquid', 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
assert!(err.is_err(), "duplicate template slug within same project must be rejected");
|
assert!(
|
||||||
|
err.is_err(),
|
||||||
|
"duplicate template slug within same project must be rejected"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -225,7 +256,10 @@ mod tests {
|
|||||||
VALUES ('s2', 'p1', 'gallery', 'Gallery2', 'utility', 'scripts/gallery.lua', 1000, 1000)",
|
VALUES ('s2', 'p1', 'gallery', 'Gallery2', 'utility', 'scripts/gallery.lua', 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
assert!(err.is_err(), "duplicate script slug within same project must be rejected");
|
assert!(
|
||||||
|
err.is_err(),
|
||||||
|
"duplicate script slug within same project must be rejected"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -238,13 +272,17 @@ mod tests {
|
|||||||
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at)
|
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at)
|
||||||
VALUES ('pm1', 'p1', 'post1', 'm1', 0, 1000)",
|
VALUES ('pm1', 'p1', 'post1', 'm1', 0, 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
let err = conn.execute(
|
let err = conn.execute(
|
||||||
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at)
|
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at)
|
||||||
VALUES ('pm2', 'p1', 'post1', 'm1', 1, 1000)",
|
VALUES ('pm2', 'p1', 'post1', 'm1', 1, 1000)",
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
assert!(err.is_err(), "duplicate (post_id, media_id) must be rejected");
|
assert!(
|
||||||
|
err.is_err(),
|
||||||
|
"duplicate (post_id, media_id) must be rejected"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -261,7 +299,10 @@ mod tests {
|
|||||||
VALUES ('p1', 'index.html', 'def456', 2000)",
|
VALUES ('p1', 'index.html', 'def456', 2000)",
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
assert!(err.is_err(), "duplicate (project_id, relative_path) must be rejected");
|
assert!(
|
||||||
|
err.is_err(),
|
||||||
|
"duplicate (project_id, relative_path) must be rejected"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -278,7 +319,10 @@ mod tests {
|
|||||||
VALUES ('d2', 'p1', 'a', 'b', 2000)",
|
VALUES ('d2', 'p1', 'a', 'b', 2000)",
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
assert!(err.is_err(), "duplicate (project_id, post_id_a, post_id_b) must be rejected");
|
assert!(
|
||||||
|
err.is_err(),
|
||||||
|
"duplicate (project_id, post_id_a, post_id_b) must be rejected"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================================================================
|
// ================================================================
|
||||||
@@ -323,19 +367,25 @@ mod tests {
|
|||||||
[],
|
[],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
let title: String = conn.query_row(
|
let title: String = conn
|
||||||
"SELECT title FROM posts WHERE id = 'post1'", [], |r| r.get(0)
|
.query_row("SELECT title FROM posts WHERE id = 'post1'", [], |r| {
|
||||||
).unwrap();
|
r.get(0)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
assert_eq!(title, "Hello");
|
assert_eq!(title, "Hello");
|
||||||
|
|
||||||
let tags: String = conn.query_row(
|
let tags: String = conn
|
||||||
"SELECT tags FROM posts WHERE id = 'post1'", [], |r| r.get(0)
|
.query_row("SELECT tags FROM posts WHERE id = 'post1'", [], |r| {
|
||||||
).unwrap();
|
r.get(0)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
assert_eq!(tags, "[\"rust\",\"blog\"]");
|
assert_eq!(tags, "[\"rust\",\"blog\"]");
|
||||||
|
|
||||||
let content: Option<String> = conn.query_row(
|
let content: Option<String> = conn
|
||||||
"SELECT content FROM posts WHERE id = 'post1'", [], |r| r.get(0)
|
.query_row("SELECT content FROM posts WHERE id = 'post1'", [], |r| {
|
||||||
).unwrap();
|
r.get(0)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
assert_eq!(content.as_deref(), Some("Body text"));
|
assert_eq!(content.as_deref(), Some("Body text"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,10 +399,15 @@ mod tests {
|
|||||||
[],
|
[],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
let content: Option<String> = conn.query_row(
|
let content: Option<String> = conn
|
||||||
"SELECT content FROM posts WHERE id = 'post1'", [], |r| r.get(0)
|
.query_row("SELECT content FROM posts WHERE id = 'post1'", [], |r| {
|
||||||
).unwrap();
|
r.get(0)
|
||||||
assert!(content.is_none(), "published post content must be null in DB");
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
content.is_none(),
|
||||||
|
"published post content must be null in DB"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -366,9 +421,13 @@ mod tests {
|
|||||||
[],
|
[],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
let (lang, title): (String, String) = conn.query_row(
|
let (lang, title): (String, String) = conn
|
||||||
"SELECT language, title FROM post_translations WHERE id = 't1'", [], |r| Ok((r.get(0)?, r.get(1)?))
|
.query_row(
|
||||||
).unwrap();
|
"SELECT language, title FROM post_translations WHERE id = 't1'",
|
||||||
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(lang, "de");
|
assert_eq!(lang, "de");
|
||||||
assert_eq!(title, "Hallo");
|
assert_eq!(title, "Hallo");
|
||||||
}
|
}
|
||||||
@@ -386,10 +445,13 @@ mod tests {
|
|||||||
[],
|
[],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
let (orig, w, h, tags): (String, Option<i32>, Option<i32>, String) = conn.query_row(
|
let (orig, w, h, tags): (String, Option<i32>, Option<i32>, String) = conn
|
||||||
"SELECT original_name, width, height, tags FROM media WHERE id = 'm1'", [],
|
.query_row(
|
||||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
|
"SELECT original_name, width, height, tags FROM media WHERE id = 'm1'",
|
||||||
).unwrap();
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(orig, "photo.jpg");
|
assert_eq!(orig, "photo.jpg");
|
||||||
assert_eq!(w, Some(1920));
|
assert_eq!(w, Some(1920));
|
||||||
assert_eq!(h, Some(1080));
|
assert_eq!(h, Some(1080));
|
||||||
@@ -407,10 +469,13 @@ mod tests {
|
|||||||
[],
|
[],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
let (title, alt): (Option<String>, Option<String>) = conn.query_row(
|
let (title, alt): (Option<String>, Option<String>) = conn
|
||||||
"SELECT title, alt FROM media_translations WHERE id = 'mt1'", [],
|
.query_row(
|
||||||
|r| Ok((r.get(0)?, r.get(1)?))
|
"SELECT title, alt FROM media_translations WHERE id = 'mt1'",
|
||||||
).unwrap();
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(title.as_deref(), Some("Sonnenuntergang"));
|
assert_eq!(title.as_deref(), Some("Sonnenuntergang"));
|
||||||
assert_eq!(alt.as_deref(), Some("Ein Sonnenuntergang"));
|
assert_eq!(alt.as_deref(), Some("Ein Sonnenuntergang"));
|
||||||
}
|
}
|
||||||
@@ -425,10 +490,13 @@ mod tests {
|
|||||||
[],
|
[],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
let (name, color, tpl): (String, Option<String>, Option<String>) = conn.query_row(
|
let (name, color, tpl): (String, Option<String>, Option<String>) = conn
|
||||||
"SELECT name, color, post_template_slug FROM tags WHERE id = 't1'", [],
|
.query_row(
|
||||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
"SELECT name, color, post_template_slug FROM tags WHERE id = 't1'",
|
||||||
).unwrap();
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(name, "rust");
|
assert_eq!(name, "rust");
|
||||||
assert_eq!(color.as_deref(), Some("#ff5733"));
|
assert_eq!(color.as_deref(), Some("#ff5733"));
|
||||||
assert_eq!(tpl.as_deref(), Some("tag-tpl"));
|
assert_eq!(tpl.as_deref(), Some("tag-tpl"));
|
||||||
@@ -444,14 +512,20 @@ mod tests {
|
|||||||
[],
|
[],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
let (kind, ver, status, content): (String, i32, String, Option<String>) = conn.query_row(
|
let (kind, ver, status, content): (String, i32, String, Option<String>) = conn
|
||||||
"SELECT kind, version, status, content FROM templates WHERE id = 'tpl1'", [],
|
.query_row(
|
||||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
|
"SELECT kind, version, status, content FROM templates WHERE id = 'tpl1'",
|
||||||
).unwrap();
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(kind, "post");
|
assert_eq!(kind, "post");
|
||||||
assert_eq!(ver, 3);
|
assert_eq!(ver, 3);
|
||||||
assert_eq!(status, "published");
|
assert_eq!(status, "published");
|
||||||
assert!(content.is_none(), "published template content should be null");
|
assert!(
|
||||||
|
content.is_none(),
|
||||||
|
"published template content should be null"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -464,10 +538,13 @@ mod tests {
|
|||||||
[],
|
[],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
let (kind, ep, content): (String, String, Option<String>) = conn.query_row(
|
let (kind, ep, content): (String, String, Option<String>) = conn
|
||||||
"SELECT kind, entrypoint, content FROM scripts WHERE id = 's1'", [],
|
.query_row(
|
||||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
"SELECT kind, entrypoint, content FROM scripts WHERE id = 's1'",
|
||||||
).unwrap();
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(kind, "macro");
|
assert_eq!(kind, "macro");
|
||||||
assert_eq!(ep, "render");
|
assert_eq!(ep, "render");
|
||||||
assert_eq!(content.as_deref(), Some("return html"));
|
assert_eq!(content.as_deref(), Some("return html"));
|
||||||
@@ -483,12 +560,16 @@ mod tests {
|
|||||||
"INSERT INTO post_links (id, source_post_id, target_post_id, link_text, created_at)
|
"INSERT INTO post_links (id, source_post_id, target_post_id, link_text, created_at)
|
||||||
VALUES ('pl1', 'post1', 'post2', 'see also', 1000)",
|
VALUES ('pl1', 'post1', 'post2', 'see also', 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let (src, tgt, txt): (String, String, Option<String>) = conn.query_row(
|
let (src, tgt, txt): (String, String, Option<String>) = conn
|
||||||
"SELECT source_post_id, target_post_id, link_text FROM post_links WHERE id = 'pl1'", [],
|
.query_row(
|
||||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
"SELECT source_post_id, target_post_id, link_text FROM post_links WHERE id = 'pl1'",
|
||||||
).unwrap();
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(src, "post1");
|
assert_eq!(src, "post1");
|
||||||
assert_eq!(tgt, "post2");
|
assert_eq!(tgt, "post2");
|
||||||
assert_eq!(txt.as_deref(), Some("see also"));
|
assert_eq!(txt.as_deref(), Some("see also"));
|
||||||
@@ -504,11 +585,16 @@ mod tests {
|
|||||||
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at)
|
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at)
|
||||||
VALUES ('pm1', 'p1', 'post1', 'm1', 5, 1000)",
|
VALUES ('pm1', 'p1', 'post1', 'm1', 5, 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let order: i32 = conn.query_row(
|
let order: i32 = conn
|
||||||
"SELECT sort_order FROM post_media WHERE id = 'pm1'", [], |r| r.get(0)
|
.query_row(
|
||||||
).unwrap();
|
"SELECT sort_order FROM post_media WHERE id = 'pm1'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(order, 5);
|
assert_eq!(order, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,11 +604,14 @@ mod tests {
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO settings (key, value, updated_at) VALUES ('theme', 'dark', 1000)",
|
"INSERT INTO settings (key, value, updated_at) VALUES ('theme', 'dark', 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let val: String = conn.query_row(
|
let val: String = conn
|
||||||
"SELECT value FROM settings WHERE key = 'theme'", [], |r| r.get(0)
|
.query_row("SELECT value FROM settings WHERE key = 'theme'", [], |r| {
|
||||||
).unwrap();
|
r.get(0)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
assert_eq!(val, "dark");
|
assert_eq!(val, "dark");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,11 +639,16 @@ mod tests {
|
|||||||
"INSERT INTO chat_conversations (id, title, model, created_at, updated_at)
|
"INSERT INTO chat_conversations (id, title, model, created_at, updated_at)
|
||||||
VALUES ('c1', 'Test Chat', 'gpt-4', 1000, 1000)",
|
VALUES ('c1', 'Test Chat', 'gpt-4', 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let title: String = conn.query_row(
|
let title: String = conn
|
||||||
"SELECT title FROM chat_conversations WHERE id = 'c1'", [], |r| r.get(0)
|
.query_row(
|
||||||
).unwrap();
|
"SELECT title FROM chat_conversations WHERE id = 'c1'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(title, "Test Chat");
|
assert_eq!(title, "Test Chat");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,28 +659,59 @@ mod tests {
|
|||||||
"INSERT INTO chat_conversations (id, title, created_at, updated_at)
|
"INSERT INTO chat_conversations (id, title, created_at, updated_at)
|
||||||
VALUES ('c1', 'Chat', 1000, 1000)",
|
VALUES ('c1', 'Chat', 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO chat_messages (conversation_id, role, content, created_at)
|
"INSERT INTO chat_messages (conversation_id, role, content, created_at)
|
||||||
VALUES ('c1', 'user', 'Hello', 1000)",
|
VALUES ('c1', 'user', 'Hello', 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let (role, content): (String, Option<String>) = conn.query_row(
|
let (role, content): (String, Option<String>) = conn
|
||||||
"SELECT role, content FROM chat_messages WHERE conversation_id = 'c1'", [],
|
.query_row(
|
||||||
|r| Ok((r.get(0)?, r.get(1)?))
|
"SELECT role, content FROM chat_messages WHERE conversation_id = 'c1'",
|
||||||
).unwrap();
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(role, "user");
|
assert_eq!(role, "user");
|
||||||
assert_eq!(content.as_deref(), Some("Hello"));
|
assert_eq!(content.as_deref(), Some("Hello"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ai_schema_uses_current_usage_and_package_columns() {
|
||||||
|
let conn = setup();
|
||||||
|
let columns = |table: &str| {
|
||||||
|
let mut statement = conn
|
||||||
|
.prepare(&format!("PRAGMA table_info({table})"))
|
||||||
|
.unwrap();
|
||||||
|
statement
|
||||||
|
.query_map([], |row| row.get::<_, String>(1))
|
||||||
|
.unwrap()
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
let message_columns = columns("chat_messages");
|
||||||
|
assert!(message_columns.contains(&"cache_read_tokens".to_string()));
|
||||||
|
assert!(message_columns.contains(&"cache_write_tokens".to_string()));
|
||||||
|
let provider_columns = columns("ai_providers");
|
||||||
|
assert!(provider_columns.contains(&"package_ref".to_string()));
|
||||||
|
assert!(!provider_columns.contains(&"npm".to_string()));
|
||||||
|
let model_columns = columns("ai_models");
|
||||||
|
assert!(model_columns.contains(&"provider_package_ref".to_string()));
|
||||||
|
assert!(!model_columns.contains(&"provider_npm".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn roundtrip_ai_provider_and_model() {
|
fn roundtrip_ai_provider_and_model() {
|
||||||
let conn = setup();
|
let conn = setup();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO ai_providers (id, name, updated_at) VALUES ('openai', 'OpenAI', 1000)",
|
"INSERT INTO ai_providers (id, name, updated_at) VALUES ('openai', 'OpenAI', 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO ai_models (provider, model_id, name, context_window, max_input_tokens, max_output_tokens, updated_at)
|
"INSERT INTO ai_models (provider, model_id, name, context_window, max_input_tokens, max_output_tokens, updated_at)
|
||||||
VALUES ('openai', 'gpt-4', 'GPT-4', 128000, 128000, 4096, 1000)",
|
VALUES ('openai', 'gpt-4', 'GPT-4', 128000, 128000, 4096, 1000)",
|
||||||
@@ -596,12 +721,16 @@ mod tests {
|
|||||||
"INSERT INTO ai_model_modalities (provider, model_id, direction, modality)
|
"INSERT INTO ai_model_modalities (provider, model_id, direction, modality)
|
||||||
VALUES ('openai', 'gpt-4', 'input', 'text')",
|
VALUES ('openai', 'gpt-4', 'input', 'text')",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let name: String = conn.query_row(
|
let name: String = conn
|
||||||
|
.query_row(
|
||||||
"SELECT name FROM ai_models WHERE provider = 'openai' AND model_id = 'gpt-4'",
|
"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");
|
assert_eq!(name, "GPT-4");
|
||||||
|
|
||||||
let modality: String = conn.query_row(
|
let modality: String = conn.query_row(
|
||||||
@@ -617,11 +746,16 @@ mod tests {
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO ai_catalog_meta (key, value) VALUES ('etag', 'abc')",
|
"INSERT INTO ai_catalog_meta (key, value) VALUES ('etag', 'abc')",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let val: String = conn.query_row(
|
let val: String = conn
|
||||||
"SELECT value FROM ai_catalog_meta WHERE key = 'etag'", [], |r| r.get(0)
|
.query_row(
|
||||||
).unwrap();
|
"SELECT value FROM ai_catalog_meta WHERE key = 'etag'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(val, "abc");
|
assert_eq!(val, "abc");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,11 +766,16 @@ mod tests {
|
|||||||
"INSERT INTO embedding_keys (label, post_id, project_id, content_hash, vector)
|
"INSERT INTO embedding_keys (label, post_id, project_id, content_hash, vector)
|
||||||
VALUES (1, 'post1', 'p1', 'hash1', 'base64vector')",
|
VALUES (1, 'post1', 'p1', 'hash1', 'base64vector')",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let vec: String = conn.query_row(
|
let vec: String = conn
|
||||||
"SELECT vector FROM embedding_keys WHERE label = 1", [], |r| r.get(0)
|
.query_row(
|
||||||
).unwrap();
|
"SELECT vector FROM embedding_keys WHERE label = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(vec, "base64vector");
|
assert_eq!(vec, "base64vector");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,10 +789,13 @@ mod tests {
|
|||||||
[],
|
[],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
let count: i64 = conn.query_row(
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
"SELECT COUNT(*) FROM dismissed_duplicate_pairs WHERE project_id = 'p1'",
|
"SELECT COUNT(*) FROM dismissed_duplicate_pairs WHERE project_id = 'p1'",
|
||||||
[], |r| r.get(0)
|
[],
|
||||||
).unwrap();
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(count, 1);
|
assert_eq!(count, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -667,9 +809,13 @@ mod tests {
|
|||||||
[],
|
[],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
let name: String = conn.query_row(
|
let name: String = conn
|
||||||
"SELECT name FROM import_definitions WHERE id = 'i1'", [], |r| r.get(0)
|
.query_row(
|
||||||
).unwrap();
|
"SELECT name FROM import_definitions WHERE id = 'i1'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(name, "WP Import");
|
assert_eq!(name, "WP Import");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,7 +826,8 @@ mod tests {
|
|||||||
"INSERT INTO db_notifications (entity_type, entity_id, action, from_cli, created_at)
|
"INSERT INTO db_notifications (entity_type, entity_id, action, from_cli, created_at)
|
||||||
VALUES ('post', 'post1', 'created', 1, 1000)",
|
VALUES ('post', 'post1', 'created', 1, 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let (etype, action, cli): (String, String, i64) = conn.query_row(
|
let (etype, action, cli): (String, String, i64) = conn.query_row(
|
||||||
"SELECT entity_type, action, from_cli FROM db_notifications WHERE entity_id = 'post1'",
|
"SELECT entity_type, action, from_cli FROM db_notifications WHERE entity_id = 'post1'",
|
||||||
@@ -715,7 +862,8 @@ mod tests {
|
|||||||
"INSERT INTO posts (id, project_id, title, slug, created_at, updated_at)
|
"INSERT INTO posts (id, project_id, title, slug, created_at, updated_at)
|
||||||
VALUES ('post1', 'p1', 'Test', 'test', 1000, 1000)",
|
VALUES ('post1', 'p1', 'Test', 'test', 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let (status, file_path, tags, cats, dnt): (String, String, String, String, i64) = conn.query_row(
|
let (status, file_path, tags, cats, dnt): (String, String, String, String, i64) = conn.query_row(
|
||||||
"SELECT status, file_path, tags, categories, do_not_translate FROM posts WHERE id = 'post1'",
|
"SELECT status, file_path, tags, categories, do_not_translate FROM posts WHERE id = 'post1'",
|
||||||
@@ -736,12 +884,16 @@ mod tests {
|
|||||||
"INSERT INTO templates (id, project_id, slug, title, file_path, created_at, updated_at)
|
"INSERT INTO templates (id, project_id, slug, title, file_path, created_at, updated_at)
|
||||||
VALUES ('tpl1', 'p1', 'test', 'Test', 'templates/test.liquid', 1000, 1000)",
|
VALUES ('tpl1', 'p1', 'test', 'Test', 'templates/test.liquid', 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let (kind, enabled, version, status): (String, i64, i64, String) = conn.query_row(
|
let (kind, enabled, version, status): (String, i64, i64, String) = conn
|
||||||
|
.query_row(
|
||||||
"SELECT kind, enabled, version, status FROM templates WHERE id = 'tpl1'",
|
"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!(kind, "post", "default kind must be 'post'");
|
||||||
assert_eq!(enabled, 1, "default enabled must be 1");
|
assert_eq!(enabled, 1, "default enabled must be 1");
|
||||||
assert_eq!(version, 1, "default version must be 1");
|
assert_eq!(version, 1, "default version must be 1");
|
||||||
@@ -756,12 +908,16 @@ mod tests {
|
|||||||
"INSERT INTO scripts (id, project_id, slug, title, file_path, created_at, updated_at)
|
"INSERT INTO scripts (id, project_id, slug, title, file_path, created_at, updated_at)
|
||||||
VALUES ('s1', 'p1', 'test', 'Test', 'scripts/test.lua', 1000, 1000)",
|
VALUES ('s1', 'p1', 'test', 'Test', 'scripts/test.lua', 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let (kind, ep, enabled, version, status): (String, String, i64, i64, String) = conn.query_row(
|
let (kind, ep, enabled, version, status): (String, String, i64, i64, String) = conn
|
||||||
|
.query_row(
|
||||||
"SELECT kind, entrypoint, enabled, version, status FROM scripts WHERE id = 's1'",
|
"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!(kind, "utility", "default kind must be 'utility'");
|
||||||
assert_eq!(ep, "render", "default entrypoint must be 'render'");
|
assert_eq!(ep, "render", "default entrypoint must be 'render'");
|
||||||
assert_eq!(enabled, 1, "default enabled must be 1");
|
assert_eq!(enabled, 1, "default enabled must be 1");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use rusqlite::{params, Connection};
|
use rusqlite::{Connection, params};
|
||||||
|
|
||||||
use crate::db::from_row::{generated_file_hash_from_row, GENERATED_FILE_HASH_COLUMNS};
|
use crate::db::from_row::{GENERATED_FILE_HASH_COLUMNS, generated_file_hash_from_row};
|
||||||
use crate::model::GeneratedFileHash;
|
use crate::model::GeneratedFileHash;
|
||||||
|
|
||||||
pub fn get_generated_file_hash(
|
pub fn get_generated_file_hash(
|
||||||
@@ -17,13 +17,21 @@ pub fn get_generated_file_hash(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn upsert_generated_file_hash(conn: &Connection, hash: &GeneratedFileHash) -> rusqlite::Result<()> {
|
pub fn upsert_generated_file_hash(
|
||||||
|
conn: &Connection,
|
||||||
|
hash: &GeneratedFileHash,
|
||||||
|
) -> rusqlite::Result<()> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO generated_file_hashes (project_id, relative_path, content_hash, updated_at)
|
"INSERT INTO generated_file_hashes (project_id, relative_path, content_hash, updated_at)
|
||||||
VALUES (?1, ?2, ?3, ?4)
|
VALUES (?1, ?2, ?3, ?4)
|
||||||
ON CONFLICT(project_id, relative_path)
|
ON CONFLICT(project_id, relative_path)
|
||||||
DO UPDATE SET content_hash = excluded.content_hash, updated_at = excluded.updated_at",
|
DO UPDATE SET content_hash = excluded.content_hash, updated_at = excluded.updated_at",
|
||||||
params![hash.project_id, hash.relative_path, hash.content_hash, hash.updated_at],
|
params![
|
||||||
|
hash.project_id,
|
||||||
|
hash.relative_path,
|
||||||
|
hash.content_hash,
|
||||||
|
hash.updated_at
|
||||||
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -54,8 +62,8 @@ pub fn list_generated_file_hashes_by_project(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let mut db = Database::open_in_memory().unwrap();
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use rusqlite::{params, Connection};
|
use rusqlite::{Connection, params};
|
||||||
|
|
||||||
use crate::db::from_row::{media_from_row, MEDIA_COLUMNS};
|
use crate::db::from_row::{MEDIA_COLUMNS, media_from_row};
|
||||||
use crate::model::Media;
|
use crate::model::Media;
|
||||||
|
use crate::util::calendar_range_unix_ms;
|
||||||
|
|
||||||
fn tags_to_json(tags: &[String]) -> String {
|
fn tags_to_json(tags: &[String]) -> String {
|
||||||
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
|
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
|
||||||
@@ -133,9 +134,7 @@ pub struct MediaFilterParams {
|
|||||||
|
|
||||||
impl MediaFilterParams {
|
impl MediaFilterParams {
|
||||||
pub fn has_active_filters(&self) -> bool {
|
pub fn has_active_filters(&self) -> bool {
|
||||||
!self.search_query.is_empty()
|
!self.search_query.is_empty() || self.year.is_some() || !self.tags.is_empty()
|
||||||
|| self.year.is_some()
|
|
||||||
|| !self.tags.is_empty()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,50 +160,14 @@ pub fn list_media_filtered(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(year) = filters.year {
|
if let Some(year) = filters.year {
|
||||||
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1)
|
let (start, end) =
|
||||||
.unwrap()
|
calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?;
|
||||||
.and_hms_opt(0, 0, 0)
|
|
||||||
.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 idx1 = param_values.len() + 1;
|
let idx1 = param_values.len() + 1;
|
||||||
let idx2 = param_values.len() + 2;
|
let idx2 = param_values.len() + 2;
|
||||||
conditions.push(format!("(created_at >= ?{idx1} AND created_at < ?{idx2})"));
|
conditions.push(format!("(created_at >= ?{idx1} AND created_at < ?{idx2})"));
|
||||||
param_values.push(Box::new(start));
|
param_values.push(Box::new(start));
|
||||||
param_values.push(Box::new(end));
|
param_values.push(Box::new(end));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for tag in &filters.tags {
|
for tag in &filters.tags {
|
||||||
let idx = param_values.len() + 1;
|
let idx = param_values.len() + 1;
|
||||||
@@ -244,7 +207,7 @@ pub fn media_calendar_counts(
|
|||||||
FROM media
|
FROM media
|
||||||
WHERE project_id = ?1
|
WHERE project_id = ?1
|
||||||
GROUP BY y, m
|
GROUP BY y, m
|
||||||
ORDER BY y DESC, m DESC"
|
ORDER BY y DESC, m DESC",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map(params![project_id], |row| {
|
let rows = stmt.query_map(params![project_id], |row| {
|
||||||
Ok((
|
Ok((
|
||||||
@@ -257,24 +220,18 @@ pub fn media_calendar_counts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Collect all distinct tag values across media for a project.
|
/// Collect all distinct tag values across media for a project.
|
||||||
pub fn distinct_media_tags(
|
pub fn distinct_media_tags(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<String>> {
|
||||||
conn: &Connection,
|
let mut stmt =
|
||||||
project_id: &str,
|
conn.prepare("SELECT DISTINCT tags FROM media WHERE project_id = ?1 AND tags != '[]'")?;
|
||||||
) -> rusqlite::Result<Vec<String>> {
|
let rows = stmt.query_map(params![project_id], |row| row.get::<_, String>(0))?;
|
||||||
let mut stmt = conn.prepare(
|
|
||||||
"SELECT DISTINCT tags FROM media WHERE project_id = ?1 AND tags != '[]'"
|
|
||||||
)?;
|
|
||||||
let rows = stmt.query_map(params![project_id], |row| {
|
|
||||||
row.get::<_, String>(0)
|
|
||||||
})?;
|
|
||||||
let mut all_tags = std::collections::BTreeSet::new();
|
let mut all_tags = std::collections::BTreeSet::new();
|
||||||
for json_str in rows {
|
for json_str in rows {
|
||||||
if let Ok(json_str) = json_str {
|
if let Ok(json_str) = json_str
|
||||||
if let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str) {
|
&& let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str)
|
||||||
|
{
|
||||||
all_tags.extend(tags);
|
all_tags.extend(tags);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Ok(all_tags.into_iter().collect())
|
Ok(all_tags.into_iter().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,8 +264,8 @@ pub fn make_test_media(id: &str, project_id: &str) -> Media {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let mut db = Database::open_in_memory().unwrap();
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
use rusqlite::{params, Connection};
|
use rusqlite::{Connection, params};
|
||||||
|
|
||||||
use crate::db::from_row::{media_translation_from_row, MEDIA_TRANSLATION_COLUMNS};
|
use crate::db::from_row::{MEDIA_TRANSLATION_COLUMNS, media_translation_from_row};
|
||||||
use crate::model::MediaTranslation;
|
use crate::model::MediaTranslation;
|
||||||
|
|
||||||
pub fn insert_media_translation(
|
pub fn insert_media_translation(conn: &Connection, t: &MediaTranslation) -> rusqlite::Result<()> {
|
||||||
conn: &Connection,
|
|
||||||
t: &MediaTranslation,
|
|
||||||
) -> rusqlite::Result<()> {
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO media_translations (
|
"INSERT INTO media_translations (
|
||||||
id, project_id, translation_for, language, title, alt, caption,
|
id, project_id, translation_for, language, title, alt, caption,
|
||||||
@@ -54,10 +51,7 @@ pub fn list_media_translations_by_media(
|
|||||||
rows.collect()
|
rows.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn upsert_media_translation(
|
pub fn upsert_media_translation(conn: &Connection, t: &MediaTranslation) -> rusqlite::Result<()> {
|
||||||
conn: &Connection,
|
|
||||||
t: &MediaTranslation,
|
|
||||||
) -> rusqlite::Result<()> {
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO media_translations (
|
"INSERT INTO media_translations (
|
||||||
id, project_id, translation_for, language, title, alt, caption,
|
id, project_id, translation_for, language, title, alt, caption,
|
||||||
@@ -98,9 +92,9 @@ pub fn delete_media_translation(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::db::Database;
|
||||||
use crate::db::queries::media::{insert_media, make_test_media};
|
use crate::db::queries::media::{insert_media, make_test_media};
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
use crate::db::Database;
|
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let mut db = Database::open_in_memory().unwrap();
|
||||||
@@ -164,8 +158,6 @@ mod tests {
|
|||||||
let db = setup();
|
let db = setup();
|
||||||
insert_media_translation(db.conn(), &make_mt("mt1", "de")).unwrap();
|
insert_media_translation(db.conn(), &make_mt("mt1", "de")).unwrap();
|
||||||
delete_media_translation(db.conn(), "m1", "de").unwrap();
|
delete_media_translation(db.conn(), "m1", "de").unwrap();
|
||||||
assert!(
|
assert!(get_media_translation_by_media_and_language(db.conn(), "m1", "de").is_err());
|
||||||
get_media_translation_by_media_and_language(db.conn(), "m1", "de").is_err()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
pub mod project;
|
pub mod generated_file_hash;
|
||||||
pub mod post;
|
|
||||||
pub mod post_translation;
|
|
||||||
pub mod media;
|
pub mod media;
|
||||||
pub mod media_translation;
|
pub mod media_translation;
|
||||||
pub mod tag;
|
pub mod post;
|
||||||
pub mod post_link;
|
pub mod post_link;
|
||||||
pub mod post_media;
|
pub mod post_media;
|
||||||
pub mod template;
|
pub mod post_translation;
|
||||||
|
pub mod project;
|
||||||
pub mod script;
|
pub mod script;
|
||||||
pub mod setting;
|
pub mod setting;
|
||||||
pub mod generated_file_hash;
|
pub mod tag;
|
||||||
|
pub mod template;
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
use rusqlite::{params, Connection};
|
use rusqlite::{Connection, params};
|
||||||
|
|
||||||
use crate::db::from_row::{post_from_row, post_status_to_str, POST_COLUMNS};
|
use crate::db::from_row::{POST_COLUMNS, post_from_row, post_status_to_str};
|
||||||
use crate::model::{Post, PostStatus};
|
use crate::model::{Post, PostStatus};
|
||||||
|
use crate::util::calendar_range_unix_ms;
|
||||||
|
|
||||||
fn tags_to_json(tags: &[String]) -> String {
|
fn tags_to_json(tags: &[String]) -> String {
|
||||||
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
|
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
|
||||||
@@ -165,6 +166,10 @@ pub fn set_post_file_path(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_arguments,
|
||||||
|
reason = "arguments mirror the published snapshot columns"
|
||||||
|
)]
|
||||||
pub fn set_published_snapshot(
|
pub fn set_published_snapshot(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
id: &str,
|
id: &str,
|
||||||
@@ -182,7 +187,16 @@ pub fn set_published_snapshot(
|
|||||||
published_categories = ?4, published_excerpt = ?5,
|
published_categories = ?4, published_excerpt = ?5,
|
||||||
published_at = ?6, updated_at = ?7
|
published_at = ?6, updated_at = ?7
|
||||||
WHERE id = ?8",
|
WHERE id = ?8",
|
||||||
params![title, content, tags, categories, excerpt, published_at, updated_at, id],
|
params![
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
tags,
|
||||||
|
categories,
|
||||||
|
excerpt,
|
||||||
|
published_at,
|
||||||
|
updated_at,
|
||||||
|
id
|
||||||
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -295,51 +309,16 @@ pub fn list_posts_filtered(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(year) = filters.year {
|
if let Some(year) = filters.year {
|
||||||
// created_at is unix ms; compute year range
|
let (start, end) =
|
||||||
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1)
|
calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?;
|
||||||
.unwrap()
|
|
||||||
.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 idx1 = param_values.len() + 1;
|
||||||
let idx2 = param_values.len() + 2;
|
let idx2 = param_values.len() + 2;
|
||||||
filter_conditions.push(format!("(p.created_at >= ?{idx1} AND p.created_at < ?{idx2})"));
|
filter_conditions.push(format!(
|
||||||
param_values.push(Box::new(m_start));
|
"(p.created_at >= ?{idx1} AND p.created_at < ?{idx2})"
|
||||||
param_values.push(Box::new(next_month));
|
));
|
||||||
} else {
|
|
||||||
let idx1 = param_values.len() + 1;
|
|
||||||
let idx2 = param_values.len() + 2;
|
|
||||||
filter_conditions.push(format!("(p.created_at >= ?{idx1} AND p.created_at < ?{idx2})"));
|
|
||||||
param_values.push(Box::new(start));
|
param_values.push(Box::new(start));
|
||||||
param_values.push(Box::new(end));
|
param_values.push(Box::new(end));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for tag in &filters.tags {
|
for tag in &filters.tags {
|
||||||
let idx = param_values.len() + 1;
|
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.
|
/// Collect all distinct tag values across posts for a project.
|
||||||
pub fn distinct_post_tags(
|
pub fn distinct_post_tags(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<String>> {
|
||||||
conn: &Connection,
|
let mut stmt =
|
||||||
project_id: &str,
|
conn.prepare("SELECT DISTINCT tags FROM posts WHERE project_id = ?1 AND tags != '[]'")?;
|
||||||
) -> rusqlite::Result<Vec<String>> {
|
let rows = stmt.query_map(params![project_id], |row| row.get::<_, String>(0))?;
|
||||||
let mut stmt = conn.prepare(
|
|
||||||
"SELECT DISTINCT tags FROM posts WHERE project_id = ?1 AND tags != '[]'"
|
|
||||||
)?;
|
|
||||||
let rows = stmt.query_map(params![project_id], |row| {
|
|
||||||
row.get::<_, String>(0)
|
|
||||||
})?;
|
|
||||||
let mut all_tags = std::collections::BTreeSet::new();
|
let mut all_tags = std::collections::BTreeSet::new();
|
||||||
for json_str in rows {
|
for json_str in rows {
|
||||||
if let Ok(json_str) = json_str {
|
if let Ok(json_str) = json_str
|
||||||
if let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str) {
|
&& let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str)
|
||||||
|
{
|
||||||
all_tags.extend(tags);
|
all_tags.extend(tags);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Ok(all_tags.into_iter().collect())
|
Ok(all_tags.into_iter().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,27 +435,25 @@ pub fn distinct_post_categories(
|
|||||||
project_id: &str,
|
project_id: &str,
|
||||||
) -> rusqlite::Result<Vec<String>> {
|
) -> rusqlite::Result<Vec<String>> {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT DISTINCT categories FROM posts WHERE project_id = ?1 AND categories != '[]'"
|
"SELECT DISTINCT categories FROM posts WHERE project_id = ?1 AND categories != '[]'",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map(params![project_id], |row| {
|
let rows = stmt.query_map(params![project_id], |row| row.get::<_, String>(0))?;
|
||||||
row.get::<_, String>(0)
|
|
||||||
})?;
|
|
||||||
let mut all_cats = std::collections::BTreeSet::new();
|
let mut all_cats = std::collections::BTreeSet::new();
|
||||||
for json_str in rows {
|
for json_str in rows {
|
||||||
if let Ok(json_str) = json_str {
|
if let Ok(json_str) = json_str
|
||||||
if let Ok(cats) = serde_json::from_str::<Vec<String>>(&json_str) {
|
&& let Ok(cats) = serde_json::from_str::<Vec<String>>(&json_str)
|
||||||
|
{
|
||||||
all_cats.extend(cats);
|
all_cats.extend(cats);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Ok(all_cats.into_iter().collect())
|
Ok(all_cats.into_iter().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let mut db = Database::open_in_memory().unwrap();
|
||||||
@@ -603,9 +574,17 @@ mod tests {
|
|||||||
let db = setup();
|
let db = setup();
|
||||||
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
||||||
set_published_snapshot(
|
set_published_snapshot(
|
||||||
db.conn(), "x1", "Pub Title", "Pub Body",
|
db.conn(),
|
||||||
"[\"rust\"]", "[\"tech\"]", Some("Pub Excerpt"), 3000, 3000,
|
"x1",
|
||||||
).unwrap();
|
"Pub Title",
|
||||||
|
"Pub Body",
|
||||||
|
"[\"rust\"]",
|
||||||
|
"[\"tech\"]",
|
||||||
|
Some("Pub Excerpt"),
|
||||||
|
3000,
|
||||||
|
3000,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
|
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
|
||||||
assert_eq!(fetched.published_title.as_deref(), Some("Pub Title"));
|
assert_eq!(fetched.published_title.as_deref(), Some("Pub Title"));
|
||||||
assert_eq!(fetched.published_content.as_deref(), Some("Pub Body"));
|
assert_eq!(fetched.published_content.as_deref(), Some("Pub Body"));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use rusqlite::{params, Connection};
|
use rusqlite::{Connection, params};
|
||||||
|
|
||||||
use crate::db::from_row::{post_link_from_row, POST_LINK_COLUMNS};
|
use crate::db::from_row::{POST_LINK_COLUMNS, post_link_from_row};
|
||||||
use crate::model::PostLink;
|
use crate::model::PostLink;
|
||||||
|
|
||||||
pub fn insert_post_link(conn: &Connection, link: &PostLink) -> rusqlite::Result<()> {
|
pub fn insert_post_link(conn: &Connection, link: &PostLink) -> rusqlite::Result<()> {
|
||||||
@@ -51,8 +51,8 @@ pub fn list_links_by_target(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let mut db = Database::open_in_memory().unwrap();
|
||||||
@@ -63,17 +63,20 @@ mod tests {
|
|||||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||||
VALUES ('a', 'p1', 'A', 'a', 'draft', 1000, 1000)",
|
VALUES ('a', 'p1', 'A', 'a', 'draft', 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
c.execute(
|
c.execute(
|
||||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||||
VALUES ('b', 'p1', 'B', 'b', 'draft', 1000, 1000)",
|
VALUES ('b', 'p1', 'B', 'b', 'draft', 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
c.execute(
|
c.execute(
|
||||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||||
VALUES ('c', 'p1', 'C', 'c', 'draft', 1000, 1000)",
|
VALUES ('c', 'p1', 'C', 'c', 'draft', 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
db
|
db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use rusqlite::{params, Connection};
|
use rusqlite::{Connection, params};
|
||||||
|
|
||||||
use crate::db::from_row::{post_media_from_row, POST_MEDIA_COLUMNS};
|
use crate::db::from_row::{POST_MEDIA_COLUMNS, post_media_from_row};
|
||||||
use crate::model::PostMedia;
|
use crate::model::PostMedia;
|
||||||
|
|
||||||
pub fn link_media(conn: &Connection, pm: &PostMedia) -> rusqlite::Result<()> {
|
pub fn link_media(conn: &Connection, pm: &PostMedia) -> rusqlite::Result<()> {
|
||||||
@@ -65,9 +65,9 @@ pub fn update_sort_order(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::db::Database;
|
||||||
use crate::db::queries::media::{insert_media, make_test_media};
|
use crate::db::queries::media::{insert_media, make_test_media};
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
use crate::db::Database;
|
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let mut db = Database::open_in_memory().unwrap();
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
use rusqlite::{params, Connection};
|
use rusqlite::{Connection, params};
|
||||||
|
|
||||||
use crate::db::from_row::{post_status_to_str, post_translation_from_row, POST_TRANSLATION_COLUMNS};
|
use crate::db::from_row::{
|
||||||
|
POST_TRANSLATION_COLUMNS, post_status_to_str, post_translation_from_row,
|
||||||
|
};
|
||||||
use crate::model::PostTranslation;
|
use crate::model::PostTranslation;
|
||||||
|
|
||||||
pub fn insert_post_translation(
|
pub fn insert_post_translation(conn: &Connection, t: &PostTranslation) -> rusqlite::Result<()> {
|
||||||
conn: &Connection,
|
|
||||||
t: &PostTranslation,
|
|
||||||
) -> rusqlite::Result<()> {
|
|
||||||
if !t.status.is_valid_for_translation() {
|
if !t.status.is_valid_for_translation() {
|
||||||
return Err(rusqlite::Error::InvalidParameterName(
|
return Err(rusqlite::Error::InvalidParameterName(
|
||||||
"translation status must be draft or published".to_string(),
|
"translation status must be draft or published".to_string(),
|
||||||
@@ -74,10 +73,7 @@ pub fn list_post_translations_by_post(
|
|||||||
rows.collect()
|
rows.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_post_translation(
|
pub fn update_post_translation(conn: &Connection, t: &PostTranslation) -> rusqlite::Result<()> {
|
||||||
conn: &Connection,
|
|
||||||
t: &PostTranslation,
|
|
||||||
) -> rusqlite::Result<()> {
|
|
||||||
if !t.status.is_valid_for_translation() {
|
if !t.status.is_valid_for_translation() {
|
||||||
return Err(rusqlite::Error::InvalidParameterName(
|
return Err(rusqlite::Error::InvalidParameterName(
|
||||||
"translation status must be draft or published".to_string(),
|
"translation status must be draft or published".to_string(),
|
||||||
@@ -104,10 +100,7 @@ pub fn update_post_translation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_post_translation(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
pub fn delete_post_translation(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||||
conn.execute(
|
conn.execute("DELETE FROM post_translations WHERE id = ?1", params![id])?;
|
||||||
"DELETE FROM post_translations WHERE id = ?1",
|
|
||||||
params![id],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,8 +118,8 @@ pub fn delete_all_translations_for_post(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
use crate::model::PostStatus;
|
use crate::model::PostStatus;
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use rusqlite::{params, Connection};
|
use rusqlite::{Connection, params};
|
||||||
|
|
||||||
use crate::db::from_row::{project_from_row, PROJECT_COLUMNS};
|
use crate::db::from_row::{PROJECT_COLUMNS, project_from_row};
|
||||||
use crate::model::Project;
|
use crate::model::Project;
|
||||||
|
|
||||||
pub fn insert_project(conn: &Connection, project: &Project) -> rusqlite::Result<()> {
|
pub fn insert_project(conn: &Connection, project: &Project) -> rusqlite::Result<()> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use rusqlite::{params, Connection};
|
use rusqlite::{Connection, params};
|
||||||
|
|
||||||
use crate::db::from_row::{
|
use crate::db::from_row::{
|
||||||
script_from_row, script_kind_to_str, script_status_to_str, SCRIPT_COLUMNS,
|
SCRIPT_COLUMNS, script_from_row, script_kind_to_str, script_status_to_str,
|
||||||
};
|
};
|
||||||
use crate::model::Script;
|
use crate::model::Script;
|
||||||
|
|
||||||
@@ -44,9 +44,7 @@ pub fn get_script_by_slug(
|
|||||||
slug: &str,
|
slug: &str,
|
||||||
) -> rusqlite::Result<Script> {
|
) -> rusqlite::Result<Script> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
&format!(
|
&format!("SELECT {SCRIPT_COLUMNS} FROM scripts WHERE project_id = ?1 AND slug = ?2"),
|
||||||
"SELECT {SCRIPT_COLUMNS} FROM scripts WHERE project_id = ?1 AND slug = ?2"
|
|
||||||
),
|
|
||||||
params![project_id, slug],
|
params![project_id, slug],
|
||||||
script_from_row,
|
script_from_row,
|
||||||
)
|
)
|
||||||
@@ -94,8 +92,8 @@ pub fn delete_script(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
use crate::model::{ScriptKind, ScriptStatus};
|
use crate::model::{ScriptKind, ScriptStatus};
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use rusqlite::{params, Connection};
|
use rusqlite::{Connection, params};
|
||||||
|
|
||||||
use crate::db::from_row::{setting_from_row, SETTING_COLUMNS};
|
use crate::db::from_row::{SETTING_COLUMNS, setting_from_row};
|
||||||
use crate::model::Setting;
|
use crate::model::Setting;
|
||||||
|
|
||||||
pub fn get_setting_by_key(conn: &Connection, key: &str) -> rusqlite::Result<Setting> {
|
pub fn get_setting_by_key(conn: &Connection, key: &str) -> rusqlite::Result<Setting> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use rusqlite::{params, Connection};
|
use rusqlite::{Connection, params};
|
||||||
|
|
||||||
use crate::db::from_row::{tag_from_row, TAG_COLUMNS};
|
use crate::db::from_row::{TAG_COLUMNS, tag_from_row};
|
||||||
use crate::model::Tag;
|
use crate::model::Tag;
|
||||||
|
|
||||||
pub fn insert_tag(conn: &Connection, tag: &Tag) -> rusqlite::Result<()> {
|
pub fn insert_tag(conn: &Connection, tag: &Tag) -> rusqlite::Result<()> {
|
||||||
@@ -73,8 +73,8 @@ pub fn delete_tag(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let mut db = Database::open_in_memory().unwrap();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use rusqlite::{params, Connection};
|
use rusqlite::{Connection, params};
|
||||||
|
|
||||||
use crate::db::from_row::{
|
use crate::db::from_row::{
|
||||||
template_from_row, template_kind_to_str, template_status_to_str, TEMPLATE_COLUMNS,
|
TEMPLATE_COLUMNS, template_from_row, template_kind_to_str, template_status_to_str,
|
||||||
};
|
};
|
||||||
use crate::model::Template;
|
use crate::model::Template;
|
||||||
|
|
||||||
@@ -43,9 +43,7 @@ pub fn get_template_by_slug(
|
|||||||
slug: &str,
|
slug: &str,
|
||||||
) -> rusqlite::Result<Template> {
|
) -> rusqlite::Result<Template> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
&format!(
|
&format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE project_id = ?1 AND slug = ?2"),
|
||||||
"SELECT {TEMPLATE_COLUMNS} FROM templates WHERE project_id = ?1 AND slug = ?2"
|
|
||||||
),
|
|
||||||
params![project_id, slug],
|
params![project_id, slug],
|
||||||
template_from_row,
|
template_from_row,
|
||||||
)
|
)
|
||||||
@@ -92,8 +90,8 @@ pub fn delete_template(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
use crate::model::{TemplateKind, TemplateStatus};
|
use crate::model::{TemplateKind, TemplateStatus};
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use keyring::Entry;
|
|||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::db::queries::setting;
|
use crate::db::queries::setting;
|
||||||
use crate::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult};
|
||||||
@@ -167,17 +167,37 @@ pub fn load_ai_settings(conn: &Connection, offline_mode: bool) -> EngineResult<A
|
|||||||
pub fn save_endpoint(conn: &Connection, endpoint: &AiEndpointConfig) -> EngineResult<()> {
|
pub fn save_endpoint(conn: &Connection, endpoint: &AiEndpointConfig) -> EngineResult<()> {
|
||||||
validate_endpoint_config(endpoint)?;
|
validate_endpoint_config(endpoint)?;
|
||||||
let checked_at = now_unix_ms();
|
let checked_at = now_unix_ms();
|
||||||
set_setting(conn, &endpoint_setting_key(endpoint.kind, "url"), endpoint.url.trim(), checked_at)?;
|
set_setting(
|
||||||
set_setting(conn, &endpoint_setting_key(endpoint.kind, "model"), endpoint.model.trim(), checked_at)?;
|
conn,
|
||||||
|
&endpoint_setting_key(endpoint.kind, "url"),
|
||||||
|
endpoint.url.trim(),
|
||||||
|
checked_at,
|
||||||
|
)?;
|
||||||
|
set_setting(
|
||||||
|
conn,
|
||||||
|
&endpoint_setting_key(endpoint.kind, "model"),
|
||||||
|
endpoint.model.trim(),
|
||||||
|
checked_at,
|
||||||
|
)?;
|
||||||
if endpoint.kind == AiEndpointKind::Online {
|
if endpoint.kind == AiEndpointKind::Online {
|
||||||
let entry = endpoint_keyring_entry(endpoint.kind)?;
|
let entry = endpoint_keyring_entry(endpoint.kind)?;
|
||||||
if let Some(api_key) = &endpoint.api_key {
|
if let Some(api_key) = &endpoint.api_key {
|
||||||
if api_key.trim().is_empty() {
|
if api_key.trim().is_empty() {
|
||||||
entry.delete_credential().ok();
|
entry.delete_credential().ok();
|
||||||
set_setting(conn, &endpoint_setting_key(endpoint.kind, "api_key_configured"), "false", checked_at)?;
|
set_setting(
|
||||||
|
conn,
|
||||||
|
&endpoint_setting_key(endpoint.kind, "api_key_configured"),
|
||||||
|
"false",
|
||||||
|
checked_at,
|
||||||
|
)?;
|
||||||
} else {
|
} else {
|
||||||
entry.set_password(api_key.trim()).map_err(keyring_error)?;
|
entry.set_password(api_key.trim()).map_err(keyring_error)?;
|
||||||
set_setting(conn, &endpoint_setting_key(endpoint.kind, "api_key_configured"), "true", checked_at)?;
|
set_setting(
|
||||||
|
conn,
|
||||||
|
&endpoint_setting_key(endpoint.kind, "api_key_configured"),
|
||||||
|
"true",
|
||||||
|
checked_at,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,7 +220,11 @@ pub fn save_model_preferences(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn active_endpoint(conn: &Connection, offline_mode: bool) -> EngineResult<AiEndpointConfig> {
|
pub fn active_endpoint(conn: &Connection, offline_mode: bool) -> EngineResult<AiEndpointConfig> {
|
||||||
let kind = if offline_mode { AiEndpointKind::Airplane } else { AiEndpointKind::Online };
|
let kind = if offline_mode {
|
||||||
|
AiEndpointKind::Airplane
|
||||||
|
} else {
|
||||||
|
AiEndpointKind::Online
|
||||||
|
};
|
||||||
let stored = load_endpoint(conn, kind)?;
|
let stored = load_endpoint(conn, kind)?;
|
||||||
if stored.url.trim().is_empty() {
|
if stored.url.trim().is_empty() {
|
||||||
return Err(EngineError::Validation(format!(
|
return Err(EngineError::Validation(format!(
|
||||||
@@ -243,14 +267,11 @@ pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<Ai
|
|||||||
validate_endpoint_config(endpoint)?;
|
validate_endpoint_config(endpoint)?;
|
||||||
let client = build_http_client()?;
|
let client = build_http_client()?;
|
||||||
let request = client.get(models_url(&endpoint.url));
|
let request = client.get(models_url(&endpoint.url));
|
||||||
let response = with_auth(request, endpoint)
|
let response = with_auth(request, endpoint).send()?.error_for_status()?;
|
||||||
.send()?
|
|
||||||
.error_for_status()?;
|
|
||||||
let body: Value = response.json()?;
|
let body: Value = response.json()?;
|
||||||
let models = body
|
let models = body.get("data").and_then(Value::as_array).ok_or_else(|| {
|
||||||
.get("data")
|
EngineError::Parse("model catalog response missing data array".to_string())
|
||||||
.and_then(Value::as_array)
|
})?;
|
||||||
.ok_or_else(|| EngineError::Parse("model catalog response missing data array".to_string()))?;
|
|
||||||
|
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for model in models {
|
for model in models {
|
||||||
@@ -275,7 +296,11 @@ pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<Ai
|
|||||||
let supports_vision = model
|
let supports_vision = model
|
||||||
.get("modalities")
|
.get("modalities")
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
.map(|modalities| modalities.iter().any(|value| value.as_str() == Some("vision")))
|
.map(|modalities| {
|
||||||
|
modalities
|
||||||
|
.iter()
|
||||||
|
.any(|value| value.as_str() == Some("vision"))
|
||||||
|
})
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
result.push(AiModelInfo {
|
result.push(AiModelInfo {
|
||||||
id,
|
id,
|
||||||
@@ -326,7 +351,12 @@ pub fn run_one_shot(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
let client = build_http_client()?;
|
let client = build_http_client()?;
|
||||||
let response = with_auth(client.post(chat_completions_url(&endpoint.url)).json(&payload), &endpoint)
|
let response = with_auth(
|
||||||
|
client
|
||||||
|
.post(chat_completions_url(&endpoint.url))
|
||||||
|
.json(&payload),
|
||||||
|
&endpoint,
|
||||||
|
)
|
||||||
.send()?
|
.send()?
|
||||||
.error_for_status()?;
|
.error_for_status()?;
|
||||||
let body: Value = response.json()?;
|
let body: Value = response.json()?;
|
||||||
@@ -337,7 +367,9 @@ pub fn run_one_shot(
|
|||||||
.and_then(|choice| choice.get("message"))
|
.and_then(|choice| choice.get("message"))
|
||||||
.and_then(|message| message.get("content"))
|
.and_then(|message| message.get("content"))
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.ok_or_else(|| EngineError::Parse("chat completions response missing message content".to_string()))?;
|
.ok_or_else(|| {
|
||||||
|
EngineError::Parse("chat completions response missing message content".to_string())
|
||||||
|
})?;
|
||||||
parse_one_shot_response(request, content)
|
parse_one_shot_response(request, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,8 +379,10 @@ fn build_http_client() -> EngineResult<Client> {
|
|||||||
|
|
||||||
fn load_endpoint(conn: &Connection, kind: AiEndpointKind) -> EngineResult<StoredAiEndpointConfig> {
|
fn load_endpoint(conn: &Connection, kind: AiEndpointKind) -> EngineResult<StoredAiEndpointConfig> {
|
||||||
let url = get_optional_setting(conn, &endpoint_setting_key(kind, "url"))?.unwrap_or_default();
|
let url = get_optional_setting(conn, &endpoint_setting_key(kind, "url"))?.unwrap_or_default();
|
||||||
let model = get_optional_setting(conn, &endpoint_setting_key(kind, "model"))?.unwrap_or_default();
|
let model =
|
||||||
let api_key_configured = get_optional_setting(conn, &endpoint_setting_key(kind, "api_key_configured"))?
|
get_optional_setting(conn, &endpoint_setting_key(kind, "model"))?.unwrap_or_default();
|
||||||
|
let api_key_configured =
|
||||||
|
get_optional_setting(conn, &endpoint_setting_key(kind, "api_key_configured"))?
|
||||||
.map(|value| value == "true")
|
.map(|value| value == "true")
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
Ok(StoredAiEndpointConfig {
|
Ok(StoredAiEndpointConfig {
|
||||||
@@ -361,48 +395,81 @@ fn load_endpoint(conn: &Connection, kind: AiEndpointKind) -> EngineResult<Stored
|
|||||||
|
|
||||||
fn validate_endpoint_config(endpoint: &AiEndpointConfig) -> EngineResult<()> {
|
fn validate_endpoint_config(endpoint: &AiEndpointConfig) -> EngineResult<()> {
|
||||||
if endpoint.url.trim().is_empty() {
|
if endpoint.url.trim().is_empty() {
|
||||||
return Err(EngineError::Validation("endpoint url is required".to_string()));
|
return Err(EngineError::Validation(
|
||||||
|
"endpoint url is required".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if endpoint.model.trim().is_empty() {
|
if endpoint.model.trim().is_empty() {
|
||||||
return Err(EngineError::Validation("endpoint model is required".to_string()));
|
return Err(EngineError::Validation(
|
||||||
|
"endpoint model is required".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if endpoint.kind == AiEndpointKind::Online
|
if endpoint.kind == AiEndpointKind::Online
|
||||||
&& endpoint.api_key.as_ref().map(|value| value.trim().is_empty()).unwrap_or(true)
|
&& endpoint
|
||||||
|
.api_key
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.trim().is_empty())
|
||||||
|
.unwrap_or(true)
|
||||||
{
|
{
|
||||||
return Err(EngineError::Validation("online endpoint api key is required".to_string()));
|
return Err(EngineError::Validation(
|
||||||
|
"online endpoint api key is required".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn select_model(settings: &AiSettings, endpoint: &AiEndpointConfig, operation: &OneShotOperation) -> EngineResult<String> {
|
fn select_model(
|
||||||
|
settings: &AiSettings,
|
||||||
|
endpoint: &AiEndpointConfig,
|
||||||
|
operation: &OneShotOperation,
|
||||||
|
) -> EngineResult<String> {
|
||||||
let selected = match operation {
|
let selected = match operation {
|
||||||
OneShotOperation::AnalyzeImage => settings.image_model.as_ref(),
|
OneShotOperation::AnalyzeImage => settings.image_model.as_ref(),
|
||||||
OneShotOperation::AnalyzeTaxonomy
|
OneShotOperation::AnalyzeTaxonomy
|
||||||
| OneShotOperation::AnalyzePost
|
| OneShotOperation::AnalyzePost
|
||||||
| OneShotOperation::DetectLanguage
|
| OneShotOperation::DetectLanguage
|
||||||
| OneShotOperation::TranslatePost { .. }
|
| OneShotOperation::TranslatePost { .. }
|
||||||
| OneShotOperation::TranslateMedia { .. } => settings.title_model.as_ref().or(settings.default_model.as_ref()),
|
| OneShotOperation::TranslateMedia { .. } => settings
|
||||||
|
.title_model
|
||||||
|
.as_ref()
|
||||||
|
.or(settings.default_model.as_ref()),
|
||||||
}
|
}
|
||||||
.filter(|model| !model.trim().is_empty())
|
.filter(|model| !model.trim().is_empty())
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| endpoint.model.clone());
|
.unwrap_or_else(|| endpoint.model.clone());
|
||||||
if selected.trim().is_empty() {
|
if selected.trim().is_empty() {
|
||||||
return Err(EngineError::Validation("AI unavailable - configure model in Settings".to_string()));
|
return Err(EngineError::Validation(
|
||||||
|
"AI unavailable - configure model in Settings".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(selected)
|
Ok(selected)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_system_prompt(base_prompt: &str, operation: &OneShotOperation) -> String {
|
fn build_system_prompt(base_prompt: &str, operation: &OneShotOperation) -> String {
|
||||||
let operation_prompt = match operation {
|
let operation_prompt = match operation {
|
||||||
OneShotOperation::AnalyzeTaxonomy => "Return only JSON with tags and categories for the post.",
|
OneShotOperation::AnalyzeTaxonomy => {
|
||||||
OneShotOperation::AnalyzePost => "Return only JSON with title, excerpt, and slug suggestions for the post.",
|
"Return only JSON with tags and categories for the post."
|
||||||
|
}
|
||||||
|
OneShotOperation::AnalyzePost => {
|
||||||
|
"Return only JSON with title, excerpt, and slug suggestions for the post."
|
||||||
|
}
|
||||||
OneShotOperation::DetectLanguage => "Return only JSON with the detected language_code.",
|
OneShotOperation::DetectLanguage => "Return only JSON with the detected language_code.",
|
||||||
OneShotOperation::TranslatePost { target_language } => {
|
OneShotOperation::TranslatePost { target_language } => {
|
||||||
return format!("{} Translate the post into {} and return only JSON with title, excerpt, and content.", base_prompt.trim(), target_language);
|
return format!(
|
||||||
|
"{} Translate the post into {} and return only JSON with title, excerpt, and content.",
|
||||||
|
base_prompt.trim(),
|
||||||
|
target_language
|
||||||
|
);
|
||||||
|
}
|
||||||
|
OneShotOperation::AnalyzeImage => {
|
||||||
|
"Return only JSON with title, alt, and caption suggestions for the image."
|
||||||
}
|
}
|
||||||
OneShotOperation::AnalyzeImage => "Return only JSON with title, alt, and caption suggestions for the image.",
|
|
||||||
OneShotOperation::TranslateMedia { target_language } => {
|
OneShotOperation::TranslateMedia { target_language } => {
|
||||||
return format!("{} Translate the media metadata into {} and return only JSON with title, alt, and caption.", base_prompt.trim(), target_language);
|
return format!(
|
||||||
|
"{} Translate the media metadata into {} and return only JSON with title, alt, and caption.",
|
||||||
|
base_prompt.trim(),
|
||||||
|
target_language
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if base_prompt.trim().is_empty() {
|
if base_prompt.trim().is_empty() {
|
||||||
@@ -417,26 +484,31 @@ fn build_one_shot_user_content(request: &OneShotRequest) -> EngineResult<Value>
|
|||||||
OneShotOperation::AnalyzeTaxonomy => Ok(format!(
|
OneShotOperation::AnalyzeTaxonomy => Ok(format!(
|
||||||
"Suggest tags and categories for this post: {}",
|
"Suggest tags and categories for this post: {}",
|
||||||
serde_json::to_string(&request.content)?
|
serde_json::to_string(&request.content)?
|
||||||
).into()),
|
)
|
||||||
|
.into()),
|
||||||
OneShotOperation::AnalyzePost => Ok(format!(
|
OneShotOperation::AnalyzePost => Ok(format!(
|
||||||
"Analyze this post and suggest title, excerpt, and slug: {}",
|
"Analyze this post and suggest title, excerpt, and slug: {}",
|
||||||
serde_json::to_string(&request.content)?
|
serde_json::to_string(&request.content)?
|
||||||
).into()),
|
)
|
||||||
|
.into()),
|
||||||
OneShotOperation::DetectLanguage => Ok(format!(
|
OneShotOperation::DetectLanguage => Ok(format!(
|
||||||
"Detect the language of this text: {}",
|
"Detect the language of this text: {}",
|
||||||
serde_json::to_string(&request.content)?
|
serde_json::to_string(&request.content)?
|
||||||
).into()),
|
)
|
||||||
|
.into()),
|
||||||
OneShotOperation::TranslatePost { target_language } => Ok(format!(
|
OneShotOperation::TranslatePost { target_language } => Ok(format!(
|
||||||
"Translate this post to {}: {}",
|
"Translate this post to {}: {}",
|
||||||
target_language,
|
target_language,
|
||||||
serde_json::to_string(&request.content)?
|
serde_json::to_string(&request.content)?
|
||||||
).into()),
|
)
|
||||||
|
.into()),
|
||||||
OneShotOperation::AnalyzeImage => build_image_analysis_user_content(&request.content),
|
OneShotOperation::AnalyzeImage => build_image_analysis_user_content(&request.content),
|
||||||
OneShotOperation::TranslateMedia { target_language } => Ok(format!(
|
OneShotOperation::TranslateMedia { target_language } => Ok(format!(
|
||||||
"Translate this media metadata to {}: {}",
|
"Translate this media metadata to {}: {}",
|
||||||
target_language,
|
target_language,
|
||||||
serde_json::to_string(&request.content)?
|
serde_json::to_string(&request.content)?
|
||||||
).into()),
|
)
|
||||||
|
.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,14 +621,29 @@ fn response_schema(operation: &OneShotOperation) -> (&'static str, Value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_one_shot_response(request: &OneShotRequest, content: &str) -> EngineResult<OneShotResponse> {
|
fn parse_one_shot_response(
|
||||||
|
request: &OneShotRequest,
|
||||||
|
content: &str,
|
||||||
|
) -> EngineResult<OneShotResponse> {
|
||||||
Ok(match request.operation {
|
Ok(match request.operation {
|
||||||
OneShotOperation::AnalyzeTaxonomy => OneShotResponse::Taxonomy(serde_json::from_str(content)?),
|
OneShotOperation::AnalyzeTaxonomy => {
|
||||||
OneShotOperation::AnalyzePost => OneShotResponse::PostAnalysis(serde_json::from_str(content)?),
|
OneShotResponse::Taxonomy(serde_json::from_str(content)?)
|
||||||
OneShotOperation::DetectLanguage => OneShotResponse::LanguageDetection(serde_json::from_str(content)?),
|
}
|
||||||
OneShotOperation::TranslatePost { .. } => OneShotResponse::Translation(serde_json::from_str(content)?),
|
OneShotOperation::AnalyzePost => {
|
||||||
OneShotOperation::AnalyzeImage => OneShotResponse::ImageAnalysis(serde_json::from_str(content)?),
|
OneShotResponse::PostAnalysis(serde_json::from_str(content)?)
|
||||||
OneShotOperation::TranslateMedia { .. } => OneShotResponse::MediaTranslation(serde_json::from_str(content)?),
|
}
|
||||||
|
OneShotOperation::DetectLanguage => {
|
||||||
|
OneShotResponse::LanguageDetection(serde_json::from_str(content)?)
|
||||||
|
}
|
||||||
|
OneShotOperation::TranslatePost { .. } => {
|
||||||
|
OneShotResponse::Translation(serde_json::from_str(content)?)
|
||||||
|
}
|
||||||
|
OneShotOperation::AnalyzeImage => {
|
||||||
|
OneShotResponse::ImageAnalysis(serde_json::from_str(content)?)
|
||||||
|
}
|
||||||
|
OneShotOperation::TranslateMedia { .. } => {
|
||||||
|
OneShotResponse::MediaTranslation(serde_json::from_str(content)?)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,7 +652,11 @@ fn endpoint_setting_key(kind: AiEndpointKind, suffix: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn endpoint_keyring_entry(kind: AiEndpointKind) -> EngineResult<Entry> {
|
fn endpoint_keyring_entry(kind: AiEndpointKind) -> EngineResult<Entry> {
|
||||||
Entry::new(KEYRING_SERVICE, &format!("{}.{}", KEYRING_SETTING_PREFIX, kind.as_str())).map_err(keyring_error)
|
Entry::new(
|
||||||
|
KEYRING_SERVICE,
|
||||||
|
&format!("{}.{}", KEYRING_SETTING_PREFIX, kind.as_str()),
|
||||||
|
)
|
||||||
|
.map_err(keyring_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn keyring_error(error: keyring::Error) -> EngineError {
|
fn keyring_error(error: keyring::Error) -> EngineError {
|
||||||
@@ -577,7 +668,12 @@ fn set_setting(conn: &Connection, key: &str, value: &str, updated_at: i64) -> En
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_optional_setting(conn: &Connection, key: &str, value: Option<&str>, updated_at: i64) -> EngineResult<()> {
|
fn set_optional_setting(
|
||||||
|
conn: &Connection,
|
||||||
|
key: &str,
|
||||||
|
value: Option<&str>,
|
||||||
|
updated_at: i64,
|
||||||
|
) -> EngineResult<()> {
|
||||||
set_setting(conn, key, value.unwrap_or(""), updated_at)
|
set_setting(conn, key, value.unwrap_or(""), updated_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,12 +787,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn refresh_model_catalog_parses_openai_models_shape() {
|
fn refresh_model_catalog_parses_openai_models_shape() {
|
||||||
let server = spawn_test_server(|request| {
|
let server = spawn_test_server(
|
||||||
|
|request| {
|
||||||
assert!(request.starts_with("GET /v1/models HTTP/1.1"));
|
assert!(request.starts_with("GET /v1/models HTTP/1.1"));
|
||||||
http_ok(
|
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"]}]}"#,
|
r#"{"data":[{"id":"gpt-4.1-mini","name":"GPT 4.1 mini","context_window":128000,"max_output_tokens":8192,"modalities":["text"]},{"id":"gpt-4.1","modalities":["text","vision"]}]}"#,
|
||||||
)
|
)
|
||||||
}, 1);
|
},
|
||||||
|
1,
|
||||||
|
);
|
||||||
let models = refresh_model_catalog(&AiEndpointConfig {
|
let models = refresh_model_catalog(&AiEndpointConfig {
|
||||||
kind: AiEndpointKind::Airplane,
|
kind: AiEndpointKind::Airplane,
|
||||||
url: server,
|
url: server,
|
||||||
@@ -713,16 +812,22 @@ mod tests {
|
|||||||
#[ignore = "touches system keychain; run explicitly when validating keychain integration"]
|
#[ignore = "touches system keychain; run explicitly when validating keychain integration"]
|
||||||
fn run_one_shot_uses_active_endpoint_and_parses_response() {
|
fn run_one_shot_uses_active_endpoint_and_parses_response() {
|
||||||
clear_keyring(AiEndpointKind::Online);
|
clear_keyring(AiEndpointKind::Online);
|
||||||
let server = spawn_test_server(|request| {
|
let server = spawn_test_server(
|
||||||
|
|request| {
|
||||||
if request.starts_with("GET /v1/models HTTP/1.1") {
|
if request.starts_with("GET /v1/models HTTP/1.1") {
|
||||||
return http_ok(r#"{"data":[{"id":"gpt-4.1-mini"}]}"#);
|
return http_ok(r#"{"data":[{"id":"gpt-4.1-mini"}]}"#);
|
||||||
}
|
}
|
||||||
assert!(request.starts_with("POST /v1/chat/completions HTTP/1.1"));
|
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(
|
http_ok(
|
||||||
r#"{"choices":[{"message":{"content":"{\"title\":\"Better title\",\"excerpt\":\"Short summary\",\"slug\":\"better-title\"}"}}]}"#,
|
r#"{"choices":[{"message":{"content":"{\"title\":\"Better title\",\"excerpt\":\"Short summary\",\"slug\":\"better-title\"}"}}]}"#,
|
||||||
)
|
)
|
||||||
}, 1);
|
},
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
let db = setup();
|
let db = setup();
|
||||||
save_endpoint(
|
save_endpoint(
|
||||||
@@ -769,9 +874,17 @@ mod tests {
|
|||||||
let parts = content.as_array().unwrap();
|
let parts = content.as_array().unwrap();
|
||||||
assert_eq!(parts.len(), 2);
|
assert_eq!(parts.len(), 2);
|
||||||
assert_eq!(parts[0]["type"], "text");
|
assert_eq!(parts[0]["type"], "text");
|
||||||
assert!(parts[0]["text"].as_str().unwrap().contains("Existing title"));
|
assert!(
|
||||||
|
parts[0]["text"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("Existing title")
|
||||||
|
);
|
||||||
assert_eq!(parts[1]["type"], "image_url");
|
assert_eq!(parts[1]["type"], "image_url");
|
||||||
assert_eq!(parts[1]["image_url"]["url"], "data:image/jpeg;base64,abc123");
|
assert_eq!(
|
||||||
|
parts[1]["image_url"]["url"],
|
||||||
|
"data:image/jpeg;base64,abc123"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -940,7 +1053,10 @@ mod tests {
|
|||||||
run_one_shot(db.conn(), true, &request).unwrap()
|
run_one_shot(db.conn(), true, &request).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_test_server(handler: impl Fn(String) -> String + Send + 'static, request_count: usize) -> String {
|
fn spawn_test_server(
|
||||||
|
handler: impl Fn(String) -> String + Send + 'static,
|
||||||
|
request_count: usize,
|
||||||
|
) -> String {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
let addr = listener.local_addr().unwrap();
|
let addr = listener.local_addr().unwrap();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
|
|||||||
@@ -69,18 +69,17 @@ mod tests {
|
|||||||
let _ = db.migrate();
|
let _ = db.migrate();
|
||||||
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let p = engine::project::create_project(
|
let p =
|
||||||
db.conn(), "Test", Some(tmp.path().to_str().unwrap()),
|
engine::project::create_project(db.conn(), "Test", Some(tmp.path().to_str().unwrap()))
|
||||||
).unwrap();
|
.unwrap();
|
||||||
|
|
||||||
regenerate_calendar(db.conn(), tmp.path(), &p.id).unwrap();
|
regenerate_calendar(db.conn(), tmp.path(), &p.id).unwrap();
|
||||||
|
|
||||||
let cal_path = tmp.path().join("html").join("calendar.json");
|
let cal_path = tmp.path().join("html").join("calendar.json");
|
||||||
assert!(cal_path.exists());
|
assert!(cal_path.exists());
|
||||||
|
|
||||||
let data: serde_json::Value = serde_json::from_str(
|
let data: serde_json::Value =
|
||||||
&std::fs::read_to_string(&cal_path).unwrap()
|
serde_json::from_str(&std::fs::read_to_string(&cal_path).unwrap()).unwrap();
|
||||||
).unwrap();
|
|
||||||
assert!(data["years"].as_object().unwrap().is_empty());
|
assert!(data["years"].as_object().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,10 +72,26 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn display_variants() {
|
fn display_variants() {
|
||||||
assert!(EngineError::Parse("bad yaml".into()).to_string().contains("parse error"));
|
assert!(
|
||||||
assert!(EngineError::NotFound("post 123".into()).to_string().contains("not found"));
|
EngineError::Parse("bad yaml".into())
|
||||||
assert!(EngineError::Conflict("slug taken".into()).to_string().contains("conflict"));
|
.to_string()
|
||||||
assert!(EngineError::Validation("title empty".into()).to_string().contains("validation"));
|
.contains("parse error")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
EngineError::NotFound("post 123".into())
|
||||||
|
.to_string()
|
||||||
|
.contains("not found")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
EngineError::Conflict("slug taken".into())
|
||||||
|
.to_string()
|
||||||
|
.contains("conflict")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
EngineError::Validation("title empty".into())
|
||||||
|
.to_string()
|
||||||
|
.contains("validation")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -24,6 +24,31 @@ pub struct PublishedPostSource {
|
|||||||
pub body_markdown: String,
|
pub body_markdown: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a post has a published snapshot eligible for site generation.
|
||||||
|
pub fn has_published_snapshot(post: &Post) -> bool {
|
||||||
|
matches!(
|
||||||
|
post.status,
|
||||||
|
crate::model::PostStatus::Published | crate::model::PostStatus::Draft
|
||||||
|
) && !post.file_path.trim().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the last-published body from disk, never from draft database content.
|
||||||
|
pub fn load_published_post_source(
|
||||||
|
data_dir: &Path,
|
||||||
|
post: Post,
|
||||||
|
) -> EngineResult<Option<PublishedPostSource>> {
|
||||||
|
if !has_published_snapshot(&post) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let raw = std::fs::read_to_string(data_dir.join(&post.file_path))?;
|
||||||
|
let (_, body_markdown) =
|
||||||
|
crate::util::frontmatter::read_post_file(&raw).map_err(EngineError::Parse)?;
|
||||||
|
Ok(Some(PublishedPostSource {
|
||||||
|
post,
|
||||||
|
body_markdown,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct GenerationReport {
|
pub struct GenerationReport {
|
||||||
pub written_paths: Vec<String>,
|
pub written_paths: Vec<String>,
|
||||||
@@ -56,11 +81,19 @@ pub fn generate_starter_site(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let artifacts = build_site_render_artifacts(conn, &data_dir, project_id, metadata, &input_posts)
|
let artifacts =
|
||||||
|
build_site_render_artifacts(conn, &data_dir, project_id, metadata, &input_posts)
|
||||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
|
|
||||||
for page in &artifacts.pages {
|
for page in &artifacts.pages {
|
||||||
write_out(conn, output_dir, project_id, &page.relative_path, &page.html, &mut report)?;
|
write_out(
|
||||||
|
conn,
|
||||||
|
output_dir,
|
||||||
|
project_id,
|
||||||
|
&page.relative_path,
|
||||||
|
&page.html,
|
||||||
|
&mut report,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
write_bundled_site_assets(conn, output_dir, project_id, &mut report)?;
|
write_bundled_site_assets(conn, output_dir, project_id, &mut report)?;
|
||||||
@@ -70,13 +103,24 @@ pub fn generate_starter_site(
|
|||||||
output_dir,
|
output_dir,
|
||||||
project_id,
|
project_id,
|
||||||
"calendar.json",
|
"calendar.json",
|
||||||
&build_calendar_json(&list_posts.iter().map(|source| source.post.clone()).collect::<Vec<_>>())?,
|
&build_calendar_json(
|
||||||
|
&list_posts
|
||||||
|
.iter()
|
||||||
|
.map(|source| source.post.clone())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)?,
|
||||||
&mut report,
|
&mut report,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
for render_language in render_languages(metadata) {
|
for render_language in render_languages(metadata) {
|
||||||
let localized_posts = localized_sources(conn, &data_dir, &list_posts, &render_language, metadata)?;
|
let localized_posts =
|
||||||
let prefix = if render_language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) {
|
localized_sources(conn, &data_dir, &list_posts, &render_language, metadata)?;
|
||||||
|
let prefix = if render_language
|
||||||
|
== metadata
|
||||||
|
.main_language
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "en".to_string())
|
||||||
|
{
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
format!("{}/", render_language)
|
format!("{}/", render_language)
|
||||||
@@ -85,19 +129,44 @@ pub fn generate_starter_site(
|
|||||||
if prefix.is_empty() {
|
if prefix.is_empty() {
|
||||||
write_out(conn, output_dir, project_id, "rss.xml", &rss, &mut report)?;
|
write_out(conn, output_dir, project_id, "rss.xml", &rss, &mut report)?;
|
||||||
}
|
}
|
||||||
write_out(conn, output_dir, project_id, &format!("{prefix}feed.xml"), &rss, &mut report)?;
|
write_out(
|
||||||
write_out(conn, output_dir, project_id, &format!("{prefix}atom.xml"), &build_atom_xml(metadata, &localized_posts, &render_language), &mut report)?;
|
conn,
|
||||||
|
output_dir,
|
||||||
|
project_id,
|
||||||
|
&format!("{prefix}feed.xml"),
|
||||||
|
&rss,
|
||||||
|
&mut report,
|
||||||
|
)?;
|
||||||
|
write_out(
|
||||||
|
conn,
|
||||||
|
output_dir,
|
||||||
|
project_id,
|
||||||
|
&format!("{prefix}atom.xml"),
|
||||||
|
&build_atom_xml(metadata, &localized_posts, &render_language),
|
||||||
|
&mut report,
|
||||||
|
)?;
|
||||||
write_out(
|
write_out(
|
||||||
conn,
|
conn,
|
||||||
output_dir,
|
output_dir,
|
||||||
project_id,
|
project_id,
|
||||||
&format!("{prefix}sitemap.xml"),
|
&format!("{prefix}sitemap.xml"),
|
||||||
&build_sitemap_xml(metadata, &artifacts.pages, &localized_posts, &render_language),
|
&build_sitemap_xml(
|
||||||
|
metadata,
|
||||||
|
&artifacts.pages,
|
||||||
|
&localized_posts,
|
||||||
|
&render_language,
|
||||||
|
),
|
||||||
&mut report,
|
&mut report,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
write_pagefind_indexes(conn, output_dir, project_id, &artifacts.pagefind_documents, &mut report)?;
|
write_pagefind_indexes(
|
||||||
|
conn,
|
||||||
|
output_dir,
|
||||||
|
project_id,
|
||||||
|
&artifacts.pagefind_documents,
|
||||||
|
&mut report,
|
||||||
|
)?;
|
||||||
|
|
||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
@@ -151,20 +220,22 @@ pub fn apply_validation_sections(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let artifacts = build_site_render_artifacts(
|
let artifacts =
|
||||||
conn,
|
build_site_render_artifacts(conn, &data_dir, project_id, metadata, &input_posts)
|
||||||
&data_dir,
|
|
||||||
project_id,
|
|
||||||
metadata,
|
|
||||||
&input_posts,
|
|
||||||
)
|
|
||||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
let mut report = GenerationReport::default();
|
let mut report = GenerationReport::default();
|
||||||
let expected_paths = expected_paths_for_sections(metadata, &artifacts.pages, §ion_set);
|
let expected_paths = expected_paths_for_sections(metadata, &artifacts.pages, §ion_set);
|
||||||
|
|
||||||
for page in &artifacts.pages {
|
for page in &artifacts.pages {
|
||||||
if path_matches_sections(&page.relative_path, §ion_set) {
|
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,
|
output_dir,
|
||||||
project_id,
|
project_id,
|
||||||
"calendar.json",
|
"calendar.json",
|
||||||
&build_calendar_json(&list_posts.iter().map(|source| source.post.clone()).collect::<Vec<_>>())?,
|
&build_calendar_json(
|
||||||
|
&list_posts
|
||||||
|
.iter()
|
||||||
|
.map(|source| source.post.clone())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)?,
|
||||||
&mut report,
|
&mut report,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
for render_language in render_languages(metadata) {
|
for render_language in render_languages(metadata) {
|
||||||
let localized_posts = localized_sources(
|
let localized_posts =
|
||||||
conn,
|
localized_sources(conn, &data_dir, &list_posts, &render_language, metadata)?;
|
||||||
&data_dir,
|
let prefix = if render_language
|
||||||
&list_posts,
|
== metadata
|
||||||
&render_language,
|
.main_language
|
||||||
metadata,
|
.clone()
|
||||||
)?;
|
.unwrap_or_else(|| "en".to_string())
|
||||||
let prefix = if render_language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) {
|
{
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
format!("{}/", render_language)
|
format!("{}/", render_language)
|
||||||
@@ -196,7 +272,14 @@ pub fn apply_validation_sections(
|
|||||||
if prefix.is_empty() {
|
if prefix.is_empty() {
|
||||||
write_out(conn, output_dir, project_id, "rss.xml", &rss, &mut report)?;
|
write_out(conn, output_dir, project_id, "rss.xml", &rss, &mut report)?;
|
||||||
}
|
}
|
||||||
write_out(conn, output_dir, project_id, &format!("{prefix}feed.xml"), &rss, &mut report)?;
|
write_out(
|
||||||
|
conn,
|
||||||
|
output_dir,
|
||||||
|
project_id,
|
||||||
|
&format!("{prefix}feed.xml"),
|
||||||
|
&rss,
|
||||||
|
&mut report,
|
||||||
|
)?;
|
||||||
write_out(
|
write_out(
|
||||||
conn,
|
conn,
|
||||||
output_dir,
|
output_dir,
|
||||||
@@ -210,40 +293,29 @@ pub fn apply_validation_sections(
|
|||||||
output_dir,
|
output_dir,
|
||||||
project_id,
|
project_id,
|
||||||
&format!("{prefix}sitemap.xml"),
|
&format!("{prefix}sitemap.xml"),
|
||||||
&build_sitemap_xml(metadata, &artifacts.pages, &localized_posts, &render_language),
|
&build_sitemap_xml(
|
||||||
|
metadata,
|
||||||
|
&artifacts.pages,
|
||||||
|
&localized_posts,
|
||||||
|
&render_language,
|
||||||
|
),
|
||||||
&mut report,
|
&mut report,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
remove_extra_section_paths(output_dir, &expected_paths, §ion_set, &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)
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_media_rewrite_map(
|
|
||||||
conn: &Connection,
|
|
||||||
project_id: &str,
|
|
||||||
) -> EngineResult<HashMap<String, String>> {
|
|
||||||
let media_items = queries::media::list_media_by_project(conn, project_id)?;
|
|
||||||
let mut map = HashMap::new();
|
|
||||||
|
|
||||||
for media in media_items {
|
|
||||||
let canonical_path = if media.file_path.starts_with('/') {
|
|
||||||
media.file_path.clone()
|
|
||||||
} else {
|
|
||||||
format!("/{}", media.file_path.trim_start_matches('/'))
|
|
||||||
};
|
|
||||||
map.insert(format!("bds-media://{}", media.id), canonical_path.clone());
|
|
||||||
|
|
||||||
let relative_key = media.file_path.trim_start_matches('/').to_lowercase();
|
|
||||||
map.insert(relative_key, canonical_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(map)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_out(
|
fn write_out(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
output_dir: &Path,
|
output_dir: &Path,
|
||||||
@@ -256,7 +328,9 @@ fn write_out(
|
|||||||
.map_err(|error| EngineError::Parse(error.to_string()))?
|
.map_err(|error| EngineError::Parse(error.to_string()))?
|
||||||
{
|
{
|
||||||
GeneratedWriteOutcome::Written => report.written_paths.push(relative_path.to_string()),
|
GeneratedWriteOutcome::Written => report.written_paths.push(relative_path.to_string()),
|
||||||
GeneratedWriteOutcome::SkippedUnchanged => report.skipped_paths.push(relative_path.to_string()),
|
GeneratedWriteOutcome::SkippedUnchanged => {
|
||||||
|
report.skipped_paths.push(relative_path.to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -273,10 +347,13 @@ fn write_pagefind_indexes(
|
|||||||
.build()
|
.build()
|
||||||
.map_err(EngineError::Io)?;
|
.map_err(EngineError::Io)?;
|
||||||
|
|
||||||
let grouped = documents.iter().fold(HashMap::<String, Vec<&crate::render::PagefindDocument>>::new(), |mut acc, doc| {
|
let grouped = documents.iter().fold(
|
||||||
|
HashMap::<String, Vec<&crate::render::PagefindDocument>>::new(),
|
||||||
|
|mut acc, doc| {
|
||||||
acc.entry(doc.language.clone()).or_default().push(doc);
|
acc.entry(doc.language.clone()).or_default().push(doc);
|
||||||
acc
|
acc
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
for (language, docs) in grouped {
|
for (language, docs) in grouped {
|
||||||
let config = PagefindServiceConfig::builder()
|
let config = PagefindServiceConfig::builder()
|
||||||
@@ -292,9 +369,16 @@ fn write_pagefind_indexes(
|
|||||||
.await
|
.await
|
||||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
}
|
}
|
||||||
let files = index.get_files().await.map_err(|error| EngineError::Parse(error.to_string()))?;
|
let files = index
|
||||||
|
.get_files()
|
||||||
|
.await
|
||||||
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
for file in files {
|
for file in files {
|
||||||
let relative = file.filename.to_string_lossy().trim_start_matches('/').to_string();
|
let relative = file
|
||||||
|
.filename
|
||||||
|
.to_string_lossy()
|
||||||
|
.trim_start_matches('/')
|
||||||
|
.to_string();
|
||||||
match write_generated_bytes(conn, output_dir, project_id, &relative, &file.contents)
|
match write_generated_bytes(conn, output_dir, project_id, &relative, &file.contents)
|
||||||
.map_err(|error| EngineError::Parse(error.to_string()))?
|
.map_err(|error| EngineError::Parse(error.to_string()))?
|
||||||
{
|
{
|
||||||
@@ -332,7 +416,12 @@ fn expected_paths_for_sections(
|
|||||||
expected.insert("calendar.json".to_string());
|
expected.insert("calendar.json".to_string());
|
||||||
expected.insert("rss.xml".to_string());
|
expected.insert("rss.xml".to_string());
|
||||||
for language in render_languages(metadata) {
|
for language in render_languages(metadata) {
|
||||||
let prefix = if language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) {
|
let prefix = if language
|
||||||
|
== metadata
|
||||||
|
.main_language
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "en".to_string())
|
||||||
|
{
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
format!("{language}/")
|
format!("{language}/")
|
||||||
@@ -376,7 +465,10 @@ fn remove_extra_section_paths(
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !matches_generated_extension(&rel) || !path_matches_sections(&rel, sections) || expected.contains(&rel) {
|
if !matches_generated_extension(&rel)
|
||||||
|
|| !path_matches_sections(&rel, sections)
|
||||||
|
|| expected.contains(&rel)
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
std::fs::remove_file(entry.path()).map_err(EngineError::Io)?;
|
std::fs::remove_file(entry.path()).map_err(EngineError::Io)?;
|
||||||
@@ -412,9 +504,14 @@ fn classify_generated_path(path: &str) -> Option<GenerationSection> {
|
|||||||
["category", ..] => Some(GenerationSection::Category),
|
["category", ..] => Some(GenerationSection::Category),
|
||||||
["tag", ..] => Some(GenerationSection::Tag),
|
["tag", ..] => Some(GenerationSection::Tag),
|
||||||
[year, "index.html"] if is_year_segment(year) => Some(GenerationSection::Date),
|
[year, "index.html"] if is_year_segment(year) => Some(GenerationSection::Date),
|
||||||
[year, month, "index.html"] if is_year_segment(year) && is_month_segment(month) => Some(GenerationSection::Date),
|
[year, month, "index.html"] if is_year_segment(year) && is_month_segment(month) => {
|
||||||
|
Some(GenerationSection::Date)
|
||||||
|
}
|
||||||
[year, month, day, _slug, "index.html"]
|
[year, month, day, _slug, "index.html"]
|
||||||
if is_year_segment(year) && is_month_segment(month) && is_day_segment(day) => Some(GenerationSection::Single),
|
if is_year_segment(year) && is_month_segment(month) && is_day_segment(day) =>
|
||||||
|
{
|
||||||
|
Some(GenerationSection::Single)
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -425,7 +522,10 @@ fn has_language_prefix(parts: &[&str]) -> bool {
|
|||||||
!is_year_segment(first)
|
!is_year_segment(first)
|
||||||
&& *first != "category"
|
&& *first != "category"
|
||||||
&& *first != "tag"
|
&& *first != "tag"
|
||||||
&& (*second == "index.html" || is_year_segment(second) || *second == "category" || *second == "tag")
|
&& (*second == "index.html"
|
||||||
|
|| is_year_segment(second)
|
||||||
|
|| *second == "category"
|
||||||
|
|| *second == "tag")
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
@@ -468,14 +568,22 @@ fn section_sort_key(section: &GenerationSection) -> u8 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn report_is_empty(report: &SiteValidationReport) -> bool {
|
fn report_is_empty(report: &SiteValidationReport) -> bool {
|
||||||
report.missing_pages.is_empty() && report.extra_pages.is_empty() && report.stale_pages.is_empty()
|
report.missing_pages.is_empty()
|
||||||
|
&& report.extra_pages.is_empty()
|
||||||
|
&& report.stale_pages.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_languages(metadata: &ProjectMetadata) -> Vec<String> {
|
fn render_languages(metadata: &ProjectMetadata) -> Vec<String> {
|
||||||
let main = metadata.main_language.clone().unwrap_or_else(|| "en".to_string());
|
let main = metadata
|
||||||
|
.main_language
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "en".to_string());
|
||||||
let mut languages = vec![main.clone()];
|
let mut languages = vec![main.clone()];
|
||||||
for language in &metadata.blog_languages {
|
for language in &metadata.blog_languages {
|
||||||
if !languages.iter().any(|existing| existing.eq_ignore_ascii_case(language)) {
|
if !languages
|
||||||
|
.iter()
|
||||||
|
.any(|existing| existing.eq_ignore_ascii_case(language))
|
||||||
|
{
|
||||||
languages.push(language.clone());
|
languages.push(language.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -496,8 +604,16 @@ fn localized_sources(
|
|||||||
localized.push(source.clone());
|
localized.push(source.clone());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Ok(translation) = queries::post_translation::get_post_translation_by_post_and_language(conn, &source.post.id, language) {
|
if let Ok(translation) =
|
||||||
let raw = std::fs::read_to_string(data_dir.join(translation.file_path.trim_start_matches('/')))
|
queries::post_translation::get_post_translation_by_post_and_language(
|
||||||
|
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)?;
|
.map_err(EngineError::Io)?;
|
||||||
let (_, body) = crate::util::frontmatter::read_translation_file(&raw)
|
let (_, body) = crate::util::frontmatter::read_translation_file(&raw)
|
||||||
.map_err(EngineError::Parse)?;
|
.map_err(EngineError::Parse)?;
|
||||||
@@ -524,7 +640,8 @@ fn filter_posts_for_lists(
|
|||||||
posts: &[PublishedPostSource],
|
posts: &[PublishedPostSource],
|
||||||
category_settings: &HashMap<String, CategorySettings>,
|
category_settings: &HashMap<String, CategorySettings>,
|
||||||
) -> Vec<PublishedPostSource> {
|
) -> Vec<PublishedPostSource> {
|
||||||
posts.iter()
|
posts
|
||||||
|
.iter()
|
||||||
.filter(|source| {
|
.filter(|source| {
|
||||||
!source.post.categories.iter().any(|category| {
|
!source.post.categories.iter().any(|category| {
|
||||||
category_settings
|
category_settings
|
||||||
@@ -537,8 +654,16 @@ fn filter_posts_for_lists(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String {
|
fn build_rss_xml(
|
||||||
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
|
metadata: &ProjectMetadata,
|
||||||
|
posts: &[PublishedPostSource],
|
||||||
|
language: &str,
|
||||||
|
) -> String {
|
||||||
|
let base_url = metadata
|
||||||
|
.public_url
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim_end_matches('/');
|
||||||
let last_build = posts
|
let last_build = posts
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at)))
|
.filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at)))
|
||||||
@@ -557,22 +682,46 @@ fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], lang
|
|||||||
];
|
];
|
||||||
|
|
||||||
for source in posts {
|
for source in posts {
|
||||||
let url = format!("{base_url}{}", build_canonical_post_path(&source.post, language, metadata.main_language.as_deref().unwrap_or("en")));
|
let url = format!(
|
||||||
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at)).unwrap_or_else(Utc::now);
|
"{base_url}{}",
|
||||||
|
build_canonical_post_path(
|
||||||
|
&source.post,
|
||||||
|
language,
|
||||||
|
metadata.main_language.as_deref().unwrap_or("en")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at))
|
||||||
|
.unwrap_or_else(Utc::now);
|
||||||
xml.push(" <item>".to_string());
|
xml.push(" <item>".to_string());
|
||||||
xml.push(format!(" <title>{}</title>", escape_xml(&source.post.title)));
|
xml.push(format!(
|
||||||
|
" <title>{}</title>",
|
||||||
|
escape_xml(&source.post.title)
|
||||||
|
));
|
||||||
xml.push(format!(" <link>{url}</link>"));
|
xml.push(format!(" <link>{url}</link>"));
|
||||||
xml.push(format!(" <guid isPermaLink=\"true\">{url}</guid>"));
|
xml.push(format!(" <guid isPermaLink=\"true\">{url}</guid>"));
|
||||||
xml.push(format!(" <pubDate>{}</pubDate>", published.format("%a, %d %b %Y %H:%M:%S GMT")));
|
xml.push(format!(
|
||||||
|
" <pubDate>{}</pubDate>",
|
||||||
|
published.format("%a, %d %b %Y %H:%M:%S GMT")
|
||||||
|
));
|
||||||
if let Some(author) = &source.post.author {
|
if let Some(author) = &source.post.author {
|
||||||
xml.push(format!(" <author>{}</author>", escape_xml(author)));
|
xml.push(format!(" <author>{}</author>", escape_xml(author)));
|
||||||
}
|
}
|
||||||
xml.push(format!(" <dc:language>{}</dc:language>", escape_xml(language)));
|
xml.push(format!(
|
||||||
|
" <dc:language>{}</dc:language>",
|
||||||
|
escape_xml(language)
|
||||||
|
));
|
||||||
let html = render_markdown_to_html(&source.body_markdown);
|
let html = render_markdown_to_html(&source.body_markdown);
|
||||||
xml.push(format!(" <description><![CDATA[{html}]]></description>"));
|
xml.push(format!(
|
||||||
xml.push(format!(" <content:encoded><![CDATA[{html}]]></content:encoded>"));
|
" <description><![CDATA[{html}]]></description>"
|
||||||
|
));
|
||||||
|
xml.push(format!(
|
||||||
|
" <content:encoded><![CDATA[{html}]]></content:encoded>"
|
||||||
|
));
|
||||||
for category in &source.post.categories {
|
for category in &source.post.categories {
|
||||||
xml.push(format!(" <category>{}</category>", escape_xml(category)));
|
xml.push(format!(
|
||||||
|
" <category>{}</category>",
|
||||||
|
escape_xml(category)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
for tag in &source.post.tags {
|
for tag in &source.post.tags {
|
||||||
xml.push(format!(" <category>{}</category>", escape_xml(tag)));
|
xml.push(format!(" <category>{}</category>", escape_xml(tag)));
|
||||||
@@ -585,8 +734,16 @@ fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], lang
|
|||||||
xml.join("\n")
|
xml.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_atom_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String {
|
fn build_atom_xml(
|
||||||
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
|
metadata: &ProjectMetadata,
|
||||||
|
posts: &[PublishedPostSource],
|
||||||
|
language: &str,
|
||||||
|
) -> String {
|
||||||
|
let base_url = metadata
|
||||||
|
.public_url
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim_end_matches('/');
|
||||||
let main_language = metadata.main_language.as_deref().unwrap_or("en");
|
let main_language = metadata.main_language.as_deref().unwrap_or("en");
|
||||||
let feed_prefix = language_prefix(language, main_language);
|
let feed_prefix = language_prefix(language, main_language);
|
||||||
let updated = posts
|
let updated = posts
|
||||||
@@ -598,30 +755,59 @@ fn build_atom_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], lan
|
|||||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>".to_string(),
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>".to_string(),
|
||||||
"<feed xmlns=\"http://www.w3.org/2005/Atom\">".to_string(),
|
"<feed xmlns=\"http://www.w3.org/2005/Atom\">".to_string(),
|
||||||
format!(" <title>{}</title>", escape_xml(&metadata.name)),
|
format!(" <title>{}</title>", escape_xml(&metadata.name)),
|
||||||
format!(" <subtitle>{}</subtitle>", escape_xml(metadata.description.as_deref().unwrap_or(""))),
|
format!(
|
||||||
|
" <subtitle>{}</subtitle>",
|
||||||
|
escape_xml(metadata.description.as_deref().unwrap_or(""))
|
||||||
|
),
|
||||||
format!(" <id>{base_url}/</id>"),
|
format!(" <id>{base_url}/</id>"),
|
||||||
format!(" <link href=\"{base_url}{feed_prefix}/\" rel=\"alternate\" />"),
|
format!(" <link href=\"{base_url}{feed_prefix}/\" rel=\"alternate\" />"),
|
||||||
format!(" <link href=\"{base_url}{feed_prefix}/atom.xml\" rel=\"self\" />"),
|
format!(" <link href=\"{base_url}{feed_prefix}/atom.xml\" rel=\"self\" />"),
|
||||||
format!(" <updated>{}</updated>", updated.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)),
|
format!(
|
||||||
|
" <updated>{}</updated>",
|
||||||
|
updated.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
for source in posts {
|
for source in posts {
|
||||||
let url = format!("{base_url}{}", build_canonical_post_path(&source.post, language, metadata.main_language.as_deref().unwrap_or("en")));
|
let url = format!(
|
||||||
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at)).unwrap_or_else(Utc::now);
|
"{base_url}{}",
|
||||||
|
build_canonical_post_path(
|
||||||
|
&source.post,
|
||||||
|
language,
|
||||||
|
metadata.main_language.as_deref().unwrap_or("en")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at))
|
||||||
|
.unwrap_or_else(Utc::now);
|
||||||
let html = render_markdown_to_html(&source.body_markdown);
|
let html = render_markdown_to_html(&source.body_markdown);
|
||||||
xml.push(format!(" <entry xml:lang=\"{}\">", escape_xml(language)));
|
xml.push(format!(" <entry xml:lang=\"{}\">", escape_xml(language)));
|
||||||
xml.push(format!(" <title>{}</title>", escape_xml(&source.post.title)));
|
xml.push(format!(
|
||||||
|
" <title>{}</title>",
|
||||||
|
escape_xml(&source.post.title)
|
||||||
|
));
|
||||||
xml.push(format!(" <id>{url}</id>"));
|
xml.push(format!(" <id>{url}</id>"));
|
||||||
xml.push(format!(" <link href=\"{url}\" />"));
|
xml.push(format!(" <link href=\"{url}\" />"));
|
||||||
xml.push(format!(" <updated>{}</updated>", published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)));
|
xml.push(format!(
|
||||||
xml.push(format!(" <published>{}</published>", published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)));
|
" <updated>{}</updated>",
|
||||||
|
published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
|
||||||
|
));
|
||||||
|
xml.push(format!(
|
||||||
|
" <published>{}</published>",
|
||||||
|
published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
|
||||||
|
));
|
||||||
if let Some(author) = &source.post.author {
|
if let Some(author) = &source.post.author {
|
||||||
xml.push(format!(" <author><name>{}</name></author>", escape_xml(author)));
|
xml.push(format!(
|
||||||
|
" <author><name>{}</name></author>",
|
||||||
|
escape_xml(author)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
xml.push(format!(" <summary type=\"xhtml\"><div xmlns=\"http://www.w3.org/1999/xhtml\">{html}</div></summary>"));
|
xml.push(format!(" <summary type=\"xhtml\"><div xmlns=\"http://www.w3.org/1999/xhtml\">{html}</div></summary>"));
|
||||||
xml.push(format!(" <content type=\"xhtml\"><div xmlns=\"http://www.w3.org/1999/xhtml\">{html}</div></content>"));
|
xml.push(format!(" <content type=\"xhtml\"><div xmlns=\"http://www.w3.org/1999/xhtml\">{html}</div></content>"));
|
||||||
for category in &source.post.categories {
|
for category in &source.post.categories {
|
||||||
xml.push(format!(" <category term=\"{}\" />", escape_xml(category)));
|
xml.push(format!(
|
||||||
|
" <category term=\"{}\" />",
|
||||||
|
escape_xml(category)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
for tag in &source.post.tags {
|
for tag in &source.post.tags {
|
||||||
xml.push(format!(" <category term=\"{}\" />", escape_xml(tag)));
|
xml.push(format!(" <category term=\"{}\" />", escape_xml(tag)));
|
||||||
@@ -639,7 +825,11 @@ fn build_sitemap_xml(
|
|||||||
posts: &[PublishedPostSource],
|
posts: &[PublishedPostSource],
|
||||||
language: &str,
|
language: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
|
let base_url = metadata
|
||||||
|
.public_url
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim_end_matches('/');
|
||||||
let main_language = metadata.main_language.as_deref().unwrap_or("en");
|
let main_language = metadata.main_language.as_deref().unwrap_or("en");
|
||||||
let languages = render_languages(metadata);
|
let languages = render_languages(metadata);
|
||||||
let index_lastmod = posts
|
let index_lastmod = posts
|
||||||
@@ -694,7 +884,10 @@ fn build_sitemap_xml(
|
|||||||
alternate.url_path,
|
alternate.url_path,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if let Some(default_page) = alternates.iter().find(|alternate| alternate.language == main_language) {
|
if let Some(default_page) = alternates
|
||||||
|
.iter()
|
||||||
|
.find(|alternate| alternate.language == main_language)
|
||||||
|
{
|
||||||
xml.push(format!(
|
xml.push(format!(
|
||||||
" <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"{base_url}{}\" />",
|
" <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"{base_url}{}\" />",
|
||||||
default_page.url_path,
|
default_page.url_path,
|
||||||
@@ -746,7 +939,9 @@ fn logical_page_key(relative_path: &str, languages: &[String], main_language: &s
|
|||||||
if first.eq_ignore_ascii_case(main_language) {
|
if first.eq_ignore_ascii_case(main_language) {
|
||||||
return parts.collect::<Vec<_>>().join("/");
|
return parts.collect::<Vec<_>>().join("/");
|
||||||
}
|
}
|
||||||
if languages.iter().any(|language| language.eq_ignore_ascii_case(first) && !language.eq_ignore_ascii_case(main_language)) {
|
if languages.iter().any(|language| {
|
||||||
|
language.eq_ignore_ascii_case(first) && !language.eq_ignore_ascii_case(main_language)
|
||||||
|
}) {
|
||||||
return parts.collect::<Vec<_>>().join("/");
|
return parts.collect::<Vec<_>>().join("/");
|
||||||
}
|
}
|
||||||
relative_path.to_string()
|
relative_path.to_string()
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ use crate::db::queries::post_media as qpm;
|
|||||||
use crate::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult};
|
||||||
use crate::model::{Media, MediaTranslation};
|
use crate::model::{Media, MediaTranslation};
|
||||||
use crate::util::sidecar::{
|
use crate::util::sidecar::{
|
||||||
read_sidecar, read_translation_sidecar, MediaSidecar, MediaTranslationSidecar,
|
MediaSidecar, MediaTranslationSidecar, read_sidecar, read_translation_sidecar,
|
||||||
};
|
};
|
||||||
use crate::util::thumbnail::{
|
use crate::util::thumbnail::{
|
||||||
generate_all_thumbnails, image_dimensions, mime_from_extension, ThumbnailFormat,
|
THUMBNAIL_SIZES, ThumbnailFormat, generate_all_thumbnails, image_dimensions,
|
||||||
THUMBNAIL_SIZES,
|
mime_from_extension,
|
||||||
};
|
};
|
||||||
use crate::util::{
|
use crate::util::{
|
||||||
atomic_write_str, content_hash, media_dir_path, media_sidecar_path,
|
atomic_write_str, content_hash, media_dir_path, media_sidecar_path,
|
||||||
@@ -46,6 +46,10 @@ const SUPPORTED_IMAGE_TYPES: &[&str] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// Import a media file (image, etc.) into the project.
|
/// Import a media file (image, etc.) into the project.
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_arguments,
|
||||||
|
reason = "arguments are the user-supplied media metadata fields"
|
||||||
|
)]
|
||||||
pub fn import_media(
|
pub fn import_media(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
@@ -142,6 +146,10 @@ pub fn import_media(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update a media item's metadata fields.
|
/// Update a media item's metadata fields.
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_arguments,
|
||||||
|
reason = "optional arguments represent independent media field changes"
|
||||||
|
)]
|
||||||
pub fn update_media(
|
pub fn update_media(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
@@ -655,9 +663,9 @@ fn rebuild_translation_sidecar(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::db::Database;
|
||||||
use crate::db::fts::ensure_fts_tables;
|
use crate::db::fts::ensure_fts_tables;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
use crate::db::Database;
|
|
||||||
use image::DynamicImage;
|
use image::DynamicImage;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
|||||||
+137
-197
@@ -1,6 +1,10 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
|
||||||
|
use quick_xml::{Reader, Writer, XmlVersion, escape::escape};
|
||||||
|
|
||||||
|
use crate::engine::EngineError;
|
||||||
use crate::engine::EngineResult;
|
use crate::engine::EngineResult;
|
||||||
use crate::util::atomic_write_str;
|
use crate::util::atomic_write_str;
|
||||||
|
|
||||||
@@ -53,14 +57,14 @@ pub fn read_menu(data_dir: &Path) -> EngineResult<Vec<MenuItem>> {
|
|||||||
}]);
|
}]);
|
||||||
}
|
}
|
||||||
let content = fs::read_to_string(&path)?;
|
let content = fs::read_to_string(&path)?;
|
||||||
Ok(parse_opml(&content))
|
parse_opml(&content)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write the navigation menu to meta/menu.opml.
|
/// Write the navigation menu to meta/menu.opml.
|
||||||
/// Per menu.allium UpdateMenu rule: Home entry is always extracted and prepended.
|
/// Per menu.allium UpdateMenu rule: Home entry is always extracted and prepended.
|
||||||
pub fn write_menu(data_dir: &Path, items: &[MenuItem]) -> EngineResult<()> {
|
pub fn write_menu(data_dir: &Path, items: &[MenuItem]) -> EngineResult<()> {
|
||||||
let normalized = normalize_menu(items);
|
let normalized = normalize_menu(items);
|
||||||
let opml = serialize_opml(&normalized);
|
let opml = serialize_opml(&normalized)?;
|
||||||
let path = data_dir.join("meta").join("menu.opml");
|
let path = data_dir.join("meta").join("menu.opml");
|
||||||
atomic_write_str(&path, &opml)?;
|
atomic_write_str(&path, &opml)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -74,7 +78,7 @@ pub fn default_menu_opml() -> String {
|
|||||||
slug: None,
|
slug: None,
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
}];
|
}];
|
||||||
serialize_opml(&items)
|
serialize_opml(&items).expect("writing menu XML to memory cannot fail")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per menu.allium HomeAlwaysPresent: ensure Home is always first.
|
/// Per menu.allium HomeAlwaysPresent: ensure Home is always first.
|
||||||
@@ -95,211 +99,136 @@ fn normalize_menu(items: &[MenuItem]) -> Vec<MenuItem> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse OPML 2.0 XML into menu items.
|
/// Parse OPML 2.0 XML into menu items.
|
||||||
fn parse_opml(content: &str) -> Vec<MenuItem> {
|
fn parse_opml(content: &str) -> EngineResult<Vec<MenuItem>> {
|
||||||
// Simple XML parsing using quick-xml-style manual parsing.
|
let mut reader = Reader::from_str(content);
|
||||||
// OPML structure: <opml><body><outline .../></body></opml>
|
reader.config_mut().trim_text(true);
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
parse_outlines(content, &mut items);
|
let mut parents = Vec::new();
|
||||||
|
let mut in_body = false;
|
||||||
|
|
||||||
// Ensure HomeAlwaysPresent
|
loop {
|
||||||
if items.is_empty() || items[0].kind != MenuItemKind::Home {
|
match reader.read_event() {
|
||||||
let without_home: Vec<MenuItem> = items
|
Ok(Event::Start(event)) if event.name().as_ref() == b"body" => in_body = true,
|
||||||
.into_iter()
|
Ok(Event::End(event)) if event.name().as_ref() == b"body" => in_body = false,
|
||||||
.filter(|i| i.kind != MenuItemKind::Home)
|
Ok(Event::Start(event)) if in_body && event.name().as_ref() == b"outline" => {
|
||||||
.collect();
|
parents.push(parse_outline(&event)?);
|
||||||
let mut normalized = vec![MenuItem {
|
|
||||||
kind: MenuItemKind::Home,
|
|
||||||
label: "Home".to_string(),
|
|
||||||
slug: None,
|
|
||||||
children: Vec::new(),
|
|
||||||
}];
|
|
||||||
normalized.extend(without_home);
|
|
||||||
return normalized;
|
|
||||||
}
|
}
|
||||||
items
|
Ok(Event::Empty(event)) if in_body && event.name().as_ref() == b"outline" => {
|
||||||
|
attach_outline(parse_outline(&event)?, &mut parents, &mut items);
|
||||||
}
|
}
|
||||||
|
Ok(Event::End(event)) if event.name().as_ref() == b"outline" => {
|
||||||
/// Simple OPML outline parser.
|
let item = parents
|
||||||
/// Parses <outline text="..." type="..." htmlUrl="..."> elements.
|
.pop()
|
||||||
fn parse_outlines(xml: &str, items: &mut Vec<MenuItem>) {
|
.ok_or_else(|| EngineError::Parse("unexpected </outline>".to_string()))?;
|
||||||
// Find all outline elements at the body level
|
attach_outline(item, &mut parents, &mut items);
|
||||||
let body_start = xml.find("<body>");
|
|
||||||
let body_end = xml.find("</body>");
|
|
||||||
if body_start.is_none() || body_end.is_none() {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
let body = &xml[body_start.unwrap() + 6..body_end.unwrap()];
|
Ok(Event::Eof) => break,
|
||||||
parse_outline_children(body, items);
|
Ok(_) => {}
|
||||||
}
|
Err(error) => return Err(EngineError::Parse(error.to_string())),
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
kind,
|
||||||
label,
|
label,
|
||||||
slug,
|
slug,
|
||||||
children,
|
children: Vec::new(),
|
||||||
});
|
})
|
||||||
pos = after_tag;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_closing_outline(content: &str) -> Option<usize> {
|
fn attach_outline(item: MenuItem, parents: &mut [MenuItem], items: &mut Vec<MenuItem>) {
|
||||||
let mut depth = 0i32;
|
if let Some(parent) = parents.last_mut() {
|
||||||
let mut pos = 0;
|
parent.children.push(item);
|
||||||
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;
|
|
||||||
} else {
|
} 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.
|
/// Serialize menu items to OPML 2.0 format.
|
||||||
fn serialize_opml(items: &[MenuItem]) -> String {
|
fn serialize_opml(items: &[MenuItem]) -> EngineResult<String> {
|
||||||
let mut out = String::new();
|
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 2);
|
||||||
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
writer
|
||||||
out.push_str("<opml version=\"2.0\">\n");
|
.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))
|
||||||
out.push_str(" <head><title>Blog Menu</title></head>\n");
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
out.push_str(" <body>\n");
|
let mut opml = BytesStart::new("opml");
|
||||||
|
opml.push_attribute(("version", "2.0"));
|
||||||
|
writer
|
||||||
|
.write_event(Event::Start(opml))
|
||||||
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
|
writer
|
||||||
|
.write_event(Event::Start(BytesStart::new("head")))
|
||||||
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
|
writer
|
||||||
|
.write_event(Event::Start(BytesStart::new("title")))
|
||||||
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
|
writer
|
||||||
|
.write_event(Event::Text(BytesText::new("Blog Menu")))
|
||||||
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
|
writer
|
||||||
|
.write_event(Event::End(BytesEnd::new("title")))
|
||||||
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
|
writer
|
||||||
|
.write_event(Event::End(BytesEnd::new("head")))
|
||||||
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
|
writer
|
||||||
|
.write_event(Event::Start(BytesStart::new("body")))
|
||||||
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
for item in items {
|
for item in items {
|
||||||
serialize_outline(&mut out, item, 2);
|
write_outline(&mut writer, item).map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
}
|
}
|
||||||
out.push_str(" </body>\n");
|
writer
|
||||||
out.push_str("</opml>\n");
|
.write_event(Event::End(BytesEnd::new("body")))
|
||||||
out
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
|
writer
|
||||||
|
.write_event(Event::End(BytesEnd::new("opml")))
|
||||||
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
|
String::from_utf8(writer.into_inner()).map_err(|error| EngineError::Parse(error.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn serialize_outline(out: &mut String, item: &MenuItem, indent: usize) {
|
fn write_outline(writer: &mut Writer<Vec<u8>>, item: &MenuItem) -> quick_xml::Result<()> {
|
||||||
let pad = " ".repeat(indent);
|
let label = escape(&item.label);
|
||||||
out.push_str(&pad);
|
let slug = item.slug.as_deref().map(escape);
|
||||||
out.push_str("<outline");
|
let mut outline = BytesStart::new("outline");
|
||||||
out.push_str(&format!(
|
outline.push_attribute(("text", label.as_ref()));
|
||||||
" text=\"{}\"",
|
outline.push_attribute(("type", item.kind.as_str()));
|
||||||
xml_escape(&item.label)
|
if let Some(slug) = &slug {
|
||||||
));
|
outline.push_attribute(("htmlUrl", slug.as_ref()));
|
||||||
out.push_str(&format!(
|
|
||||||
" type=\"{}\"",
|
|
||||||
item.kind.as_str()
|
|
||||||
));
|
|
||||||
if let Some(ref slug) = item.slug {
|
|
||||||
out.push_str(&format!(
|
|
||||||
" htmlUrl=\"{}\"",
|
|
||||||
xml_escape(slug)
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
if item.children.is_empty() {
|
if item.children.is_empty() {
|
||||||
out.push_str("/>\n");
|
writer.write_event(Event::Empty(outline))?;
|
||||||
} else {
|
} else {
|
||||||
out.push_str(">\n");
|
writer.write_event(Event::Start(outline))?;
|
||||||
for child in &item.children {
|
for child in &item.children {
|
||||||
serialize_outline(out, child, indent + 1);
|
write_outline(writer, child)?;
|
||||||
}
|
}
|
||||||
out.push_str(&pad);
|
writer.write_event(Event::End(BytesEnd::new("outline")))?;
|
||||||
out.push_str("</outline>\n");
|
|
||||||
}
|
}
|
||||||
}
|
Ok(())
|
||||||
|
|
||||||
fn xml_escape(s: &str) -> String {
|
|
||||||
s.replace('&', "&")
|
|
||||||
.replace('<', "<")
|
|
||||||
.replace('>', ">")
|
|
||||||
.replace('"', """)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -332,18 +261,16 @@ mod tests {
|
|||||||
kind: MenuItemKind::Submenu,
|
kind: MenuItemKind::Submenu,
|
||||||
label: "Categories".into(),
|
label: "Categories".into(),
|
||||||
slug: None,
|
slug: None,
|
||||||
children: vec![
|
children: vec![MenuItem {
|
||||||
MenuItem {
|
|
||||||
kind: MenuItemKind::CategoryArchive,
|
kind: MenuItemKind::CategoryArchive,
|
||||||
label: "Tech".into(),
|
label: "Tech".into(),
|
||||||
slug: Some("/category/tech/".into()),
|
slug: Some("/category/tech/".into()),
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
},
|
}],
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let opml = serialize_opml(&items);
|
let opml = serialize_opml(&items).unwrap();
|
||||||
let parsed = parse_opml(&opml);
|
let parsed = parse_opml(&opml).unwrap();
|
||||||
assert_eq!(parsed.len(), 3);
|
assert_eq!(parsed.len(), 3);
|
||||||
assert_eq!(parsed[0].kind, MenuItemKind::Home);
|
assert_eq!(parsed[0].kind, MenuItemKind::Home);
|
||||||
assert_eq!(parsed[1].label, "About");
|
assert_eq!(parsed[1].label, "About");
|
||||||
@@ -354,14 +281,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn normalize_prepends_home() {
|
fn normalize_prepends_home() {
|
||||||
let items = vec![
|
let items = vec![MenuItem {
|
||||||
MenuItem {
|
|
||||||
kind: MenuItemKind::Page,
|
kind: MenuItemKind::Page,
|
||||||
label: "About".into(),
|
label: "About".into(),
|
||||||
slug: Some("/about".into()),
|
slug: Some("/about".into()),
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
},
|
}];
|
||||||
];
|
|
||||||
let normalized = normalize_menu(&items);
|
let normalized = normalize_menu(&items);
|
||||||
assert_eq!(normalized.len(), 2);
|
assert_eq!(normalized.len(), 2);
|
||||||
assert_eq!(normalized[0].kind, MenuItemKind::Home);
|
assert_eq!(normalized[0].kind, MenuItemKind::Home);
|
||||||
@@ -381,14 +306,12 @@ mod tests {
|
|||||||
fn write_and_read_menu() {
|
fn write_and_read_menu() {
|
||||||
let dir = tempfile::TempDir::new().unwrap();
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
|
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
|
||||||
let items = vec![
|
let items = vec![MenuItem {
|
||||||
MenuItem {
|
|
||||||
kind: MenuItemKind::Page,
|
kind: MenuItemKind::Page,
|
||||||
label: "Blog".into(),
|
label: "Blog".into(),
|
||||||
slug: Some("/blog".into()),
|
slug: Some("/blog".into()),
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
},
|
}];
|
||||||
];
|
|
||||||
write_menu(dir.path(), &items).unwrap();
|
write_menu(dir.path(), &items).unwrap();
|
||||||
let read = read_menu(dir.path()).unwrap();
|
let read = read_menu(dir.path()).unwrap();
|
||||||
// Home is always prepended
|
// Home is always prepended
|
||||||
@@ -398,8 +321,25 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn xml_escape_special_chars() {
|
fn reads_single_quoted_xml_attributes() {
|
||||||
let escaped = xml_escape("Tom & Jerry <3 \"quotes\"");
|
let parsed = parse_opml(
|
||||||
assert_eq!(escaped, "Tom & Jerry <3 "quotes"");
|
"<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 std::path::Path;
|
||||||
|
|
||||||
use crate::engine::EngineResult;
|
use crate::engine::EngineResult;
|
||||||
use crate::model::metadata::{CategorySettings, ProjectMetadata, TagEntry};
|
|
||||||
use crate::model::PublishingPreferences;
|
use crate::model::PublishingPreferences;
|
||||||
|
use crate::model::metadata::{CategorySettings, ProjectMetadata, TagEntry};
|
||||||
use crate::util::atomic_write_str;
|
use crate::util::atomic_write_str;
|
||||||
|
|
||||||
// ── project.json ────────────────────────────────────────────────────
|
// ── project.json ────────────────────────────────────────────────────
|
||||||
@@ -38,7 +38,7 @@ pub fn read_categories_json(data_dir: &Path) -> EngineResult<Vec<String>> {
|
|||||||
/// Sort categories, then atomic write to meta/categories.json.
|
/// Sort categories, then atomic write to meta/categories.json.
|
||||||
pub fn write_categories_json(data_dir: &Path, categories: &[String]) -> EngineResult<()> {
|
pub fn write_categories_json(data_dir: &Path, categories: &[String]) -> EngineResult<()> {
|
||||||
let mut sorted = categories.to_vec();
|
let mut sorted = categories.to_vec();
|
||||||
sorted.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
|
sorted.sort_by_key(|a| a.to_lowercase());
|
||||||
let path = data_dir.join("meta").join("categories.json");
|
let path = data_dir.join("meta").join("categories.json");
|
||||||
let json = serde_json::to_string_pretty(&sorted)?;
|
let json = serde_json::to_string_pretty(&sorted)?;
|
||||||
atomic_write_str(&path, &json)?;
|
atomic_write_str(&path, &json)?;
|
||||||
@@ -48,9 +48,7 @@ pub fn write_categories_json(data_dir: &Path, categories: &[String]) -> EngineRe
|
|||||||
// ── category-meta.json ──────────────────────────────────────────────
|
// ── category-meta.json ──────────────────────────────────────────────
|
||||||
|
|
||||||
/// Read meta/category-meta.json.
|
/// Read meta/category-meta.json.
|
||||||
pub fn read_category_meta_json(
|
pub fn read_category_meta_json(data_dir: &Path) -> EngineResult<HashMap<String, CategorySettings>> {
|
||||||
data_dir: &Path,
|
|
||||||
) -> EngineResult<HashMap<String, CategorySettings>> {
|
|
||||||
let path = data_dir.join("meta").join("category-meta.json");
|
let path = data_dir.join("meta").join("category-meta.json");
|
||||||
let content = fs::read_to_string(&path)?;
|
let content = fs::read_to_string(&path)?;
|
||||||
let meta: HashMap<String, CategorySettings> = serde_json::from_str(&content)?;
|
let meta: HashMap<String, CategorySettings> = serde_json::from_str(&content)?;
|
||||||
@@ -79,10 +77,7 @@ pub fn read_publishing_json(data_dir: &Path) -> EngineResult<PublishingPreferenc
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Atomic write to meta/publishing.json.
|
/// Atomic write to meta/publishing.json.
|
||||||
pub fn write_publishing_json(
|
pub fn write_publishing_json(data_dir: &Path, prefs: &PublishingPreferences) -> EngineResult<()> {
|
||||||
data_dir: &Path,
|
|
||||||
prefs: &PublishingPreferences,
|
|
||||||
) -> EngineResult<()> {
|
|
||||||
let path = data_dir.join("meta").join("publishing.json");
|
let path = data_dir.join("meta").join("publishing.json");
|
||||||
let json = serde_json::to_string_pretty(prefs)?;
|
let json = serde_json::to_string_pretty(prefs)?;
|
||||||
atomic_write_str(&path, &json)?;
|
atomic_write_str(&path, &json)?;
|
||||||
@@ -102,7 +97,7 @@ pub fn read_tags_json(data_dir: &Path) -> EngineResult<Vec<TagEntry>> {
|
|||||||
/// Sort by name case-insensitive, then atomic write to meta/tags.json.
|
/// Sort by name case-insensitive, then atomic write to meta/tags.json.
|
||||||
pub fn write_tags_json(data_dir: &Path, tags: &[TagEntry]) -> EngineResult<()> {
|
pub fn write_tags_json(data_dir: &Path, tags: &[TagEntry]) -> EngineResult<()> {
|
||||||
let mut sorted = tags.to_vec();
|
let mut sorted = tags.to_vec();
|
||||||
sorted.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
sorted.sort_by_key(|a| a.name.to_lowercase());
|
||||||
let path = data_dir.join("meta").join("tags.json");
|
let path = data_dir.join("meta").join("tags.json");
|
||||||
let json = serde_json::to_string_pretty(&sorted)?;
|
let json = serde_json::to_string_pretty(&sorted)?;
|
||||||
atomic_write_str(&path, &json)?;
|
atomic_write_str(&path, &json)?;
|
||||||
@@ -162,10 +157,7 @@ pub fn update_blog_languages(data_dir: &Path, languages: Vec<String>) -> EngineR
|
|||||||
|
|
||||||
/// Update arbitrary fields of project metadata.
|
/// Update arbitrary fields of project metadata.
|
||||||
/// Per metadata.allium UpdateProjectMetadata rule.
|
/// Per metadata.allium UpdateProjectMetadata rule.
|
||||||
pub fn update_project_metadata(
|
pub fn update_project_metadata(data_dir: &Path, changes: &serde_json::Value) -> EngineResult<()> {
|
||||||
data_dir: &Path,
|
|
||||||
changes: &serde_json::Value,
|
|
||||||
) -> EngineResult<()> {
|
|
||||||
let mut meta = read_project_json(data_dir)?;
|
let mut meta = read_project_json(data_dir)?;
|
||||||
if let Some(name) = changes.get("name").and_then(|v| v.as_str()) {
|
if let Some(name) = changes.get("name").and_then(|v| v.as_str()) {
|
||||||
meta.name = name.to_string();
|
meta.name = name.to_string();
|
||||||
@@ -191,7 +183,10 @@ pub fn update_project_metadata(
|
|||||||
if let Some(theme) = changes.get("picoTheme") {
|
if let Some(theme) = changes.get("picoTheme") {
|
||||||
meta.pico_theme = theme.as_str().map(|s| s.to_string());
|
meta.pico_theme = theme.as_str().map(|s| s.to_string());
|
||||||
}
|
}
|
||||||
if let Some(enabled) = changes.get("semanticSimilarityEnabled").and_then(|v| v.as_bool()) {
|
if let Some(enabled) = changes
|
||||||
|
.get("semanticSimilarityEnabled")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
{
|
||||||
meta.semantic_similarity_enabled = enabled;
|
meta.semantic_similarity_enabled = enabled;
|
||||||
}
|
}
|
||||||
if let Some(langs) = changes.get("blogLanguages").and_then(|v| v.as_array()) {
|
if let Some(langs) = changes.get("blogLanguages").and_then(|v| v.as_array()) {
|
||||||
@@ -200,7 +195,8 @@ pub fn update_project_metadata(
|
|||||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||||
.collect();
|
.collect();
|
||||||
}
|
}
|
||||||
meta.validate().map_err(|e| crate::engine::EngineError::Validation(e))?;
|
meta.validate()
|
||||||
|
.map_err(crate::engine::EngineError::Validation)?;
|
||||||
write_project_json(data_dir, &meta)?;
|
write_project_json(data_dir, &meta)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -380,7 +376,7 @@ mod tests {
|
|||||||
fn add_category_creates_entries() {
|
fn add_category_creates_entries() {
|
||||||
let dir = setup();
|
let dir = setup();
|
||||||
// Seed files
|
// Seed files
|
||||||
write_categories_json(dir.path(), &vec!["article".into()]).unwrap();
|
write_categories_json(dir.path(), &["article".into()]).unwrap();
|
||||||
write_category_meta_json(dir.path(), &HashMap::new()).unwrap();
|
write_category_meta_json(dir.path(), &HashMap::new()).unwrap();
|
||||||
|
|
||||||
add_category(dir.path(), "page").unwrap();
|
add_category(dir.path(), "page").unwrap();
|
||||||
@@ -395,7 +391,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn add_category_idempotent() {
|
fn add_category_idempotent() {
|
||||||
let dir = setup();
|
let dir = setup();
|
||||||
write_categories_json(dir.path(), &vec!["article".into()]).unwrap();
|
write_categories_json(dir.path(), &["article".into()]).unwrap();
|
||||||
write_category_meta_json(dir.path(), &HashMap::new()).unwrap();
|
write_category_meta_json(dir.path(), &HashMap::new()).unwrap();
|
||||||
|
|
||||||
add_category(dir.path(), "article").unwrap();
|
add_category(dir.path(), "article").unwrap();
|
||||||
@@ -406,7 +402,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn remove_category_deletes_entries() {
|
fn remove_category_deletes_entries() {
|
||||||
let dir = setup();
|
let dir = setup();
|
||||||
write_categories_json(dir.path(), &vec!["article".into(), "page".into()]).unwrap();
|
write_categories_json(dir.path(), &["article".into(), "page".into()]).unwrap();
|
||||||
let mut meta = HashMap::new();
|
let mut meta = HashMap::new();
|
||||||
meta.insert(
|
meta.insert(
|
||||||
"article".to_string(),
|
"article".to_string(),
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ use crate::db::queries::script as qs;
|
|||||||
use crate::db::queries::template as qtpl;
|
use crate::db::queries::template as qtpl;
|
||||||
use crate::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult};
|
||||||
use crate::model::{Media, Post, PostStatus, PostTranslation, Script, Template};
|
use crate::model::{Media, Post, PostStatus, PostTranslation, Script, Template};
|
||||||
use crate::util::frontmatter::{read_post_file, read_script_file, read_template_file, read_translation_file};
|
use crate::util::frontmatter::{
|
||||||
|
read_post_file, read_script_file, read_template_file, read_translation_file,
|
||||||
|
};
|
||||||
use crate::util::sidecar::read_sidecar;
|
use crate::util::sidecar::read_sidecar;
|
||||||
|
|
||||||
/// A single field difference.
|
/// A single field difference.
|
||||||
@@ -139,7 +141,11 @@ fn opt_to_str(opt: &Option<String>) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn bool_to_str(b: bool) -> String {
|
fn bool_to_str(b: bool) -> String {
|
||||||
if b { "true".to_string() } else { "false".to_string() }
|
if b {
|
||||||
|
"true".to_string()
|
||||||
|
} else {
|
||||||
|
"false".to_string()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tags_to_json(tags: &[String]) -> String {
|
fn tags_to_json(tags: &[String]) -> String {
|
||||||
@@ -172,7 +178,7 @@ fn diff_post(data_dir: &Path, post: &Post) -> EngineResult<Option<EntityDiff>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let content = fs::read_to_string(&abs_path)?;
|
let content = fs::read_to_string(&abs_path)?;
|
||||||
let (fm, _body) = read_post_file(&content).map_err(|e| EngineError::Parse(e))?;
|
let (fm, _body) = read_post_file(&content).map_err(EngineError::Parse)?;
|
||||||
|
|
||||||
let mut fields = Vec::new();
|
let mut fields = Vec::new();
|
||||||
|
|
||||||
@@ -257,21 +263,23 @@ fn diff_post(data_dir: &Path, post: &Post) -> EngineResult<Option<EntityDiff>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn diff_translation(
|
fn diff_translation(data_dir: &Path, t: &PostTranslation) -> EngineResult<Option<EntityDiff>> {
|
||||||
data_dir: &Path,
|
|
||||||
t: &PostTranslation,
|
|
||||||
) -> EngineResult<Option<EntityDiff>> {
|
|
||||||
let abs_path = data_dir.join(&t.file_path);
|
let abs_path = data_dir.join(&t.file_path);
|
||||||
if !abs_path.exists() {
|
if !abs_path.exists() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = fs::read_to_string(&abs_path)?;
|
let content = fs::read_to_string(&abs_path)?;
|
||||||
let (fm, _body) = read_translation_file(&content).map_err(|e| EngineError::Parse(e))?;
|
let (fm, _body) = read_translation_file(&content).map_err(EngineError::Parse)?;
|
||||||
|
|
||||||
let mut fields = Vec::new();
|
let mut fields = Vec::new();
|
||||||
|
|
||||||
compare_field(&mut fields, "translationFor", &t.translation_for, &fm.translation_for);
|
compare_field(
|
||||||
|
&mut fields,
|
||||||
|
"translationFor",
|
||||||
|
&t.translation_for,
|
||||||
|
&fm.translation_for,
|
||||||
|
);
|
||||||
compare_field(&mut fields, "language", &t.language, &fm.language);
|
compare_field(&mut fields, "language", &t.language, &fm.language);
|
||||||
compare_field(&mut fields, "title", &t.title, &fm.title);
|
compare_field(&mut fields, "title", &t.title, &fm.title);
|
||||||
compare_field(
|
compare_field(
|
||||||
@@ -285,7 +293,7 @@ fn diff_translation(
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
} else {
|
} else {
|
||||||
Ok(Some(EntityDiff {
|
Ok(Some(EntityDiff {
|
||||||
entity_type: "translation".to_string(),
|
entity_type: "post_translation".to_string(),
|
||||||
entity_id: t.id.clone(),
|
entity_id: t.id.clone(),
|
||||||
file_path: t.file_path.clone(),
|
file_path: t.file_path.clone(),
|
||||||
fields,
|
fields,
|
||||||
@@ -300,7 +308,7 @@ fn diff_media(data_dir: &Path, media: &Media) -> EngineResult<Option<EntityDiff>
|
|||||||
}
|
}
|
||||||
|
|
||||||
let content = fs::read_to_string(&abs_path)?;
|
let content = fs::read_to_string(&abs_path)?;
|
||||||
let sc = read_sidecar(&content).map_err(|e| EngineError::Parse(e))?;
|
let sc = read_sidecar(&content).map_err(EngineError::Parse)?;
|
||||||
|
|
||||||
let mut fields = Vec::new();
|
let mut fields = Vec::new();
|
||||||
|
|
||||||
@@ -360,7 +368,7 @@ fn diff_template(data_dir: &Path, tpl: &Template) -> EngineResult<Option<EntityD
|
|||||||
}
|
}
|
||||||
|
|
||||||
let content = fs::read_to_string(&abs_path)?;
|
let content = fs::read_to_string(&abs_path)?;
|
||||||
let (fm, _body) = read_template_file(&content).map_err(|e| EngineError::Parse(e))?;
|
let (fm, _body) = read_template_file(&content).map_err(EngineError::Parse)?;
|
||||||
|
|
||||||
let mut fields = Vec::new();
|
let mut fields = Vec::new();
|
||||||
|
|
||||||
@@ -403,7 +411,7 @@ fn diff_script(data_dir: &Path, script: &Script) -> EngineResult<Option<EntityDi
|
|||||||
}
|
}
|
||||||
|
|
||||||
let content = fs::read_to_string(&abs_path)?;
|
let content = fs::read_to_string(&abs_path)?;
|
||||||
let (fm, _body) = read_script_file(&content).map_err(|e| EngineError::Parse(e))?;
|
let (fm, _body) = read_script_file(&content).map_err(EngineError::Parse)?;
|
||||||
|
|
||||||
let mut fields = Vec::new();
|
let mut fields = Vec::new();
|
||||||
|
|
||||||
@@ -414,7 +422,12 @@ fn diff_script(data_dir: &Path, script: &Script) -> EngineResult<Option<EntityDi
|
|||||||
script_kind_to_str(&script.kind),
|
script_kind_to_str(&script.kind),
|
||||||
&fm.kind,
|
&fm.kind,
|
||||||
);
|
);
|
||||||
compare_field(&mut fields, "entrypoint", &script.entrypoint, &fm.entrypoint);
|
compare_field(
|
||||||
|
&mut fields,
|
||||||
|
"entrypoint",
|
||||||
|
&script.entrypoint,
|
||||||
|
&fm.entrypoint,
|
||||||
|
);
|
||||||
compare_field(
|
compare_field(
|
||||||
&mut fields,
|
&mut fields,
|
||||||
"enabled",
|
"enabled",
|
||||||
@@ -494,10 +507,7 @@ fn detect_orphan_files(
|
|||||||
if !dir_path.exists() {
|
if !dir_path.exists() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for entry in WalkDir::new(&dir_path)
|
for entry in WalkDir::new(&dir_path).into_iter().filter_map(|e| e.ok()) {
|
||||||
.into_iter()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
{
|
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if !path.is_file() {
|
if !path.is_file() {
|
||||||
continue;
|
continue;
|
||||||
@@ -600,20 +610,20 @@ fn detect_orphan_files(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::db::Database;
|
||||||
use crate::db::fts::ensure_fts_tables;
|
use crate::db::fts::ensure_fts_tables;
|
||||||
use crate::db::queries::media::insert_media;
|
use crate::db::queries::media::insert_media;
|
||||||
use crate::db::queries::post::insert_post;
|
use crate::db::queries::post::insert_post;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
use crate::db::queries::script::insert_script;
|
use crate::db::queries::script::insert_script;
|
||||||
use crate::db::queries::template::insert_template;
|
use crate::db::queries::template::insert_template;
|
||||||
use crate::db::Database;
|
|
||||||
use crate::engine::post::{create_post, publish_post};
|
use crate::engine::post::{create_post, publish_post};
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
Media, Post, PostStatus, Script, ScriptKind, ScriptStatus, Template, TemplateKind,
|
Media, Post, PostStatus, Script, ScriptKind, ScriptStatus, Template, TemplateKind,
|
||||||
TemplateStatus,
|
TemplateStatus,
|
||||||
};
|
};
|
||||||
use crate::util::frontmatter::{
|
use crate::util::frontmatter::{
|
||||||
write_script_file, write_template_file, ScriptFrontmatter, TemplateFrontmatter,
|
ScriptFrontmatter, TemplateFrontmatter, write_script_file, write_template_file,
|
||||||
};
|
};
|
||||||
use crate::util::sidecar::MediaSidecar;
|
use crate::util::sidecar::MediaSidecar;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
@@ -658,11 +668,7 @@ mod tests {
|
|||||||
let non_time_diffs: Vec<_> = report
|
let non_time_diffs: Vec<_> = report
|
||||||
.diffs
|
.diffs
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|d| {
|
.filter(|d| d.fields.iter().any(|f| f.field_name != "updatedAt"))
|
||||||
d.fields
|
|
||||||
.iter()
|
|
||||||
.any(|f| f.field_name != "updatedAt")
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
assert!(
|
assert!(
|
||||||
non_time_diffs.is_empty(),
|
non_time_diffs.is_empty(),
|
||||||
@@ -786,7 +792,11 @@ mod tests {
|
|||||||
// Create a .md file in posts/ that is not in the DB
|
// Create a .md file in posts/ that is not in the DB
|
||||||
let posts_dir = dir.path().join("posts/2024/01");
|
let posts_dir = dir.path().join("posts/2024/01");
|
||||||
fs::create_dir_all(&posts_dir).unwrap();
|
fs::create_dir_all(&posts_dir).unwrap();
|
||||||
fs::write(posts_dir.join("orphan.md"), "---\ntitle: Orphan\n---\nBody\n").unwrap();
|
fs::write(
|
||||||
|
posts_dir.join("orphan.md"),
|
||||||
|
"---\ntitle: Orphan\n---\nBody\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
|
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
|
||||||
|
|
||||||
@@ -801,7 +811,9 @@ mod tests {
|
|||||||
"expected at least one orphan file"
|
"expected at least one orphan file"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
orphan_files.iter().any(|o| o.file_path.contains("orphan.md")),
|
orphan_files
|
||||||
|
.iter()
|
||||||
|
.any(|o| o.file_path.contains("orphan.md")),
|
||||||
"expected orphan.md in orphans, got: {orphan_files:?}"
|
"expected orphan.md in orphans, got: {orphan_files:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
pub mod error;
|
|
||||||
pub mod ai;
|
pub mod ai;
|
||||||
|
pub mod calendar;
|
||||||
pub mod context;
|
pub mod context;
|
||||||
pub mod project;
|
pub mod error;
|
||||||
pub mod meta;
|
pub mod generation;
|
||||||
pub mod tag;
|
|
||||||
pub mod post;
|
|
||||||
pub mod media;
|
pub mod media;
|
||||||
|
pub mod menu;
|
||||||
|
pub mod meta;
|
||||||
|
pub mod metadata_diff;
|
||||||
|
pub mod post;
|
||||||
pub mod post_media;
|
pub mod post_media;
|
||||||
pub mod template;
|
pub mod preview;
|
||||||
pub mod template_rebuild;
|
pub mod project;
|
||||||
|
pub mod rebuild;
|
||||||
pub mod script;
|
pub mod script;
|
||||||
pub mod script_rebuild;
|
pub mod script_rebuild;
|
||||||
pub mod task;
|
|
||||||
pub mod metadata_diff;
|
|
||||||
pub mod site_assets;
|
|
||||||
pub mod rebuild;
|
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod calendar;
|
pub mod site_assets;
|
||||||
pub mod generation;
|
pub mod tag;
|
||||||
pub mod preview;
|
pub mod task;
|
||||||
|
pub mod template;
|
||||||
|
pub mod template_rebuild;
|
||||||
|
pub mod validate_content;
|
||||||
|
pub mod validate_media;
|
||||||
pub mod validate_site;
|
pub mod validate_site;
|
||||||
pub mod validate_translations;
|
pub mod validate_translations;
|
||||||
pub mod validate_media;
|
|
||||||
pub mod validate_content;
|
|
||||||
pub mod menu;
|
|
||||||
|
|
||||||
pub use error::{EngineError, EngineResult};
|
|
||||||
pub use context::EngineContext;
|
pub use context::EngineContext;
|
||||||
|
pub use error::{EngineError, EngineResult};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use rusqlite::{params, Connection};
|
use rusqlite::{Connection, params};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
@@ -30,6 +30,10 @@ pub struct RebuildReport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new draft post.
|
/// Create a new draft post.
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_arguments,
|
||||||
|
reason = "arguments are the user-supplied post fields"
|
||||||
|
)]
|
||||||
pub fn create_post(
|
pub fn create_post(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
_data_dir: &Path,
|
_data_dir: &Path,
|
||||||
@@ -90,6 +94,10 @@ pub fn create_post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update a post's fields.
|
/// Update a post's fields.
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_arguments,
|
||||||
|
reason = "optional arguments represent independent post field changes"
|
||||||
|
)]
|
||||||
pub fn update_post(
|
pub fn update_post(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
_data_dir: &Path,
|
_data_dir: &Path,
|
||||||
@@ -115,15 +123,14 @@ pub fn update_post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Slug uniqueness check
|
// Slug uniqueness check
|
||||||
if let Some(new_slug) = slug {
|
if let Some(new_slug) = slug
|
||||||
if new_slug != post.slug {
|
&& new_slug != post.slug
|
||||||
if qp::get_post_by_project_and_slug(conn, &post.project_id, new_slug).is_ok() {
|
&& qp::get_post_by_project_and_slug(conn, &post.project_id, new_slug).is_ok()
|
||||||
|
{
|
||||||
return Err(EngineError::Conflict(format!(
|
return Err(EngineError::Conflict(format!(
|
||||||
"slug '{new_slug}' already exists in this project"
|
"slug '{new_slug}' already exists in this project"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(t) = title {
|
if let Some(t) = title {
|
||||||
post.title = t.to_string();
|
post.title = t.to_string();
|
||||||
@@ -161,14 +168,13 @@ pub fn update_post(
|
|||||||
// Reload content from filesystem if content field is NULL (published state)
|
// Reload content from filesystem if content field is NULL (published state)
|
||||||
if post.content.is_none() && !post.file_path.is_empty() {
|
if post.content.is_none() && !post.file_path.is_empty() {
|
||||||
let abs_path = _data_dir.join(&post.file_path);
|
let abs_path = _data_dir.join(&post.file_path);
|
||||||
if abs_path.exists() {
|
if abs_path.exists()
|
||||||
if let Ok(file_content) = fs::read_to_string(&abs_path) {
|
&& let Ok(file_content) = fs::read_to_string(&abs_path)
|
||||||
if let Ok((_fm, body)) = read_post_file(&file_content) {
|
&& let Ok((_fm, body)) = read_post_file(&file_content)
|
||||||
|
{
|
||||||
post.content = Some(body);
|
post.content = Some(body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
post.status = PostStatus::Draft;
|
post.status = PostStatus::Draft;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +213,7 @@ pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
|
|||||||
c.clone()
|
c.clone()
|
||||||
} else if abs_path.exists() {
|
} else if abs_path.exists() {
|
||||||
let file_content = fs::read_to_string(&abs_path)?;
|
let file_content = fs::read_to_string(&abs_path)?;
|
||||||
let (_fm, body) = read_post_file(&file_content).map_err(|e| EngineError::Parse(e))?;
|
let (_fm, body) = read_post_file(&file_content).map_err(EngineError::Parse)?;
|
||||||
body
|
body
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -310,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()
|
if post.status == PostStatus::Published && post.content.is_none() && !post.file_path.is_empty()
|
||||||
{
|
{
|
||||||
let abs_path = data_dir.join(&post.file_path);
|
let abs_path = data_dir.join(&post.file_path);
|
||||||
if abs_path.exists() {
|
if abs_path.exists()
|
||||||
if let Ok(file_content) = fs::read_to_string(&abs_path) {
|
&& let Ok(file_content) = fs::read_to_string(&abs_path)
|
||||||
if let Ok((_fm, body)) = read_post_file(&file_content) {
|
&& let Ok((_fm, body)) = read_post_file(&file_content)
|
||||||
|
{
|
||||||
post.content = Some(body);
|
post.content = Some(body);
|
||||||
qp::update_post(conn, &post)?;
|
qp::update_post(conn, &post)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
let now = now_unix_ms();
|
let now = now_unix_ms();
|
||||||
qp::update_post_status(conn, post_id, &PostStatus::Archived, now)?;
|
qp::update_post_status(conn, post_id, &PostStatus::Archived, now)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discard unpublished draft changes and restore the published version.
|
/// Discard unpublished draft changes and restore the published version.
|
||||||
pub fn discard_post_draft(
|
pub fn discard_post_draft(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<Post> {
|
||||||
conn: &Connection,
|
|
||||||
data_dir: &Path,
|
|
||||||
post_id: &str,
|
|
||||||
) -> EngineResult<Post> {
|
|
||||||
let mut post = qp::get_post_by_id(conn, post_id)?;
|
let mut post = qp::get_post_by_id(conn, post_id)?;
|
||||||
if post.published_at.is_none() {
|
if post.published_at.is_none() {
|
||||||
return Err(EngineError::Conflict(
|
return Err(EngineError::Conflict(
|
||||||
@@ -337,8 +338,18 @@ pub fn discard_post_draft(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let (title, slug, excerpt, author, language, template_slug, do_not_translate, tags, categories, checksum) =
|
let (
|
||||||
if !post.file_path.is_empty() {
|
title,
|
||||||
|
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);
|
let abs_path = data_dir.join(&post.file_path);
|
||||||
if abs_path.exists() {
|
if abs_path.exists() {
|
||||||
let raw = fs::read_to_string(&abs_path)?;
|
let raw = fs::read_to_string(&abs_path)?;
|
||||||
@@ -357,9 +368,13 @@ pub fn discard_post_draft(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
(
|
(
|
||||||
post.published_title.clone().unwrap_or_else(|| post.title.clone()),
|
post.published_title
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| post.title.clone()),
|
||||||
post.slug.clone(),
|
post.slug.clone(),
|
||||||
post.published_excerpt.clone().or_else(|| post.excerpt.clone()),
|
post.published_excerpt
|
||||||
|
.clone()
|
||||||
|
.or_else(|| post.excerpt.clone()),
|
||||||
post.author.clone(),
|
post.author.clone(),
|
||||||
post.language.clone(),
|
post.language.clone(),
|
||||||
post.template_slug.clone(),
|
post.template_slug.clone(),
|
||||||
@@ -377,9 +392,13 @@ pub fn discard_post_draft(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
(
|
(
|
||||||
post.published_title.clone().unwrap_or_else(|| post.title.clone()),
|
post.published_title
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| post.title.clone()),
|
||||||
post.slug.clone(),
|
post.slug.clone(),
|
||||||
post.published_excerpt.clone().or_else(|| post.excerpt.clone()),
|
post.published_excerpt
|
||||||
|
.clone()
|
||||||
|
.or_else(|| post.excerpt.clone()),
|
||||||
post.author.clone(),
|
post.author.clone(),
|
||||||
post.language.clone(),
|
post.language.clone(),
|
||||||
post.template_slug.clone(),
|
post.template_slug.clone(),
|
||||||
@@ -415,11 +434,7 @@ pub fn discard_post_draft(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new draft post by duplicating an existing post.
|
/// Create a new draft post by duplicating an existing post.
|
||||||
pub fn duplicate_post(
|
pub fn duplicate_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<Post> {
|
||||||
conn: &Connection,
|
|
||||||
data_dir: &Path,
|
|
||||||
post_id: &str,
|
|
||||||
) -> EngineResult<Post> {
|
|
||||||
let source = qp::get_post_by_id(conn, post_id)?;
|
let source = qp::get_post_by_id(conn, post_id)?;
|
||||||
let body = if let Some(ref content) = source.content {
|
let body = if let Some(ref content) = source.content {
|
||||||
content.clone()
|
content.clone()
|
||||||
@@ -529,7 +544,7 @@ pub fn canonical_url(created_at_ms: i64, slug: &str) -> String {
|
|||||||
/// Upsert a translation for a post.
|
/// Upsert a translation for a post.
|
||||||
pub fn upsert_translation(
|
pub fn upsert_translation(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
_data_dir: &Path,
|
data_dir: &Path,
|
||||||
post_id: &str,
|
post_id: &str,
|
||||||
language: &str,
|
language: &str,
|
||||||
title: &str,
|
title: &str,
|
||||||
@@ -548,7 +563,20 @@ pub fn upsert_translation(
|
|||||||
let existing = qt::get_post_translation_by_post_and_language(conn, post_id, language);
|
let existing = qt::get_post_translation_by_post_and_language(conn, post_id, language);
|
||||||
match existing {
|
match existing {
|
||||||
Ok(mut t) => {
|
Ok(mut t) => {
|
||||||
// Update existing
|
let published_body = if t.status == PostStatus::Published && !t.file_path.is_empty() {
|
||||||
|
let raw = fs::read_to_string(data_dir.join(&t.file_path))?;
|
||||||
|
let (_, body) = read_translation_file(&raw).map_err(EngineError::Parse)?;
|
||||||
|
Some(body)
|
||||||
|
} else {
|
||||||
|
t.content.clone()
|
||||||
|
};
|
||||||
|
let affects_published_content = t.title != title
|
||||||
|
|| t.excerpt.as_deref() != excerpt
|
||||||
|
|| content.is_some_and(|value| published_body.as_deref() != Some(value));
|
||||||
|
if t.status == PostStatus::Published && affects_published_content {
|
||||||
|
t.status = PostStatus::Draft;
|
||||||
|
t.content = published_body;
|
||||||
|
}
|
||||||
t.title = title.to_string();
|
t.title = title.to_string();
|
||||||
t.excerpt = excerpt.map(|s| s.to_string());
|
t.excerpt = excerpt.map(|s| s.to_string());
|
||||||
if let Some(c) = content {
|
if let Some(c) = content {
|
||||||
@@ -834,8 +862,7 @@ fn publish_translation(
|
|||||||
c.clone()
|
c.clone()
|
||||||
} else if abs_path.exists() {
|
} else if abs_path.exists() {
|
||||||
let file_content = fs::read_to_string(&abs_path)?;
|
let file_content = fs::read_to_string(&abs_path)?;
|
||||||
let (_fm, body) =
|
let (_fm, body) = read_translation_file(&file_content).map_err(EngineError::Parse)?;
|
||||||
read_translation_file(&file_content).map_err(|e| EngineError::Parse(e))?;
|
|
||||||
body
|
body
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -912,7 +939,7 @@ fn rebuild_canonical_post(
|
|||||||
path: &Path,
|
path: &Path,
|
||||||
) -> EngineResult<bool> {
|
) -> EngineResult<bool> {
|
||||||
let content = fs::read_to_string(path)?;
|
let content = fs::read_to_string(path)?;
|
||||||
let (fm, body) = read_post_file(&content).map_err(|e| EngineError::Parse(e))?;
|
let (fm, body) = read_post_file(&content).map_err(EngineError::Parse)?;
|
||||||
|
|
||||||
let rel_path = path
|
let rel_path = path
|
||||||
.strip_prefix(data_dir)
|
.strip_prefix(data_dir)
|
||||||
@@ -1002,7 +1029,7 @@ fn rebuild_translation(
|
|||||||
path: &Path,
|
path: &Path,
|
||||||
) -> EngineResult<bool> {
|
) -> EngineResult<bool> {
|
||||||
let content = fs::read_to_string(path)?;
|
let content = fs::read_to_string(path)?;
|
||||||
let (fm, body) = read_translation_file(&content).map_err(|e| EngineError::Parse(e))?;
|
let (fm, body) = read_translation_file(&content).map_err(EngineError::Parse)?;
|
||||||
|
|
||||||
let rel_path = path
|
let rel_path = path
|
||||||
.strip_prefix(data_dir)
|
.strip_prefix(data_dir)
|
||||||
@@ -1011,7 +1038,6 @@ fn rebuild_translation(
|
|||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let hash = content_hash(content.as_bytes());
|
let hash = content_hash(content.as_bytes());
|
||||||
let now = now_unix_ms();
|
|
||||||
|
|
||||||
// Check if parent post exists
|
// Check if parent post exists
|
||||||
let parent = qp::get_post_by_id(conn, &fm.translation_for);
|
let parent = qp::get_post_by_id(conn, &fm.translation_for);
|
||||||
@@ -1023,12 +1049,17 @@ fn rebuild_translation(
|
|||||||
}
|
}
|
||||||
let parent = parent.unwrap();
|
let parent = parent.unwrap();
|
||||||
|
|
||||||
// Determine status from parent
|
// Current files carry independent translation metadata. Legacy files fall back to the
|
||||||
let status = if parent.status == PostStatus::Published {
|
// canonical post values for compatibility.
|
||||||
PostStatus::Published
|
let status = match fm.status.as_deref() {
|
||||||
} else {
|
Some("published") => PostStatus::Published,
|
||||||
PostStatus::Draft
|
Some("draft") => PostStatus::Draft,
|
||||||
|
_ if parent.status == PostStatus::Published => PostStatus::Published,
|
||||||
|
_ => PostStatus::Draft,
|
||||||
};
|
};
|
||||||
|
let created_at = fm.created_at.unwrap_or(parent.created_at);
|
||||||
|
let updated_at = fm.updated_at.unwrap_or(parent.updated_at);
|
||||||
|
let published_at = fm.published_at.or(parent.published_at);
|
||||||
|
|
||||||
// Check if translation exists
|
// Check if translation exists
|
||||||
let existing =
|
let existing =
|
||||||
@@ -1045,13 +1076,15 @@ fn rebuild_translation(
|
|||||||
t.status = status;
|
t.status = status;
|
||||||
t.file_path = rel_path;
|
t.file_path = rel_path;
|
||||||
t.checksum = Some(hash);
|
t.checksum = Some(hash);
|
||||||
t.updated_at = now;
|
t.created_at = created_at;
|
||||||
|
t.updated_at = updated_at;
|
||||||
|
t.published_at = published_at;
|
||||||
qt::update_post_translation(conn, &t)?;
|
qt::update_post_translation(conn, &t)?;
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let t = PostTranslation {
|
let t = PostTranslation {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: fm.id.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
||||||
project_id: project_id.to_string(),
|
project_id: project_id.to_string(),
|
||||||
translation_for: fm.translation_for,
|
translation_for: fm.translation_for,
|
||||||
language: fm.language,
|
language: fm.language,
|
||||||
@@ -1065,13 +1098,9 @@ fn rebuild_translation(
|
|||||||
status,
|
status,
|
||||||
file_path: rel_path,
|
file_path: rel_path,
|
||||||
checksum: Some(hash),
|
checksum: Some(hash),
|
||||||
created_at: now,
|
created_at,
|
||||||
updated_at: now,
|
updated_at,
|
||||||
published_at: if parent.published_at.is_some() {
|
published_at,
|
||||||
Some(now)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
qt::insert_post_translation(conn, &t)?;
|
qt::insert_post_translation(conn, &t)?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -1093,15 +1122,9 @@ pub fn post_insert_link(slug: &str) -> String {
|
|||||||
/// Returns the Markdown syntax:  or [name](bds-media://id)
|
/// Returns the Markdown syntax:  or [name](bds-media://id)
|
||||||
pub fn post_insert_media(media_id: &str, is_image: bool, original_name: &str) -> String {
|
pub fn post_insert_media(media_id: &str, is_image: bool, original_name: &str) -> String {
|
||||||
if is_image {
|
if is_image {
|
||||||
format!(
|
format!("")
|
||||||
"",
|
|
||||||
bds_media_url = format!("bds-media://{media_id}")
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!("[{original_name}](bds-media://{media_id})")
|
||||||
"[{original_name}]({bds_media_url})",
|
|
||||||
bds_media_url = format!("bds-media://{media_id}")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1120,9 +1143,9 @@ pub fn list_post_backlinks(conn: &Connection, post_id: &str) -> EngineResult<Vec
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::db::Database;
|
||||||
use crate::db::fts::ensure_fts_tables;
|
use crate::db::fts::ensure_fts_tables;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
use crate::db::Database;
|
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
@@ -1584,6 +1607,49 @@ mod tests {
|
|||||||
assert_eq!(t2.title, "Neuer Titel");
|
assert_eq!(t2.title, "Neuer Titel");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editing_published_translation_reopens_draft() {
|
||||||
|
let (db, dir) = setup();
|
||||||
|
let post = create_post(
|
||||||
|
db.conn(),
|
||||||
|
dir.path(),
|
||||||
|
"p1",
|
||||||
|
"Parent",
|
||||||
|
Some("body"),
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
Some("en"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
upsert_translation(
|
||||||
|
db.conn(),
|
||||||
|
dir.path(),
|
||||||
|
&post.id,
|
||||||
|
"de",
|
||||||
|
"Titel",
|
||||||
|
None,
|
||||||
|
Some("Inhalt"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||||
|
|
||||||
|
let edited = upsert_translation(
|
||||||
|
db.conn(),
|
||||||
|
dir.path(),
|
||||||
|
&post.id,
|
||||||
|
"de",
|
||||||
|
"Neuer Titel",
|
||||||
|
None,
|
||||||
|
Some("Neuer Inhalt"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(edited.status, PostStatus::Draft);
|
||||||
|
assert_eq!(edited.content.as_deref(), Some("Neuer Inhalt"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn delete_translation_removes_file_and_db() {
|
fn delete_translation_removes_file_and_db() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
|
|||||||
@@ -107,10 +107,10 @@ mod tests {
|
|||||||
|
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
use crate::db::Database;
|
||||||
use crate::db::queries::media::{insert_media, make_test_media};
|
use crate::db::queries::media::{insert_media, make_test_media};
|
||||||
use crate::db::queries::post_media::list_post_media_by_post;
|
use crate::db::queries::post_media::list_post_media_by_post;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
use crate::db::Database;
|
|
||||||
use crate::util::sidecar::read_sidecar;
|
use crate::util::sidecar::read_sidecar;
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
@@ -182,12 +182,7 @@ mod tests {
|
|||||||
link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m2", 1).unwrap();
|
link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m2", 1).unwrap();
|
||||||
|
|
||||||
// Reverse the order: m2 first, m1 second
|
// Reverse the order: m2 first, m1 second
|
||||||
reorder_post_media(
|
reorder_post_media(db.conn(), "post1", &["m2".to_string(), "m1".to_string()]).unwrap();
|
||||||
db.conn(),
|
|
||||||
"post1",
|
|
||||||
&["m2".to_string(), "m1".to_string()],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let list = list_post_media_by_post(db.conn(), "post1").unwrap();
|
let list = list_post_media_by_post(db.conn(), "post1").unwrap();
|
||||||
assert_eq!(list.len(), 2);
|
assert_eq!(list.len(), 2);
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ use std::fs;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
use axum::extract::{Path as AxumPath, Query, State};
|
use axum::extract::{Path as AxumPath, Query, State};
|
||||||
use axum::http::{StatusCode, Uri, header};
|
use axum::http::{StatusCode, Uri, header};
|
||||||
use axum::response::{Html, IntoResponse, Response};
|
use axum::response::{Html, IntoResponse, Response};
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use axum::Router;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
@@ -81,7 +81,9 @@ pub fn start_preview_server(
|
|||||||
};
|
};
|
||||||
let listener = std::net::TcpListener::bind((PREVIEW_HOST, PREVIEW_PORT)).map_err(|error| {
|
let listener = std::net::TcpListener::bind((PREVIEW_HOST, PREVIEW_PORT)).map_err(|error| {
|
||||||
if error.kind() == std::io::ErrorKind::AddrInUse {
|
if error.kind() == std::io::ErrorKind::AddrInUse {
|
||||||
EngineError::Conflict(format!("preview server already running on {PREVIEW_HOST}:{PREVIEW_PORT}"))
|
EngineError::Conflict(format!(
|
||||||
|
"preview server already running on {PREVIEW_HOST}:{PREVIEW_PORT}"
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
EngineError::Io(error)
|
EngineError::Io(error)
|
||||||
}
|
}
|
||||||
@@ -135,23 +137,23 @@ async fn handle_draft_preview(
|
|||||||
theme: query.theme.clone(),
|
theme: query.theme.clone(),
|
||||||
mode: query.mode.clone(),
|
mode: query.mode.clone(),
|
||||||
};
|
};
|
||||||
match render_preview_response(&state, &format!("/__draft/{post_id}"), query.language.as_deref(), Some(&style)) {
|
match render_preview_response(
|
||||||
|
&state,
|
||||||
|
&format!("/__draft/{post_id}"),
|
||||||
|
query.language.as_deref(),
|
||||||
|
Some(&style),
|
||||||
|
) {
|
||||||
Ok(response) => response,
|
Ok(response) => response,
|
||||||
Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_style_preview(
|
async fn handle_style_preview(Query(query): Query<StylePreviewQuery>) -> Response {
|
||||||
Query(query): Query<StylePreviewQuery>,
|
|
||||||
) -> Response {
|
|
||||||
let theme = query.theme.unwrap_or_else(|| "default".to_string());
|
let theme = query.theme.unwrap_or_else(|| "default".to_string());
|
||||||
let mode = query.mode.unwrap_or_else(|| "auto".to_string());
|
let mode = query.mode.unwrap_or_else(|| "auto".to_string());
|
||||||
let html = format!(
|
let html = format!(
|
||||||
"<!doctype html><html lang=\"en\" data-theme=\"{}\" data-mode=\"{}\"><head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /><title>Style Preview</title><link rel=\"stylesheet\" href=\"/assets/pico.min.css\" /><link rel=\"stylesheet\" href=\"/assets/bds.css\" /></head><body><main><section class=\"style-preview\"><h1>Style Preview</h1><p>Theme: {}</p><p>Mode: {}</p></section></main></body></html>",
|
"<!doctype html><html lang=\"en\" data-theme=\"{}\" data-mode=\"{}\"><head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /><title>Style Preview</title><link rel=\"stylesheet\" href=\"/assets/pico.min.css\" /><link rel=\"stylesheet\" href=\"/assets/bds.css\" /></head><body><main><section class=\"style-preview\"><h1>Style Preview</h1><p>Theme: {}</p><p>Mode: {}</p></section></main></body></html>",
|
||||||
theme,
|
theme, mode, theme, mode,
|
||||||
mode,
|
|
||||||
theme,
|
|
||||||
mode,
|
|
||||||
);
|
);
|
||||||
Html(html).into_response()
|
Html(html).into_response()
|
||||||
}
|
}
|
||||||
@@ -163,7 +165,10 @@ fn render_preview_response(
|
|||||||
style: Option<&StylePreviewQuery>,
|
style: Option<&StylePreviewQuery>,
|
||||||
) -> EngineResult<Response> {
|
) -> EngineResult<Response> {
|
||||||
if let Some(post_id) = path.strip_prefix("/__draft/") {
|
if let Some(post_id) = path.strip_prefix("/__draft/") {
|
||||||
let html = apply_preview_style(render_draft_preview(state, post_id, requested_language)?, style);
|
let html = apply_preview_style(
|
||||||
|
render_draft_preview(state, post_id, requested_language)?,
|
||||||
|
style,
|
||||||
|
);
|
||||||
return Ok(Html(html).into_response());
|
return Ok(Html(html).into_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +183,14 @@ fn render_preview_response(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let response = build_preview_response(db.conn(), &state.data_dir, &state.project_id, &metadata, &input_posts, path)
|
let response = build_preview_response(
|
||||||
|
db.conn(),
|
||||||
|
&state.data_dir,
|
||||||
|
&state.project_id,
|
||||||
|
&metadata,
|
||||||
|
&input_posts,
|
||||||
|
path,
|
||||||
|
)
|
||||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
let status = StatusCode::from_u16(response.status_code).unwrap_or(StatusCode::OK);
|
let status = StatusCode::from_u16(response.status_code).unwrap_or(StatusCode::OK);
|
||||||
Ok((status, Html(apply_preview_style(response.html, style))).into_response())
|
Ok((status, Html(apply_preview_style(response.html, style))).into_response())
|
||||||
@@ -188,7 +200,9 @@ fn apply_preview_style(html: String, style: Option<&StylePreviewQuery>) -> Strin
|
|||||||
let Some(style) = style else {
|
let Some(style) = style else {
|
||||||
return html;
|
return html;
|
||||||
};
|
};
|
||||||
if style.theme.as_deref().unwrap_or("").is_empty() && style.mode.as_deref().unwrap_or("").is_empty() {
|
if style.theme.as_deref().unwrap_or("").is_empty()
|
||||||
|
&& style.mode.as_deref().unwrap_or("").is_empty()
|
||||||
|
{
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,15 +252,20 @@ fn render_draft_preview(
|
|||||||
let db = Database::open(&state.db_path)?;
|
let db = Database::open(&state.db_path)?;
|
||||||
let metadata = crate::engine::meta::read_project_json(&state.data_dir)?;
|
let metadata = crate::engine::meta::read_project_json(&state.data_dir)?;
|
||||||
let post = queries::post::get_post_by_id(db.conn(), post_id)?;
|
let post = queries::post::get_post_by_id(db.conn(), post_id)?;
|
||||||
let canonical_language = post.language.as_deref().unwrap_or_else(|| metadata.main_language.as_deref().unwrap_or("en"));
|
let canonical_language = post
|
||||||
|
.language
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_else(|| metadata.main_language.as_deref().unwrap_or("en"));
|
||||||
let target_language = requested_language.unwrap_or(canonical_language);
|
let target_language = requested_language.unwrap_or(canonical_language);
|
||||||
|
|
||||||
if target_language != canonical_language {
|
if target_language != canonical_language
|
||||||
if let Ok(translation) = queries::post_translation::get_post_translation_by_post_and_language(
|
&& let Ok(translation) =
|
||||||
|
queries::post_translation::get_post_translation_by_post_and_language(
|
||||||
db.conn(),
|
db.conn(),
|
||||||
post_id,
|
post_id,
|
||||||
target_language,
|
target_language,
|
||||||
) {
|
)
|
||||||
|
{
|
||||||
let mut translated_post = post.clone();
|
let mut translated_post = post.clone();
|
||||||
translated_post.title = translation.title.clone();
|
translated_post.title = translation.title.clone();
|
||||||
translated_post.excerpt = translation.excerpt.clone();
|
translated_post.excerpt = translation.excerpt.clone();
|
||||||
@@ -261,12 +280,15 @@ fn render_draft_preview(
|
|||||||
&state.project_id,
|
&state.project_id,
|
||||||
&metadata,
|
&metadata,
|
||||||
&[(translated_post, body)],
|
&[(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()))?;
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
return Ok(response.html);
|
return Ok(response.html);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let body = load_post_body(&state.data_dir, &post)?;
|
let body = load_post_body(&state.data_dir, &post)?;
|
||||||
let response = build_preview_response(
|
let response = build_preview_response(
|
||||||
@@ -275,7 +297,11 @@ fn render_draft_preview(
|
|||||||
&state.project_id,
|
&state.project_id,
|
||||||
&metadata,
|
&metadata,
|
||||||
&[(post.clone(), body)],
|
&[(post.clone(), body)],
|
||||||
&crate::render::build_canonical_post_path(&post, canonical_language, metadata.main_language.as_deref().unwrap_or("en")),
|
&crate::render::build_canonical_post_path(
|
||||||
|
&post,
|
||||||
|
canonical_language,
|
||||||
|
metadata.main_language.as_deref().unwrap_or("en"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
Ok(response.html)
|
Ok(response.html)
|
||||||
@@ -288,7 +314,10 @@ fn collect_published_posts(
|
|||||||
let db = Database::open(&state.db_path)?;
|
let db = Database::open(&state.db_path)?;
|
||||||
let posts = queries::post::list_posts_by_project(db.conn(), &state.project_id)?;
|
let posts = queries::post::list_posts_by_project(db.conn(), &state.project_id)?;
|
||||||
let mut published = Vec::new();
|
let mut published = Vec::new();
|
||||||
for post in posts.into_iter().filter(|post| matches!(post.status, PostStatus::Published)) {
|
for post in posts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|post| matches!(post.status, PostStatus::Published))
|
||||||
|
{
|
||||||
published.push(PublishedPostSource {
|
published.push(PublishedPostSource {
|
||||||
body_markdown: load_post_body(&state.data_dir, &post)?,
|
body_markdown: load_post_body(&state.data_dir, &post)?,
|
||||||
post,
|
post,
|
||||||
@@ -318,7 +347,11 @@ fn load_translation_body(
|
|||||||
load_markdown_body(data_dir, &translation.file_path, true)
|
load_markdown_body(data_dir, &translation.file_path, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_markdown_body(data_dir: &Path, relative_path: &str, translation: bool) -> EngineResult<String> {
|
fn load_markdown_body(
|
||||||
|
data_dir: &Path,
|
||||||
|
relative_path: &str,
|
||||||
|
translation: bool,
|
||||||
|
) -> EngineResult<String> {
|
||||||
let raw = fs::read_to_string(data_dir.join(relative_path.trim_start_matches('/')))?;
|
let raw = fs::read_to_string(data_dir.join(relative_path.trim_start_matches('/')))?;
|
||||||
let body = if translation {
|
let body = if translation {
|
||||||
read_translation_file(&raw).map(|(_, body)| body)
|
read_translation_file(&raw).map(|(_, body)| body)
|
||||||
@@ -351,25 +384,32 @@ fn serve_scoped_file(
|
|||||||
let scope_root = data_dir.join(scope_dir);
|
let scope_root = data_dir.join(scope_dir);
|
||||||
let candidate = scope_root.join(relative);
|
let candidate = scope_root.join(relative);
|
||||||
if !candidate.exists() || !candidate.is_file() {
|
if !candidate.exists() || !candidate.is_file() {
|
||||||
return Ok(Some(error_response(StatusCode::NOT_FOUND, "preview asset not found")));
|
return Ok(Some(error_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"preview asset not found",
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
let canonical_candidate = candidate.canonicalize()?;
|
let canonical_candidate = candidate.canonicalize()?;
|
||||||
let canonical_scope_root = scope_root.canonicalize().unwrap_or(scope_root);
|
let canonical_scope_root = scope_root.canonicalize().unwrap_or(scope_root);
|
||||||
if !canonical_candidate.starts_with(&canonical_scope_root) {
|
if !canonical_candidate.starts_with(&canonical_scope_root) {
|
||||||
return Ok(Some(error_response(StatusCode::NOT_FOUND, "preview asset not found")));
|
return Ok(Some(error_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"preview asset not found",
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
let bytes = fs::read(&canonical_candidate)?;
|
let bytes = fs::read(&canonical_candidate)?;
|
||||||
let mime = guess_content_type(&canonical_candidate);
|
let mime = guess_content_type(&canonical_candidate);
|
||||||
Ok(Some((
|
Ok(Some(
|
||||||
StatusCode::OK,
|
(StatusCode::OK, [(header::CONTENT_TYPE, mime)], bytes).into_response(),
|
||||||
[(header::CONTENT_TYPE, mime)],
|
))
|
||||||
bytes,
|
|
||||||
)
|
|
||||||
.into_response()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn guess_content_type(path: &Path) -> &'static str {
|
fn guess_content_type(path: &Path) -> &'static str {
|
||||||
match path.extension().and_then(|ext| ext.to_str()).unwrap_or_default() {
|
match path
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
{
|
||||||
"css" => "text/css; charset=utf-8",
|
"css" => "text/css; charset=utf-8",
|
||||||
"js" => "application/javascript; charset=utf-8",
|
"js" => "application/javascript; charset=utf-8",
|
||||||
"json" => "application/json; charset=utf-8",
|
"json" => "application/json; charset=utf-8",
|
||||||
@@ -383,7 +423,12 @@ fn guess_content_type(path: &Path) -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn error_response(status: StatusCode, message: &str) -> Response {
|
fn error_response(status: StatusCode, message: &str) -> Response {
|
||||||
(status, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], message.to_string()).into_response()
|
(
|
||||||
|
status,
|
||||||
|
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
|
||||||
|
message.to_string(),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -391,7 +436,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::queries;
|
use crate::db::queries;
|
||||||
use crate::engine::meta;
|
use crate::engine::meta;
|
||||||
use crate::model::{Post, Project, ProjectMetadata, PostStatus};
|
use crate::model::{Post, PostStatus, Project, ProjectMetadata};
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
fn preview_port_guard() -> &'static Mutex<()> {
|
fn preview_port_guard() -> &'static Mutex<()> {
|
||||||
@@ -504,7 +549,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn root_preview_renders_index_page() {
|
fn root_preview_renders_index_page() {
|
||||||
let db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
let html = build_preview_response(db.conn(), Path::new("."), "project-1", &make_metadata(), &[(make_post().post, make_post().body_markdown)], "/")
|
let html = build_preview_response(
|
||||||
|
db.conn(),
|
||||||
|
Path::new("."),
|
||||||
|
"project-1",
|
||||||
|
&make_metadata(),
|
||||||
|
&[(make_post().post, make_post().body_markdown)],
|
||||||
|
"/",
|
||||||
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.html;
|
.html;
|
||||||
assert!(html.contains("post-list"));
|
assert!(html.contains("post-list"));
|
||||||
@@ -568,12 +620,16 @@ mod tests {
|
|||||||
let client = reqwest::blocking::Client::new();
|
let client = reqwest::blocking::Client::new();
|
||||||
let mut body = None;
|
let mut body = None;
|
||||||
for _ in 0..20 {
|
for _ in 0..20 {
|
||||||
if let Ok(response) = client.get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/__draft/post-1")).send() {
|
if let Ok(response) = client
|
||||||
if response.status().is_success() {
|
.get(format!(
|
||||||
|
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/__draft/post-1"
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
&& response.status().is_success()
|
||||||
|
{
|
||||||
body = Some(response.text().unwrap());
|
body = Some(response.text().unwrap());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||||
}
|
}
|
||||||
server.stop().unwrap();
|
server.stop().unwrap();
|
||||||
@@ -599,7 +655,9 @@ mod tests {
|
|||||||
|
|
||||||
let client = reqwest::blocking::Client::new();
|
let client = reqwest::blocking::Client::new();
|
||||||
let response = client
|
let response = client
|
||||||
.get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/media/../outside.txt"))
|
.get(format!(
|
||||||
|
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/media/../outside.txt"
|
||||||
|
))
|
||||||
.send()
|
.send()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
server.stop().unwrap();
|
server.stop().unwrap();
|
||||||
@@ -628,7 +686,9 @@ mod tests {
|
|||||||
.send()
|
.send()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let asset = client
|
let asset = client
|
||||||
.get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/assets/site.css"))
|
.get(format!(
|
||||||
|
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/assets/site.css"
|
||||||
|
))
|
||||||
.send()
|
.send()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let media_body = media.text().unwrap();
|
let media_body = media.text().unwrap();
|
||||||
@@ -739,7 +799,10 @@ mod tests {
|
|||||||
dir.path(),
|
dir.path(),
|
||||||
"project-1",
|
"project-1",
|
||||||
&make_metadata(),
|
&make_metadata(),
|
||||||
&[(hidden.post.clone(), hidden.body_markdown.clone()), (featured.post.clone(), featured.body_markdown.clone())],
|
&[
|
||||||
|
(hidden.post.clone(), hidden.body_markdown.clone()),
|
||||||
|
(featured.post.clone(), featured.body_markdown.clone()),
|
||||||
|
],
|
||||||
"/category/hidden",
|
"/category/hidden",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -750,7 +813,10 @@ mod tests {
|
|||||||
dir.path(),
|
dir.path(),
|
||||||
"project-1",
|
"project-1",
|
||||||
&make_metadata(),
|
&make_metadata(),
|
||||||
&[(hidden.post, hidden.body_markdown), (featured.post, featured.body_markdown)],
|
&[
|
||||||
|
(hidden.post, hidden.body_markdown),
|
||||||
|
(featured.post, featured.body_markdown),
|
||||||
|
],
|
||||||
"/category/featured",
|
"/category/featured",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -893,6 +959,10 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(response.status_code, 404);
|
assert_eq!(response.status_code, 404);
|
||||||
assert!(response.html.contains("No rendered page exists for /missing/route"));
|
assert!(
|
||||||
|
response
|
||||||
|
.html
|
||||||
|
.contains("No rendered page exists for /missing/route")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@ use crate::engine::site_assets::copy_bundled_site_assets;
|
|||||||
use crate::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult};
|
||||||
use crate::model::Project;
|
use crate::model::Project;
|
||||||
use crate::model::metadata::ProjectMetadata;
|
use crate::model::metadata::ProjectMetadata;
|
||||||
use crate::util::{atomic_write_str, now_unix_ms, slugify, ensure_unique};
|
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
|
||||||
|
|
||||||
/// The well-known ID of the default project (spec: DefaultProjectExists).
|
/// The well-known ID of the default project (spec: DefaultProjectExists).
|
||||||
pub const DEFAULT_PROJECT_ID: &str = "default";
|
pub const DEFAULT_PROJECT_ID: &str = "default";
|
||||||
@@ -113,7 +113,11 @@ pub fn list_projects(conn: &Connection) -> EngineResult<Vec<Project>> {
|
|||||||
/// Delete a project row (cascading handled by queries).
|
/// Delete a project row (cascading handled by queries).
|
||||||
/// Rejects deletion of the default project and the currently active project.
|
/// Rejects deletion of the default project and the currently active project.
|
||||||
/// Only cleans up the internal project data directory (not external custom paths).
|
/// Only cleans up the internal project data directory (not external custom paths).
|
||||||
pub fn delete_project(conn: &Connection, project_id: &str, internal_data_dir: Option<&Path>) -> EngineResult<()> {
|
pub fn delete_project(
|
||||||
|
conn: &Connection,
|
||||||
|
project_id: &str,
|
||||||
|
internal_data_dir: Option<&Path>,
|
||||||
|
) -> EngineResult<()> {
|
||||||
// Cannot delete the default project
|
// Cannot delete the default project
|
||||||
if project_id == DEFAULT_PROJECT_ID {
|
if project_id == DEFAULT_PROJECT_ID {
|
||||||
return Err(EngineError::Validation(
|
return Err(EngineError::Validation(
|
||||||
@@ -122,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)
|
// Check if this is the active project (don't delete active)
|
||||||
if let Ok(active) = q::get_active_project(conn) {
|
if let Ok(active) = q::get_active_project(conn)
|
||||||
if active.id == project_id {
|
&& active.id == project_id
|
||||||
|
{
|
||||||
return Err(EngineError::Validation(
|
return Err(EngineError::Validation(
|
||||||
"cannot delete the active project".to_string(),
|
"cannot delete the active project".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Only delete internal data directory, never external custom data_path.
|
// Only delete internal data directory, never external custom data_path.
|
||||||
// The caller must pass the internal path only.
|
// 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)?;
|
q::delete_project(conn, project_id)?;
|
||||||
|
|
||||||
// Clean up internal filesystem only (not custom external paths per spec)
|
// Clean up internal filesystem only (not custom external paths per spec)
|
||||||
if !is_custom_path {
|
if !is_custom_path
|
||||||
if let Some(dir) = internal_data_dir {
|
&& let Some(dir) = internal_data_dir
|
||||||
if dir.exists() {
|
&& dir.exists()
|
||||||
|
{
|
||||||
let _ = fs::remove_dir_all(dir);
|
let _ = fs::remove_dir_all(dir);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -153,7 +156,15 @@ pub fn delete_project(conn: &Connection, project_id: &str, internal_data_dir: Op
|
|||||||
// ── helpers ──────────────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn create_directory_structure(data_dir: &Path) -> EngineResult<()> {
|
fn create_directory_structure(data_dir: &Path) -> EngineResult<()> {
|
||||||
let subdirs = ["posts", "media", "meta", "thumbnails", "templates", "scripts", "assets"];
|
let subdirs = [
|
||||||
|
"posts",
|
||||||
|
"media",
|
||||||
|
"meta",
|
||||||
|
"thumbnails",
|
||||||
|
"templates",
|
||||||
|
"scripts",
|
||||||
|
"assets",
|
||||||
|
];
|
||||||
for sub in &subdirs {
|
for sub in &subdirs {
|
||||||
fs::create_dir_all(data_dir.join(sub))?;
|
fs::create_dir_all(data_dir.join(sub))?;
|
||||||
}
|
}
|
||||||
@@ -215,15 +226,36 @@ fn copy_starter_templates(data_dir: &Path) -> EngineResult<()> {
|
|||||||
|
|
||||||
// Starter templates embedded at compile time from assets/starter-templates/
|
// Starter templates embedded at compile time from assets/starter-templates/
|
||||||
let templates: &[(&str, &str)] = &[
|
let templates: &[(&str, &str)] = &[
|
||||||
("single-post.liquid", include_str!("../../../../assets/starter-templates/single-post.liquid")),
|
(
|
||||||
("post-list.liquid", include_str!("../../../../assets/starter-templates/post-list.liquid")),
|
"single-post.liquid",
|
||||||
("not-found.liquid", include_str!("../../../../assets/starter-templates/not-found.liquid")),
|
include_str!("../../../../assets/starter-templates/single-post.liquid"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"post-list.liquid",
|
||||||
|
include_str!("../../../../assets/starter-templates/post-list.liquid"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"not-found.liquid",
|
||||||
|
include_str!("../../../../assets/starter-templates/not-found.liquid"),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
let partials: &[(&str, &str)] = &[
|
let partials: &[(&str, &str)] = &[
|
||||||
("head.liquid", include_str!("../../../../assets/starter-templates/partials/head.liquid")),
|
(
|
||||||
("menu.liquid", include_str!("../../../../assets/starter-templates/partials/menu.liquid")),
|
"head.liquid",
|
||||||
("menu-items.liquid", include_str!("../../../../assets/starter-templates/partials/menu-items.liquid")),
|
include_str!("../../../../assets/starter-templates/partials/head.liquid"),
|
||||||
("language-switcher.liquid", include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid")),
|
),
|
||||||
|
(
|
||||||
|
"menu.liquid",
|
||||||
|
include_str!("../../../../assets/starter-templates/partials/menu.liquid"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"menu-items.liquid",
|
||||||
|
include_str!("../../../../assets/starter-templates/partials/menu-items.liquid"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"language-switcher.liquid",
|
||||||
|
include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid"),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (name, content) in templates {
|
for (name, content) in templates {
|
||||||
@@ -258,12 +290,8 @@ mod tests {
|
|||||||
fn create_project_inserts_and_creates_dirs() {
|
fn create_project_inserts_and_creates_dirs() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
let data_path = dir.path().join("my-blog");
|
let data_path = dir.path().join("my-blog");
|
||||||
let project = create_project(
|
let project =
|
||||||
db.conn(),
|
create_project(db.conn(), "My Blog", Some(data_path.to_str().unwrap())).unwrap();
|
||||||
"My Blog",
|
|
||||||
Some(data_path.to_str().unwrap()),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(project.name, "My Blog");
|
assert_eq!(project.name, "My Blog");
|
||||||
assert_eq!(project.slug, "my-blog");
|
assert_eq!(project.slug, "my-blog");
|
||||||
@@ -287,13 +315,15 @@ mod tests {
|
|||||||
assert_eq!(project_json["name"], "My Blog");
|
assert_eq!(project_json["name"], "My Blog");
|
||||||
assert_eq!(project_json["maxPostsPerPage"], 50);
|
assert_eq!(project_json["maxPostsPerPage"], 50);
|
||||||
|
|
||||||
let cats: Vec<String> =
|
let cats: Vec<String> = serde_json::from_str(
|
||||||
serde_json::from_str(&fs::read_to_string(data_path.join("meta/categories.json")).unwrap())
|
&fs::read_to_string(data_path.join("meta/categories.json")).unwrap(),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(cats, vec!["article", "aside", "page", "picture"]);
|
assert_eq!(cats, vec!["article", "aside", "page", "picture"]);
|
||||||
|
|
||||||
let cat_meta: serde_json::Value =
|
let cat_meta: serde_json::Value = serde_json::from_str(
|
||||||
serde_json::from_str(&fs::read_to_string(data_path.join("meta/category-meta.json")).unwrap())
|
&fs::read_to_string(data_path.join("meta/category-meta.json")).unwrap(),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(cat_meta.as_object().unwrap().is_empty());
|
assert!(cat_meta.as_object().unwrap().is_empty());
|
||||||
|
|
||||||
@@ -364,7 +394,10 @@ mod tests {
|
|||||||
assert!(internal_dir.join("posts").is_dir());
|
assert!(internal_dir.join("posts").is_dir());
|
||||||
delete_project(db.conn(), &project.id, Some(&internal_dir)).unwrap();
|
delete_project(db.conn(), &project.id, Some(&internal_dir)).unwrap();
|
||||||
assert!(list_projects(db.conn()).unwrap().is_empty());
|
assert!(list_projects(db.conn()).unwrap().is_empty());
|
||||||
assert!(!internal_dir.exists(), "internal directory should be cleaned up");
|
assert!(
|
||||||
|
!internal_dir.exists(),
|
||||||
|
"internal directory should be cleaned up"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ use std::sync::Arc;
|
|||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
use crate::db::fts;
|
use crate::db::fts;
|
||||||
|
use crate::engine::EngineResult;
|
||||||
use crate::engine::media;
|
use crate::engine::media;
|
||||||
use crate::engine::post;
|
use crate::engine::post;
|
||||||
use crate::engine::script_rebuild;
|
use crate::engine::script_rebuild;
|
||||||
use crate::engine::template_rebuild;
|
use crate::engine::template_rebuild;
|
||||||
use crate::engine::EngineResult;
|
|
||||||
|
|
||||||
/// Report from a full rebuild operation.
|
/// Report from a full rebuild operation.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -68,7 +68,11 @@ pub fn rebuild_from_filesystem_with_progress(
|
|||||||
let post_item_cb: Option<post::ItemProgressFn> = on_progress.as_ref().map(|cb| {
|
let post_item_cb: Option<post::ItemProgressFn> = on_progress.as_ref().map(|cb| {
|
||||||
let cb = Arc::clone(cb);
|
let cb = Arc::clone(cb);
|
||||||
let f: post::ItemProgressFn = Box::new(move |current, total, name| {
|
let f: post::ItemProgressFn = Box::new(move |current, total, name| {
|
||||||
let phase_pct = if total > 0 { current as f32 / total as f32 } else { 1.0 };
|
let phase_pct = if total > 0 {
|
||||||
|
current as f32 / total as f32
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
let global_pct = 0.01 + phase_pct * 0.34;
|
let global_pct = 0.01 + phase_pct * 0.34;
|
||||||
let msg = format!("Posts: {current}/{total} \u{2014} {name}");
|
let msg = format!("Posts: {current}/{total} \u{2014} {name}");
|
||||||
cb(global_pct, &msg);
|
cb(global_pct, &msg);
|
||||||
@@ -76,7 +80,10 @@ pub fn rebuild_from_filesystem_with_progress(
|
|||||||
f
|
f
|
||||||
});
|
});
|
||||||
let post_report = post::rebuild_posts_from_filesystem_with_progress(
|
let post_report = post::rebuild_posts_from_filesystem_with_progress(
|
||||||
conn, data_dir, project_id, post_item_cb,
|
conn,
|
||||||
|
data_dir,
|
||||||
|
project_id,
|
||||||
|
post_item_cb,
|
||||||
)?;
|
)?;
|
||||||
report.posts_created = post_report.posts_created;
|
report.posts_created = post_report.posts_created;
|
||||||
report.posts_updated = post_report.posts_updated;
|
report.posts_updated = post_report.posts_updated;
|
||||||
@@ -89,7 +96,11 @@ pub fn rebuild_from_filesystem_with_progress(
|
|||||||
let media_item_cb: Option<media::ItemProgressFn> = on_progress.as_ref().map(|cb| {
|
let media_item_cb: Option<media::ItemProgressFn> = on_progress.as_ref().map(|cb| {
|
||||||
let cb = Arc::clone(cb);
|
let cb = Arc::clone(cb);
|
||||||
let f: media::ItemProgressFn = Box::new(move |current, total, name| {
|
let f: media::ItemProgressFn = Box::new(move |current, total, name| {
|
||||||
let phase_pct = if total > 0 { current as f32 / total as f32 } else { 1.0 };
|
let phase_pct = if total > 0 {
|
||||||
|
current as f32 / total as f32
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
let global_pct = 0.35 + phase_pct * 0.35;
|
let global_pct = 0.35 + phase_pct * 0.35;
|
||||||
let msg = format!("Media: {current}/{total} \u{2014} {name}");
|
let msg = format!("Media: {current}/{total} \u{2014} {name}");
|
||||||
cb(global_pct, &msg);
|
cb(global_pct, &msg);
|
||||||
@@ -97,7 +108,10 @@ pub fn rebuild_from_filesystem_with_progress(
|
|||||||
f
|
f
|
||||||
});
|
});
|
||||||
let media_report = media::rebuild_media_from_filesystem_with_progress(
|
let media_report = media::rebuild_media_from_filesystem_with_progress(
|
||||||
conn, data_dir, project_id, media_item_cb,
|
conn,
|
||||||
|
data_dir,
|
||||||
|
project_id,
|
||||||
|
media_item_cb,
|
||||||
)?;
|
)?;
|
||||||
report.media_created = media_report.media_created;
|
report.media_created = media_report.media_created;
|
||||||
report.media_updated = media_report.media_updated;
|
report.media_updated = media_report.media_updated;
|
||||||
@@ -133,12 +147,12 @@ pub fn rebuild_from_filesystem_with_progress(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::db::Database;
|
||||||
use crate::db::queries::media as qm;
|
use crate::db::queries::media as qm;
|
||||||
use crate::db::queries::post as qp;
|
use crate::db::queries::post as qp;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
use crate::db::queries::script as qs;
|
use crate::db::queries::script as qs;
|
||||||
use crate::db::queries::template as qtpl;
|
use crate::db::queries::template as qtpl;
|
||||||
use crate::db::Database;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
@@ -276,11 +290,7 @@ updatedAt: 2024-01-15T12:00:00.000Z
|
|||||||
tags: []
|
tags: []
|
||||||
---
|
---
|
||||||
";
|
";
|
||||||
fs::write(
|
fs::write(media_dir.join("test-media-1.jpg.meta"), sidecar_content).unwrap();
|
||||||
media_dir.join("test-media-1.jpg.meta"),
|
|
||||||
sidecar_content,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Template
|
// Template
|
||||||
let tpl_dir = dir.path().join("templates");
|
let tpl_dir = dir.path().join("templates");
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use uuid::Uuid;
|
|||||||
use crate::db::queries::script as qs;
|
use crate::db::queries::script as qs;
|
||||||
use crate::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult};
|
||||||
use crate::model::{Script, ScriptKind, ScriptStatus};
|
use crate::model::{Script, ScriptKind, ScriptStatus};
|
||||||
use crate::util::frontmatter::{write_script_file, ScriptFrontmatter};
|
use crate::util::frontmatter::{ScriptFrontmatter, write_script_file};
|
||||||
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
|
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
|
||||||
|
|
||||||
/// Create a new draft script. Content stored in DB, no file written.
|
/// Create a new draft script. Content stored in DB, no file written.
|
||||||
@@ -53,6 +53,10 @@ pub fn create_script(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update a script's metadata and/or content. Bumps version.
|
/// Update a script's metadata and/or content. Bumps version.
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_arguments,
|
||||||
|
reason = "optional arguments represent independent script field changes"
|
||||||
|
)]
|
||||||
pub fn update_script(
|
pub fn update_script(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
script_id: &str,
|
script_id: &str,
|
||||||
@@ -67,15 +71,14 @@ pub fn update_script(
|
|||||||
let mut script = qs::get_script_by_id(conn, script_id)?;
|
let mut script = qs::get_script_by_id(conn, script_id)?;
|
||||||
|
|
||||||
// Slug uniqueness
|
// Slug uniqueness
|
||||||
if let Some(new_slug) = slug {
|
if let Some(new_slug) = slug
|
||||||
if new_slug != script.slug {
|
&& new_slug != script.slug
|
||||||
if qs::get_script_by_slug(conn, project_id, new_slug).is_ok() {
|
&& qs::get_script_by_slug(conn, project_id, new_slug).is_ok()
|
||||||
|
{
|
||||||
return Err(EngineError::Conflict(format!(
|
return Err(EngineError::Conflict(format!(
|
||||||
"script slug '{new_slug}' already exists"
|
"script slug '{new_slug}' already exists"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(t) = title {
|
if let Some(t) = title {
|
||||||
script.title = t.to_string();
|
script.title = t.to_string();
|
||||||
@@ -108,11 +111,7 @@ pub fn update_script(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Save script content (editor save). Updates DB, bumps version.
|
/// Save script content (editor save). Updates DB, bumps version.
|
||||||
pub fn save_script(
|
pub fn save_script(conn: &Connection, script_id: &str, content: &str) -> EngineResult<Script> {
|
||||||
conn: &Connection,
|
|
||||||
script_id: &str,
|
|
||||||
content: &str,
|
|
||||||
) -> EngineResult<Script> {
|
|
||||||
let mut script = qs::get_script_by_id(conn, script_id)?;
|
let mut script = qs::get_script_by_id(conn, script_id)?;
|
||||||
script.content = Some(content.to_string());
|
script.content = Some(content.to_string());
|
||||||
script.version += 1;
|
script.version += 1;
|
||||||
@@ -161,11 +160,7 @@ pub fn discover_entrypoints(content: &str) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Publish a script: write frontmatter+body to file, clear content in DB.
|
/// Publish a script: write frontmatter+body to file, clear content in DB.
|
||||||
pub fn publish_script(
|
pub fn publish_script(conn: &Connection, data_dir: &Path, script_id: &str) -> EngineResult<Script> {
|
||||||
conn: &Connection,
|
|
||||||
data_dir: &Path,
|
|
||||||
script_id: &str,
|
|
||||||
) -> EngineResult<Script> {
|
|
||||||
let mut script = qs::get_script_by_id(conn, script_id)?;
|
let mut script = qs::get_script_by_id(conn, script_id)?;
|
||||||
|
|
||||||
if script.status == ScriptStatus::Published {
|
if script.status == ScriptStatus::Published {
|
||||||
@@ -221,9 +216,7 @@ pub fn unpublish_script(
|
|||||||
let mut script = qs::get_script_by_id(conn, script_id)?;
|
let mut script = qs::get_script_by_id(conn, script_id)?;
|
||||||
|
|
||||||
if script.status != ScriptStatus::Published {
|
if script.status != ScriptStatus::Published {
|
||||||
return Err(EngineError::Conflict(
|
return Err(EngineError::Conflict("script is not published".to_string()));
|
||||||
"script is not published".to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !script.file_path.is_empty() {
|
if !script.file_path.is_empty() {
|
||||||
@@ -244,11 +237,7 @@ pub fn unpublish_script(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a script and its file.
|
/// Delete a script and its file.
|
||||||
pub fn delete_script(
|
pub fn delete_script(conn: &Connection, data_dir: &Path, script_id: &str) -> EngineResult<()> {
|
||||||
conn: &Connection,
|
|
||||||
data_dir: &Path,
|
|
||||||
script_id: &str,
|
|
||||||
) -> EngineResult<()> {
|
|
||||||
let script = qs::get_script_by_id(conn, script_id)?;
|
let script = qs::get_script_by_id(conn, script_id)?;
|
||||||
|
|
||||||
// Delete file if exists
|
// Delete file if exists
|
||||||
@@ -311,8 +300,8 @@ fn validate_lua_blocks(content: &str) -> Result<(), String> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
@@ -326,7 +315,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn create_draft_script() {
|
fn create_draft_script() {
|
||||||
let (db, _dir) = setup();
|
let (db, _dir) = setup();
|
||||||
let s = create_script(db.conn(), "p1", "My Script", ScriptKind::Macro, "return 'hi'", None).unwrap();
|
let s = create_script(
|
||||||
|
db.conn(),
|
||||||
|
"p1",
|
||||||
|
"My Script",
|
||||||
|
ScriptKind::Macro,
|
||||||
|
"return 'hi'",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(s.title, "My Script");
|
assert_eq!(s.title, "My Script");
|
||||||
assert_eq!(s.slug, "my-script");
|
assert_eq!(s.slug, "my-script");
|
||||||
assert_eq!(s.kind, ScriptKind::Macro);
|
assert_eq!(s.kind, ScriptKind::Macro);
|
||||||
@@ -340,7 +337,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn create_with_custom_entrypoint() {
|
fn create_with_custom_entrypoint() {
|
||||||
let (db, _dir) = setup();
|
let (db, _dir) = setup();
|
||||||
let s = create_script(db.conn(), "p1", "Util", ScriptKind::Utility, "", Some("main")).unwrap();
|
let s = create_script(
|
||||||
|
db.conn(),
|
||||||
|
"p1",
|
||||||
|
"Util",
|
||||||
|
ScriptKind::Utility,
|
||||||
|
"",
|
||||||
|
Some("main"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(s.entrypoint, "main");
|
assert_eq!(s.entrypoint, "main");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +362,18 @@ mod tests {
|
|||||||
fn update_script_bumps_version() {
|
fn update_script_bumps_version() {
|
||||||
let (db, _dir) = setup();
|
let (db, _dir) = setup();
|
||||||
let s = create_script(db.conn(), "p1", "S", ScriptKind::Macro, "old", None).unwrap();
|
let s = create_script(db.conn(), "p1", "S", ScriptKind::Macro, "old", None).unwrap();
|
||||||
let updated = update_script(db.conn(), &s.id, "p1", Some("New"), None, None, None, None, Some("new")).unwrap();
|
let updated = update_script(
|
||||||
|
db.conn(),
|
||||||
|
&s.id,
|
||||||
|
"p1",
|
||||||
|
Some("New"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("new"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(updated.title, "New");
|
assert_eq!(updated.title, "New");
|
||||||
assert_eq!(updated.content, Some("new".to_string()));
|
assert_eq!(updated.content, Some("new".to_string()));
|
||||||
assert_eq!(updated.version, 2);
|
assert_eq!(updated.version, 2);
|
||||||
@@ -375,7 +391,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn publish_and_unpublish_script() {
|
fn publish_and_unpublish_script() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
let s = create_script(db.conn(), "p1", "Pub", ScriptKind::Macro, "function render()\n return 'hi'\nend", None).unwrap();
|
let s = create_script(
|
||||||
|
db.conn(),
|
||||||
|
"p1",
|
||||||
|
"Pub",
|
||||||
|
ScriptKind::Macro,
|
||||||
|
"function render()\n return 'hi'\nend",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let published = publish_script(db.conn(), dir.path(), &s.id).unwrap();
|
let published = publish_script(db.conn(), dir.path(), &s.id).unwrap();
|
||||||
assert_eq!(published.status, ScriptStatus::Published);
|
assert_eq!(published.status, ScriptStatus::Published);
|
||||||
@@ -391,7 +415,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn delete_script_removes_file() {
|
fn delete_script_removes_file() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
let s = create_script(db.conn(), "p1", "Del", ScriptKind::Macro, "function render()\nend", None).unwrap();
|
let s = create_script(
|
||||||
|
db.conn(),
|
||||||
|
"p1",
|
||||||
|
"Del",
|
||||||
|
ScriptKind::Macro,
|
||||||
|
"function render()\nend",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
publish_script(db.conn(), dir.path(), &s.id).unwrap();
|
publish_script(db.conn(), dir.path(), &s.id).unwrap();
|
||||||
assert!(dir.path().join("scripts/del.lua").exists());
|
assert!(dir.path().join("scripts/del.lua").exists());
|
||||||
|
|
||||||
|
|||||||
@@ -140,8 +140,8 @@ fn rebuild_single_script(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
@@ -176,8 +176,7 @@ end
|
|||||||
";
|
";
|
||||||
fs::write(scripts_dir.join("my-macro.lua"), content).unwrap();
|
fs::write(scripts_dir.join("my-macro.lua"), content).unwrap();
|
||||||
|
|
||||||
let report =
|
let report = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||||
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
|
||||||
|
|
||||||
assert_eq!(report.created, 1);
|
assert_eq!(report.created, 1);
|
||||||
assert_eq!(report.updated, 0);
|
assert_eq!(report.updated, 0);
|
||||||
@@ -191,7 +190,10 @@ end
|
|||||||
assert!(script.enabled);
|
assert!(script.enabled);
|
||||||
assert_eq!(script.version, 1);
|
assert_eq!(script.version, 1);
|
||||||
assert_eq!(script.status, ScriptStatus::Published);
|
assert_eq!(script.status, ScriptStatus::Published);
|
||||||
assert!(script.content.is_none(), "published script should have content=None in DB");
|
assert!(
|
||||||
|
script.content.is_none(),
|
||||||
|
"published script should have content=None in DB"
|
||||||
|
);
|
||||||
assert_eq!(script.file_path, "scripts/my-macro.lua");
|
assert_eq!(script.file_path, "scripts/my-macro.lua");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,8 +221,7 @@ def main():
|
|||||||
";
|
";
|
||||||
fs::write(scripts_dir.join("my-utility.py"), content).unwrap();
|
fs::write(scripts_dir.join("my-utility.py"), content).unwrap();
|
||||||
|
|
||||||
let report =
|
let report = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||||
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
|
||||||
|
|
||||||
assert_eq!(report.created, 1);
|
assert_eq!(report.created, 1);
|
||||||
assert_eq!(report.updated, 0);
|
assert_eq!(report.updated, 0);
|
||||||
@@ -280,8 +281,7 @@ end
|
|||||||
";
|
";
|
||||||
fs::write(scripts_dir.join("updated-script.lua"), content).unwrap();
|
fs::write(scripts_dir.join("updated-script.lua"), content).unwrap();
|
||||||
|
|
||||||
let report =
|
let report = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||||
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
|
||||||
|
|
||||||
assert_eq!(report.created, 0);
|
assert_eq!(report.created, 0);
|
||||||
assert_eq!(report.updated, 1);
|
assert_eq!(report.updated, 1);
|
||||||
@@ -321,14 +321,12 @@ function run() end
|
|||||||
fs::write(scripts_dir.join("idem.lua"), content).unwrap();
|
fs::write(scripts_dir.join("idem.lua"), content).unwrap();
|
||||||
|
|
||||||
// First run
|
// First run
|
||||||
let r1 =
|
let r1 = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||||
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
|
||||||
assert_eq!(r1.created, 1);
|
assert_eq!(r1.created, 1);
|
||||||
assert_eq!(r1.updated, 0);
|
assert_eq!(r1.updated, 0);
|
||||||
|
|
||||||
// Second run - should update, not create
|
// Second run - should update, not create
|
||||||
let r2 =
|
let r2 = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||||
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
|
||||||
assert_eq!(r2.created, 0);
|
assert_eq!(r2.created, 0);
|
||||||
assert_eq!(r2.updated, 1);
|
assert_eq!(r2.updated, 1);
|
||||||
|
|
||||||
|
|||||||
@@ -13,42 +13,222 @@ pub(crate) struct BundledSiteAsset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BUNDLED_SITE_ASSETS: &[BundledSiteAsset] = &[
|
const BUNDLED_SITE_ASSETS: &[BundledSiteAsset] = &[
|
||||||
BundledSiteAsset { relative_path: "assets/bds.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/bds.css") },
|
BundledSiteAsset {
|
||||||
BundledSiteAsset { relative_path: "assets/calendar-runtime.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/calendar-runtime.js") },
|
relative_path: "assets/bds.css",
|
||||||
BundledSiteAsset { relative_path: "assets/code-enhancements.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/code-enhancements.js") },
|
bytes: include_bytes!(
|
||||||
BundledSiteAsset { relative_path: "assets/d3.layout.cloud.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/d3.layout.cloud.js") },
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/bds.css"
|
||||||
BundledSiteAsset { relative_path: "assets/highlight.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/highlight.min.css") },
|
),
|
||||||
BundledSiteAsset { relative_path: "assets/highlight.min.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/highlight.min.js") },
|
},
|
||||||
BundledSiteAsset { relative_path: "assets/lightbox.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/lightbox.min.css") },
|
BundledSiteAsset {
|
||||||
BundledSiteAsset { relative_path: "assets/lightbox.min.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/lightbox.min.js") },
|
relative_path: "assets/calendar-runtime.js",
|
||||||
BundledSiteAsset { relative_path: "assets/pico.amber.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.amber.min.css") },
|
bytes: include_bytes!(
|
||||||
BundledSiteAsset { relative_path: "assets/pico.blue.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.blue.min.css") },
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/calendar-runtime.js"
|
||||||
BundledSiteAsset { relative_path: "assets/pico.cyan.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.cyan.min.css") },
|
),
|
||||||
BundledSiteAsset { relative_path: "assets/pico.fuchsia.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.fuchsia.min.css") },
|
},
|
||||||
BundledSiteAsset { relative_path: "assets/pico.green.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.green.min.css") },
|
BundledSiteAsset {
|
||||||
BundledSiteAsset { relative_path: "assets/pico.grey.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.grey.min.css") },
|
relative_path: "assets/code-enhancements.js",
|
||||||
BundledSiteAsset { relative_path: "assets/pico.indigo.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.indigo.min.css") },
|
bytes: include_bytes!(
|
||||||
BundledSiteAsset { relative_path: "assets/pico.jade.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.jade.min.css") },
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/code-enhancements.js"
|
||||||
BundledSiteAsset { relative_path: "assets/pico.lime.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.lime.min.css") },
|
),
|
||||||
BundledSiteAsset { relative_path: "assets/pico.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.min.css") },
|
},
|
||||||
BundledSiteAsset { relative_path: "assets/pico.orange.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.orange.min.css") },
|
BundledSiteAsset {
|
||||||
BundledSiteAsset { relative_path: "assets/pico.pink.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.pink.min.css") },
|
relative_path: "assets/d3.layout.cloud.js",
|
||||||
BundledSiteAsset { relative_path: "assets/pico.pumpkin.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.pumpkin.min.css") },
|
bytes: include_bytes!(
|
||||||
BundledSiteAsset { relative_path: "assets/pico.purple.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.purple.min.css") },
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/d3.layout.cloud.js"
|
||||||
BundledSiteAsset { relative_path: "assets/pico.red.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.red.min.css") },
|
),
|
||||||
BundledSiteAsset { relative_path: "assets/pico.sand.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.sand.min.css") },
|
},
|
||||||
BundledSiteAsset { relative_path: "assets/pico.slate.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.slate.min.css") },
|
BundledSiteAsset {
|
||||||
BundledSiteAsset { relative_path: "assets/pico.violet.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.violet.min.css") },
|
relative_path: "assets/highlight.min.css",
|
||||||
BundledSiteAsset { relative_path: "assets/pico.yellow.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.yellow.min.css") },
|
bytes: include_bytes!(
|
||||||
BundledSiteAsset { relative_path: "assets/pico.zinc.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.zinc.min.css") },
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/highlight.min.css"
|
||||||
BundledSiteAsset { relative_path: "assets/search-runtime.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/search-runtime.js") },
|
),
|
||||||
BundledSiteAsset { relative_path: "assets/tag-cloud.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/tag-cloud.js") },
|
},
|
||||||
BundledSiteAsset { relative_path: "assets/vanilla-calendar.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.css") },
|
BundledSiteAsset {
|
||||||
BundledSiteAsset { relative_path: "assets/vanilla-calendar.min.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.js") },
|
relative_path: "assets/highlight.min.js",
|
||||||
BundledSiteAsset { relative_path: "images/close.png", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/close.png") },
|
bytes: include_bytes!(
|
||||||
BundledSiteAsset { relative_path: "images/loading.gif", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/loading.gif") },
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/highlight.min.js"
|
||||||
BundledSiteAsset { relative_path: "images/next.png", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/next.png") },
|
),
|
||||||
BundledSiteAsset { relative_path: "images/prev.png", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/prev.png") },
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/lightbox.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/lightbox.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/lightbox.min.js",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/lightbox.min.js"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/pico.amber.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.amber.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/pico.blue.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.blue.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/pico.cyan.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.cyan.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/pico.fuchsia.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.fuchsia.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/pico.green.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.green.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/pico.grey.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.grey.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/pico.indigo.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.indigo.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/pico.jade.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.jade.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/pico.lime.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.lime.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/pico.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/pico.orange.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.orange.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/pico.pink.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.pink.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/pico.pumpkin.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.pumpkin.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/pico.purple.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.purple.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/pico.red.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.red.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/pico.sand.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.sand.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/pico.slate.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.slate.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/pico.violet.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.violet.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/pico.yellow.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.yellow.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/pico.zinc.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.zinc.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/search-runtime.js",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/search-runtime.js"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/tag-cloud.js",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/tag-cloud.js"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/vanilla-calendar.min.css",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.css"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "assets/vanilla-calendar.min.js",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.js"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "images/close.png",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/images/close.png"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "images/loading.gif",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/images/loading.gif"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "images/next.png",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/images/next.png"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BundledSiteAsset {
|
||||||
|
relative_path: "images/prev.png",
|
||||||
|
bytes: include_bytes!(
|
||||||
|
"../../../../fixtures/golden-generated-sites/rfc1437-sample/images/prev.png"
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
pub(crate) fn copy_bundled_site_assets(project_dir: &Path) -> EngineResult<()> {
|
pub(crate) fn copy_bundled_site_assets(project_dir: &Path) -> EngineResult<()> {
|
||||||
@@ -72,11 +252,21 @@ pub(crate) fn write_bundled_site_assets(
|
|||||||
report: &mut crate::engine::generation::GenerationReport,
|
report: &mut crate::engine::generation::GenerationReport,
|
||||||
) -> EngineResult<()> {
|
) -> EngineResult<()> {
|
||||||
for asset in BUNDLED_SITE_ASSETS {
|
for asset in BUNDLED_SITE_ASSETS {
|
||||||
match write_generated_bytes(conn, output_dir, project_id, asset.relative_path, asset.bytes)
|
match write_generated_bytes(
|
||||||
|
conn,
|
||||||
|
output_dir,
|
||||||
|
project_id,
|
||||||
|
asset.relative_path,
|
||||||
|
asset.bytes,
|
||||||
|
)
|
||||||
.map_err(|error| EngineError::Parse(error.to_string()))?
|
.map_err(|error| EngineError::Parse(error.to_string()))?
|
||||||
{
|
{
|
||||||
GeneratedWriteOutcome::Written => report.written_paths.push(asset.relative_path.to_string()),
|
GeneratedWriteOutcome::Written => {
|
||||||
GeneratedWriteOutcome::SkippedUnchanged => report.skipped_paths.push(asset.relative_path.to_string()),
|
report.written_paths.push(asset.relative_path.to_string())
|
||||||
|
}
|
||||||
|
GeneratedWriteOutcome::SkippedUnchanged => {
|
||||||
|
report.skipped_paths.push(asset.relative_path.to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ use crate::db::queries::post as post_q;
|
|||||||
use crate::db::queries::tag as tag_q;
|
use crate::db::queries::tag as tag_q;
|
||||||
use crate::engine::meta;
|
use crate::engine::meta;
|
||||||
use crate::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult};
|
||||||
use crate::model::metadata::TagEntry;
|
|
||||||
use crate::model::Tag;
|
use crate::model::Tag;
|
||||||
|
use crate::model::metadata::TagEntry;
|
||||||
use crate::util::now_unix_ms;
|
use crate::util::now_unix_ms;
|
||||||
|
|
||||||
/// Create a new tag. Case-insensitive duplicate check.
|
/// Create a new tag. Case-insensitive duplicate check.
|
||||||
@@ -240,10 +240,7 @@ pub fn import_tags_from_file(
|
|||||||
|
|
||||||
/// Sync tags from all posts: collect unique tag names, create missing tags in DB.
|
/// Sync tags from all posts: collect unique tag names, create missing tags in DB.
|
||||||
/// This is additive only — it does NOT rewrite tags.json.
|
/// This is additive only — it does NOT rewrite tags.json.
|
||||||
pub fn sync_tags_from_posts(
|
pub fn sync_tags_from_posts(conn: &Connection, project_id: &str) -> EngineResult<Vec<Tag>> {
|
||||||
conn: &Connection,
|
|
||||||
project_id: &str,
|
|
||||||
) -> EngineResult<Vec<Tag>> {
|
|
||||||
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
||||||
|
|
||||||
// Collect all unique tag names from posts (preserve original casing per spec).
|
// Collect all unique tag names from posts (preserve original casing per spec).
|
||||||
@@ -290,11 +287,7 @@ pub fn discover_tags(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Rewrite meta/tags.json from DB state.
|
/// Rewrite meta/tags.json from DB state.
|
||||||
pub fn rewrite_tags_json(
|
pub fn rewrite_tags_json(conn: &Connection, data_dir: &Path, project_id: &str) -> EngineResult<()> {
|
||||||
conn: &Connection,
|
|
||||||
data_dir: &Path,
|
|
||||||
project_id: &str,
|
|
||||||
) -> EngineResult<()> {
|
|
||||||
let tags = tag_q::list_tags_by_project(conn, project_id)?;
|
let tags = tag_q::list_tags_by_project(conn, project_id)?;
|
||||||
let entries: Vec<TagEntry> = tags
|
let entries: Vec<TagEntry> = tags
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -317,8 +310,8 @@ fn flush_post_frontmatter(
|
|||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
post_ids: &[String],
|
post_ids: &[String],
|
||||||
) -> EngineResult<()> {
|
) -> EngineResult<()> {
|
||||||
use crate::util::frontmatter::write_post_file;
|
|
||||||
use crate::util::atomic_write_str;
|
use crate::util::atomic_write_str;
|
||||||
|
use crate::util::frontmatter::write_post_file;
|
||||||
|
|
||||||
for post_id in post_ids {
|
for post_id in post_ids {
|
||||||
let post = post_q::get_post_by_id(conn, post_id)?;
|
let post = post_q::get_post_by_id(conn, post_id)?;
|
||||||
@@ -328,7 +321,7 @@ fn flush_post_frontmatter(
|
|||||||
// Read existing body from file
|
// Read existing body from file
|
||||||
let content = std::fs::read_to_string(&abs_path)?;
|
let content = std::fs::read_to_string(&abs_path)?;
|
||||||
let (_fm, body) = crate::util::frontmatter::read_post_file(&content)
|
let (_fm, body) = crate::util::frontmatter::read_post_file(&content)
|
||||||
.map_err(|e| EngineError::Parse(e))?;
|
.map_err(EngineError::Parse)?;
|
||||||
// Rewrite with updated frontmatter
|
// Rewrite with updated frontmatter
|
||||||
let file_content = write_post_file(&post, &body);
|
let file_content = write_post_file(&post, &body);
|
||||||
atomic_write_str(&abs_path, &file_content)?;
|
atomic_write_str(&abs_path, &file_content)?;
|
||||||
@@ -348,8 +341,7 @@ fn remove_tag_name_from_posts(
|
|||||||
let mut modified = Vec::new();
|
let mut modified = Vec::new();
|
||||||
for mut post in posts {
|
for mut post in posts {
|
||||||
if post.tags.iter().any(|t| t.eq_ignore_ascii_case(tag_name)) {
|
if post.tags.iter().any(|t| t.eq_ignore_ascii_case(tag_name)) {
|
||||||
post.tags
|
post.tags.retain(|t| !t.eq_ignore_ascii_case(tag_name));
|
||||||
.retain(|t| !t.eq_ignore_ascii_case(tag_name));
|
|
||||||
post.updated_at = now;
|
post.updated_at = now;
|
||||||
post_q::update_post(conn, &post)?;
|
post_q::update_post(conn, &post)?;
|
||||||
modified.push(post.id.clone());
|
modified.push(post.id.clone());
|
||||||
@@ -361,9 +353,9 @@ fn remove_tag_name_from_posts(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::db::Database;
|
||||||
use crate::db::queries::post::insert_post;
|
use crate::db::queries::post::insert_post;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
use crate::db::Database;
|
|
||||||
use crate::model::{Post, PostStatus};
|
use crate::model::{Post, PostStatus};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
@@ -470,11 +462,7 @@ mod tests {
|
|||||||
fn rename_tag_updates_posts() {
|
fn rename_tag_updates_posts() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
let tag = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
|
let tag = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
|
||||||
insert_post(
|
insert_post(db.conn(), &make_post("x1", "hello", vec!["rust".into()])).unwrap();
|
||||||
db.conn(),
|
|
||||||
&make_post("x1", "hello", vec!["rust".into()]),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
rename_tag(db.conn(), dir.path(), "p1", &tag.id, "golang").unwrap();
|
rename_tag(db.conn(), dir.path(), "p1", &tag.id, "golang").unwrap();
|
||||||
|
|
||||||
@@ -493,25 +481,14 @@ mod tests {
|
|||||||
let t2 = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
|
let t2 = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
|
||||||
let t3 = create_tag(db.conn(), dir.path(), "p1", "target", None).unwrap();
|
let t3 = create_tag(db.conn(), dir.path(), "p1", "target", None).unwrap();
|
||||||
|
|
||||||
insert_post(
|
insert_post(db.conn(), &make_post("x1", "a", vec!["rs".into()])).unwrap();
|
||||||
db.conn(),
|
|
||||||
&make_post("x1", "a", vec!["rs".into()]),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
insert_post(
|
insert_post(
|
||||||
db.conn(),
|
db.conn(),
|
||||||
&make_post("x2", "b", vec!["rust".into(), "target".into()]),
|
&make_post("x2", "b", vec!["rust".into(), "target".into()]),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
merge_tags(
|
merge_tags(db.conn(), dir.path(), "p1", &[&t1.id, &t2.id], &t3.id).unwrap();
|
||||||
db.conn(),
|
|
||||||
dir.path(),
|
|
||||||
"p1",
|
|
||||||
&[&t1.id, &t2.id],
|
|
||||||
&t3.id,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Post x1 should now have "target"
|
// Post x1 should now have "target"
|
||||||
let p1 = crate::db::queries::post::get_post_by_id(db.conn(), "x1").unwrap();
|
let p1 = crate::db::queries::post::get_post_by_id(db.conn(), "x1").unwrap();
|
||||||
@@ -563,7 +540,10 @@ mod tests {
|
|||||||
assert_eq!(tags.len(), 2);
|
assert_eq!(tags.len(), 2);
|
||||||
|
|
||||||
let entries = meta::read_tags_json(dir.path()).unwrap();
|
let entries = meta::read_tags_json(dir.path()).unwrap();
|
||||||
let names = entries.into_iter().map(|entry| entry.name).collect::<Vec<_>>();
|
let names = entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|entry| entry.name)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
assert_eq!(names, vec!["rust".to_string(), "web".to_string()]);
|
assert_eq!(names, vec!["rust".to_string(), "web".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,8 +552,16 @@ mod tests {
|
|||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
// Write tags.json with colors
|
// Write tags.json with colors
|
||||||
let entries = vec![
|
let entries = vec![
|
||||||
TagEntry { name: "rust".into(), color: Some("#ff0000".into()), post_template_slug: None },
|
TagEntry {
|
||||||
TagEntry { name: "web".into(), color: None, post_template_slug: Some("blog".into()) },
|
name: "rust".into(),
|
||||||
|
color: Some("#ff0000".into()),
|
||||||
|
post_template_slug: None,
|
||||||
|
},
|
||||||
|
TagEntry {
|
||||||
|
name: "web".into(),
|
||||||
|
color: None,
|
||||||
|
post_template_slug: Some("blog".into()),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
meta::write_tags_json(dir.path(), &entries).unwrap();
|
meta::write_tags_json(dir.path(), &entries).unwrap();
|
||||||
|
|
||||||
@@ -605,9 +593,18 @@ mod tests {
|
|||||||
use crate::db::fts::ensure_fts_tables;
|
use crate::db::fts::ensure_fts_tables;
|
||||||
ensure_fts_tables(db.conn()).unwrap();
|
ensure_fts_tables(db.conn()).unwrap();
|
||||||
let post = crate::engine::post::create_post(
|
let post = crate::engine::post::create_post(
|
||||||
db.conn(), dir.path(), "p1", "Tagged Post", Some("body content"),
|
db.conn(),
|
||||||
vec!["rust".into()], vec![], None, None, None,
|
dir.path(),
|
||||||
).unwrap();
|
"p1",
|
||||||
|
"Tagged Post",
|
||||||
|
Some("body content"),
|
||||||
|
vec!["rust".into()],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
crate::engine::post::publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
crate::engine::post::publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||||
|
|
||||||
let tag = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
|
let tag = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
|
||||||
@@ -616,7 +613,13 @@ mod tests {
|
|||||||
// Read the file from disk and verify tag was updated
|
// Read the file from disk and verify tag was updated
|
||||||
let from_db = crate::db::queries::post::get_post_by_id(db.conn(), &post.id).unwrap();
|
let from_db = crate::db::queries::post::get_post_by_id(db.conn(), &post.id).unwrap();
|
||||||
let file_content = std::fs::read_to_string(dir.path().join(&from_db.file_path)).unwrap();
|
let file_content = std::fs::read_to_string(dir.path().join(&from_db.file_path)).unwrap();
|
||||||
assert!(file_content.contains("golang"), "frontmatter should contain renamed tag");
|
assert!(
|
||||||
assert!(!file_content.contains("rust"), "frontmatter should not contain old tag name");
|
file_content.contains("golang"),
|
||||||
|
"frontmatter should contain renamed tag"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!file_content.contains("rust"),
|
||||||
|
"frontmatter should not contain old tag name"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Instant;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
/// Unique task identifier.
|
/// Unique task identifier.
|
||||||
pub type TaskId = u64;
|
pub type TaskId = u64;
|
||||||
@@ -23,6 +23,19 @@ pub struct TaskProgress {
|
|||||||
pub percent: Option<f32>,
|
pub percent: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Immutable task state exposed to UI consumers.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TaskSnapshot {
|
||||||
|
pub id: TaskId,
|
||||||
|
pub label: String,
|
||||||
|
pub group_id: Option<String>,
|
||||||
|
pub group_name: Option<String>,
|
||||||
|
pub status: TaskStatus,
|
||||||
|
pub progress: Option<f32>,
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub created_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
/// Entry tracking a task.
|
/// Entry tracking a task.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct TaskEntry {
|
struct TaskEntry {
|
||||||
@@ -35,6 +48,7 @@ struct TaskEntry {
|
|||||||
progress: Option<f32>,
|
progress: Option<f32>,
|
||||||
message: Option<String>,
|
message: Option<String>,
|
||||||
created_at: Instant,
|
created_at: Instant,
|
||||||
|
finished_at: Option<Instant>,
|
||||||
last_progress_report: Option<Instant>,
|
last_progress_report: Option<Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,18 +85,24 @@ impl TaskManager {
|
|||||||
progress: None,
|
progress: None,
|
||||||
message: None,
|
message: None,
|
||||||
created_at: Instant::now(),
|
created_at: Instant::now(),
|
||||||
|
finished_at: None,
|
||||||
last_progress_report: None,
|
last_progress_report: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut tasks = self.tasks.lock().unwrap();
|
let mut tasks = self.tasks.lock().unwrap();
|
||||||
tasks.push(entry);
|
tasks.push(entry);
|
||||||
// Auto-start if under capacity
|
// Auto-start if under capacity
|
||||||
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();
|
let running = tasks
|
||||||
if running < self.max_concurrent {
|
.iter()
|
||||||
if let Some(t) = tasks.iter_mut().find(|t| t.id == id && t.status == TaskStatus::Pending) {
|
.filter(|t| t.status == TaskStatus::Running)
|
||||||
|
.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;
|
t.status = TaskStatus::Running;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,27 +121,31 @@ impl TaskManager {
|
|||||||
/// Running, false if concurrency is at capacity or the task is not Queued.
|
/// Running, false if concurrency is at capacity or the task is not Queued.
|
||||||
pub fn try_start(&self, task_id: TaskId) -> bool {
|
pub fn try_start(&self, task_id: TaskId) -> bool {
|
||||||
let mut tasks = self.tasks.lock().unwrap();
|
let mut tasks = self.tasks.lock().unwrap();
|
||||||
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();
|
let running = tasks
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.status == TaskStatus::Running)
|
||||||
|
.count();
|
||||||
if running >= self.max_concurrent {
|
if running >= self.max_concurrent {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
|
||||||
if entry.status == TaskStatus::Pending {
|
&& entry.status == TaskStatus::Pending
|
||||||
|
{
|
||||||
entry.status = TaskStatus::Running;
|
entry.status = TaskStatus::Running;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark a task as completed.
|
/// Mark a task as completed.
|
||||||
pub fn complete(&self, task_id: TaskId) {
|
pub fn complete(&self, task_id: TaskId) {
|
||||||
let mut tasks = self.tasks.lock().unwrap();
|
let mut tasks = self.tasks.lock().unwrap();
|
||||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
|
||||||
if matches!(entry.status, TaskStatus::Running) {
|
&& matches!(entry.status, TaskStatus::Running)
|
||||||
|
{
|
||||||
entry.status = TaskStatus::Completed;
|
entry.status = TaskStatus::Completed;
|
||||||
entry.progress = Some(1.0);
|
entry.progress = Some(1.0);
|
||||||
}
|
entry.finished_at = Some(Instant::now());
|
||||||
}
|
}
|
||||||
Self::promote_next(&mut tasks, self.max_concurrent);
|
Self::promote_next(&mut tasks, self.max_concurrent);
|
||||||
}
|
}
|
||||||
@@ -129,11 +153,12 @@ impl TaskManager {
|
|||||||
/// Mark a task as failed with an error message.
|
/// Mark a task as failed with an error message.
|
||||||
pub fn fail(&self, task_id: TaskId, error: String) {
|
pub fn fail(&self, task_id: TaskId, error: String) {
|
||||||
let mut tasks = self.tasks.lock().unwrap();
|
let mut tasks = self.tasks.lock().unwrap();
|
||||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
|
||||||
if matches!(entry.status, TaskStatus::Running) {
|
&& matches!(entry.status, TaskStatus::Running)
|
||||||
|
{
|
||||||
entry.message = Some(error.clone());
|
entry.message = Some(error.clone());
|
||||||
entry.status = TaskStatus::Failed(error);
|
entry.status = TaskStatus::Failed(error);
|
||||||
}
|
entry.finished_at = Some(Instant::now());
|
||||||
}
|
}
|
||||||
Self::promote_next(&mut tasks, self.max_concurrent);
|
Self::promote_next(&mut tasks, self.max_concurrent);
|
||||||
}
|
}
|
||||||
@@ -141,11 +166,12 @@ impl TaskManager {
|
|||||||
/// Cancel a task by setting its cancel flag and status.
|
/// Cancel a task by setting its cancel flag and status.
|
||||||
pub fn cancel(&self, task_id: TaskId) {
|
pub fn cancel(&self, task_id: TaskId) {
|
||||||
let mut tasks = self.tasks.lock().unwrap();
|
let mut tasks = self.tasks.lock().unwrap();
|
||||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
|
||||||
if matches!(entry.status, TaskStatus::Running | TaskStatus::Pending) {
|
&& matches!(entry.status, TaskStatus::Running | TaskStatus::Pending)
|
||||||
|
{
|
||||||
entry.cancel_flag.store(true, Ordering::Release);
|
entry.cancel_flag.store(true, Ordering::Release);
|
||||||
entry.status = TaskStatus::Cancelled;
|
entry.status = TaskStatus::Cancelled;
|
||||||
}
|
entry.finished_at = Some(Instant::now());
|
||||||
}
|
}
|
||||||
Self::promote_next(&mut tasks, self.max_concurrent);
|
Self::promote_next(&mut tasks, self.max_concurrent);
|
||||||
}
|
}
|
||||||
@@ -163,44 +189,64 @@ impl TaskManager {
|
|||||||
/// Return the current status of a task.
|
/// Return the current status of a task.
|
||||||
pub fn status(&self, task_id: TaskId) -> Option<TaskStatus> {
|
pub fn status(&self, task_id: TaskId) -> Option<TaskStatus> {
|
||||||
let tasks = self.tasks.lock().unwrap();
|
let tasks = self.tasks.lock().unwrap();
|
||||||
tasks.iter().find(|t| t.id == task_id).map(|t| t.status.clone())
|
tasks
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.id == task_id)
|
||||||
|
.map(|t| t.status.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Count tasks that are still queued.
|
/// Count tasks that are still queued.
|
||||||
pub fn pending_count(&self) -> usize {
|
pub fn pending_count(&self) -> usize {
|
||||||
let tasks = self.tasks.lock().unwrap();
|
let tasks = self.tasks.lock().unwrap();
|
||||||
tasks.iter().filter(|t| t.status == TaskStatus::Pending).count()
|
tasks
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.status == TaskStatus::Pending)
|
||||||
|
.count()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Count tasks that are currently running.
|
/// Count tasks that are currently running.
|
||||||
pub fn running_count(&self) -> usize {
|
pub fn running_count(&self) -> usize {
|
||||||
let tasks = self.tasks.lock().unwrap();
|
let tasks = self.tasks.lock().unwrap();
|
||||||
tasks.iter().filter(|t| t.status == TaskStatus::Running).count()
|
tasks
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.status == TaskStatus::Running)
|
||||||
|
.count()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove all completed, failed, and cancelled tasks.
|
/// Remove finished tasks older than the configured retention period.
|
||||||
pub fn drain_completed(&self) {
|
pub fn evict_expired(&self) {
|
||||||
|
let cutoff = Instant::now() - FINISHED_TASK_TTL;
|
||||||
let mut tasks = self.tasks.lock().unwrap();
|
let mut tasks = self.tasks.lock().unwrap();
|
||||||
tasks.retain(|t| matches!(t.status, TaskStatus::Pending | TaskStatus::Running));
|
tasks.retain(|task| {
|
||||||
|
task.finished_at
|
||||||
|
.is_none_or(|finished_at| finished_at > cutoff)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the label of a task.
|
/// Return the label of a task.
|
||||||
pub fn label(&self, task_id: TaskId) -> Option<String> {
|
pub fn label(&self, task_id: TaskId) -> Option<String> {
|
||||||
let tasks = self.tasks.lock().unwrap();
|
let tasks = self.tasks.lock().unwrap();
|
||||||
tasks.iter().find(|t| t.id == task_id).map(|t| t.label.clone())
|
tasks
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.id == task_id)
|
||||||
|
.map(|t| t.label.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the id of the first queued task (FIFO order).
|
/// Return the id of the first queued task (FIFO order).
|
||||||
pub fn next_queued(&self) -> Option<TaskId> {
|
pub fn next_queued(&self) -> Option<TaskId> {
|
||||||
let tasks = self.tasks.lock().unwrap();
|
let tasks = self.tasks.lock().unwrap();
|
||||||
tasks.iter().find(|t| t.status == TaskStatus::Pending).map(|t| t.id)
|
tasks
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.status == TaskStatus::Pending)
|
||||||
|
.map(|t| t.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update progress for a running task. Throttled to at most once per 250ms.
|
/// Update progress for a running task. Throttled to at most once per 250ms.
|
||||||
pub fn report_progress(&self, task_id: TaskId, progress: Option<f32>, message: Option<String>) {
|
pub fn report_progress(&self, task_id: TaskId, progress: Option<f32>, message: Option<String>) {
|
||||||
let mut tasks = self.tasks.lock().unwrap();
|
let mut tasks = self.tasks.lock().unwrap();
|
||||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
|
||||||
if entry.status == TaskStatus::Running {
|
&& entry.status == TaskStatus::Running
|
||||||
|
{
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let should_report = match entry.last_progress_report {
|
let should_report = match entry.last_progress_report {
|
||||||
Some(prev) => now.duration_since(prev).as_millis() >= PROGRESS_THROTTLE_MS as u128,
|
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.
|
/// Return the current progress of a task.
|
||||||
pub fn progress(&self, task_id: TaskId) -> Option<f32> {
|
pub fn progress(&self, task_id: TaskId) -> Option<f32> {
|
||||||
let tasks = self.tasks.lock().unwrap();
|
let tasks = self.tasks.lock().unwrap();
|
||||||
tasks.iter().find(|t| t.id == task_id).and_then(|t| t.progress)
|
tasks
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.id == task_id)
|
||||||
|
.and_then(|t| t.progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the current message of a task.
|
/// Return the current message of a task.
|
||||||
pub fn message(&self, task_id: TaskId) -> Option<String> {
|
pub fn message(&self, task_id: TaskId) -> Option<String> {
|
||||||
let tasks = self.tasks.lock().unwrap();
|
let tasks = self.tasks.lock().unwrap();
|
||||||
tasks.iter().find(|t| t.id == task_id).and_then(|t| t.message.clone())
|
tasks
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.id == task_id)
|
||||||
|
.and_then(|t| t.message.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a snapshot of all tasks for UI display.
|
/// Return a snapshot of all tasks for UI display.
|
||||||
pub fn snapshots(&self) -> Vec<(TaskId, String, TaskStatus, Option<f32>, Option<String>)> {
|
pub fn snapshots(&self) -> Vec<TaskSnapshot> {
|
||||||
let tasks = self.tasks.lock().unwrap();
|
let tasks = self.tasks.lock().unwrap();
|
||||||
|
let mut snapshots = tasks
|
||||||
|
.iter()
|
||||||
|
.filter(|task| task.finished_at.is_none())
|
||||||
|
.chain(
|
||||||
tasks
|
tasks
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| (t.id, t.label.clone(), t.status.clone(), t.progress, t.message.clone()))
|
.rev()
|
||||||
.collect()
|
.filter(|task| task.finished_at.is_some())
|
||||||
|
.take(RECENT_FINISHED_LIMIT),
|
||||||
|
)
|
||||||
|
.map(|task| TaskSnapshot {
|
||||||
|
id: task.id,
|
||||||
|
label: task.label.clone(),
|
||||||
|
group_id: task.group_id.clone(),
|
||||||
|
group_name: task.group_name.clone(),
|
||||||
|
status: task.status.clone(),
|
||||||
|
progress: task.progress,
|
||||||
|
message: task.message.clone(),
|
||||||
|
created_at: task.created_at,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
snapshots.sort_by_key(|snapshot| snapshot.created_at);
|
||||||
|
snapshots
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Promote the next queued task to running if capacity allows.
|
/// Promote the next queued task to running if capacity allows.
|
||||||
fn promote_next(tasks: &mut Vec<TaskEntry>, max_concurrent: usize) {
|
fn promote_next(tasks: &mut [TaskEntry], max_concurrent: usize) {
|
||||||
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();
|
let running = tasks
|
||||||
if running < max_concurrent {
|
.iter()
|
||||||
if let Some(t) = tasks.iter_mut().find(|t| t.status == TaskStatus::Pending) {
|
.filter(|t| t.status == TaskStatus::Running)
|
||||||
|
.count();
|
||||||
|
if running < max_concurrent
|
||||||
|
&& let Some(t) = tasks.iter_mut().find(|t| t.status == TaskStatus::Pending)
|
||||||
|
{
|
||||||
t.status = TaskStatus::Running;
|
t.status = TaskStatus::Running;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for TaskManager {
|
impl Default for TaskManager {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
@@ -255,6 +328,8 @@ impl Default for TaskManager {
|
|||||||
|
|
||||||
/// Default progress throttle interval (250ms per spec).
|
/// Default progress throttle interval (250ms per spec).
|
||||||
pub const PROGRESS_THROTTLE_MS: u64 = 250;
|
pub const PROGRESS_THROTTLE_MS: u64 = 250;
|
||||||
|
pub const RECENT_FINISHED_LIMIT: usize = 10;
|
||||||
|
pub const FINISHED_TASK_TTL: Duration = Duration::from_secs(60 * 60);
|
||||||
|
|
||||||
/// Throttles progress reporting to avoid flooding.
|
/// Throttles progress reporting to avoid flooding.
|
||||||
pub struct ProgressThrottle {
|
pub struct ProgressThrottle {
|
||||||
@@ -344,13 +419,16 @@ mod tests {
|
|||||||
mgr.fail(bad, "disk full".into());
|
mgr.fail(bad, "disk full".into());
|
||||||
|
|
||||||
assert_eq!(mgr.status(ok), Some(TaskStatus::Completed));
|
assert_eq!(mgr.status(ok), Some(TaskStatus::Completed));
|
||||||
assert_eq!(mgr.status(bad), Some(TaskStatus::Failed("disk full".into())));
|
assert_eq!(
|
||||||
|
mgr.status(bad),
|
||||||
|
Some(TaskStatus::Failed("disk full".into()))
|
||||||
|
);
|
||||||
// Progress should be 1.0 on completed
|
// Progress should be 1.0 on completed
|
||||||
assert_eq!(mgr.progress(ok), Some(1.0));
|
assert_eq!(mgr.progress(ok), Some(1.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn drain_removes_finished() {
|
fn eviction_removes_only_expired_finished_tasks() {
|
||||||
let mgr = TaskManager::new(3);
|
let mgr = TaskManager::new(3);
|
||||||
let a = mgr.submit("done"); // auto-starts
|
let a = mgr.submit("done"); // auto-starts
|
||||||
let b = mgr.submit("broken"); // auto-starts
|
let b = mgr.submit("broken"); // auto-starts
|
||||||
@@ -364,7 +442,14 @@ mod tests {
|
|||||||
// After a completes: c promoted to running
|
// After a completes: c promoted to running
|
||||||
// After b fails: d promoted to running
|
// After b fails: d promoted to running
|
||||||
|
|
||||||
mgr.drain_completed();
|
{
|
||||||
|
let mut tasks = mgr.tasks.lock().unwrap();
|
||||||
|
for task in tasks.iter_mut().filter(|task| task.finished_at.is_some()) {
|
||||||
|
task.finished_at =
|
||||||
|
Some(Instant::now() - FINISHED_TASK_TTL - Duration::from_secs(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mgr.evict_expired();
|
||||||
|
|
||||||
assert_eq!(mgr.status(a), None);
|
assert_eq!(mgr.status(a), None);
|
||||||
assert_eq!(mgr.status(b), None);
|
assert_eq!(mgr.status(b), None);
|
||||||
@@ -372,6 +457,17 @@ mod tests {
|
|||||||
assert_eq!(mgr.status(e), Some(TaskStatus::Running));
|
assert_eq!(mgr.status(e), Some(TaskStatus::Running));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshots_retain_only_ten_finished_tasks() {
|
||||||
|
let mgr = TaskManager::new(20);
|
||||||
|
for index in 0..12 {
|
||||||
|
let id = mgr.submit(&format!("task {index}"));
|
||||||
|
mgr.complete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(mgr.snapshots().len(), 10);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn completing_task_starts_next_queued() {
|
fn completing_task_starts_next_queued() {
|
||||||
let mgr = TaskManager::new(1);
|
let mgr = TaskManager::new(1);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use uuid::Uuid;
|
|||||||
use crate::db::queries::template as qt;
|
use crate::db::queries::template as qt;
|
||||||
use crate::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult};
|
||||||
use crate::model::{Template, TemplateKind, TemplateStatus};
|
use crate::model::{Template, TemplateKind, TemplateStatus};
|
||||||
use crate::util::frontmatter::{write_template_file, TemplateFrontmatter};
|
use crate::util::frontmatter::{TemplateFrontmatter, write_template_file};
|
||||||
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
|
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
|
||||||
|
|
||||||
/// Create a new draft template. Content stored in DB, no file written.
|
/// Create a new draft template. Content stored in DB, no file written.
|
||||||
@@ -51,6 +51,10 @@ pub fn create_template(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update a template's metadata and/or content. Bumps version.
|
/// Update a template's metadata and/or content. Bumps version.
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_arguments,
|
||||||
|
reason = "optional arguments represent independent template field changes"
|
||||||
|
)]
|
||||||
pub fn update_template(
|
pub fn update_template(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
template_id: &str,
|
template_id: &str,
|
||||||
@@ -64,15 +68,14 @@ pub fn update_template(
|
|||||||
let mut tpl = qt::get_template_by_id(conn, template_id)?;
|
let mut tpl = qt::get_template_by_id(conn, template_id)?;
|
||||||
|
|
||||||
// Slug uniqueness check
|
// Slug uniqueness check
|
||||||
if let Some(new_slug) = slug {
|
if let Some(new_slug) = slug
|
||||||
if new_slug != tpl.slug {
|
&& new_slug != tpl.slug
|
||||||
if qt::get_template_by_slug(conn, project_id, new_slug).is_ok() {
|
&& qt::get_template_by_slug(conn, project_id, new_slug).is_ok()
|
||||||
|
{
|
||||||
return Err(EngineError::Conflict(format!(
|
return Err(EngineError::Conflict(format!(
|
||||||
"template slug '{new_slug}' already exists"
|
"template slug '{new_slug}' already exists"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(t) = title {
|
if let Some(t) = title {
|
||||||
tpl.title = t.to_string();
|
tpl.title = t.to_string();
|
||||||
@@ -149,10 +152,7 @@ pub fn publish_template(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let body = tpl
|
let body = tpl.content.clone().unwrap_or_default();
|
||||||
.content
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
// Validate before publishing
|
// Validate before publishing
|
||||||
validate_template(&body).map_err(EngineError::Validation)?;
|
validate_template(&body).map_err(EngineError::Validation)?;
|
||||||
@@ -210,8 +210,7 @@ pub fn unpublish_template(
|
|||||||
let abs_path = data_dir.join(&tpl.file_path);
|
let abs_path = data_dir.join(&tpl.file_path);
|
||||||
if abs_path.exists() {
|
if abs_path.exists() {
|
||||||
let file_content = fs::read_to_string(&abs_path)?;
|
let file_content = fs::read_to_string(&abs_path)?;
|
||||||
let (_fm, body) =
|
let (_fm, body) = crate::util::frontmatter::read_template_file(&file_content)
|
||||||
crate::util::frontmatter::read_template_file(&file_content)
|
|
||||||
.map_err(EngineError::Parse)?;
|
.map_err(EngineError::Parse)?;
|
||||||
tpl.content = Some(body);
|
tpl.content = Some(body);
|
||||||
}
|
}
|
||||||
@@ -340,25 +339,29 @@ fn validate_liquid_blocks(content: &str) -> Result<(), String> {
|
|||||||
let start = i + 2;
|
let start = i + 2;
|
||||||
if let Some(end_offset) = content[start..].find("%}") {
|
if let Some(end_offset) = content[start..].find("%}") {
|
||||||
let tag_content = content[start..start + end_offset].trim();
|
let tag_content = content[start..start + end_offset].trim();
|
||||||
let tag_content = tag_content.trim_start_matches('-').trim_end_matches('-').trim();
|
let tag_content = tag_content
|
||||||
let first_word = tag_content
|
.trim_start_matches('-')
|
||||||
.split_whitespace()
|
.trim_end_matches('-')
|
||||||
.next()
|
.trim();
|
||||||
.unwrap_or("");
|
let first_word = tag_content.split_whitespace().next().unwrap_or("");
|
||||||
|
|
||||||
match first_word {
|
match first_word {
|
||||||
"if" => if_depth += 1,
|
"if" => if_depth += 1,
|
||||||
"endif" => {
|
"endif" => {
|
||||||
if_depth -= 1;
|
if_depth -= 1;
|
||||||
if if_depth < 0 {
|
if if_depth < 0 {
|
||||||
return Err("unexpected {% endif %} without matching {% if %}".to_string());
|
return Err(
|
||||||
|
"unexpected {% endif %} without matching {% if %}".to_string()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"for" => for_depth += 1,
|
"for" => for_depth += 1,
|
||||||
"endfor" => {
|
"endfor" => {
|
||||||
for_depth -= 1;
|
for_depth -= 1;
|
||||||
if for_depth < 0 {
|
if for_depth < 0 {
|
||||||
return Err("unexpected {% endfor %} without matching {% for %}".to_string());
|
return Err(
|
||||||
|
"unexpected {% endfor %} without matching {% for %}".to_string()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -416,8 +419,8 @@ fn null_template_slug_on_tags(conn: &Connection, slug: &str) -> EngineResult<()>
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
@@ -431,7 +434,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn create_draft_template() {
|
fn create_draft_template() {
|
||||||
let (db, _dir) = setup();
|
let (db, _dir) = setup();
|
||||||
let tpl = create_template(db.conn(), "p1", "My Template", TemplateKind::Post, "<p>hello</p>").unwrap();
|
let tpl = create_template(
|
||||||
|
db.conn(),
|
||||||
|
"p1",
|
||||||
|
"My Template",
|
||||||
|
TemplateKind::Post,
|
||||||
|
"<p>hello</p>",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(tpl.title, "My Template");
|
assert_eq!(tpl.title, "My Template");
|
||||||
assert_eq!(tpl.slug, "my-template");
|
assert_eq!(tpl.slug, "my-template");
|
||||||
assert_eq!(tpl.kind, TemplateKind::Post);
|
assert_eq!(tpl.kind, TemplateKind::Post);
|
||||||
@@ -456,9 +466,16 @@ mod tests {
|
|||||||
let (db, _dir) = setup();
|
let (db, _dir) = setup();
|
||||||
let tpl = create_template(db.conn(), "p1", "Tpl", TemplateKind::Post, "old").unwrap();
|
let tpl = create_template(db.conn(), "p1", "Tpl", TemplateKind::Post, "old").unwrap();
|
||||||
let updated = update_template(
|
let updated = update_template(
|
||||||
db.conn(), &tpl.id, "p1",
|
db.conn(),
|
||||||
Some("New Title"), None, None, None, Some("new content"),
|
&tpl.id,
|
||||||
).unwrap();
|
"p1",
|
||||||
|
Some("New Title"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("new content"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(updated.title, "New Title");
|
assert_eq!(updated.title, "New Title");
|
||||||
assert_eq!(updated.content, Some("new content".to_string()));
|
assert_eq!(updated.content, Some("new content".to_string()));
|
||||||
assert_eq!(updated.version, 2);
|
assert_eq!(updated.version, 2);
|
||||||
@@ -469,7 +486,16 @@ mod tests {
|
|||||||
let (db, _dir) = setup();
|
let (db, _dir) = setup();
|
||||||
create_template(db.conn(), "p1", "Alpha", TemplateKind::Post, "").unwrap();
|
create_template(db.conn(), "p1", "Alpha", TemplateKind::Post, "").unwrap();
|
||||||
let t2 = create_template(db.conn(), "p1", "Beta", TemplateKind::Post, "").unwrap();
|
let t2 = create_template(db.conn(), "p1", "Beta", TemplateKind::Post, "").unwrap();
|
||||||
let result = update_template(db.conn(), &t2.id, "p1", None, Some("alpha"), None, None, None);
|
let result = update_template(
|
||||||
|
db.conn(),
|
||||||
|
&t2.id,
|
||||||
|
"p1",
|
||||||
|
None,
|
||||||
|
Some("alpha"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,7 +511,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn publish_and_unpublish_template() {
|
fn publish_and_unpublish_template() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
let tpl = create_template(db.conn(), "p1", "Pub", TemplateKind::Post, "<div>body</div>").unwrap();
|
let tpl = create_template(
|
||||||
|
db.conn(),
|
||||||
|
"p1",
|
||||||
|
"Pub",
|
||||||
|
TemplateKind::Post,
|
||||||
|
"<div>body</div>",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Publish
|
// Publish
|
||||||
let published = publish_template(db.conn(), dir.path(), &tpl.id).unwrap();
|
let published = publish_template(db.conn(), dir.path(), &tpl.id).unwrap();
|
||||||
|
|||||||
@@ -139,8 +139,8 @@ fn rebuild_single_template(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
@@ -172,8 +172,7 @@ updatedAt: \"2024-01-01T00:00:00.000Z\"
|
|||||||
";
|
";
|
||||||
fs::write(tpl_dir.join("my-template.liquid"), content).unwrap();
|
fs::write(tpl_dir.join("my-template.liquid"), content).unwrap();
|
||||||
|
|
||||||
let report =
|
let report = rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||||
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
|
||||||
|
|
||||||
assert_eq!(report.created, 1);
|
assert_eq!(report.created, 1);
|
||||||
assert_eq!(report.updated, 0);
|
assert_eq!(report.updated, 0);
|
||||||
@@ -186,7 +185,10 @@ updatedAt: \"2024-01-01T00:00:00.000Z\"
|
|||||||
assert!(tpl.enabled);
|
assert!(tpl.enabled);
|
||||||
assert_eq!(tpl.version, 1);
|
assert_eq!(tpl.version, 1);
|
||||||
assert_eq!(tpl.status, TemplateStatus::Published);
|
assert_eq!(tpl.status, TemplateStatus::Published);
|
||||||
assert!(tpl.content.is_none(), "published template should have content=None in DB");
|
assert!(
|
||||||
|
tpl.content.is_none(),
|
||||||
|
"published template should have content=None in DB"
|
||||||
|
);
|
||||||
assert_eq!(tpl.file_path, "templates/my-template.liquid");
|
assert_eq!(tpl.file_path, "templates/my-template.liquid");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,8 +232,7 @@ updatedAt: \"2024-01-01T00:00:00.000Z\"
|
|||||||
";
|
";
|
||||||
fs::write(tpl_dir.join("updated-slug.liquid"), content).unwrap();
|
fs::write(tpl_dir.join("updated-slug.liquid"), content).unwrap();
|
||||||
|
|
||||||
let report =
|
let report = rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||||
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
|
||||||
|
|
||||||
assert_eq!(report.created, 0);
|
assert_eq!(report.created, 0);
|
||||||
assert_eq!(report.updated, 1);
|
assert_eq!(report.updated, 1);
|
||||||
@@ -256,8 +257,7 @@ updatedAt: \"2024-01-01T00:00:00.000Z\"
|
|||||||
fs::write(tpl_dir.join("readme.txt"), "not a template").unwrap();
|
fs::write(tpl_dir.join("readme.txt"), "not a template").unwrap();
|
||||||
fs::write(tpl_dir.join("styles.css"), "body {}").unwrap();
|
fs::write(tpl_dir.join("styles.css"), "body {}").unwrap();
|
||||||
|
|
||||||
let report =
|
let report = rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||||
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
|
||||||
|
|
||||||
assert_eq!(report.created, 0);
|
assert_eq!(report.created, 0);
|
||||||
assert_eq!(report.updated, 0);
|
assert_eq!(report.updated, 0);
|
||||||
@@ -286,14 +286,12 @@ updatedAt: \"2024-01-01T00:00:00.000Z\"
|
|||||||
fs::write(tpl_dir.join("idem.liquid"), content).unwrap();
|
fs::write(tpl_dir.join("idem.liquid"), content).unwrap();
|
||||||
|
|
||||||
// First run
|
// First run
|
||||||
let r1 =
|
let r1 = rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||||
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
|
||||||
assert_eq!(r1.created, 1);
|
assert_eq!(r1.created, 1);
|
||||||
assert_eq!(r1.updated, 0);
|
assert_eq!(r1.updated, 0);
|
||||||
|
|
||||||
// Second run - should update, not create
|
// Second run - should update, not create
|
||||||
let r2 =
|
let r2 = rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||||
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
|
||||||
assert_eq!(r2.created, 0);
|
assert_eq!(r2.created, 0);
|
||||||
assert_eq!(r2.updated, 1);
|
assert_eq!(r2.updated, 1);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/// Validation functions for template (Liquid) and script (Lua/Python) content.
|
//! Validation functions for template (Liquid) and script (Lua/Python) content.
|
||||||
/// Per template.allium and script.allium, these are pre-publish gates.
|
//! Per template.allium and script.allium, these are pre-publish gates.
|
||||||
///
|
//!
|
||||||
/// Current implementation: basic structural checks.
|
//! Current implementation: basic structural checks.
|
||||||
/// When a full Liquid parser crate is added, upgrade `validate_liquid` to
|
//! When a full Liquid parser crate is added, upgrade `validate_liquid` to
|
||||||
/// attempt a real parse. Similarly for `validate_script` with a Lua parser.
|
//! attempt a real parse. Similarly for `validate_script` with a Lua parser.
|
||||||
|
|
||||||
/// Result of a validation check.
|
/// Result of a validation check.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -35,8 +35,10 @@ pub fn validate_liquid(content: &str) -> ValidationResult {
|
|||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
// Check for unmatched Liquid block tags
|
// Check for unmatched Liquid block tags
|
||||||
let block_tags = ["if", "unless", "for", "case", "capture", "comment",
|
let block_tags = [
|
||||||
"raw", "paginate", "tablerow", "block", "schema"];
|
"if", "unless", "for", "case", "capture", "comment", "raw", "paginate", "tablerow",
|
||||||
|
"block", "schema",
|
||||||
|
];
|
||||||
|
|
||||||
for tag in &block_tags {
|
for tag in &block_tags {
|
||||||
let open_pattern = format!("{{% {tag}");
|
let open_pattern = format!("{{% {tag}");
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ pub fn validate_site(
|
|||||||
let metadata = crate::engine::meta::read_project_json(data_dir)?;
|
let metadata = crate::engine::meta::read_project_json(data_dir)?;
|
||||||
let output_dir = generated_output_dir(data_dir);
|
let output_dir = generated_output_dir(data_dir);
|
||||||
let published_posts = load_published_posts(data_dir, conn, project_id)?;
|
let published_posts = load_published_posts(data_dir, conn, project_id)?;
|
||||||
let artifacts = build_site_render_artifacts(conn, data_dir, project_id, &metadata, &published_posts)
|
let artifacts =
|
||||||
|
build_site_render_artifacts(conn, data_dir, project_id, &metadata, &published_posts)
|
||||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
|
|
||||||
let mut expected = artifacts
|
let mut expected = artifacts
|
||||||
@@ -36,7 +37,12 @@ pub fn validate_site(
|
|||||||
expected.insert("calendar.json".to_string());
|
expected.insert("calendar.json".to_string());
|
||||||
expected.insert("rss.xml".to_string());
|
expected.insert("rss.xml".to_string());
|
||||||
for language in render_languages(&metadata) {
|
for language in render_languages(&metadata) {
|
||||||
let prefix = if language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) {
|
let prefix = if language
|
||||||
|
== metadata
|
||||||
|
.main_language
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "en".to_string())
|
||||||
|
{
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
format!("{language}/")
|
format!("{language}/")
|
||||||
@@ -79,7 +85,9 @@ pub fn validate_site(
|
|||||||
|
|
||||||
let mut stale_pages = Vec::new();
|
let mut stale_pages = Vec::new();
|
||||||
for rel in expected.intersection(&actual) {
|
for rel in expected.intersection(&actual) {
|
||||||
if let Ok(stored) = queries::generated_file_hash::get_generated_file_hash(conn, project_id, rel) {
|
if let Ok(stored) =
|
||||||
|
queries::generated_file_hash::get_generated_file_hash(conn, project_id, rel)
|
||||||
|
{
|
||||||
let actual_hash = file_hash(&output_dir.join(rel))?;
|
let actual_hash = file_hash(&output_dir.join(rel))?;
|
||||||
if actual_hash != stored.content_hash {
|
if actual_hash != stored.content_hash {
|
||||||
stale_pages.push(rel.clone());
|
stale_pages.push(rel.clone());
|
||||||
@@ -114,13 +122,17 @@ fn load_published_posts(
|
|||||||
) -> EngineResult<Vec<(Post, String)>> {
|
) -> EngineResult<Vec<(Post, String)>> {
|
||||||
let posts = queries::post::list_posts_by_project(conn, project_id)?;
|
let posts = queries::post::list_posts_by_project(conn, project_id)?;
|
||||||
let mut published = Vec::new();
|
let mut published = Vec::new();
|
||||||
for post in posts.into_iter().filter(|post| post.status == PostStatus::Published) {
|
for post in posts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|post| post.status == PostStatus::Published)
|
||||||
|
{
|
||||||
let body = if let Some(content) = &post.content {
|
let body = if let Some(content) = &post.content {
|
||||||
content.clone()
|
content.clone()
|
||||||
} else if let Some(content) = &post.published_content {
|
} else if let Some(content) = &post.published_content {
|
||||||
content.clone()
|
content.clone()
|
||||||
} else {
|
} else {
|
||||||
let raw = std::fs::read_to_string(data_dir.join(post.file_path.trim_start_matches('/')))?;
|
let raw =
|
||||||
|
std::fs::read_to_string(data_dir.join(post.file_path.trim_start_matches('/')))?;
|
||||||
crate::util::frontmatter::read_post_file(&raw)
|
crate::util::frontmatter::read_post_file(&raw)
|
||||||
.map(|(_, body)| body)
|
.map(|(_, body)| body)
|
||||||
.map_err(EngineError::Parse)?
|
.map_err(EngineError::Parse)?
|
||||||
@@ -131,10 +143,16 @@ fn load_published_posts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_languages(metadata: &crate::model::ProjectMetadata) -> Vec<String> {
|
fn render_languages(metadata: &crate::model::ProjectMetadata) -> Vec<String> {
|
||||||
let main = metadata.main_language.clone().unwrap_or_else(|| "en".to_string());
|
let main = metadata
|
||||||
|
.main_language
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "en".to_string());
|
||||||
let mut languages = vec![main.clone()];
|
let mut languages = vec![main.clone()];
|
||||||
for language in &metadata.blog_languages {
|
for language in &metadata.blog_languages {
|
||||||
if !languages.iter().any(|existing| existing.eq_ignore_ascii_case(language)) {
|
if !languages
|
||||||
|
.iter()
|
||||||
|
.any(|existing| existing.eq_ignore_ascii_case(language))
|
||||||
|
{
|
||||||
languages.push(language.clone());
|
languages.push(language.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,14 @@ pub fn validate_translations(
|
|||||||
blog_languages: &[String],
|
blog_languages: &[String],
|
||||||
main_language: &str,
|
main_language: &str,
|
||||||
) -> EngineResult<TranslationValidationReport> {
|
) -> EngineResult<TranslationValidationReport> {
|
||||||
validate_translations_with_progress(conn, data_dir, project_id, blog_languages, main_language, None)
|
validate_translations_with_progress(
|
||||||
|
conn,
|
||||||
|
data_dir,
|
||||||
|
project_id,
|
||||||
|
blog_languages,
|
||||||
|
main_language,
|
||||||
|
None,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Like `validate_translations` but with optional per-item progress.
|
/// Like `validate_translations` but with optional per-item progress.
|
||||||
@@ -193,11 +200,13 @@ pub fn validate_translations_with_progress(
|
|||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some((yaml_str, _body)) = crate::util::frontmatter::split_frontmatter(&content) else {
|
let Some((yaml_str, _body)) = crate::util::frontmatter::split_frontmatter(&content)
|
||||||
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let Ok(fm) = crate::util::frontmatter::TranslationFrontmatter::from_yaml(yaml_str) else {
|
let Ok(fm) = crate::util::frontmatter::TranslationFrontmatter::from_yaml(yaml_str)
|
||||||
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -133,11 +133,11 @@ pub fn translate(locale: UiLocale, key: &str) -> String {
|
|||||||
if let Some(val) = catalog_for(locale).get(key) {
|
if let Some(val) = catalog_for(locale).get(key) {
|
||||||
return val.clone();
|
return val.clone();
|
||||||
}
|
}
|
||||||
if locale != UiLocale::En {
|
if locale != UiLocale::En
|
||||||
if let Some(val) = CATALOG_EN.get(key) {
|
&& let Some(val) = CATALOG_EN.get(key)
|
||||||
|
{
|
||||||
return val.clone();
|
return val.clone();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
key.to_string()
|
key.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,11 +163,11 @@ pub fn translate_render(language: &str, key: &str) -> String {
|
|||||||
if let Some(val) = catalog.get(key) {
|
if let Some(val) = catalog.get(key) {
|
||||||
return val.clone();
|
return val.clone();
|
||||||
}
|
}
|
||||||
if !language.starts_with("en") {
|
if !language.starts_with("en")
|
||||||
if let Some(val) = RENDER_EN.get(key) {
|
&& let Some(val) = RENDER_EN.get(key)
|
||||||
|
{
|
||||||
return val.clone();
|
return val.clone();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
key.to_string()
|
key.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +302,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn translate_render_missing_key_returns_key() {
|
fn translate_render_missing_key_returns_key() {
|
||||||
assert_eq!(translate_render("en", "render.nonexistent"), "render.nonexistent");
|
assert_eq!(
|
||||||
|
translate_render("en", "render.nonexistent"),
|
||||||
|
"render.nonexistent"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
|
pub mod i18n;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
pub mod i18n;
|
|
||||||
pub mod scripting;
|
pub mod scripting;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
@@ -154,10 +154,15 @@ mod tests {
|
|||||||
fn max_posts_per_page_validation() {
|
fn max_posts_per_page_validation() {
|
||||||
let mut meta = ProjectMetadata {
|
let mut meta = ProjectMetadata {
|
||||||
name: "Test".into(),
|
name: "Test".into(),
|
||||||
description: None, public_url: None, main_language: None,
|
description: None,
|
||||||
default_author: None, max_posts_per_page: 50, blogmark_category: None,
|
public_url: None,
|
||||||
|
main_language: None,
|
||||||
|
default_author: None,
|
||||||
|
max_posts_per_page: 50,
|
||||||
|
blogmark_category: None,
|
||||||
pico_theme: None,
|
pico_theme: None,
|
||||||
semantic_similarity_enabled: false, blog_languages: vec![],
|
semantic_similarity_enabled: false,
|
||||||
|
blog_languages: vec![],
|
||||||
};
|
};
|
||||||
assert!(meta.validate().is_ok());
|
assert!(meta.validate().is_ok());
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
mod post;
|
|
||||||
mod media;
|
|
||||||
mod tag;
|
|
||||||
mod project;
|
|
||||||
mod template;
|
|
||||||
mod script;
|
|
||||||
mod generation;
|
mod generation;
|
||||||
|
mod media;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
|
mod post;
|
||||||
|
mod project;
|
||||||
|
mod script;
|
||||||
|
mod tag;
|
||||||
|
mod template;
|
||||||
|
|
||||||
pub use post::{Post, PostLink, PostMedia, PostStatus, PostTranslation};
|
|
||||||
pub use media::{Media, MediaTranslation};
|
|
||||||
pub use tag::Tag;
|
|
||||||
pub use project::{Project, Setting};
|
|
||||||
pub use template::{Template, TemplateKind, TemplateStatus};
|
|
||||||
pub use script::{Script, ScriptKind, ScriptStatus};
|
|
||||||
pub use generation::{
|
pub use generation::{
|
||||||
DbNotification, GeneratedFileHash, NotificationAction, NotificationEntity,
|
DbNotification, GeneratedFileHash, NotificationAction, NotificationEntity,
|
||||||
PublishingPreferences, SshMode,
|
PublishingPreferences, SshMode,
|
||||||
};
|
};
|
||||||
|
pub use media::{Media, MediaTranslation};
|
||||||
pub use metadata::{CategorySettings, ProjectMetadata, TagEntry};
|
pub use metadata::{CategorySettings, ProjectMetadata, TagEntry};
|
||||||
|
pub use post::{Post, PostLink, PostMedia, PostStatus, PostTranslation};
|
||||||
|
pub use project::{Project, Setting};
|
||||||
|
pub use script::{Script, ScriptKind, ScriptStatus};
|
||||||
|
pub use tag::Tag;
|
||||||
|
pub use template::{Template, TemplateKind, TemplateStatus};
|
||||||
@@ -32,14 +32,13 @@ pub fn write_generated_file(
|
|||||||
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let hash = content_hash(content.as_bytes());
|
let hash = content_hash(content.as_bytes());
|
||||||
let target_path = output_dir.join(relative_path);
|
let target_path = output_dir.join(relative_path);
|
||||||
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) {
|
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path)
|
||||||
if existing.content_hash == hash
|
&& existing.content_hash == hash
|
||||||
&& target_path.exists()
|
&& target_path.exists()
|
||||||
&& file_hash(&target_path)? == hash
|
&& file_hash(&target_path)? == hash
|
||||||
{
|
{
|
||||||
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
|
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(parent) = target_path.parent() {
|
if let Some(parent) = target_path.parent() {
|
||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent)?;
|
||||||
@@ -68,14 +67,13 @@ pub fn write_generated_bytes(
|
|||||||
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let hash = content_hash(content);
|
let hash = content_hash(content);
|
||||||
let target_path = output_dir.join(relative_path);
|
let target_path = output_dir.join(relative_path);
|
||||||
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) {
|
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path)
|
||||||
if existing.content_hash == hash
|
&& existing.content_hash == hash
|
||||||
&& target_path.exists()
|
&& target_path.exists()
|
||||||
&& file_hash(&target_path)? == hash
|
&& file_hash(&target_path)? == hash
|
||||||
{
|
{
|
||||||
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
|
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(parent) = target_path.parent() {
|
if let Some(parent) = target_path.parent() {
|
||||||
fs::create_dir_all(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;
|
*days.entry(day).or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
CalendarArchiveData { years, months, days }
|
CalendarArchiveData {
|
||||||
|
years,
|
||||||
|
months,
|
||||||
|
days,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_calendar_json(posts: &[Post]) -> serde_json::Result<String> {
|
pub fn build_calendar_json(posts: &[Post]) -> serde_json::Result<String> {
|
||||||
|
|||||||
@@ -52,17 +52,10 @@ fn render_macro(invocation: &str, context: &MacroRenderContext) -> Option<String
|
|||||||
"vimeo" => Some(render_vimeo(&args)),
|
"vimeo" => Some(render_vimeo(&args)),
|
||||||
"photo_archive" => Some(render_photo_archive(&args, context)),
|
"photo_archive" => Some(render_photo_archive(&args, context)),
|
||||||
"tag_cloud" => Some(render_tag_cloud(&args, context)),
|
"tag_cloud" => Some(render_tag_cloud(&args, context)),
|
||||||
_ => Some(unsupported_macro(name)),
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unsupported_macro(name: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"<section class=\"macro-unsupported\"><p>Unsupported macro: <code>{}</code></p></section>",
|
|
||||||
escape_html_attr(name),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tokenize_invocation(invocation: &str) -> Vec<String> {
|
fn tokenize_invocation(invocation: &str) -> Vec<String> {
|
||||||
let mut tokens = Vec::new();
|
let mut tokens = Vec::new();
|
||||||
let mut current = String::new();
|
let mut current = String::new();
|
||||||
@@ -100,13 +93,12 @@ fn tokenize_invocation(invocation: &str) -> Vec<String> {
|
|||||||
|
|
||||||
fn resolve_token(raw: &str, context: &MacroRenderContext) -> JsonValue {
|
fn resolve_token(raw: &str, context: &MacroRenderContext) -> JsonValue {
|
||||||
let trimmed = raw.trim();
|
let trimmed = raw.trim();
|
||||||
if trimmed.len() >= 2 {
|
if trimmed.len() >= 2
|
||||||
if (trimmed.starts_with('"') && trimmed.ends_with('"'))
|
&& ((trimmed.starts_with('"') && trimmed.ends_with('"'))
|
||||||
|| (trimmed.starts_with('\'') && trimmed.ends_with('\''))
|
|| (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
|
||||||
{
|
{
|
||||||
return JsonValue::String(trimmed[1..trimmed.len() - 1].to_string());
|
return JsonValue::String(trimmed[1..trimmed.len() - 1].to_string());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
match trimmed {
|
match trimmed {
|
||||||
"true" => return JsonValue::Bool(true),
|
"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 {
|
fn render_youtube(args: &HashMap<String, JsonValue>) -> String {
|
||||||
let video_id = args.get("id").map(stringify_scalar).unwrap_or_default();
|
let video_id = args.get("id").map(stringify_scalar).unwrap_or_default();
|
||||||
if video_id.is_empty() {
|
if video_id.is_empty() {
|
||||||
return empty_block("macro-youtube", "gallery-empty", "Missing YouTube video id.");
|
return empty_block(
|
||||||
|
"macro-youtube",
|
||||||
|
"gallery-empty",
|
||||||
|
"Missing YouTube video id.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
format!(
|
format!(
|
||||||
"<section class=\"macro-youtube\"><iframe src=\"https://www.youtube.com/embed/{}\" title=\"YouTube video\" loading=\"lazy\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\" allowfullscreen></iframe></section>",
|
"<section class=\"macro-youtube\"><iframe src=\"https://www.youtube.com/embed/{}\" title=\"YouTube video\" loading=\"lazy\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\" allowfullscreen></iframe></section>",
|
||||||
@@ -224,10 +220,18 @@ fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRender
|
|||||||
.or_else(|| linked_media(context));
|
.or_else(|| linked_media(context));
|
||||||
|
|
||||||
let Some(media) = media else {
|
let Some(media) = media else {
|
||||||
return empty_block("macro-photo-archive", "photo-archive-empty", "No photos available.");
|
return empty_block(
|
||||||
|
"macro-photo-archive",
|
||||||
|
"photo-archive-empty",
|
||||||
|
"No photos available.",
|
||||||
|
);
|
||||||
};
|
};
|
||||||
if media.is_empty() {
|
if media.is_empty() {
|
||||||
return empty_block("macro-photo-archive", "photo-archive-empty", "No photos available.");
|
return empty_block(
|
||||||
|
"macro-photo-archive",
|
||||||
|
"photo-archive-empty",
|
||||||
|
"No photos available.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut grouped = BTreeMap::<String, Vec<JsonValue>>::new();
|
let mut grouped = BTreeMap::<String, Vec<JsonValue>>::new();
|
||||||
@@ -243,7 +247,11 @@ fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRender
|
|||||||
let single_month = grouped.len() == 1;
|
let single_month = grouped.len() == 1;
|
||||||
let mut html = format!(
|
let mut html = format!(
|
||||||
"<section class=\"macro-photo-archive{}\"><div class=\"photo-archive-container\">",
|
"<section class=\"macro-photo-archive{}\"><div class=\"photo-archive-container\">",
|
||||||
if single_month { " photo-archive-single-month" } else { "" }
|
if single_month {
|
||||||
|
" photo-archive-single-month"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
);
|
);
|
||||||
for (bucket, items) in grouped.into_iter().rev() {
|
for (bucket, items) in grouped.into_iter().rev() {
|
||||||
html.push_str("<section class=\"photo-archive-month\">");
|
html.push_str("<section class=\"photo-archive-month\">");
|
||||||
@@ -362,7 +370,10 @@ fn tag_items(context: &MacroRenderContext) -> Option<Vec<JsonValue>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn image_path(image: &JsonValue) -> Option<String> {
|
fn image_path(image: &JsonValue) -> Option<String> {
|
||||||
image.get("file_path").and_then(JsonValue::as_str).map(ToOwned::to_owned)
|
image
|
||||||
|
.get("file_path")
|
||||||
|
.and_then(JsonValue::as_str)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn image_title(image: &JsonValue) -> Option<String> {
|
fn image_title(image: &JsonValue) -> Option<String> {
|
||||||
@@ -391,7 +402,9 @@ fn image_alt(image: &JsonValue, fallback: Option<&str>) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn value_as_u64(value: &JsonValue) -> Option<u64> {
|
fn value_as_u64(value: &JsonValue) -> Option<u64> {
|
||||||
value.as_u64().or_else(|| value.as_i64().map(|number| number.max(0) as u64))
|
value
|
||||||
|
.as_u64()
|
||||||
|
.or_else(|| value.as_i64().map(|number| number.max(0) as u64))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stringify_scalar(value: &JsonValue) -> String {
|
fn stringify_scalar(value: &JsonValue) -> String {
|
||||||
@@ -407,7 +420,9 @@ fn stringify_scalar(value: &JsonValue) -> String {
|
|||||||
fn month_bucket(path: &str) -> Option<String> {
|
fn month_bucket(path: &str) -> Option<String> {
|
||||||
let segments = path.trim_matches('/').split('/').collect::<Vec<_>>();
|
let segments = path.trim_matches('/').split('/').collect::<Vec<_>>();
|
||||||
match segments.as_slice() {
|
match segments.as_slice() {
|
||||||
["media", year, month, ..] if year.len() == 4 && month.len() == 2 => Some(format!("{year}-{month}")),
|
["media", year, month, ..] if year.len() == 4 && month.len() == 2 => {
|
||||||
|
Some(format!("{year}-{month}"))
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -422,11 +437,16 @@ fn empty_block(wrapper_class: &str, message_class: &str, message: &str) -> Strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn escape_html(value: &str) -> String {
|
fn escape_html(value: &str) -> String {
|
||||||
value.replace('&', "&").replace('<', "<").replace('>', ">")
|
value
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn escape_html_attr(value: &str) -> String {
|
fn escape_html_attr(value: &str) -> String {
|
||||||
escape_html(value).replace('"', """).replace('\'', "'")
|
escape_html(value)
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -470,4 +490,13 @@ mod tests {
|
|||||||
assert!(html.contains("macro-tag-cloud"));
|
assert!(html.contains("macro-tag-cloud"));
|
||||||
assert!(html.contains("data-tag-cloud=\"true\""));
|
assert!(html.contains("data-tag-cloud=\"true\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leaves_unknown_macros_verbatim() {
|
||||||
|
let markdown = "Before [[future_macro value='x']] after";
|
||||||
|
assert_eq!(
|
||||||
|
expand_builtin_macros(markdown, &MacroRenderContext::default()),
|
||||||
|
markdown
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
mod markdown;
|
|
||||||
mod macros;
|
|
||||||
mod generation;
|
mod generation;
|
||||||
|
mod macros;
|
||||||
|
mod markdown;
|
||||||
mod page_renderer;
|
mod page_renderer;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod site;
|
mod site;
|
||||||
mod template_lookup;
|
mod template_lookup;
|
||||||
|
|
||||||
pub use generation::{
|
pub use generation::{
|
||||||
CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json,
|
CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths,
|
||||||
build_core_generation_paths, write_generated_bytes, write_generated_file,
|
write_generated_bytes, write_generated_file,
|
||||||
};
|
};
|
||||||
pub use markdown::render_markdown_to_html;
|
pub use markdown::render_markdown_to_html;
|
||||||
pub use page_renderer::{RenderError, render_liquid_template};
|
pub use page_renderer::{RenderError, render_liquid_template};
|
||||||
@@ -18,10 +18,9 @@ pub use routes::{
|
|||||||
render_starter_single_post_page_with_media_map,
|
render_starter_single_post_page_with_media_map,
|
||||||
};
|
};
|
||||||
pub use site::{
|
pub use site::{
|
||||||
PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts,
|
PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts, build_preview_response,
|
||||||
build_preview_response, build_site_render_artifacts,
|
build_site_render_artifacts,
|
||||||
};
|
};
|
||||||
pub use template_lookup::{
|
pub use template_lookup::{
|
||||||
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError,
|
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError, resolve_post_template,
|
||||||
resolve_post_template,
|
|
||||||
};
|
};
|
||||||
@@ -2,11 +2,11 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use liquid::ParserBuilder;
|
use liquid::ParserBuilder;
|
||||||
use liquid::partials::{EagerCompiler, InMemorySource};
|
use liquid::partials::{EagerCompiler, InMemorySource};
|
||||||
use liquid_core::{
|
|
||||||
Display_filter, Expression, Filter, FilterParameters, FilterReflection,
|
|
||||||
FromFilterParameters, ParseFilter, Runtime, Value, ValueView,
|
|
||||||
};
|
|
||||||
use liquid_core::model::ScalarCow;
|
use liquid_core::model::ScalarCow;
|
||||||
|
use liquid_core::{
|
||||||
|
Display_filter, Expression, Filter, FilterParameters, FilterReflection, FromFilterParameters,
|
||||||
|
ParseFilter, Runtime, Value, ValueView,
|
||||||
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::{Map as JsonMap, Value as JsonValue};
|
use serde_json::{Map as JsonMap, Value as JsonValue};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
@@ -14,6 +14,7 @@ use thiserror::Error;
|
|||||||
use crate::i18n::translate_render;
|
use crate::i18n::translate_render;
|
||||||
use crate::render::macros::{MacroRenderContext, expand_builtin_macros};
|
use crate::render::macros::{MacroRenderContext, expand_builtin_macros};
|
||||||
use crate::render::render_markdown_to_html;
|
use crate::render::render_markdown_to_html;
|
||||||
|
use crate::util::slugify;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum RenderError {
|
pub enum RenderError {
|
||||||
@@ -40,6 +41,7 @@ pub fn render_liquid_template<T: Serialize>(
|
|||||||
let parser = ParserBuilder::with_stdlib()
|
let parser = ParserBuilder::with_stdlib()
|
||||||
.filter(I18n)
|
.filter(I18n)
|
||||||
.filter(Markdown)
|
.filter(Markdown)
|
||||||
|
.filter(Slugify)
|
||||||
.partials(compiled_partials)
|
.partials(compiled_partials)
|
||||||
.build()?;
|
.build()?;
|
||||||
let template = parser.parse(template_source)?;
|
let template = parser.parse(template_source)?;
|
||||||
@@ -47,6 +49,28 @@ pub fn render_liquid_template<T: Serialize>(
|
|||||||
Ok(template.render(&globals)?)
|
Ok(template.render(&globals)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, ParseFilter, FilterReflection)]
|
||||||
|
#[filter(
|
||||||
|
name = "slugify",
|
||||||
|
description = "Convert text to the canonical post slug format.",
|
||||||
|
parsed(SlugifyFilter)
|
||||||
|
)]
|
||||||
|
struct Slugify;
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Display_filter)]
|
||||||
|
#[name = "slugify"]
|
||||||
|
struct SlugifyFilter;
|
||||||
|
|
||||||
|
impl Filter for SlugifyFilter {
|
||||||
|
fn evaluate(
|
||||||
|
&self,
|
||||||
|
input: &dyn ValueView,
|
||||||
|
_runtime: &dyn Runtime,
|
||||||
|
) -> liquid_core::Result<Value> {
|
||||||
|
Ok(Value::scalar(slugify(input.to_kstr().as_str())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, FilterParameters)]
|
#[derive(Debug, FilterParameters)]
|
||||||
struct I18nArgs {
|
struct I18nArgs {
|
||||||
#[parameter(description = "Render language", arg_type = "str")]
|
#[parameter(description = "Render language", arg_type = "str")]
|
||||||
@@ -74,7 +98,10 @@ impl Filter for I18nFilter {
|
|||||||
let args = self.args.evaluate(runtime)?;
|
let args = self.args.evaluate(runtime)?;
|
||||||
let key = input.to_kstr();
|
let key = input.to_kstr();
|
||||||
let language = args.language.to_kstr();
|
let language = args.language.to_kstr();
|
||||||
Ok(Value::scalar(translate_render(language.as_str(), key.as_str())))
|
Ok(Value::scalar(translate_render(
|
||||||
|
language.as_str(),
|
||||||
|
key.as_str(),
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +162,10 @@ impl Filter for MarkdownFilter {
|
|||||||
|
|
||||||
let expanded = expand_builtin_macros(markdown.as_str(), ¯o_context);
|
let expanded = expand_builtin_macros(markdown.as_str(), ¯o_context);
|
||||||
let rendered = render_markdown_to_html(&expanded);
|
let rendered = render_markdown_to_html(&expanded);
|
||||||
Ok(Value::scalar(rewrite_rendered_html_urls(&rendered, &rewrite_context)))
|
Ok(Value::scalar(rewrite_rendered_html_urls(
|
||||||
|
&rendered,
|
||||||
|
&rewrite_context,
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,11 +178,11 @@ fn collect_macro_roots(runtime: &dyn Runtime) -> JsonMap<String, JsonValue> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !roots.contains_key("Tags") {
|
if !roots.contains_key("Tags")
|
||||||
if let Some(tags) = roots.get("post_tags").cloned() {
|
&& let Some(tags) = roots.get("post_tags").cloned()
|
||||||
|
{
|
||||||
roots.insert("Tags".to_string(), tags);
|
roots.insert("Tags".to_string(), tags);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
roots
|
roots
|
||||||
}
|
}
|
||||||
@@ -202,9 +232,16 @@ fn value_to_string_map(value: &impl ValueView) -> HashMap<String, String> {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn rewrite_rendered_html_urls(html: &str, rewrite_context: &impl RewriteContextView) -> String {
|
pub(crate) fn rewrite_rendered_html_urls(
|
||||||
let rewritten = rewrite_attribute_urls(html, "href", |href| normalize_preview_href(href, rewrite_context));
|
html: &str,
|
||||||
rewrite_attribute_urls(&rewritten, "src", |src| normalize_preview_src(src, rewrite_context))
|
rewrite_context: &impl RewriteContextView,
|
||||||
|
) -> String {
|
||||||
|
let rewritten = rewrite_attribute_urls(html, "href", |href| {
|
||||||
|
normalize_preview_href(href, rewrite_context)
|
||||||
|
});
|
||||||
|
rewrite_attribute_urls(&rewritten, "src", |src| {
|
||||||
|
normalize_preview_src(src, rewrite_context)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) trait RewriteContextView {
|
pub(crate) trait RewriteContextView {
|
||||||
@@ -387,7 +424,9 @@ fn extract_post_slug(path: &str) -> Option<String> {
|
|||||||
let segments: Vec<_> = trimmed.split('/').collect();
|
let segments: Vec<_> = trimmed.split('/').collect();
|
||||||
match segments.as_slice() {
|
match segments.as_slice() {
|
||||||
["post" | "posts", slug] => Some(trim_html_suffix(slug)),
|
["post" | "posts", slug] => Some(trim_html_suffix(slug)),
|
||||||
["post" | "posts", year, month, slug] if year.len() == 4 && month.chars().all(|ch| ch.is_ascii_digit()) => {
|
["post" | "posts", year, month, slug]
|
||||||
|
if year.len() == 4 && month.chars().all(|ch| ch.is_ascii_digit()) =>
|
||||||
|
{
|
||||||
Some(trim_html_suffix(slug))
|
Some(trim_html_suffix(slug))
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -422,7 +461,7 @@ fn trim_html_suffix(value: &str) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{rewrite_rendered_html_urls, RewriteContextView};
|
use super::{RewriteContextView, render_liquid_template, rewrite_rendered_html_urls};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
struct TestRewriteContext {
|
struct TestRewriteContext {
|
||||||
@@ -457,4 +496,16 @@ mod tests {
|
|||||||
|
|
||||||
assert!(html.contains("src=\"/media/2026/04/media-1.png\""));
|
assert!(html.contains("src=\"/media/2026/04/media-1.png\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exposes_slugify_liquid_filter() {
|
||||||
|
let rendered = render_liquid_template(
|
||||||
|
"{{ title | slugify }}",
|
||||||
|
&HashMap::new(),
|
||||||
|
&serde_json::json!({"title": "Über die Brücke"}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(rendered, "ueber-die-bruecke");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -7,11 +7,16 @@ use crate::i18n::normalize_language;
|
|||||||
use crate::model::{Post, ProjectMetadata};
|
use crate::model::{Post, ProjectMetadata};
|
||||||
use crate::render::{RenderError, render_liquid_template};
|
use crate::render::{RenderError, render_liquid_template};
|
||||||
|
|
||||||
const STARTER_SINGLE_POST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/single-post.liquid");
|
const STARTER_SINGLE_POST_TEMPLATE: &str =
|
||||||
const STARTER_POST_LIST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/post-list.liquid");
|
include_str!("../../../../assets/starter-templates/single-post.liquid");
|
||||||
const STARTER_HEAD_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/head.liquid");
|
const STARTER_POST_LIST_TEMPLATE: &str =
|
||||||
const STARTER_MENU_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/menu.liquid");
|
include_str!("../../../../assets/starter-templates/post-list.liquid");
|
||||||
const STARTER_LANGUAGE_SWITCHER_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid");
|
const STARTER_HEAD_PARTIAL: &str =
|
||||||
|
include_str!("../../../../assets/starter-templates/partials/head.liquid");
|
||||||
|
const STARTER_MENU_PARTIAL: &str =
|
||||||
|
include_str!("../../../../assets/starter-templates/partials/menu.liquid");
|
||||||
|
const STARTER_LANGUAGE_SWITCHER_PARTIAL: &str =
|
||||||
|
include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid");
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct RenderedPage {
|
pub struct RenderedPage {
|
||||||
@@ -136,7 +141,10 @@ pub fn render_starter_single_post_page_with_media_map(
|
|||||||
language: &str,
|
language: &str,
|
||||||
canonical_media_path_by_source_path: HashMap<String, String>,
|
canonical_media_path_by_source_path: HashMap<String, String>,
|
||||||
) -> Result<RenderedPage, RenderError> {
|
) -> Result<RenderedPage, RenderError> {
|
||||||
let relative_path = format!("{}/index.html", build_canonical_post_path(post, language, main_language(metadata)).trim_start_matches('/'));
|
let relative_path = format!(
|
||||||
|
"{}/index.html",
|
||||||
|
build_canonical_post_path(post, language, main_language(metadata)).trim_start_matches('/')
|
||||||
|
);
|
||||||
let canonical_path = build_canonical_post_path(post, language, main_language(metadata));
|
let canonical_path = build_canonical_post_path(post, language, main_language(metadata));
|
||||||
let (calendar_initial_year, calendar_initial_month) = calendar_initial_parts(post);
|
let (calendar_initial_year, calendar_initial_month) = calendar_initial_parts(post);
|
||||||
let context = PostTemplateContext {
|
let context = PostTemplateContext {
|
||||||
@@ -171,13 +179,12 @@ pub fn render_starter_single_post_page_with_media_map(
|
|||||||
post_data_json_by_id: HashMap::new(),
|
post_data_json_by_id: HashMap::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let html = render_liquid_template(
|
let html = render_liquid_template(STARTER_SINGLE_POST_TEMPLATE, &starter_partials(), &context)?;
|
||||||
STARTER_SINGLE_POST_TEMPLATE,
|
|
||||||
&starter_partials(),
|
|
||||||
&context,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(RenderedPage { relative_path, html })
|
Ok(RenderedPage {
|
||||||
|
relative_path,
|
||||||
|
html,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_starter_list_page(
|
pub fn render_starter_list_page(
|
||||||
@@ -246,26 +253,33 @@ pub fn render_starter_list_page_with_media_map(
|
|||||||
post_data_json_by_id: HashMap::new(),
|
post_data_json_by_id: HashMap::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let html = render_liquid_template(
|
let html =
|
||||||
&starter_post_list_template(),
|
render_liquid_template(&starter_post_list_template(), &starter_partials(), &context)?;
|
||||||
&starter_partials(),
|
|
||||||
&context,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(RenderedPage { relative_path, html })
|
Ok(RenderedPage {
|
||||||
|
relative_path,
|
||||||
|
html,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn starter_partials() -> HashMap<String, String> {
|
fn starter_partials() -> HashMap<String, String> {
|
||||||
HashMap::from([
|
HashMap::from([
|
||||||
("partials/head".to_string(), STARTER_HEAD_PARTIAL.to_string()),
|
(
|
||||||
("partials/menu".to_string(), STARTER_MENU_PARTIAL.to_string()),
|
"partials/head".to_string(),
|
||||||
|
STARTER_HEAD_PARTIAL.to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"partials/menu".to_string(),
|
||||||
|
STARTER_MENU_PARTIAL.to_string(),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"partials/language-switcher".to_string(),
|
"partials/language-switcher".to_string(),
|
||||||
STARTER_LANGUAGE_SWITCHER_PARTIAL.to_string(),
|
STARTER_LANGUAGE_SWITCHER_PARTIAL.to_string(),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"partials/menu-items".to_string(),
|
"partials/menu-items".to_string(),
|
||||||
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}".to_string(),
|
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}"
|
||||||
|
.to_string(),
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@@ -305,7 +319,11 @@ fn calendar_initial_parts(post: &Post) -> (i32, u32) {
|
|||||||
.unwrap_or((1970, 1))
|
.unwrap_or((1970, 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_alternate_links(post: &Post, metadata: &ProjectMetadata, current_language: &str) -> Vec<AlternateLinkContext> {
|
fn build_alternate_links(
|
||||||
|
post: &Post,
|
||||||
|
metadata: &ProjectMetadata,
|
||||||
|
current_language: &str,
|
||||||
|
) -> Vec<AlternateLinkContext> {
|
||||||
metadata
|
metadata
|
||||||
.blog_languages
|
.blog_languages
|
||||||
.iter()
|
.iter()
|
||||||
@@ -320,7 +338,11 @@ fn build_alternate_links(post: &Post, metadata: &ProjectMetadata, current_langua
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_blog_languages(post: &Post, metadata: &ProjectMetadata, current_language: &str) -> Vec<BlogLanguageContext> {
|
fn build_blog_languages(
|
||||||
|
post: &Post,
|
||||||
|
metadata: &ProjectMetadata,
|
||||||
|
current_language: &str,
|
||||||
|
) -> Vec<BlogLanguageContext> {
|
||||||
metadata
|
metadata
|
||||||
.blog_languages
|
.blog_languages
|
||||||
.iter()
|
.iter()
|
||||||
@@ -334,7 +356,10 @@ fn build_blog_languages(post: &Post, metadata: &ProjectMetadata, current_languag
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_blog_languages_for_index(metadata: &ProjectMetadata, current_language: &str) -> Vec<BlogLanguageContext> {
|
fn build_blog_languages_for_index(
|
||||||
|
metadata: &ProjectMetadata,
|
||||||
|
current_language: &str,
|
||||||
|
) -> Vec<BlogLanguageContext> {
|
||||||
metadata
|
metadata
|
||||||
.blog_languages
|
.blog_languages
|
||||||
.iter()
|
.iter()
|
||||||
@@ -349,12 +374,23 @@ fn build_blog_languages_for_index(metadata: &ProjectMetadata, current_language:
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_absolute_post_url(post: &Post, metadata: &ProjectMetadata, language: &str) -> String {
|
fn build_absolute_post_url(post: &Post, metadata: &ProjectMetadata, language: &str) -> String {
|
||||||
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
|
let base_url = metadata
|
||||||
format!("{base_url}{}", build_canonical_post_path(post, language, main_language(metadata)))
|
.public_url
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim_end_matches('/');
|
||||||
|
format!(
|
||||||
|
"{base_url}{}",
|
||||||
|
build_canonical_post_path(post, language, main_language(metadata))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_absolute_index_url(metadata: &ProjectMetadata, language: &str) -> String {
|
fn build_absolute_index_url(metadata: &ProjectMetadata, language: &str) -> String {
|
||||||
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
|
let base_url = metadata
|
||||||
|
.public_url
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim_end_matches('/');
|
||||||
let suffix = if language.eq_ignore_ascii_case(main_language(metadata)) {
|
let suffix = if language.eq_ignore_ascii_case(main_language(metadata)) {
|
||||||
"/".to_string()
|
"/".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -377,7 +413,12 @@ fn build_day_blocks(posts: &[(Post, String)]) -> Vec<DayBlockContext> {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let key = format!("{:04}-{:02}-{:02}", timestamp.year(), timestamp.month(), timestamp.day());
|
let key = format!(
|
||||||
|
"{:04}-{:02}-{:02}",
|
||||||
|
timestamp.year(),
|
||||||
|
timestamp.month(),
|
||||||
|
timestamp.day()
|
||||||
|
);
|
||||||
if current_key.as_deref() != Some(key.as_str()) {
|
if current_key.as_deref() != Some(key.as_str()) {
|
||||||
if let Some(last) = blocks.last_mut() {
|
if let Some(last) = blocks.last_mut() {
|
||||||
last.show_separator = true;
|
last.show_separator = true;
|
||||||
@@ -385,7 +426,12 @@ fn build_day_blocks(posts: &[(Post, String)]) -> Vec<DayBlockContext> {
|
|||||||
current_key = Some(key);
|
current_key = Some(key);
|
||||||
blocks.push(DayBlockContext {
|
blocks.push(DayBlockContext {
|
||||||
show_date_marker: true,
|
show_date_marker: true,
|
||||||
date_label: format!("{:02}.{:02}.{:04}", timestamp.day(), timestamp.month(), timestamp.year()),
|
date_label: format!(
|
||||||
|
"{:02}.{:02}.{:04}",
|
||||||
|
timestamp.day(),
|
||||||
|
timestamp.month(),
|
||||||
|
timestamp.year()
|
||||||
|
),
|
||||||
posts: Vec::new(),
|
posts: Vec::new(),
|
||||||
show_separator: false,
|
show_separator: false,
|
||||||
});
|
});
|
||||||
@@ -456,19 +502,39 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn canonical_post_paths_follow_language_prefix_rule() {
|
fn canonical_post_paths_follow_language_prefix_rule() {
|
||||||
let post = make_post();
|
let post = make_post();
|
||||||
assert_eq!(build_canonical_post_path(&post, "en", "en"), "/2024/03/09/hello");
|
assert_eq!(
|
||||||
assert_eq!(build_canonical_post_path(&post, "de", "en"), "/de/2024/03/09/hello");
|
build_canonical_post_path(&post, "en", "en"),
|
||||||
|
"/2024/03/09/hello"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
build_canonical_post_path(&post, "de", "en"),
|
||||||
|
"/de/2024/03/09/hello"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn starter_single_post_renderer_uses_canonical_route_and_language_links() {
|
fn starter_single_post_renderer_uses_canonical_route_and_language_links() {
|
||||||
let post = make_post();
|
let post = make_post();
|
||||||
let metadata = make_metadata();
|
let metadata = make_metadata();
|
||||||
let rendered = render_starter_single_post_page(&post, "Body with [link](/posts/hello)", &metadata, "en").unwrap();
|
let rendered = render_starter_single_post_page(
|
||||||
|
&post,
|
||||||
|
"Body with [link](/posts/hello)",
|
||||||
|
&metadata,
|
||||||
|
"en",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(rendered.relative_path, "2024/03/09/hello/index.html");
|
assert_eq!(rendered.relative_path, "2024/03/09/hello/index.html");
|
||||||
assert!(rendered.html.contains("https://example.com/2024/03/09/hello"));
|
assert!(
|
||||||
assert!(rendered.html.contains("https://example.com/de/2024/03/09/hello"));
|
rendered
|
||||||
|
.html
|
||||||
|
.contains("https://example.com/2024/03/09/hello")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rendered
|
||||||
|
.html
|
||||||
|
.contains("https://example.com/de/2024/03/09/hello")
|
||||||
|
);
|
||||||
assert!(rendered.html.contains("href=\"/2024/03/09/hello\""));
|
assert!(rendered.html.contains("href=\"/2024/03/09/hello\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,17 +10,28 @@ use serde_json::{Value, json};
|
|||||||
|
|
||||||
use crate::db::queries;
|
use crate::db::queries;
|
||||||
use crate::engine::menu::{self, MenuItemKind};
|
use crate::engine::menu::{self, MenuItemKind};
|
||||||
use crate::model::{CategorySettings, Media, Post, ProjectMetadata, Tag, Template, TemplateKind, TemplateStatus};
|
use crate::model::{
|
||||||
use crate::render::{RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path, render_liquid_template, resolve_post_template};
|
CategorySettings, Media, Post, ProjectMetadata, Tag, Template, TemplateKind, TemplateStatus,
|
||||||
|
};
|
||||||
|
use crate::render::{
|
||||||
|
RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path,
|
||||||
|
render_liquid_template, resolve_post_template,
|
||||||
|
};
|
||||||
use crate::util::frontmatter::{read_template_file, read_translation_file};
|
use crate::util::frontmatter::{read_template_file, read_translation_file};
|
||||||
use crate::util::slugify;
|
use crate::util::slugify;
|
||||||
|
|
||||||
const STARTER_SINGLE_POST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/single-post.liquid");
|
const STARTER_SINGLE_POST_TEMPLATE: &str =
|
||||||
const STARTER_POST_LIST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/post-list.liquid");
|
include_str!("../../../../assets/starter-templates/single-post.liquid");
|
||||||
const STARTER_NOT_FOUND_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/not-found.liquid");
|
const STARTER_POST_LIST_TEMPLATE: &str =
|
||||||
const STARTER_HEAD_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/head.liquid");
|
include_str!("../../../../assets/starter-templates/post-list.liquid");
|
||||||
const STARTER_MENU_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/menu.liquid");
|
const STARTER_NOT_FOUND_TEMPLATE: &str =
|
||||||
const STARTER_LANGUAGE_SWITCHER_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid");
|
include_str!("../../../../assets/starter-templates/not-found.liquid");
|
||||||
|
const STARTER_HEAD_PARTIAL: &str =
|
||||||
|
include_str!("../../../../assets/starter-templates/partials/head.liquid");
|
||||||
|
const STARTER_MENU_PARTIAL: &str =
|
||||||
|
include_str!("../../../../assets/starter-templates/partials/menu.liquid");
|
||||||
|
const STARTER_LANGUAGE_SWITCHER_PARTIAL: &str =
|
||||||
|
include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid");
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct SitePage {
|
pub struct SitePage {
|
||||||
@@ -100,9 +111,16 @@ pub fn build_site_render_artifacts(
|
|||||||
|
|
||||||
let mut artifacts = SiteRenderArtifacts::default();
|
let mut artifacts = SiteRenderArtifacts::default();
|
||||||
for language in languages {
|
for language in languages {
|
||||||
let localized_posts = load_language_posts(conn, data_dir, published_posts, &language, &main_language)?;
|
let localized_posts =
|
||||||
|
load_language_posts(conn, data_dir, published_posts, &language, &main_language)?;
|
||||||
let localized_list_posts = filter_posts_for_lists(&localized_posts, &category_settings);
|
let localized_list_posts = filter_posts_for_lists(&localized_posts, &category_settings);
|
||||||
let routes = build_language_routes(&localized_list_posts, metadata, &language, &tags, &category_settings);
|
let routes = build_language_routes(
|
||||||
|
&localized_list_posts,
|
||||||
|
metadata,
|
||||||
|
&language,
|
||||||
|
&tags,
|
||||||
|
&category_settings,
|
||||||
|
);
|
||||||
let post_data_json_by_id = build_post_data_json_by_id(&localized_posts);
|
let post_data_json_by_id = build_post_data_json_by_id(&localized_posts);
|
||||||
let menu_items = build_menu_items(data_dir, &language, &main_language)?;
|
let menu_items = build_menu_items(data_dir, &language, &main_language)?;
|
||||||
let rendered_list_pages = routes
|
let rendered_list_pages = routes
|
||||||
@@ -138,7 +156,8 @@ pub fn build_site_render_artifacts(
|
|||||||
artifacts.pages.push(page);
|
artifacts.pages.push(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
let canonical_map = canonical_post_path_by_slug(&localized_posts, &language, &main_language);
|
let canonical_map =
|
||||||
|
canonical_post_path_by_slug(&localized_posts, &language, &main_language);
|
||||||
for record in &localized_posts {
|
for record in &localized_posts {
|
||||||
let html = render_post_route(
|
let html = render_post_route(
|
||||||
conn,
|
conn,
|
||||||
@@ -183,9 +202,14 @@ pub fn build_preview_response(
|
|||||||
published_posts: &[(Post, String)],
|
published_posts: &[(Post, String)],
|
||||||
requested_path: &str,
|
requested_path: &str,
|
||||||
) -> Result<PreviewRenderResult, Box<dyn Error + Send + Sync>> {
|
) -> Result<PreviewRenderResult, Box<dyn Error + Send + Sync>> {
|
||||||
let artifacts = build_site_render_artifacts(conn, data_dir, project_id, metadata, published_posts)?;
|
let artifacts =
|
||||||
|
build_site_render_artifacts(conn, data_dir, project_id, metadata, published_posts)?;
|
||||||
let normalized = normalize_request_path(requested_path);
|
let normalized = normalize_request_path(requested_path);
|
||||||
if let Some(page) = artifacts.pages.iter().find(|page| page.url_path == normalized) {
|
if let Some(page) = artifacts
|
||||||
|
.pages
|
||||||
|
.iter()
|
||||||
|
.find(|page| page.url_path == normalized)
|
||||||
|
{
|
||||||
return Ok(PreviewRenderResult {
|
return Ok(PreviewRenderResult {
|
||||||
status_code: 200,
|
status_code: 200,
|
||||||
html: page.html.clone(),
|
html: page.html.clone(),
|
||||||
@@ -207,7 +231,8 @@ fn load_template_bundle(
|
|||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
) -> Result<TemplateBundle, Box<dyn Error + Send + Sync>> {
|
) -> Result<TemplateBundle, Box<dyn Error + Send + Sync>> {
|
||||||
let templates = queries::template::list_templates_by_project(conn, project_id).unwrap_or_default();
|
let templates =
|
||||||
|
queries::template::list_templates_by_project(conn, project_id).unwrap_or_default();
|
||||||
let mut template_source_by_slug = HashMap::new();
|
let mut template_source_by_slug = HashMap::new();
|
||||||
let mut post_templates = Vec::new();
|
let mut post_templates = Vec::new();
|
||||||
let mut list_template_sources = HashMap::new();
|
let mut list_template_sources = HashMap::new();
|
||||||
@@ -237,7 +262,10 @@ fn load_template_bundle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
TemplateKind::NotFound => {
|
TemplateKind::NotFound => {
|
||||||
if template.slug == "not-found" || template.slug == "not_found" || not_found_template == STARTER_NOT_FOUND_TEMPLATE {
|
if template.slug == "not-found"
|
||||||
|
|| template.slug == "not_found"
|
||||||
|
|| not_found_template == STARTER_NOT_FOUND_TEMPLATE
|
||||||
|
{
|
||||||
not_found_template = source;
|
not_found_template = source;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,7 +286,10 @@ fn load_template_bundle(
|
|||||||
.entry("list".to_string())
|
.entry("list".to_string())
|
||||||
.or_insert_with(|| STARTER_POST_LIST_TEMPLATE.to_string());
|
.or_insert_with(|| STARTER_POST_LIST_TEMPLATE.to_string());
|
||||||
|
|
||||||
if !post_templates.iter().any(|template| template.slug == "post") {
|
if !post_templates
|
||||||
|
.iter()
|
||||||
|
.any(|template| template.slug == "post")
|
||||||
|
{
|
||||||
post_templates.push(Template {
|
post_templates.push(Template {
|
||||||
id: "starter-post-template".to_string(),
|
id: "starter-post-template".to_string(),
|
||||||
project_id: project_id.to_string(),
|
project_id: project_id.to_string(),
|
||||||
@@ -273,7 +304,8 @@ fn load_template_bundle(
|
|||||||
created_at: 0,
|
created_at: 0,
|
||||||
updated_at: 0,
|
updated_at: 0,
|
||||||
});
|
});
|
||||||
template_source_by_slug.insert("post".to_string(), STARTER_SINGLE_POST_TEMPLATE.to_string());
|
template_source_by_slug
|
||||||
|
.insert("post".to_string(), STARTER_SINGLE_POST_TEMPLATE.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(TemplateBundle {
|
Ok(TemplateBundle {
|
||||||
@@ -318,7 +350,11 @@ fn load_language_posts(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(translation) = queries::post_translation::get_post_translation_by_post_and_language(conn, &post.id, language) {
|
if let Ok(translation) =
|
||||||
|
queries::post_translation::get_post_translation_by_post_and_language(
|
||||||
|
conn, &post.id, language,
|
||||||
|
)
|
||||||
|
{
|
||||||
let raw = fs::read_to_string(data_dir.join(&translation.file_path))?;
|
let raw = fs::read_to_string(data_dir.join(&translation.file_path))?;
|
||||||
let (_, translated_body) = read_translation_file(&raw)?;
|
let (_, translated_body) = read_translation_file(&raw)?;
|
||||||
let mut translated_post = post.clone();
|
let mut translated_post = post.clone();
|
||||||
@@ -336,7 +372,10 @@ fn load_language_posts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
posts.sort_by(|left, right| {
|
posts.sort_by(|left, right| {
|
||||||
right.post.published_at.unwrap_or(right.post.created_at)
|
right
|
||||||
|
.post
|
||||||
|
.published_at
|
||||||
|
.unwrap_or(right.post.created_at)
|
||||||
.cmp(&left.post.published_at.unwrap_or(left.post.created_at))
|
.cmp(&left.post.published_at.unwrap_or(left.post.created_at))
|
||||||
});
|
});
|
||||||
Ok(posts)
|
Ok(posts)
|
||||||
@@ -367,14 +406,29 @@ fn build_language_routes(
|
|||||||
|
|
||||||
for record in posts {
|
for record in posts {
|
||||||
for category in &record.post.categories {
|
for category in &record.post.categories {
|
||||||
category_posts.entry(category.clone()).or_default().push(record.clone());
|
category_posts
|
||||||
|
.entry(category.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(record.clone());
|
||||||
}
|
}
|
||||||
for tag in &record.post.tags {
|
for tag in &record.post.tags {
|
||||||
tag_posts.entry(tag.clone()).or_default().push(record.clone());
|
tag_posts
|
||||||
|
.entry(tag.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(record.clone());
|
||||||
}
|
}
|
||||||
if let Some(timestamp) = Utc.timestamp_millis_opt(record.post.published_at.unwrap_or(record.post.created_at)).single() {
|
if let Some(timestamp) = Utc
|
||||||
year_posts.entry(timestamp.year()).or_default().push(record.clone());
|
.timestamp_millis_opt(record.post.published_at.unwrap_or(record.post.created_at))
|
||||||
month_posts.entry((timestamp.year(), timestamp.month())).or_default().push(record.clone());
|
.single()
|
||||||
|
{
|
||||||
|
year_posts
|
||||||
|
.entry(timestamp.year())
|
||||||
|
.or_default()
|
||||||
|
.push(record.clone());
|
||||||
|
month_posts
|
||||||
|
.entry((timestamp.year(), timestamp.month()))
|
||||||
|
.or_default()
|
||||||
|
.push(record.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,7 +437,10 @@ fn build_language_routes(
|
|||||||
routes.extend(paginated_route_specs(
|
routes.extend(paginated_route_specs(
|
||||||
&records,
|
&records,
|
||||||
per_page,
|
per_page,
|
||||||
format!("{}/category/{slug}", language_root_prefix(language, metadata)),
|
format!(
|
||||||
|
"{}/category/{slug}",
|
||||||
|
language_root_prefix(language, metadata)
|
||||||
|
),
|
||||||
category.clone(),
|
category.clone(),
|
||||||
Some(json!({"kind": "category", "name": category})),
|
Some(json!({"kind": "category", "name": category})),
|
||||||
category_settings
|
category_settings
|
||||||
@@ -424,7 +481,10 @@ fn build_language_routes(
|
|||||||
routes.extend(paginated_route_specs(
|
routes.extend(paginated_route_specs(
|
||||||
&records,
|
&records,
|
||||||
per_page,
|
per_page,
|
||||||
format!("{}/{year}/{month:02}", language_root_prefix(language, metadata)),
|
format!(
|
||||||
|
"{}/{year}/{month:02}",
|
||||||
|
language_root_prefix(language, metadata)
|
||||||
|
),
|
||||||
format!("{} {year}-{month:02}", metadata.name),
|
format!("{} {year}-{month:02}", metadata.name),
|
||||||
Some(json!({"kind": "month", "year": year, "month": month})),
|
Some(json!({"kind": "month", "year": year, "month": month})),
|
||||||
None,
|
None,
|
||||||
@@ -449,7 +509,11 @@ fn paginated_route_specs(
|
|||||||
let current_page = page_index + 1;
|
let current_page = page_index + 1;
|
||||||
let start = page_index * per_page;
|
let start = page_index * per_page;
|
||||||
let end = (start + per_page).min(total_items);
|
let end = (start + per_page).min(total_items);
|
||||||
let slice = if start < end { posts[start..end].to_vec() } else { Vec::new() };
|
let slice = if start < end {
|
||||||
|
posts[start..end].to_vec()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
let relative_base = base_path.trim_matches('/');
|
let relative_base = base_path.trim_matches('/');
|
||||||
let relative_path = if current_page == 1 {
|
let relative_path = if current_page == 1 {
|
||||||
if relative_base.is_empty() {
|
if relative_base.is_empty() {
|
||||||
@@ -479,6 +543,10 @@ fn paginated_route_specs(
|
|||||||
pages
|
pages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_arguments,
|
||||||
|
reason = "render inputs are existing domain data with distinct lifetimes"
|
||||||
|
)]
|
||||||
fn render_list_route(
|
fn render_list_route(
|
||||||
route: &RouteSpec,
|
route: &RouteSpec,
|
||||||
metadata: &ProjectMetadata,
|
metadata: &ProjectMetadata,
|
||||||
@@ -536,7 +604,11 @@ fn render_list_route(
|
|||||||
"not_found_back_label": serde_json::Value::Null,
|
"not_found_back_label": serde_json::Value::Null,
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(render_liquid_template(list_template, &bundle.partials, &context)?)
|
Ok(render_liquid_template(
|
||||||
|
list_template,
|
||||||
|
&bundle.partials,
|
||||||
|
&context,
|
||||||
|
)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -558,9 +630,12 @@ fn render_post_route(
|
|||||||
let render_categories = category_settings
|
let render_categories = category_settings
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(name, settings)| {
|
.map(|(name, settings)| {
|
||||||
(name.clone(), RenderCategorySettings {
|
(
|
||||||
|
name.clone(),
|
||||||
|
RenderCategorySettings {
|
||||||
post_template_slug: settings.post_template_slug.clone(),
|
post_template_slug: settings.post_template_slug.clone(),
|
||||||
})
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.collect::<HashMap<_, _>>();
|
.collect::<HashMap<_, _>>();
|
||||||
let resolved = resolve_post_template(RenderTemplateLookup {
|
let resolved = resolve_post_template(RenderTemplateLookup {
|
||||||
@@ -583,12 +658,26 @@ fn render_post_route(
|
|||||||
.filter_map(|link| media_by_id.get(&link.media_id))
|
.filter_map(|link| media_by_id.get(&link.media_id))
|
||||||
.map(media_context)
|
.map(media_context)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let outgoing_links = queries::post_link::list_links_by_source(conn, &record.post.id).unwrap_or_default();
|
let outgoing_links =
|
||||||
let incoming_links = queries::post_link::list_links_by_target(conn, &record.post.id).unwrap_or_default();
|
queries::post_link::list_links_by_source(conn, &record.post.id).unwrap_or_default();
|
||||||
let post_by_id = all_posts.iter().map(|item| (item.post.id.clone(), item)).collect::<HashMap<_, _>>();
|
let incoming_links =
|
||||||
let outgoing_link_context = outgoing_links.iter().map(|link| link_context(link, &post_by_id, language, main_language)).collect::<Vec<_>>();
|
queries::post_link::list_links_by_target(conn, &record.post.id).unwrap_or_default();
|
||||||
let incoming_link_context = incoming_links.iter().map(|link| link_context(link, &post_by_id, language, main_language)).collect::<Vec<_>>();
|
let post_by_id = all_posts
|
||||||
let backlinks = incoming_links.iter().map(|link| backlink_context(link, &post_by_id, language, main_language)).collect::<Vec<_>>();
|
.iter()
|
||||||
|
.map(|item| (item.post.id.clone(), item))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
let outgoing_link_context = outgoing_links
|
||||||
|
.iter()
|
||||||
|
.map(|link| link_context(link, &post_by_id, language, main_language))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let incoming_link_context = incoming_links
|
||||||
|
.iter()
|
||||||
|
.map(|link| link_context(link, &post_by_id, language, main_language))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let backlinks = incoming_links
|
||||||
|
.iter()
|
||||||
|
.map(|link| backlink_context(link, &post_by_id, language, main_language))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
let taxonomy_counts = build_taxonomy_counts(all_posts, tags);
|
let taxonomy_counts = build_taxonomy_counts(all_posts, tags);
|
||||||
|
|
||||||
let context = json!({
|
let context = json!({
|
||||||
@@ -626,7 +715,11 @@ fn render_post_route(
|
|||||||
"not_found_back_label": serde_json::Value::Null,
|
"not_found_back_label": serde_json::Value::Null,
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(render_liquid_template(&template_source, &bundle.partials, &context)?)
|
Ok(render_liquid_template(
|
||||||
|
&template_source,
|
||||||
|
&bundle.partials,
|
||||||
|
&context,
|
||||||
|
)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_not_found_route(
|
fn render_not_found_route(
|
||||||
@@ -670,7 +763,11 @@ fn render_not_found_route(
|
|||||||
"canonical_media_path_by_source_path": HashMap::<String, String>::new(),
|
"canonical_media_path_by_source_path": HashMap::<String, String>::new(),
|
||||||
"post_data_json_by_id": HashMap::<String, Value>::new(),
|
"post_data_json_by_id": HashMap::<String, Value>::new(),
|
||||||
});
|
});
|
||||||
Ok(render_liquid_template(&bundle.not_found_template, &bundle.partials, &context)?)
|
Ok(render_liquid_template(
|
||||||
|
&bundle.not_found_template,
|
||||||
|
&bundle.partials,
|
||||||
|
&context,
|
||||||
|
)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_menu_items(
|
fn build_menu_items(
|
||||||
@@ -728,17 +825,17 @@ fn prefixed_slug_path(prefix: &str, slug: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn prefix_or_root(prefix: &str) -> &str {
|
fn prefix_or_root(prefix: &str) -> &str {
|
||||||
if prefix.is_empty() {
|
if prefix.is_empty() { "/" } else { prefix }
|
||||||
"/"
|
|
||||||
} else {
|
|
||||||
prefix
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn route_href(route: &RouteSpec, page: usize) -> String {
|
fn route_href(route: &RouteSpec, page: usize) -> String {
|
||||||
let base = route.url_path.trim_end_matches('/');
|
let base = route.url_path.trim_end_matches('/');
|
||||||
if page <= 1 {
|
if page <= 1 {
|
||||||
if base.is_empty() { "/".to_string() } else { base.to_string() }
|
if base.is_empty() {
|
||||||
|
"/".to_string()
|
||||||
|
} else {
|
||||||
|
base.to_string()
|
||||||
|
}
|
||||||
} else if base.is_empty() || base == "/" {
|
} else if base.is_empty() || base == "/" {
|
||||||
format!("/page/{page}")
|
format!("/page/{page}")
|
||||||
} else {
|
} else {
|
||||||
@@ -760,7 +857,12 @@ fn build_day_blocks(
|
|||||||
let Some(timestamp) = Utc.timestamp_millis_opt(timestamp_ms).single() else {
|
let Some(timestamp) = Utc.timestamp_millis_opt(timestamp_ms).single() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let key = format!("{:04}-{:02}-{:02}", timestamp.year(), timestamp.month(), timestamp.day());
|
let key = format!(
|
||||||
|
"{:04}-{:02}-{:02}",
|
||||||
|
timestamp.year(),
|
||||||
|
timestamp.month(),
|
||||||
|
timestamp.day()
|
||||||
|
);
|
||||||
if !current_key.is_empty() && current_key != key {
|
if !current_key.is_empty() && current_key != key {
|
||||||
blocks.push(json!({
|
blocks.push(json!({
|
||||||
"show_date_marker": true,
|
"show_date_marker": true,
|
||||||
@@ -771,7 +873,12 @@ fn build_day_blocks(
|
|||||||
current_posts = Vec::new();
|
current_posts = Vec::new();
|
||||||
}
|
}
|
||||||
current_key = key;
|
current_key = key;
|
||||||
current_label = format!("{:02}.{:02}.{:04}", timestamp.day(), timestamp.month(), timestamp.year());
|
current_label = format!(
|
||||||
|
"{:02}.{:02}.{:04}",
|
||||||
|
timestamp.day(),
|
||||||
|
timestamp.month(),
|
||||||
|
timestamp.year()
|
||||||
|
);
|
||||||
let show_title = should_show_list_title(&record.post, category_settings);
|
let show_title = should_show_list_title(&record.post, category_settings);
|
||||||
current_posts.push(json!({
|
current_posts.push(json!({
|
||||||
"id": record.post.id,
|
"id": record.post.id,
|
||||||
@@ -797,7 +904,8 @@ fn filter_posts_for_lists(
|
|||||||
posts: &[RenderPostRecord],
|
posts: &[RenderPostRecord],
|
||||||
category_settings: &HashMap<String, CategorySettings>,
|
category_settings: &HashMap<String, CategorySettings>,
|
||||||
) -> Vec<RenderPostRecord> {
|
) -> Vec<RenderPostRecord> {
|
||||||
posts.iter()
|
posts
|
||||||
|
.iter()
|
||||||
.filter(|record| !is_post_excluded_from_lists(&record.post, category_settings))
|
.filter(|record| !is_post_excluded_from_lists(&record.post, category_settings))
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect()
|
.collect()
|
||||||
@@ -850,11 +958,14 @@ fn canonical_post_path_by_slug(
|
|||||||
language: &str,
|
language: &str,
|
||||||
main_language: &str,
|
main_language: &str,
|
||||||
) -> HashMap<String, String> {
|
) -> HashMap<String, String> {
|
||||||
posts.iter()
|
posts
|
||||||
.map(|record| (
|
.iter()
|
||||||
|
.map(|record| {
|
||||||
|
(
|
||||||
record.post.slug.clone(),
|
record.post.slug.clone(),
|
||||||
build_canonical_post_path(&record.post, language, main_language),
|
build_canonical_post_path(&record.post, language, main_language),
|
||||||
))
|
)
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -874,7 +985,9 @@ fn extract_media_refs(markdown: &str) -> Vec<String> {
|
|||||||
let mut remainder = markdown;
|
let mut remainder = markdown;
|
||||||
while let Some(index) = remainder.find("bds-media://") {
|
while let Some(index) = remainder.find("bds-media://") {
|
||||||
let rest = &remainder[index..];
|
let rest = &remainder[index..];
|
||||||
let end = rest.find(|ch: char| ch == ')' || ch == ']' || ch.is_whitespace()).unwrap_or(rest.len());
|
let end = rest
|
||||||
|
.find(|ch: char| ch == ')' || ch == ']' || ch.is_whitespace())
|
||||||
|
.unwrap_or(rest.len());
|
||||||
refs.push(rest[..end].to_string());
|
refs.push(rest[..end].to_string());
|
||||||
remainder = &rest[end..];
|
remainder = &rest[end..];
|
||||||
}
|
}
|
||||||
@@ -927,29 +1040,50 @@ fn build_taxonomy_counts(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let categories = category_counts.into_iter().map(|(name, count)| json!({
|
let categories = category_counts
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, count)| {
|
||||||
|
json!({
|
||||||
"name": name,
|
"name": name,
|
||||||
"slug": slugify(&name),
|
"slug": slugify(&name),
|
||||||
"post_count": count,
|
"post_count": count,
|
||||||
})).collect::<Vec<_>>();
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let tag_items = tag_counts.into_iter().map(|(name, count)| {
|
let tag_items = tag_counts
|
||||||
let color = tags.iter().find(|tag| tag.name.eq_ignore_ascii_case(&name)).and_then(|tag| tag.color.clone());
|
.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!({
|
json!({
|
||||||
"name": name,
|
"name": name,
|
||||||
"slug": slugify(&name),
|
"slug": slugify(&name),
|
||||||
"color": color,
|
"color": color,
|
||||||
"post_count": count,
|
"post_count": count,
|
||||||
})
|
})
|
||||||
}).collect::<Vec<_>>();
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
(categories, tag_items, tag_colors)
|
(categories, tag_items, tag_colors)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn taxonomy_items_for_categories(names: &[String], posts: &[RenderPostRecord]) -> Vec<Value> {
|
fn taxonomy_items_for_categories(names: &[String], posts: &[RenderPostRecord]) -> Vec<Value> {
|
||||||
names.iter()
|
names
|
||||||
|
.iter()
|
||||||
.map(|name| {
|
.map(|name| {
|
||||||
let count = posts.iter().filter(|record| record.post.categories.iter().any(|category| category.eq_ignore_ascii_case(name))).count();
|
let count = posts
|
||||||
|
.iter()
|
||||||
|
.filter(|record| {
|
||||||
|
record
|
||||||
|
.post
|
||||||
|
.categories
|
||||||
|
.iter()
|
||||||
|
.any(|category| category.eq_ignore_ascii_case(name))
|
||||||
|
})
|
||||||
|
.count();
|
||||||
json!({
|
json!({
|
||||||
"name": name,
|
"name": name,
|
||||||
"slug": slugify(name),
|
"slug": slugify(name),
|
||||||
@@ -959,11 +1093,28 @@ fn taxonomy_items_for_categories(names: &[String], posts: &[RenderPostRecord]) -
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn taxonomy_items_for_tags(names: &[String], posts: &[RenderPostRecord], tags: &[Tag]) -> Vec<Value> {
|
fn taxonomy_items_for_tags(
|
||||||
names.iter()
|
names: &[String],
|
||||||
|
posts: &[RenderPostRecord],
|
||||||
|
tags: &[Tag],
|
||||||
|
) -> Vec<Value> {
|
||||||
|
names
|
||||||
|
.iter()
|
||||||
.map(|name| {
|
.map(|name| {
|
||||||
let count = posts.iter().filter(|record| record.post.tags.iter().any(|tag| tag.eq_ignore_ascii_case(name))).count();
|
let count = posts
|
||||||
let color = tags.iter().find(|tag| tag.name.eq_ignore_ascii_case(name)).and_then(|tag| tag.color.clone());
|
.iter()
|
||||||
|
.filter(|record| {
|
||||||
|
record
|
||||||
|
.post
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.any(|tag| tag.eq_ignore_ascii_case(name))
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
let color = tags
|
||||||
|
.iter()
|
||||||
|
.find(|tag| tag.name.eq_ignore_ascii_case(name))
|
||||||
|
.and_then(|tag| tag.color.clone());
|
||||||
json!({
|
json!({
|
||||||
"name": name,
|
"name": name,
|
||||||
"slug": slugify(name),
|
"slug": slugify(name),
|
||||||
@@ -1076,7 +1227,11 @@ fn build_alternate_post_links(post: &Post, metadata: &ProjectMetadata) -> Vec<Va
|
|||||||
links
|
links
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_post_blog_languages(post: &Post, metadata: &ProjectMetadata, current_language: &str) -> Vec<Value> {
|
fn build_post_blog_languages(
|
||||||
|
post: &Post,
|
||||||
|
metadata: &ProjectMetadata,
|
||||||
|
current_language: &str,
|
||||||
|
) -> Vec<Value> {
|
||||||
let main_language = main_language(metadata);
|
let main_language = main_language(metadata);
|
||||||
render_languages(metadata)
|
render_languages(metadata)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -1090,7 +1245,11 @@ fn build_post_blog_languages(post: &Post, metadata: &ProjectMetadata, current_la
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_list_blog_languages(metadata: &ProjectMetadata, current_language: &str, current_url_path: &str) -> Vec<Value> {
|
fn build_list_blog_languages(
|
||||||
|
metadata: &ProjectMetadata,
|
||||||
|
current_language: &str,
|
||||||
|
current_url_path: &str,
|
||||||
|
) -> Vec<Value> {
|
||||||
let main_language = main_language(metadata);
|
let main_language = main_language(metadata);
|
||||||
render_languages(metadata)
|
render_languages(metadata)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -1126,7 +1285,11 @@ fn build_alternate_list_links(metadata: &ProjectMetadata, current_url_path: &str
|
|||||||
links
|
links
|
||||||
}
|
}
|
||||||
|
|
||||||
fn relocalize_url_path(current_url_path: &str, target_language: &str, main_language: &str) -> String {
|
fn relocalize_url_path(
|
||||||
|
current_url_path: &str,
|
||||||
|
target_language: &str,
|
||||||
|
main_language: &str,
|
||||||
|
) -> String {
|
||||||
let stripped = strip_language_prefix(current_url_path, main_language);
|
let stripped = strip_language_prefix(current_url_path, main_language);
|
||||||
if target_language.eq_ignore_ascii_case(main_language) {
|
if target_language.eq_ignore_ascii_case(main_language) {
|
||||||
stripped
|
stripped
|
||||||
@@ -1140,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 {
|
fn strip_language_prefix(path: &str, main_language: &str) -> String {
|
||||||
let normalized = normalize_request_path(path);
|
let normalized = normalize_request_path(path);
|
||||||
let trimmed = normalized.trim_start_matches('/');
|
let trimmed = normalized.trim_start_matches('/');
|
||||||
if let Some((first, remainder)) = trimmed.split_once('/') {
|
if let Some((first, remainder)) = trimmed.split_once('/')
|
||||||
if first.len() == 2 && !first.eq_ignore_ascii_case(main_language) {
|
&& first.len() == 2
|
||||||
|
&& !first.eq_ignore_ascii_case(main_language)
|
||||||
|
{
|
||||||
return format!("/{}", remainder.trim_start_matches('/'));
|
return format!("/{}", remainder.trim_start_matches('/'));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
normalized
|
normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1161,17 +1325,21 @@ fn relative_to_url_path(relative_path: &str) -> String {
|
|||||||
if relative_path == "index.html" {
|
if relative_path == "index.html" {
|
||||||
return "/".to_string();
|
return "/".to_string();
|
||||||
}
|
}
|
||||||
let trimmed = relative_path.trim_end_matches("index.html").trim_end_matches('/');
|
let trimmed = relative_path
|
||||||
|
.trim_end_matches("index.html")
|
||||||
|
.trim_end_matches('/');
|
||||||
format!("/{}", trimmed.trim_start_matches('/'))
|
format!("/{}", trimmed.trim_start_matches('/'))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn language_from_path(path: &str, metadata: &ProjectMetadata) -> String {
|
fn language_from_path(path: &str, metadata: &ProjectMetadata) -> String {
|
||||||
let trimmed = path.trim_start_matches('/');
|
let trimmed = path.trim_start_matches('/');
|
||||||
if let Some((candidate, _)) = trimmed.split_once('/') {
|
if let Some((candidate, _)) = trimmed.split_once('/')
|
||||||
if render_languages(metadata).iter().any(|language| language == candidate) {
|
&& render_languages(metadata)
|
||||||
|
.iter()
|
||||||
|
.any(|language| language == candidate)
|
||||||
|
{
|
||||||
return candidate.to_string();
|
return candidate.to_string();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
main_language(metadata).to_string()
|
main_language(metadata).to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1179,7 +1347,10 @@ fn render_languages(metadata: &ProjectMetadata) -> Vec<String> {
|
|||||||
let main = main_language(metadata).to_string();
|
let main = main_language(metadata).to_string();
|
||||||
let mut languages = vec![main.clone()];
|
let mut languages = vec![main.clone()];
|
||||||
for language in &metadata.blog_languages {
|
for language in &metadata.blog_languages {
|
||||||
if !languages.iter().any(|existing| existing.eq_ignore_ascii_case(language)) {
|
if !languages
|
||||||
|
.iter()
|
||||||
|
.any(|existing| existing.eq_ignore_ascii_case(language))
|
||||||
|
{
|
||||||
languages.push(language.clone());
|
languages.push(language.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1213,18 +1384,31 @@ fn normalize_partial_slug(slug: &str) -> String {
|
|||||||
|
|
||||||
fn starter_partials() -> HashMap<String, String> {
|
fn starter_partials() -> HashMap<String, String> {
|
||||||
HashMap::from([
|
HashMap::from([
|
||||||
("partials/head".to_string(), STARTER_HEAD_PARTIAL.to_string()),
|
(
|
||||||
("partials/menu".to_string(), STARTER_MENU_PARTIAL.to_string()),
|
"partials/head".to_string(),
|
||||||
("partials/language-switcher".to_string(), STARTER_LANGUAGE_SWITCHER_PARTIAL.to_string()),
|
STARTER_HEAD_PARTIAL.to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"partials/menu".to_string(),
|
||||||
|
STARTER_MENU_PARTIAL.to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"partials/language-switcher".to_string(),
|
||||||
|
STARTER_LANGUAGE_SWITCHER_PARTIAL.to_string(),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"partials/menu-items".to_string(),
|
"partials/menu-items".to_string(),
|
||||||
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}".to_string(),
|
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}"
|
||||||
|
.to_string(),
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pico_stylesheet_href(metadata: &ProjectMetadata) -> Option<String> {
|
fn pico_stylesheet_href(metadata: &ProjectMetadata) -> Option<String> {
|
||||||
metadata.pico_theme.as_ref().map(|_| "/assets/pico.min.css".to_string())
|
metadata
|
||||||
|
.pico_theme
|
||||||
|
.as_ref()
|
||||||
|
.map(|_| "/assets/pico.min.css".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn calendar_initial_parts(post: &Post) -> (i32, u32) {
|
fn calendar_initial_parts(post: &Post) -> (i32, u32) {
|
||||||
|
|||||||
@@ -21,13 +21,17 @@ pub enum TemplateLookupError {
|
|||||||
MissingDefaultTemplate,
|
MissingDefaultTemplate,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_post_template<'a>(lookup: RenderTemplateLookup<'a>) -> Result<&'a Template, TemplateLookupError> {
|
pub fn resolve_post_template<'a>(
|
||||||
|
lookup: RenderTemplateLookup<'a>,
|
||||||
|
) -> Result<&'a Template, TemplateLookupError> {
|
||||||
if let Some(explicit_slug) = lookup.post.template_slug.as_deref() {
|
if let Some(explicit_slug) = lookup.post.template_slug.as_deref() {
|
||||||
return lookup
|
return lookup
|
||||||
.templates
|
.templates
|
||||||
.iter()
|
.iter()
|
||||||
.find(|template| is_enabled_post_template(template, explicit_slug))
|
.find(|template| is_enabled_post_template(template, explicit_slug))
|
||||||
.ok_or_else(|| TemplateLookupError::MissingExplicitTemplate(explicit_slug.to_string()));
|
.ok_or_else(|| {
|
||||||
|
TemplateLookupError::MissingExplicitTemplate(explicit_slug.to_string())
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for post_tag in &lookup.post.tags {
|
for post_tag in &lookup.post.tags {
|
||||||
@@ -36,8 +40,7 @@ pub fn resolve_post_template<'a>(lookup: RenderTemplateLookup<'a>) -> Result<&'a
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|tag| tag.name.eq_ignore_ascii_case(post_tag))
|
.find(|tag| tag.name.eq_ignore_ascii_case(post_tag))
|
||||||
.and_then(|tag| tag.post_template_slug.as_deref())
|
.and_then(|tag| tag.post_template_slug.as_deref())
|
||||||
{
|
&& let Some(template) = lookup
|
||||||
if let Some(template) = lookup
|
|
||||||
.templates
|
.templates
|
||||||
.iter()
|
.iter()
|
||||||
.find(|template| is_enabled_post_template(template, template_slug))
|
.find(|template| is_enabled_post_template(template, template_slug))
|
||||||
@@ -45,15 +48,13 @@ pub fn resolve_post_template<'a>(lookup: RenderTemplateLookup<'a>) -> Result<&'a
|
|||||||
return Ok(template);
|
return Ok(template);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for category_name in &lookup.post.categories {
|
for category_name in &lookup.post.categories {
|
||||||
if let Some(template_slug) = lookup
|
if let Some(template_slug) = lookup
|
||||||
.category_settings
|
.category_settings
|
||||||
.get(category_name)
|
.get(category_name)
|
||||||
.and_then(|settings| settings.post_template_slug.as_deref())
|
.and_then(|settings| settings.post_template_slug.as_deref())
|
||||||
{
|
&& let Some(template) = lookup
|
||||||
if let Some(template) = lookup
|
|
||||||
.templates
|
.templates
|
||||||
.iter()
|
.iter()
|
||||||
.find(|template| is_enabled_post_template(template, template_slug))
|
.find(|template| is_enabled_post_template(template, template_slug))
|
||||||
@@ -61,7 +62,6 @@ pub fn resolve_post_template<'a>(lookup: RenderTemplateLookup<'a>) -> Result<&'a
|
|||||||
return Ok(template);
|
return Ok(template);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
lookup
|
lookup
|
||||||
.templates
|
.templates
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::model::{Post, PostTranslation};
|
use crate::model::{Post, PostTranslation};
|
||||||
use crate::util::timestamp::{unix_ms_to_iso, iso_to_unix_ms};
|
use crate::util::timestamp::{iso_to_unix_ms, unix_ms_to_iso};
|
||||||
|
|
||||||
/// Split content at `---` delimiters into (yaml, body).
|
/// Split content at `---` delimiters into (yaml, body).
|
||||||
/// Returns `None` if the content does not start with `---`.
|
/// Returns `None` if the content does not start with `---`.
|
||||||
@@ -118,29 +118,29 @@ impl PostFrontmatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Conditional fields (only when truthy)
|
// Conditional fields (only when truthy)
|
||||||
if let Some(ref excerpt) = self.excerpt {
|
if let Some(ref excerpt) = self.excerpt
|
||||||
if !excerpt.is_empty() {
|
&& !excerpt.is_empty()
|
||||||
|
{
|
||||||
lines.push(format!("excerpt: {}", yaml_string_value(excerpt)));
|
lines.push(format!("excerpt: {}", yaml_string_value(excerpt)));
|
||||||
}
|
}
|
||||||
}
|
if let Some(ref author) = self.author
|
||||||
if let Some(ref author) = self.author {
|
&& !author.is_empty()
|
||||||
if !author.is_empty() {
|
{
|
||||||
lines.push(format!("author: {}", yaml_string_value(author)));
|
lines.push(format!("author: {}", yaml_string_value(author)));
|
||||||
}
|
}
|
||||||
}
|
if let Some(ref language) = self.language
|
||||||
if let Some(ref language) = self.language {
|
&& !language.is_empty()
|
||||||
if !language.is_empty() {
|
{
|
||||||
lines.push(format!("language: {language}"));
|
lines.push(format!("language: {language}"));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if self.do_not_translate {
|
if self.do_not_translate {
|
||||||
lines.push("doNotTranslate: true".to_string());
|
lines.push("doNotTranslate: true".to_string());
|
||||||
}
|
}
|
||||||
if let Some(ref template_slug) = self.template_slug {
|
if let Some(ref template_slug) = self.template_slug
|
||||||
if !template_slug.is_empty() {
|
&& !template_slug.is_empty()
|
||||||
|
{
|
||||||
lines.push(format!("templateSlug: {template_slug}"));
|
lines.push(format!("templateSlug: {template_slug}"));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if let Some(published_at) = self.published_at {
|
if let Some(published_at) = self.published_at {
|
||||||
lines.push(format!("publishedAt: '{}'", unix_ms_to_iso(published_at)));
|
lines.push(format!("publishedAt: '{}'", unix_ms_to_iso(published_at)));
|
||||||
}
|
}
|
||||||
@@ -157,7 +157,7 @@ impl PostFrontmatter {
|
|||||||
.ok_or("frontmatter is not a YAML mapping")?;
|
.ok_or("frontmatter is not a YAML mapping")?;
|
||||||
|
|
||||||
let get_str = |key: &str| -> Option<String> {
|
let get_str = |key: &str| -> Option<String> {
|
||||||
map.get(&serde_yaml::Value::String(key.to_string()))
|
map.get(serde_yaml::Value::String(key.to_string()))
|
||||||
.and_then(|v| match v {
|
.and_then(|v| match v {
|
||||||
serde_yaml::Value::String(s) => Some(s.clone()),
|
serde_yaml::Value::String(s) => Some(s.clone()),
|
||||||
serde_yaml::Value::Number(n) => Some(n.to_string()),
|
serde_yaml::Value::Number(n) => Some(n.to_string()),
|
||||||
@@ -167,7 +167,7 @@ impl PostFrontmatter {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let get_string_list = |key: &str| -> Vec<String> {
|
let get_string_list = |key: &str| -> Vec<String> {
|
||||||
map.get(&serde_yaml::Value::String(key.to_string()))
|
map.get(serde_yaml::Value::String(key.to_string()))
|
||||||
.and_then(|v| v.as_sequence())
|
.and_then(|v| v.as_sequence())
|
||||||
.map(|seq| {
|
.map(|seq| {
|
||||||
seq.iter()
|
seq.iter()
|
||||||
@@ -198,8 +198,7 @@ impl PostFrontmatter {
|
|||||||
do_not_translate: get_str("doNotTranslate")
|
do_not_translate: get_str("doNotTranslate")
|
||||||
.map(|s| s == "true")
|
.map(|s| s == "true")
|
||||||
.unwrap_or(false),
|
.unwrap_or(false),
|
||||||
published_at: get_str("publishedAt")
|
published_at: get_str("publishedAt").and_then(|s| iso_to_unix_ms(&s).ok()),
|
||||||
.and_then(|s| iso_to_unix_ms(&s).ok()),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,8 +211,7 @@ pub fn write_post_file(post: &Post, body: &str) -> String {
|
|||||||
|
|
||||||
/// Read a complete post file, returning frontmatter + body.
|
/// Read a complete post file, returning frontmatter + body.
|
||||||
pub fn read_post_file(content: &str) -> Result<(PostFrontmatter, String), String> {
|
pub fn read_post_file(content: &str) -> Result<(PostFrontmatter, String), String> {
|
||||||
let (yaml, body) = split_frontmatter(content)
|
let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
|
||||||
.ok_or("no frontmatter delimiters found")?;
|
|
||||||
let fm = PostFrontmatter::from_yaml(yaml)?;
|
let fm = PostFrontmatter::from_yaml(yaml)?;
|
||||||
Ok((fm, body.to_string()))
|
Ok((fm, body.to_string()))
|
||||||
}
|
}
|
||||||
@@ -223,31 +221,61 @@ pub fn read_post_file(content: &str) -> Result<(PostFrontmatter, String), String
|
|||||||
/// Parsed translation frontmatter.
|
/// Parsed translation frontmatter.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct TranslationFrontmatter {
|
pub struct TranslationFrontmatter {
|
||||||
|
pub id: Option<String>,
|
||||||
pub translation_for: String,
|
pub translation_for: String,
|
||||||
pub language: String,
|
pub language: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub excerpt: Option<String>,
|
pub excerpt: Option<String>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
pub published_at: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TranslationFrontmatter {
|
impl TranslationFrontmatter {
|
||||||
pub fn from_translation(t: &PostTranslation) -> Self {
|
pub fn from_translation(t: &PostTranslation) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
id: Some(t.id.clone()),
|
||||||
translation_for: t.translation_for.clone(),
|
translation_for: t.translation_for.clone(),
|
||||||
language: t.language.clone(),
|
language: t.language.clone(),
|
||||||
title: t.title.clone(),
|
title: t.title.clone(),
|
||||||
excerpt: t.excerpt.clone(),
|
excerpt: t.excerpt.clone(),
|
||||||
|
status: Some(
|
||||||
|
serde_json::to_string(&t.status)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim_matches('"')
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
created_at: Some(t.created_at),
|
||||||
|
updated_at: Some(t.updated_at),
|
||||||
|
published_at: t.published_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_yaml(&self) -> String {
|
pub fn to_yaml(&self) -> String {
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
|
if let Some(id) = &self.id {
|
||||||
|
lines.push(format!("id: {id}"));
|
||||||
|
}
|
||||||
lines.push(format!("translationFor: {}", self.translation_for));
|
lines.push(format!("translationFor: {}", self.translation_for));
|
||||||
lines.push(format!("language: {}", self.language));
|
lines.push(format!("language: {}", self.language));
|
||||||
lines.push(format!("title: {}", yaml_string_value(&self.title)));
|
lines.push(format!("title: {}", yaml_string_value(&self.title)));
|
||||||
if let Some(ref excerpt) = self.excerpt {
|
if let Some(ref excerpt) = self.excerpt
|
||||||
if !excerpt.is_empty() {
|
&& !excerpt.is_empty()
|
||||||
|
{
|
||||||
lines.push(format!("excerpt: {}", yaml_string_value(excerpt)));
|
lines.push(format!("excerpt: {}", yaml_string_value(excerpt)));
|
||||||
}
|
}
|
||||||
|
if let Some(status) = &self.status {
|
||||||
|
lines.push(format!("status: {status}"));
|
||||||
|
}
|
||||||
|
if let Some(created_at) = self.created_at {
|
||||||
|
lines.push(format!("createdAt: '{}'", unix_ms_to_iso(created_at)));
|
||||||
|
}
|
||||||
|
if let Some(updated_at) = self.updated_at {
|
||||||
|
lines.push(format!("updatedAt: '{}'", unix_ms_to_iso(updated_at)));
|
||||||
|
}
|
||||||
|
if let Some(published_at) = self.published_at {
|
||||||
|
lines.push(format!("publishedAt: '{}'", unix_ms_to_iso(published_at)));
|
||||||
}
|
}
|
||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
@@ -260,16 +288,21 @@ impl TranslationFrontmatter {
|
|||||||
.ok_or("frontmatter is not a YAML mapping")?;
|
.ok_or("frontmatter is not a YAML mapping")?;
|
||||||
|
|
||||||
let get_str = |key: &str| -> Option<String> {
|
let get_str = |key: &str| -> Option<String> {
|
||||||
map.get(&serde_yaml::Value::String(key.to_string()))
|
map.get(serde_yaml::Value::String(key.to_string()))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
id: get_str("id"),
|
||||||
translation_for: get_str("translationFor").ok_or("missing 'translationFor'")?,
|
translation_for: get_str("translationFor").ok_or("missing 'translationFor'")?,
|
||||||
language: get_str("language").ok_or("missing 'language'")?,
|
language: get_str("language").ok_or("missing 'language'")?,
|
||||||
title: get_str("title").ok_or("missing 'title'")?,
|
title: get_str("title").ok_or("missing 'title'")?,
|
||||||
excerpt: get_str("excerpt").filter(|s| !s.is_empty()),
|
excerpt: get_str("excerpt").filter(|s| !s.is_empty()),
|
||||||
|
status: get_str("status"),
|
||||||
|
created_at: get_str("createdAt").and_then(|value| iso_to_unix_ms(&value).ok()),
|
||||||
|
updated_at: get_str("updatedAt").and_then(|value| iso_to_unix_ms(&value).ok()),
|
||||||
|
published_at: get_str("publishedAt").and_then(|value| iso_to_unix_ms(&value).ok()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,8 +315,7 @@ pub fn write_translation_file(translation: &PostTranslation, body: &str) -> Stri
|
|||||||
|
|
||||||
/// Read a complete translation file.
|
/// Read a complete translation file.
|
||||||
pub fn read_translation_file(content: &str) -> Result<(TranslationFrontmatter, String), String> {
|
pub fn read_translation_file(content: &str) -> Result<(TranslationFrontmatter, String), String> {
|
||||||
let (yaml, body) = split_frontmatter(content)
|
let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
|
||||||
.ok_or("no frontmatter delimiters found")?;
|
|
||||||
let fm = TranslationFrontmatter::from_yaml(yaml)?;
|
let fm = TranslationFrontmatter::from_yaml(yaml)?;
|
||||||
Ok((fm, body.to_string()))
|
Ok((fm, body.to_string()))
|
||||||
}
|
}
|
||||||
@@ -317,8 +349,14 @@ impl TemplateFrontmatter {
|
|||||||
lines.push(format!("kind: \"{}\"", self.kind));
|
lines.push(format!("kind: \"{}\"", self.kind));
|
||||||
lines.push(format!("enabled: {}", self.enabled));
|
lines.push(format!("enabled: {}", self.enabled));
|
||||||
lines.push(format!("version: {}", self.version));
|
lines.push(format!("version: {}", self.version));
|
||||||
lines.push(format!("createdAt: \"{}\"", unix_ms_to_iso(self.created_at)));
|
lines.push(format!(
|
||||||
lines.push(format!("updatedAt: \"{}\"", unix_ms_to_iso(self.updated_at)));
|
"createdAt: \"{}\"",
|
||||||
|
unix_ms_to_iso(self.created_at)
|
||||||
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
"updatedAt: \"{}\"",
|
||||||
|
unix_ms_to_iso(self.updated_at)
|
||||||
|
));
|
||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +366,7 @@ impl TemplateFrontmatter {
|
|||||||
let map = doc.as_mapping().ok_or("not a YAML mapping")?;
|
let map = doc.as_mapping().ok_or("not a YAML mapping")?;
|
||||||
|
|
||||||
let get_str = |key: &str| -> Option<String> {
|
let get_str = |key: &str| -> Option<String> {
|
||||||
map.get(&serde_yaml::Value::String(key.to_string()))
|
map.get(serde_yaml::Value::String(key.to_string()))
|
||||||
.and_then(|v| match v {
|
.and_then(|v| match v {
|
||||||
serde_yaml::Value::String(s) => Some(s.clone()),
|
serde_yaml::Value::String(s) => Some(s.clone()),
|
||||||
serde_yaml::Value::Number(n) => Some(n.to_string()),
|
serde_yaml::Value::Number(n) => Some(n.to_string()),
|
||||||
@@ -359,8 +397,7 @@ impl TemplateFrontmatter {
|
|||||||
|
|
||||||
/// Read a template file (frontmatter + body).
|
/// Read a template file (frontmatter + body).
|
||||||
pub fn read_template_file(content: &str) -> Result<(TemplateFrontmatter, String), String> {
|
pub fn read_template_file(content: &str) -> Result<(TemplateFrontmatter, String), String> {
|
||||||
let (yaml, body) = split_frontmatter(content)
|
let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
|
||||||
.ok_or("no frontmatter delimiters found")?;
|
|
||||||
let fm = TemplateFrontmatter::from_yaml(yaml)?;
|
let fm = TemplateFrontmatter::from_yaml(yaml)?;
|
||||||
Ok((fm, body.to_string()))
|
Ok((fm, body.to_string()))
|
||||||
}
|
}
|
||||||
@@ -401,8 +438,14 @@ impl ScriptFrontmatter {
|
|||||||
lines.push(format!("entrypoint: \"{}\"", self.entrypoint));
|
lines.push(format!("entrypoint: \"{}\"", self.entrypoint));
|
||||||
lines.push(format!("enabled: {}", self.enabled));
|
lines.push(format!("enabled: {}", self.enabled));
|
||||||
lines.push(format!("version: {}", self.version));
|
lines.push(format!("version: {}", self.version));
|
||||||
lines.push(format!("createdAt: \"{}\"", unix_ms_to_iso(self.created_at)));
|
lines.push(format!(
|
||||||
lines.push(format!("updatedAt: \"{}\"", unix_ms_to_iso(self.updated_at)));
|
"createdAt: \"{}\"",
|
||||||
|
unix_ms_to_iso(self.created_at)
|
||||||
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
"updatedAt: \"{}\"",
|
||||||
|
unix_ms_to_iso(self.updated_at)
|
||||||
|
));
|
||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +455,7 @@ impl ScriptFrontmatter {
|
|||||||
let map = doc.as_mapping().ok_or("not a YAML mapping")?;
|
let map = doc.as_mapping().ok_or("not a YAML mapping")?;
|
||||||
|
|
||||||
let get_str = |key: &str| -> Option<String> {
|
let get_str = |key: &str| -> Option<String> {
|
||||||
map.get(&serde_yaml::Value::String(key.to_string()))
|
map.get(serde_yaml::Value::String(key.to_string()))
|
||||||
.and_then(|v| match v {
|
.and_then(|v| match v {
|
||||||
serde_yaml::Value::String(s) => Some(s.clone()),
|
serde_yaml::Value::String(s) => Some(s.clone()),
|
||||||
serde_yaml::Value::Number(n) => Some(n.to_string()),
|
serde_yaml::Value::Number(n) => Some(n.to_string()),
|
||||||
@@ -450,8 +493,7 @@ pub fn read_script_file(content: &str) -> Result<(ScriptFrontmatter, String), St
|
|||||||
return Ok((fm, body.to_string()));
|
return Ok((fm, body.to_string()));
|
||||||
}
|
}
|
||||||
// Fall back to standard --- format (Lua scripts)
|
// Fall back to standard --- format (Lua scripts)
|
||||||
let (yaml, body) = split_frontmatter(content)
|
let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
|
||||||
.ok_or("no frontmatter delimiters found")?;
|
|
||||||
let fm = ScriptFrontmatter::from_yaml(yaml)?;
|
let fm = ScriptFrontmatter::from_yaml(yaml)?;
|
||||||
Ok((fm, body.to_string()))
|
Ok((fm, body.to_string()))
|
||||||
}
|
}
|
||||||
@@ -513,6 +555,7 @@ fn yaml_string_value(s: &str) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::model::PostStatus;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
@@ -551,7 +594,10 @@ mod tests {
|
|||||||
assert_eq!(fm.title, "Esmeralda");
|
assert_eq!(fm.title, "Esmeralda");
|
||||||
assert_eq!(fm.slug, "esmeralda");
|
assert_eq!(fm.slug, "esmeralda");
|
||||||
assert_eq!(fm.status, "published");
|
assert_eq!(fm.status, "published");
|
||||||
assert_eq!(fm.tags, vec!["fotografie", "makro", "natur", "spinne", "tiere"]);
|
assert_eq!(
|
||||||
|
fm.tags,
|
||||||
|
vec!["fotografie", "makro", "natur", "spinne", "tiere"]
|
||||||
|
);
|
||||||
assert_eq!(fm.categories, vec!["picture"]);
|
assert_eq!(fm.categories, vec!["picture"]);
|
||||||
assert_eq!(fm.language.as_deref(), Some("es"));
|
assert_eq!(fm.language.as_deref(), Some("es"));
|
||||||
assert_eq!(fm.published_at, Some(1131883200000));
|
assert_eq!(fm.published_at, Some(1131883200000));
|
||||||
@@ -655,7 +701,36 @@ mod tests {
|
|||||||
|
|
||||||
let yaml = fm.to_yaml();
|
let yaml = fm.to_yaml();
|
||||||
let actual = format_frontmatter(&yaml, &body);
|
let actual = format_frontmatter(&yaml, &body);
|
||||||
assert_eq!(actual, expected, "golden output mismatch for esmeralda.en.md");
|
assert_eq!(
|
||||||
|
actual, expected,
|
||||||
|
"golden output mismatch for esmeralda.en.md"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_translation_output_carries_full_metadata() {
|
||||||
|
let translation = PostTranslation {
|
||||||
|
id: "translation-1".into(),
|
||||||
|
project_id: "project-1".into(),
|
||||||
|
translation_for: "post-1".into(),
|
||||||
|
language: "de".into(),
|
||||||
|
title: "Titel".into(),
|
||||||
|
excerpt: None,
|
||||||
|
content: None,
|
||||||
|
status: PostStatus::Published,
|
||||||
|
file_path: "posts/2026/07/post.de.md".into(),
|
||||||
|
checksum: None,
|
||||||
|
created_at: 1_751_328_000_000,
|
||||||
|
updated_at: 1_751_414_400_000,
|
||||||
|
published_at: Some(1_751_414_400_000),
|
||||||
|
};
|
||||||
|
|
||||||
|
let output = write_translation_file(&translation, "Inhalt");
|
||||||
|
assert!(output.contains("id: translation-1"));
|
||||||
|
assert!(output.contains("status: published"));
|
||||||
|
assert!(output.contains("createdAt:"));
|
||||||
|
assert!(output.contains("updatedAt:"));
|
||||||
|
assert!(output.contains("publishedAt:"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -723,7 +798,10 @@ mod tests {
|
|||||||
let content = fs::read_to_string(path).unwrap();
|
let content = fs::read_to_string(path).unwrap();
|
||||||
let (fm, body) = read_template_file(&content).unwrap();
|
let (fm, body) = read_template_file(&content).unwrap();
|
||||||
assert_eq!(fm.id, "38704737-b7e7-4dd4-b010-9208bcf80ef6");
|
assert_eq!(fm.id, "38704737-b7e7-4dd4-b010-9208bcf80ef6");
|
||||||
assert_eq!(fm.project_id.as_deref(), Some("1979237c-034d-41f6-99a0-f35eb57b3f6c"));
|
assert_eq!(
|
||||||
|
fm.project_id.as_deref(),
|
||||||
|
Some("1979237c-034d-41f6-99a0-f35eb57b3f6c")
|
||||||
|
);
|
||||||
assert_eq!(fm.slug, "testvorlage");
|
assert_eq!(fm.slug, "testvorlage");
|
||||||
assert_eq!(fm.title, "Testvorlage");
|
assert_eq!(fm.title, "Testvorlage");
|
||||||
assert_eq!(fm.kind, "post");
|
assert_eq!(fm.kind, "post");
|
||||||
@@ -738,7 +816,10 @@ mod tests {
|
|||||||
let expected = fs::read_to_string(&path).unwrap();
|
let expected = fs::read_to_string(&path).unwrap();
|
||||||
let (fm, body) = read_template_file(&expected).unwrap();
|
let (fm, body) = read_template_file(&expected).unwrap();
|
||||||
let actual = write_template_file(&fm, &body);
|
let actual = write_template_file(&fm, &body);
|
||||||
assert_eq!(actual, expected, "golden output mismatch for testvorlage.liquid");
|
assert_eq!(
|
||||||
|
actual, expected,
|
||||||
|
"golden output mismatch for testvorlage.liquid"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Script frontmatter tests ---
|
// --- Script frontmatter tests ---
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
mod slug;
|
|
||||||
mod checksum;
|
|
||||||
pub mod timestamp;
|
|
||||||
pub mod atomic_write;
|
pub mod atomic_write;
|
||||||
pub mod paths;
|
mod checksum;
|
||||||
pub mod frontmatter;
|
pub mod frontmatter;
|
||||||
|
pub mod paths;
|
||||||
pub mod sidecar;
|
pub mod sidecar;
|
||||||
|
mod slug;
|
||||||
pub mod thumbnail;
|
pub mod thumbnail;
|
||||||
|
pub mod timestamp;
|
||||||
|
|
||||||
pub use slug::{slugify, ensure_unique};
|
|
||||||
pub use checksum::{content_hash, file_hash};
|
|
||||||
pub use timestamp::{unix_ms_to_iso, iso_to_unix_ms, year_month_from_unix_ms, year_month_day_from_unix_ms, now_unix_ms};
|
|
||||||
pub use atomic_write::{atomic_write, atomic_write_str};
|
pub use atomic_write::{atomic_write, atomic_write_str};
|
||||||
|
pub use checksum::{content_hash, file_hash};
|
||||||
pub use paths::*;
|
pub use paths::*;
|
||||||
|
pub use slug::{ensure_unique, slugify};
|
||||||
|
pub use timestamp::{
|
||||||
|
calendar_range_unix_ms, iso_to_unix_ms, now_unix_ms, unix_ms_to_iso,
|
||||||
|
year_month_day_from_unix_ms, year_month_from_unix_ms,
|
||||||
|
};
|
||||||
@@ -8,7 +8,11 @@ pub fn post_file_path(created_at_ms: i64, slug: &str) -> String {
|
|||||||
|
|
||||||
/// Translation file path: `posts/YYYY/MM/{slug}.{lang}.md` from canonical
|
/// Translation file path: `posts/YYYY/MM/{slug}.{lang}.md` from canonical
|
||||||
/// post's `created_at`.
|
/// post's `created_at`.
|
||||||
pub fn translation_file_path(canonical_created_at_ms: i64, canonical_slug: &str, language: &str) -> String {
|
pub fn translation_file_path(
|
||||||
|
canonical_created_at_ms: i64,
|
||||||
|
canonical_slug: &str,
|
||||||
|
language: &str,
|
||||||
|
) -> String {
|
||||||
let (y, m) = year_month_from_unix_ms(canonical_created_at_ms);
|
let (y, m) = year_month_from_unix_ms(canonical_created_at_ms);
|
||||||
format!("posts/{y}/{m}/{canonical_slug}.{language}.md")
|
format!("posts/{y}/{m}/{canonical_slug}.{language}.md")
|
||||||
}
|
}
|
||||||
@@ -101,7 +105,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn template_and_script_paths() {
|
fn template_and_script_paths() {
|
||||||
assert_eq!(template_file_path("testvorlage"), "templates/testvorlage.liquid");
|
assert_eq!(
|
||||||
|
template_file_path("testvorlage"),
|
||||||
|
"templates/testvorlage.liquid"
|
||||||
|
);
|
||||||
assert_eq!(script_file_path("bgg_link"), "scripts/bgg_link.lua");
|
assert_eq!(script_file_path("bgg_link"), "scripts/bgg_link.lua");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
use std::fmt::{self, Display};
|
||||||
|
|
||||||
use crate::model::Media;
|
use crate::model::Media;
|
||||||
use crate::util::timestamp::{unix_ms_to_iso, iso_to_unix_ms};
|
use crate::util::timestamp::{iso_to_unix_ms, unix_ms_to_iso};
|
||||||
|
|
||||||
/// Parsed media sidecar fields.
|
/// Parsed media sidecar fields.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -53,11 +55,14 @@ impl MediaSidecar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize to the hand-built YAML-like format matching TypeScript output.
|
/// Serialize to the hand-built YAML-like format matching TypeScript output.
|
||||||
pub fn to_string(&self) -> String {
|
fn serialize(&self) -> String {
|
||||||
let mut lines: Vec<String> = Vec::new();
|
let mut lines: Vec<String> = Vec::new();
|
||||||
lines.push("---".into());
|
lines.push("---".into());
|
||||||
lines.push(format!("id: {}", self.id));
|
lines.push(format!("id: {}", self.id));
|
||||||
lines.push(format!("originalName: \"{}\"", escape_double_quotes(&self.original_name)));
|
lines.push(format!(
|
||||||
|
"originalName: \"{}\"",
|
||||||
|
escape_double_quotes(&self.original_name)
|
||||||
|
));
|
||||||
lines.push(format!("mimeType: {}", self.mime_type));
|
lines.push(format!("mimeType: {}", self.mime_type));
|
||||||
lines.push(format!("size: {}", self.size));
|
lines.push(format!("size: {}", self.size));
|
||||||
if let Some(w) = self.width {
|
if let Some(w) = self.width {
|
||||||
@@ -66,31 +71,31 @@ impl MediaSidecar {
|
|||||||
if let Some(h) = self.height {
|
if let Some(h) = self.height {
|
||||||
lines.push(format!("height: {h}"));
|
lines.push(format!("height: {h}"));
|
||||||
}
|
}
|
||||||
if let Some(ref title) = self.title {
|
if let Some(ref title) = self.title
|
||||||
if !title.is_empty() {
|
&& !title.is_empty()
|
||||||
|
{
|
||||||
lines.push(format!("title: \"{}\"", escape_double_quotes(title)));
|
lines.push(format!("title: \"{}\"", escape_double_quotes(title)));
|
||||||
}
|
}
|
||||||
}
|
if let Some(ref alt) = self.alt
|
||||||
if let Some(ref alt) = self.alt {
|
&& !alt.is_empty()
|
||||||
if !alt.is_empty() {
|
{
|
||||||
lines.push(format!("alt: \"{}\"", escape_double_quotes(alt)));
|
lines.push(format!("alt: \"{}\"", escape_double_quotes(alt)));
|
||||||
}
|
}
|
||||||
}
|
if let Some(ref caption) = self.caption
|
||||||
if let Some(ref caption) = self.caption {
|
&& !caption.is_empty()
|
||||||
if !caption.is_empty() {
|
{
|
||||||
lines.push(format!("caption: \"{}\"", escape_double_quotes(caption)));
|
lines.push(format!("caption: \"{}\"", escape_double_quotes(caption)));
|
||||||
}
|
}
|
||||||
}
|
if let Some(ref author) = self.author
|
||||||
if let Some(ref author) = self.author {
|
&& !author.is_empty()
|
||||||
if !author.is_empty() {
|
{
|
||||||
lines.push(format!("author: \"{}\"", escape_double_quotes(author)));
|
lines.push(format!("author: \"{}\"", escape_double_quotes(author)));
|
||||||
}
|
}
|
||||||
}
|
if let Some(ref language) = self.language
|
||||||
if let Some(ref language) = self.language {
|
&& !language.is_empty()
|
||||||
if !language.is_empty() {
|
{
|
||||||
lines.push(format!("language: {language}"));
|
lines.push(format!("language: {language}"));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
lines.push(format!("createdAt: {}", unix_ms_to_iso(self.created_at)));
|
lines.push(format!("createdAt: {}", unix_ms_to_iso(self.created_at)));
|
||||||
lines.push(format!("updatedAt: {}", unix_ms_to_iso(self.updated_at)));
|
lines.push(format!("updatedAt: {}", unix_ms_to_iso(self.updated_at)));
|
||||||
|
|
||||||
@@ -98,13 +103,21 @@ impl MediaSidecar {
|
|||||||
if self.tags.is_empty() {
|
if self.tags.is_empty() {
|
||||||
lines.push("tags: []".into());
|
lines.push("tags: []".into());
|
||||||
} else {
|
} else {
|
||||||
let quoted: Vec<String> = self.tags.iter().map(|t| format!("\"{}\"", escape_double_quotes(t))).collect();
|
let quoted: Vec<String> = self
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.map(|t| format!("\"{}\"", escape_double_quotes(t)))
|
||||||
|
.collect();
|
||||||
lines.push(format!("tags: [{}]", quoted.join(", ")));
|
lines.push(format!("tags: [{}]", quoted.join(", ")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// linkedPostIds: only if non-empty
|
// linkedPostIds: only if non-empty
|
||||||
if !self.linked_post_ids.is_empty() {
|
if !self.linked_post_ids.is_empty() {
|
||||||
let quoted: Vec<String> = self.linked_post_ids.iter().map(|id| format!("\"{id}\"")).collect();
|
let quoted: Vec<String> = self
|
||||||
|
.linked_post_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| format!("\"{id}\""))
|
||||||
|
.collect();
|
||||||
lines.push(format!("linkedPostIds: [{}]", quoted.join(", ")));
|
lines.push(format!("linkedPostIds: [{}]", quoted.join(", ")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,33 +126,45 @@ impl MediaSidecar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Display for MediaSidecar {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str(&self.serialize())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl MediaTranslationSidecar {
|
impl MediaTranslationSidecar {
|
||||||
/// Serialize to the hand-built format.
|
/// Serialize to the hand-built format.
|
||||||
pub fn to_string(&self) -> String {
|
fn serialize(&self) -> String {
|
||||||
let mut lines: Vec<String> = Vec::new();
|
let mut lines: Vec<String> = Vec::new();
|
||||||
lines.push("---".into());
|
lines.push("---".into());
|
||||||
lines.push(format!("translationFor: {}", self.translation_for));
|
lines.push(format!("translationFor: {}", self.translation_for));
|
||||||
lines.push(format!("language: {}", self.language));
|
lines.push(format!("language: {}", self.language));
|
||||||
if let Some(ref title) = self.title {
|
if let Some(ref title) = self.title
|
||||||
if !title.is_empty() {
|
&& !title.is_empty()
|
||||||
|
{
|
||||||
lines.push(format!("title: \"{}\"", escape_double_quotes(title)));
|
lines.push(format!("title: \"{}\"", escape_double_quotes(title)));
|
||||||
}
|
}
|
||||||
}
|
if let Some(ref alt) = self.alt
|
||||||
if let Some(ref alt) = self.alt {
|
&& !alt.is_empty()
|
||||||
if !alt.is_empty() {
|
{
|
||||||
lines.push(format!("alt: \"{}\"", escape_double_quotes(alt)));
|
lines.push(format!("alt: \"{}\"", escape_double_quotes(alt)));
|
||||||
}
|
}
|
||||||
}
|
if let Some(ref caption) = self.caption
|
||||||
if let Some(ref caption) = self.caption {
|
&& !caption.is_empty()
|
||||||
if !caption.is_empty() {
|
{
|
||||||
lines.push(format!("caption: \"{}\"", escape_double_quotes(caption)));
|
lines.push(format!("caption: \"{}\"", escape_double_quotes(caption)));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
lines.push("---".into());
|
lines.push("---".into());
|
||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Display for MediaTranslationSidecar {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str(&self.serialize())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse a canonical media sidecar.
|
/// Parse a canonical media sidecar.
|
||||||
pub fn read_sidecar(content: &str) -> Result<MediaSidecar, String> {
|
pub fn read_sidecar(content: &str) -> Result<MediaSidecar, String> {
|
||||||
// Strip --- delimiters
|
// Strip --- delimiters
|
||||||
@@ -301,7 +326,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_fixture_sidecar() {
|
fn parse_fixture_sidecar() {
|
||||||
let path = fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
|
let path =
|
||||||
|
fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
|
||||||
let content = fs::read_to_string(path).unwrap();
|
let content = fs::read_to_string(path).unwrap();
|
||||||
let sc = read_sidecar(&content).unwrap();
|
let sc = read_sidecar(&content).unwrap();
|
||||||
assert_eq!(sc.id, "eb0cf9d7-6fbd-4b74-9be3-759d6e16f240");
|
assert_eq!(sc.id, "eb0cf9d7-6fbd-4b74-9be3-759d6e16f240");
|
||||||
@@ -314,17 +340,25 @@ mod tests {
|
|||||||
assert!(sc.alt.as_ref().unwrap().contains("Spinnenfrau"));
|
assert!(sc.alt.as_ref().unwrap().contains("Spinnenfrau"));
|
||||||
assert!(sc.caption.as_ref().unwrap().contains("Handwerkskunst"));
|
assert!(sc.caption.as_ref().unwrap().contains("Handwerkskunst"));
|
||||||
assert!(sc.tags.is_empty());
|
assert!(sc.tags.is_empty());
|
||||||
assert_eq!(sc.linked_post_ids, vec!["40a83ab1-423d-4310-aac4-642d84675007"]);
|
assert_eq!(
|
||||||
|
sc.linked_post_ids,
|
||||||
|
vec!["40a83ab1-423d-4310-aac4-642d84675007"]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn golden_output_sidecar() {
|
fn golden_output_sidecar() {
|
||||||
let path = fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
|
let path =
|
||||||
|
fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
|
||||||
let expected = fs::read_to_string(&path).unwrap();
|
let expected = fs::read_to_string(&path).unwrap();
|
||||||
let sc = read_sidecar(&expected).unwrap();
|
let sc = read_sidecar(&expected).unwrap();
|
||||||
let actual = sc.to_string();
|
let actual = sc.to_string();
|
||||||
// Compare trimmed (fixture may or may not have trailing newline)
|
// Compare trimmed (fixture may or may not have trailing newline)
|
||||||
assert_eq!(actual.trim(), expected.trim(), "golden output mismatch for media sidecar");
|
assert_eq!(
|
||||||
|
actual.trim(),
|
||||||
|
expected.trim(),
|
||||||
|
"golden output mismatch for media sidecar"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -378,10 +412,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn inline_json_array_parsing() {
|
fn inline_json_array_parsing() {
|
||||||
assert_eq!(parse_inline_json_array("[]"), Vec::<String>::new());
|
assert_eq!(parse_inline_json_array("[]"), Vec::<String>::new());
|
||||||
assert_eq!(
|
assert_eq!(parse_inline_json_array("[\"a\", \"b\"]"), vec!["a", "b"]);
|
||||||
parse_inline_json_array("[\"a\", \"b\"]"),
|
|
||||||
vec!["a", "b"]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use deunicode::deunicode;
|
use deunicode::deunicode;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
/// Pre-process German characters to match TypeScript `transliteration` npm output.
|
/// Pre-process German characters to match TypeScript `transliteration` npm output.
|
||||||
/// deunicode maps ä→a, ö→o, ü→u but TypeScript produces ä→ae, ö→oe, ü→ue.
|
/// deunicode maps ä→a, ö→o, ü→u but TypeScript produces ä→ae, ö→oe, ü→ue.
|
||||||
@@ -50,7 +49,7 @@ pub fn slugify(input: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure a slug is unique within a project, using the spec's algorithm:
|
/// Ensure a slug is unique within a project, using the spec's algorithm:
|
||||||
/// tries base, then {slug}-2 .. {slug}-999, then {slug}-{timestamp}.
|
/// tries base, then unbounded numeric suffixes `{slug}-2`, `{slug}-3`, and so on.
|
||||||
///
|
///
|
||||||
/// `exists` is a predicate that returns true if the candidate slug is already taken.
|
/// `exists` is a predicate that returns true if the candidate slug is already taken.
|
||||||
pub fn ensure_unique<F>(base: &str, exists: F) -> String
|
pub fn ensure_unique<F>(base: &str, exists: F) -> String
|
||||||
@@ -60,17 +59,13 @@ where
|
|||||||
if !exists(base) {
|
if !exists(base) {
|
||||||
return base.to_string();
|
return base.to_string();
|
||||||
}
|
}
|
||||||
for n in 2..=999 {
|
for n in 2_u64.. {
|
||||||
let candidate = format!("{base}-{n}");
|
let candidate = format!("{base}-{n}");
|
||||||
if !exists(&candidate) {
|
if !exists(&candidate) {
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let ts = SystemTime::now()
|
unreachable!()
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
format!("{base}-{ts}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -121,7 +116,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ensure_unique_sequential_taken() {
|
fn ensure_unique_sequential_taken() {
|
||||||
let slug = ensure_unique("hello", |s| s == "hello" || s == "hello-2" || s == "hello-3");
|
let slug = ensure_unique("hello", |s| {
|
||||||
|
s == "hello" || s == "hello-2" || s == "hello-3"
|
||||||
|
});
|
||||||
assert_eq!(slug, "hello-4");
|
assert_eq!(slug, "hello-4");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,20 +175,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ensure_unique_all_999_taken() {
|
fn ensure_unique_continues_after_999() {
|
||||||
let slug = ensure_unique("x", |s| {
|
let slug = ensure_unique("x", |s| {
|
||||||
if s == "x" { return true; }
|
if s == "x" {
|
||||||
if let Some(suffix) = s.strip_prefix("x-") {
|
return true;
|
||||||
if let Ok(n) = suffix.parse::<u32>() {
|
|
||||||
return n <= 999;
|
|
||||||
}
|
}
|
||||||
|
if let Some(suffix) = s.strip_prefix("x-")
|
||||||
|
&& let Ok(n) = suffix.parse::<u32>()
|
||||||
|
{
|
||||||
|
return n <= 999;
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
});
|
});
|
||||||
// Should fall back to timestamp-based slug
|
assert_eq!(slug, "x-1000");
|
||||||
assert!(slug.starts_with("x-"));
|
|
||||||
let suffix = slug.strip_prefix("x-").unwrap();
|
|
||||||
let ts: u64 = suffix.parse().expect("should be a timestamp");
|
|
||||||
assert!(ts > 1_000_000_000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,10 +32,34 @@ pub enum ThumbnailFit {
|
|||||||
/// Standard thumbnail sizes matching spec: small/medium/large are
|
/// Standard thumbnail sizes matching spec: small/medium/large are
|
||||||
/// width-constrained aspect-preserving; AI is letterboxed on black.
|
/// width-constrained aspect-preserving; AI is letterboxed on black.
|
||||||
pub const THUMBNAIL_SIZES: &[ThumbnailSize] = &[
|
pub const THUMBNAIL_SIZES: &[ThumbnailSize] = &[
|
||||||
ThumbnailSize { name: "small", width: 150, height: u32::MAX, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
|
ThumbnailSize {
|
||||||
ThumbnailSize { name: "medium", width: 400, height: u32::MAX, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
|
name: "small",
|
||||||
ThumbnailSize { name: "large", width: 800, height: u32::MAX, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
|
width: 150,
|
||||||
ThumbnailSize { name: "ai", width: 448, height: 448, format: ThumbnailFormat::Jpeg, fit: ThumbnailFit::Contain },
|
height: u32::MAX,
|
||||||
|
format: ThumbnailFormat::Webp,
|
||||||
|
fit: ThumbnailFit::Inside,
|
||||||
|
},
|
||||||
|
ThumbnailSize {
|
||||||
|
name: "medium",
|
||||||
|
width: 400,
|
||||||
|
height: u32::MAX,
|
||||||
|
format: ThumbnailFormat::Webp,
|
||||||
|
fit: ThumbnailFit::Inside,
|
||||||
|
},
|
||||||
|
ThumbnailSize {
|
||||||
|
name: "large",
|
||||||
|
width: 800,
|
||||||
|
height: u32::MAX,
|
||||||
|
format: ThumbnailFormat::Webp,
|
||||||
|
fit: ThumbnailFit::Inside,
|
||||||
|
},
|
||||||
|
ThumbnailSize {
|
||||||
|
name: "ai",
|
||||||
|
width: 448,
|
||||||
|
height: 448,
|
||||||
|
format: ThumbnailFormat::Jpeg,
|
||||||
|
fit: ThumbnailFit::Contain,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Generate a single thumbnail from a source image file.
|
/// Generate a single thumbnail from a source image file.
|
||||||
@@ -57,9 +81,7 @@ pub fn generate_thumbnail(
|
|||||||
img.resize(size.width, size.height, FilterType::Lanczos3)
|
img.resize(size.width, size.height, FilterType::Lanczos3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ThumbnailFit::Cover => {
|
ThumbnailFit::Cover => img.resize_to_fill(size.width, size.height, FilterType::Lanczos3),
|
||||||
img.resize_to_fill(size.width, size.height, FilterType::Lanczos3)
|
|
||||||
}
|
|
||||||
ThumbnailFit::Contain => {
|
ThumbnailFit::Contain => {
|
||||||
// Resize to fit, then place on black background
|
// Resize to fit, then place on black background
|
||||||
let resized = img.resize(size.width, size.height, FilterType::Lanczos3);
|
let resized = img.resize(size.width, size.height, FilterType::Lanczos3);
|
||||||
@@ -118,7 +140,11 @@ pub fn generate_all_thumbnails(
|
|||||||
let dest = thumbnails_dir
|
let dest = thumbnails_dir
|
||||||
.join(prefix)
|
.join(prefix)
|
||||||
.join(format!("{media_id}-{}.{ext}", size.name));
|
.join(format!("{media_id}-{}.{ext}", size.name));
|
||||||
let quality = if size.format == ThumbnailFormat::Jpeg { 85 } else { 80 };
|
let quality = if size.format == ThumbnailFormat::Jpeg {
|
||||||
|
85
|
||||||
|
} else {
|
||||||
|
80
|
||||||
|
};
|
||||||
generate_thumbnail(source, &dest, size, quality)?;
|
generate_thumbnail(source, &dest, size, quality)?;
|
||||||
paths.push(dest.to_string_lossy().to_string());
|
paths.push(dest.to_string_lossy().to_string());
|
||||||
}
|
}
|
||||||
@@ -136,11 +162,11 @@ fn load_and_orient(path: &Path) -> Result<DynamicImage, String> {
|
|||||||
let mut img = reader.decode().map_err(|e| format!("decode image: {e}"))?;
|
let mut img = reader.decode().map_err(|e| format!("decode image: {e}"))?;
|
||||||
|
|
||||||
// Try to read EXIF orientation from JPEG files
|
// Try to read EXIF orientation from JPEG files
|
||||||
if let Ok(data) = fs::read(path) {
|
if let Ok(data) = fs::read(path)
|
||||||
if let Some(orientation) = read_exif_orientation(&data) {
|
&& let Some(orientation) = read_exif_orientation(&data)
|
||||||
|
{
|
||||||
img = apply_orientation(img, orientation);
|
img = apply_orientation(img, orientation);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Ok(img)
|
Ok(img)
|
||||||
}
|
}
|
||||||
@@ -180,7 +206,9 @@ fn parse_tiff_orientation(data: &[u8], tiff_start: usize) -> Option<u16> {
|
|||||||
}
|
}
|
||||||
let is_le = data[tiff_start] == b'I' && data[tiff_start + 1] == b'I';
|
let is_le = data[tiff_start] == b'I' && data[tiff_start + 1] == b'I';
|
||||||
let read_u16 = |offset: usize| -> Option<u16> {
|
let read_u16 = |offset: usize| -> Option<u16> {
|
||||||
if offset + 2 > data.len() { return None; }
|
if offset + 2 > data.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
if is_le {
|
if is_le {
|
||||||
Some(u16::from_le_bytes([data[offset], data[offset + 1]]))
|
Some(u16::from_le_bytes([data[offset], data[offset + 1]]))
|
||||||
} else {
|
} else {
|
||||||
@@ -188,11 +216,23 @@ fn parse_tiff_orientation(data: &[u8], tiff_start: usize) -> Option<u16> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let read_u32 = |offset: usize| -> Option<u32> {
|
let read_u32 = |offset: usize| -> Option<u32> {
|
||||||
if offset + 4 > data.len() { return None; }
|
if offset + 4 > data.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
if is_le {
|
if is_le {
|
||||||
Some(u32::from_le_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]))
|
Some(u32::from_le_bytes([
|
||||||
|
data[offset],
|
||||||
|
data[offset + 1],
|
||||||
|
data[offset + 2],
|
||||||
|
data[offset + 3],
|
||||||
|
]))
|
||||||
} else {
|
} else {
|
||||||
Some(u32::from_be_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]))
|
Some(u32::from_be_bytes([
|
||||||
|
data[offset],
|
||||||
|
data[offset + 1],
|
||||||
|
data[offset + 2],
|
||||||
|
data[offset + 3],
|
||||||
|
]))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -202,7 +242,9 @@ fn parse_tiff_orientation(data: &[u8], tiff_start: usize) -> Option<u16> {
|
|||||||
|
|
||||||
for i in 0..entry_count {
|
for i in 0..entry_count {
|
||||||
let entry_pos = ifd_pos + 2 + i * 12;
|
let entry_pos = ifd_pos + 2 + i * 12;
|
||||||
if entry_pos + 12 > data.len() { break; }
|
if entry_pos + 12 > data.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
let tag = read_u16(entry_pos)?;
|
let tag = read_u16(entry_pos)?;
|
||||||
if tag == 0x0112 {
|
if tag == 0x0112 {
|
||||||
// Orientation tag
|
// Orientation tag
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use chrono::{DateTime, Utc, TimeZone};
|
use chrono::{DateTime, NaiveDate, TimeZone, Utc};
|
||||||
|
|
||||||
/// Convert Unix milliseconds to ISO 8601 string (e.g., `2005-11-13T12:00:00.000Z`).
|
/// Convert Unix milliseconds to ISO 8601 string (e.g., `2005-11-13T12:00:00.000Z`).
|
||||||
pub fn unix_ms_to_iso(ms: i64) -> String {
|
pub fn unix_ms_to_iso(ms: i64) -> String {
|
||||||
@@ -35,6 +35,24 @@ pub fn now_unix_ms() -> i64 {
|
|||||||
Utc::now().timestamp_millis()
|
Utc::now().timestamp_millis()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the inclusive start and exclusive end of a calendar year or month.
|
||||||
|
pub fn calendar_range_unix_ms(year: i32, month: Option<u32>) -> Option<(i64, i64)> {
|
||||||
|
let start_month = month.unwrap_or(1);
|
||||||
|
let (end_year, end_month) = match month {
|
||||||
|
Some(12) | None => (year.checked_add(1)?, 1),
|
||||||
|
Some(month) => (year, month.checked_add(1)?),
|
||||||
|
};
|
||||||
|
let start = NaiveDate::from_ymd_opt(year, start_month, 1)?
|
||||||
|
.and_hms_opt(0, 0, 0)?
|
||||||
|
.and_utc()
|
||||||
|
.timestamp_millis();
|
||||||
|
let end = NaiveDate::from_ymd_opt(end_year, end_month, 1)?
|
||||||
|
.and_hms_opt(0, 0, 0)?
|
||||||
|
.and_utc()
|
||||||
|
.timestamp_millis();
|
||||||
|
Some((start, end))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -83,4 +101,16 @@ mod tests {
|
|||||||
fn invalid_iso_returns_error() {
|
fn invalid_iso_returns_error() {
|
||||||
assert!(iso_to_unix_ms("not a date").is_err());
|
assert!(iso_to_unix_ms("not a date").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn calendar_range_rejects_invalid_month() {
|
||||||
|
assert_eq!(calendar_range_unix_ms(2026, Some(13)), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn calendar_range_covers_requested_month() {
|
||||||
|
let (start, end) = calendar_range_unix_ms(2026, Some(7)).unwrap();
|
||||||
|
assert_eq!(unix_ms_to_iso(start), "2026-07-01T00:00:00.000Z");
|
||||||
|
assert_eq!(unix_ms_to_iso(end), "2026-08-01T00:00:00.000Z");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,15 @@ fn read_project() {
|
|||||||
.query_row(
|
.query_row(
|
||||||
"SELECT id, name, slug, data_path, is_active FROM projects WHERE id = ?1",
|
"SELECT id, name, slug, data_path, is_active FROM projects WHERE id = ?1",
|
||||||
[PROJECT_ID],
|
[PROJECT_ID],
|
||||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
|
|row| {
|
||||||
|
Ok((
|
||||||
|
row.get(0)?,
|
||||||
|
row.get(1)?,
|
||||||
|
row.get(2)?,
|
||||||
|
row.get(3)?,
|
||||||
|
row.get(4)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -52,7 +60,10 @@ fn read_published_post_has_null_content() {
|
|||||||
assert_eq!(title, "Esmeralda");
|
assert_eq!(title, "Esmeralda");
|
||||||
assert_eq!(slug, "esmeralda");
|
assert_eq!(slug, "esmeralda");
|
||||||
assert_eq!(status, "published");
|
assert_eq!(status, "published");
|
||||||
assert!(content.is_none(), "published posts must have NULL content in DB");
|
assert!(
|
||||||
|
content.is_none(),
|
||||||
|
"published posts must have NULL content in DB"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -96,8 +107,14 @@ fn published_posts_have_file_paths() {
|
|||||||
|
|
||||||
assert_eq!(rows.len(), 3);
|
assert_eq!(rows.len(), 3);
|
||||||
for (slug, path) in &rows {
|
for (slug, path) in &rows {
|
||||||
assert!(!path.is_empty(), "published post '{slug}' must have a file_path");
|
assert!(
|
||||||
assert!(path.ends_with(&format!("{slug}.md")), "file_path must end with {slug}.md");
|
!path.is_empty(),
|
||||||
|
"published post '{slug}' must have a file_path"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
path.ends_with(&format!("{slug}.md")),
|
||||||
|
"file_path must end with {slug}.md"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,8 +148,14 @@ fn post_timestamps_are_unix_integers() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Sanity: timestamps should be in reasonable Unix range (year 2000+)
|
// Sanity: timestamps should be in reasonable Unix range (year 2000+)
|
||||||
assert!(created_at > 946_684_800, "created_at should be after year 2000");
|
assert!(
|
||||||
assert!(updated_at > 946_684_800, "updated_at should be after year 2000");
|
created_at > 946_684_800,
|
||||||
|
"created_at should be after year 2000"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
updated_at > 946_684_800,
|
||||||
|
"updated_at should be after year 2000"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -147,7 +170,10 @@ fn post_unique_constraint_on_project_slug() {
|
|||||||
.map(|r| r.unwrap())
|
.map(|r| r.unwrap())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
assert!(dupes.is_empty(), "found duplicate (project_id, slug) pairs: {dupes:?}");
|
assert!(
|
||||||
|
dupes.is_empty(),
|
||||||
|
"found duplicate (project_id, slug) pairs: {dupes:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Post Translations ───────────────────────────────────────────────
|
// ── Post Translations ───────────────────────────────────────────────
|
||||||
@@ -156,7 +182,9 @@ fn post_unique_constraint_on_project_slug() {
|
|||||||
fn read_post_translations() {
|
fn read_post_translations() {
|
||||||
let conn = fixture_db();
|
let conn = fixture_db();
|
||||||
let count: i64 = conn
|
let count: i64 = conn
|
||||||
.query_row("SELECT COUNT(*) FROM post_translations", [], |row| row.get(0))
|
.query_row("SELECT COUNT(*) FROM post_translations", [], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 4);
|
assert_eq!(count, 4);
|
||||||
}
|
}
|
||||||
@@ -177,7 +205,10 @@ fn translation_references_valid_post() {
|
|||||||
.map(|r| r.unwrap())
|
.map(|r| r.unwrap())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
assert!(orphans.is_empty(), "orphan translations referencing missing posts: {orphans:?}");
|
assert!(
|
||||||
|
orphans.is_empty(),
|
||||||
|
"orphan translations referencing missing posts: {orphans:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -193,7 +224,10 @@ fn published_translations_have_null_content() {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
for (id, content) in &rows {
|
for (id, content) in &rows {
|
||||||
assert!(content.is_none(), "published translation {id} must have NULL content");
|
assert!(
|
||||||
|
content.is_none(),
|
||||||
|
"published translation {id} must have NULL content"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +327,16 @@ fn tag_names_are_expected() {
|
|||||||
.map(|r| r.unwrap())
|
.map(|r| r.unwrap())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
assert_eq!(names, vec!["fotografie", "mac-os-x", "natur", "programmierung", "sysadmin"]);
|
assert_eq!(
|
||||||
|
names,
|
||||||
|
vec![
|
||||||
|
"fotografie",
|
||||||
|
"mac-os-x",
|
||||||
|
"natur",
|
||||||
|
"programmierung",
|
||||||
|
"sysadmin"
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -308,7 +351,10 @@ fn tag_unique_constraint_on_project_name() {
|
|||||||
.map(|r| r.unwrap())
|
.map(|r| r.unwrap())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
assert!(dupes.is_empty(), "found duplicate (project_id, name) tag pairs: {dupes:?}");
|
assert!(
|
||||||
|
dupes.is_empty(),
|
||||||
|
"found duplicate (project_id, name) tag pairs: {dupes:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Templates ───────────────────────────────────────────────────────
|
// ── Templates ───────────────────────────────────────────────────────
|
||||||
@@ -345,7 +391,10 @@ fn read_template() {
|
|||||||
assert_eq!(kind, "post");
|
assert_eq!(kind, "post");
|
||||||
assert!(enabled);
|
assert!(enabled);
|
||||||
assert_eq!(status, "published");
|
assert_eq!(status, "published");
|
||||||
assert!(content.is_none(), "published template content should be NULL in DB");
|
assert!(
|
||||||
|
content.is_none(),
|
||||||
|
"published template content should be NULL in DB"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Scripts ─────────────────────────────────────────────────────────
|
// ── Scripts ─────────────────────────────────────────────────────────
|
||||||
@@ -420,7 +469,10 @@ fn settings_are_key_value_pairs() {
|
|||||||
assert!(!value.is_empty());
|
assert!(!value.is_empty());
|
||||||
}
|
}
|
||||||
// All setting keys should contain the project ID (namespaced)
|
// All setting keys should contain the project ID (namespaced)
|
||||||
let project_keys: Vec<_> = pairs.iter().filter(|(k, _)| k.contains(PROJECT_ID)).collect();
|
let project_keys: Vec<_> = pairs
|
||||||
|
.iter()
|
||||||
|
.filter(|(k, _)| k.contains(PROJECT_ID))
|
||||||
|
.collect();
|
||||||
assert_eq!(project_keys.len(), 5);
|
assert_eq!(project_keys.len(), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,7 +531,10 @@ fn all_posts_belong_to_existing_project() {
|
|||||||
.map(|r| r.unwrap())
|
.map(|r| r.unwrap())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
assert!(orphans.is_empty(), "posts referencing missing projects: {orphans:?}");
|
assert!(
|
||||||
|
orphans.is_empty(),
|
||||||
|
"posts referencing missing projects: {orphans:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -498,7 +553,10 @@ fn all_tags_belong_to_existing_project() {
|
|||||||
.map(|r| r.unwrap())
|
.map(|r| r.unwrap())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
assert!(orphans.is_empty(), "tags referencing missing projects: {orphans:?}");
|
assert!(
|
||||||
|
orphans.is_empty(),
|
||||||
|
"tags referencing missing projects: {orphans:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -517,7 +575,10 @@ fn all_media_belong_to_existing_project() {
|
|||||||
.map(|r| r.unwrap())
|
.map(|r| r.unwrap())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
assert!(orphans.is_empty(), "media referencing missing projects: {orphans:?}");
|
assert!(
|
||||||
|
orphans.is_empty(),
|
||||||
|
"media referencing missing projects: {orphans:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -537,7 +598,10 @@ fn post_links_reference_valid_posts() {
|
|||||||
.map(|r| r.unwrap())
|
.map(|r| r.unwrap())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
assert!(orphans.is_empty(), "post_links with invalid references: {orphans:?}");
|
assert!(
|
||||||
|
orphans.is_empty(),
|
||||||
|
"post_links with invalid references: {orphans:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -557,5 +621,8 @@ fn post_media_references_valid_entities() {
|
|||||||
.map(|r| r.unwrap())
|
.map(|r| r.unwrap())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
assert!(orphans.is_empty(), "post_media with invalid references: {orphans:?}");
|
assert!(
|
||||||
|
orphans.is_empty(),
|
||||||
|
"post_media with invalid references: {orphans:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -4,23 +4,22 @@
|
|||||||
//! - Step 20: Golden-file comparisons against fixture files
|
//! - Step 20: Golden-file comparisons against fixture files
|
||||||
//! - Step 21: Metadata diff coverage matrix (every diffable field covered)
|
//! - Step 21: Metadata diff coverage matrix (every diffable field covered)
|
||||||
|
|
||||||
|
use bds_core::db::Database;
|
||||||
use bds_core::db::fts::ensure_fts_tables;
|
use bds_core::db::fts::ensure_fts_tables;
|
||||||
use bds_core::db::queries::project::insert_project;
|
use bds_core::db::queries::project::insert_project;
|
||||||
use bds_core::db::queries::script as qs;
|
use bds_core::db::queries::script as qs;
|
||||||
use bds_core::db::queries::template as qtpl;
|
use bds_core::db::queries::template as qtpl;
|
||||||
use bds_core::db::Database;
|
|
||||||
use bds_core::engine::media;
|
use bds_core::engine::media;
|
||||||
use bds_core::engine::meta;
|
use bds_core::engine::meta;
|
||||||
use bds_core::engine::metadata_diff;
|
use bds_core::engine::metadata_diff;
|
||||||
use bds_core::engine::post;
|
use bds_core::engine::post;
|
||||||
use bds_core::engine::tag;
|
use bds_core::engine::tag;
|
||||||
use bds_core::model::{
|
use bds_core::model::{
|
||||||
PostStatus, Project, Script, ScriptKind, ScriptStatus, Template, TemplateKind,
|
PostStatus, Project, Script, ScriptKind, ScriptStatus, Template, TemplateKind, TemplateStatus,
|
||||||
TemplateStatus,
|
|
||||||
};
|
};
|
||||||
use bds_core::util::frontmatter::{
|
use bds_core::util::frontmatter::{
|
||||||
read_post_file, read_template_file, read_translation_file,
|
ScriptFrontmatter, TemplateFrontmatter, read_post_file, read_template_file,
|
||||||
write_script_file, write_template_file, ScriptFrontmatter, TemplateFrontmatter,
|
read_translation_file, write_script_file, write_template_file,
|
||||||
};
|
};
|
||||||
use bds_core::util::sidecar::{read_sidecar, read_translation_sidecar};
|
use bds_core::util::sidecar::{read_sidecar, read_translation_sidecar};
|
||||||
use image::DynamicImage;
|
use image::DynamicImage;
|
||||||
@@ -325,10 +324,7 @@ fn test_golden_post_esmeralda() {
|
|||||||
let actual = bds_core::util::frontmatter::format_frontmatter(&yaml, &body);
|
let actual = bds_core::util::frontmatter::format_frontmatter(&yaml, &body);
|
||||||
|
|
||||||
// Compare byte-for-byte with fixture
|
// Compare byte-for-byte with fixture
|
||||||
assert_eq!(
|
assert_eq!(actual, expected, "golden output mismatch for esmeralda.md");
|
||||||
actual, expected,
|
|
||||||
"golden output mismatch for esmeralda.md"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -353,8 +349,7 @@ fn test_golden_translation_esmeralda_en() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_golden_sidecar() {
|
fn test_golden_sidecar() {
|
||||||
let path =
|
let path = fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
|
||||||
fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
|
|
||||||
let expected = fs::read_to_string(&path).unwrap();
|
let expected = fs::read_to_string(&path).unwrap();
|
||||||
let sc = read_sidecar(&expected).unwrap();
|
let sc = read_sidecar(&expected).unwrap();
|
||||||
|
|
||||||
@@ -401,7 +396,14 @@ fn test_golden_meta_files() {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
categories,
|
categories,
|
||||||
vec![
|
vec![
|
||||||
"article", "aside", "kochbuch", "metaowl", "page", "picture", "spielelog", "wiki"
|
"article",
|
||||||
|
"aside",
|
||||||
|
"kochbuch",
|
||||||
|
"metaowl",
|
||||||
|
"page",
|
||||||
|
"picture",
|
||||||
|
"spielelog",
|
||||||
|
"wiki"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
// Verify they are sorted
|
// Verify they are sorted
|
||||||
@@ -416,7 +418,7 @@ fn test_golden_meta_files() {
|
|||||||
// Verify sorted by name
|
// Verify sorted by name
|
||||||
let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
|
let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
|
||||||
let mut sorted_names = names.clone();
|
let mut sorted_names = names.clone();
|
||||||
sorted_names.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
|
sorted_names.sort_by_key(|a| a.to_lowercase());
|
||||||
assert_eq!(names, sorted_names, "tags should be sorted by name");
|
assert_eq!(names, sorted_names, "tags should be sorted by name");
|
||||||
// Spot-check a known tag
|
// Spot-check a known tag
|
||||||
assert!(tags.iter().any(|t| t.name == "rust"));
|
assert!(tags.iter().any(|t| t.name == "rust"));
|
||||||
@@ -457,9 +459,7 @@ fn test_golden_template() {
|
|||||||
|
|
||||||
/// Helper: create a post, publish it, then modify a specific field in the
|
/// Helper: create a post, publish it, then modify a specific field in the
|
||||||
/// file on disk. Run the diff and return all detected field names.
|
/// file on disk. Run the diff and return all detected field names.
|
||||||
fn post_diff_for_field(
|
fn post_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
|
||||||
modify_fn: impl FnOnce(&str) -> String,
|
|
||||||
) -> Vec<String> {
|
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
|
|
||||||
let created = post::create_post(
|
let created = post::create_post(
|
||||||
@@ -519,9 +519,8 @@ fn test_diff_detects_post_slug() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_diff_detects_post_status() {
|
fn test_diff_detects_post_status() {
|
||||||
let fields = post_diff_for_field(|content| {
|
let fields =
|
||||||
content.replace("status: published", "status: draft")
|
post_diff_for_field(|content| content.replace("status: published", "status: draft"));
|
||||||
});
|
|
||||||
assert!(
|
assert!(
|
||||||
fields.contains(&"status".to_string()),
|
fields.contains(&"status".to_string()),
|
||||||
"expected 'status' in diff fields, got: {fields:?}"
|
"expected 'status' in diff fields, got: {fields:?}"
|
||||||
@@ -530,9 +529,7 @@ fn test_diff_detects_post_status() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_diff_detects_post_tags() {
|
fn test_diff_detects_post_tags() {
|
||||||
let fields = post_diff_for_field(|content| {
|
let fields = post_diff_for_field(|content| content.replace(" - tag1", " - changed-tag"));
|
||||||
content.replace(" - tag1", " - changed-tag")
|
|
||||||
});
|
|
||||||
assert!(
|
assert!(
|
||||||
fields.contains(&"tags".to_string()),
|
fields.contains(&"tags".to_string()),
|
||||||
"expected 'tags' in diff fields, got: {fields:?}"
|
"expected 'tags' in diff fields, got: {fields:?}"
|
||||||
@@ -541,9 +538,7 @@ fn test_diff_detects_post_tags() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_diff_detects_post_categories() {
|
fn test_diff_detects_post_categories() {
|
||||||
let fields = post_diff_for_field(|content| {
|
let fields = post_diff_for_field(|content| content.replace(" - cat1", " - changed-cat"));
|
||||||
content.replace(" - cat1", " - changed-cat")
|
|
||||||
});
|
|
||||||
assert!(
|
assert!(
|
||||||
fields.contains(&"categories".to_string()),
|
fields.contains(&"categories".to_string()),
|
||||||
"expected 'categories' in diff fields, got: {fields:?}"
|
"expected 'categories' in diff fields, got: {fields:?}"
|
||||||
@@ -578,9 +573,7 @@ fn test_diff_detects_post_author() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_diff_detects_post_language() {
|
fn test_diff_detects_post_language() {
|
||||||
let fields = post_diff_for_field(|content| {
|
let fields = post_diff_for_field(|content| content.replace("language: en", "language: de"));
|
||||||
content.replace("language: en", "language: de")
|
|
||||||
});
|
|
||||||
assert!(
|
assert!(
|
||||||
fields.contains(&"language".to_string()),
|
fields.contains(&"language".to_string()),
|
||||||
"expected 'language' in diff fields, got: {fields:?}"
|
"expected 'language' in diff fields, got: {fields:?}"
|
||||||
@@ -687,9 +680,7 @@ fn test_diff_detects_post_published_at() {
|
|||||||
|
|
||||||
/// Helper: import media, then modify a field in the sidecar file.
|
/// Helper: import media, then modify a field in the sidecar file.
|
||||||
/// Returns the list of diff field names detected.
|
/// Returns the list of diff field names detected.
|
||||||
fn media_diff_for_field(
|
fn media_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
|
||||||
modify_fn: impl FnOnce(&str) -> String,
|
|
||||||
) -> Vec<String> {
|
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
|
|
||||||
let source_path = create_test_image(dir.path());
|
let source_path = create_test_image(dir.path());
|
||||||
@@ -764,10 +755,7 @@ fn test_diff_detects_media_caption() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_diff_detects_media_author() {
|
fn test_diff_detects_media_author() {
|
||||||
let fields = media_diff_for_field(|content| {
|
let fields = media_diff_for_field(|content| {
|
||||||
content.replace(
|
content.replace("author: \"Original Author\"", "author: \"Changed Author\"")
|
||||||
"author: \"Original Author\"",
|
|
||||||
"author: \"Changed Author\"",
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
assert!(
|
assert!(
|
||||||
fields.contains(&"author".to_string()),
|
fields.contains(&"author".to_string()),
|
||||||
@@ -778,10 +766,7 @@ fn test_diff_detects_media_author() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_diff_detects_media_tags() {
|
fn test_diff_detects_media_tags() {
|
||||||
let fields = media_diff_for_field(|content| {
|
let fields = media_diff_for_field(|content| {
|
||||||
content.replace(
|
content.replace("tags: [\"original-tag\"]", "tags: [\"changed-tag\"]")
|
||||||
"tags: [\"original-tag\"]",
|
|
||||||
"tags: [\"changed-tag\"]",
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
assert!(
|
assert!(
|
||||||
fields.contains(&"tags".to_string()),
|
fields.contains(&"tags".to_string()),
|
||||||
@@ -791,9 +776,7 @@ fn test_diff_detects_media_tags() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_diff_detects_media_language() {
|
fn test_diff_detects_media_language() {
|
||||||
let fields = media_diff_for_field(|content| {
|
let fields = media_diff_for_field(|content| content.replace("language: en", "language: de"));
|
||||||
content.replace("language: en", "language: de")
|
|
||||||
});
|
|
||||||
assert!(
|
assert!(
|
||||||
fields.contains(&"language".to_string()),
|
fields.contains(&"language".to_string()),
|
||||||
"expected 'language' in media diff fields, got: {fields:?}"
|
"expected 'language' in media diff fields, got: {fields:?}"
|
||||||
@@ -804,9 +787,7 @@ fn test_diff_detects_media_language() {
|
|||||||
|
|
||||||
/// Helper: insert a template in DB + write file, then modify a field in
|
/// Helper: insert a template in DB + write file, then modify a field in
|
||||||
/// the file. Returns the list of diff field names.
|
/// the file. Returns the list of diff field names.
|
||||||
fn template_diff_for_field(
|
fn template_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
|
||||||
modify_fn: impl FnOnce(&str) -> String,
|
|
||||||
) -> Vec<String> {
|
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
|
|
||||||
let tpl = Template {
|
let tpl = Template {
|
||||||
@@ -870,9 +851,8 @@ fn test_diff_detects_template_title() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_diff_detects_template_kind() {
|
fn test_diff_detects_template_kind() {
|
||||||
let fields = template_diff_for_field(|content| {
|
let fields =
|
||||||
content.replace("kind: \"post\"", "kind: \"list\"")
|
template_diff_for_field(|content| content.replace("kind: \"post\"", "kind: \"list\""));
|
||||||
});
|
|
||||||
assert!(
|
assert!(
|
||||||
fields.contains(&"kind".to_string()),
|
fields.contains(&"kind".to_string()),
|
||||||
"expected 'kind' in template diff fields, got: {fields:?}"
|
"expected 'kind' in template diff fields, got: {fields:?}"
|
||||||
@@ -881,9 +861,8 @@ fn test_diff_detects_template_kind() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_diff_detects_template_enabled() {
|
fn test_diff_detects_template_enabled() {
|
||||||
let fields = template_diff_for_field(|content| {
|
let fields =
|
||||||
content.replace("enabled: true", "enabled: false")
|
template_diff_for_field(|content| content.replace("enabled: true", "enabled: false"));
|
||||||
});
|
|
||||||
assert!(
|
assert!(
|
||||||
fields.contains(&"enabled".to_string()),
|
fields.contains(&"enabled".to_string()),
|
||||||
"expected 'enabled' in template diff fields, got: {fields:?}"
|
"expected 'enabled' in template diff fields, got: {fields:?}"
|
||||||
@@ -892,9 +871,7 @@ fn test_diff_detects_template_enabled() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_diff_detects_template_version() {
|
fn test_diff_detects_template_version() {
|
||||||
let fields = template_diff_for_field(|content| {
|
let fields = template_diff_for_field(|content| content.replace("version: 1", "version: 99"));
|
||||||
content.replace("version: 1", "version: 99")
|
|
||||||
});
|
|
||||||
assert!(
|
assert!(
|
||||||
fields.contains(&"version".to_string()),
|
fields.contains(&"version".to_string()),
|
||||||
"expected 'version' in template diff fields, got: {fields:?}"
|
"expected 'version' in template diff fields, got: {fields:?}"
|
||||||
@@ -905,9 +882,7 @@ fn test_diff_detects_template_version() {
|
|||||||
|
|
||||||
/// Helper: insert a script in DB + write file, then modify a field in the
|
/// Helper: insert a script in DB + write file, then modify a field in the
|
||||||
/// file. Returns the list of diff field names.
|
/// file. Returns the list of diff field names.
|
||||||
fn script_diff_for_field(
|
fn script_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
|
||||||
modify_fn: impl FnOnce(&str) -> String,
|
|
||||||
) -> Vec<String> {
|
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
|
|
||||||
let script = Script {
|
let script = Script {
|
||||||
@@ -995,9 +970,8 @@ fn test_diff_detects_script_entrypoint() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_diff_detects_script_enabled() {
|
fn test_diff_detects_script_enabled() {
|
||||||
let fields = script_diff_for_field(|content| {
|
let fields =
|
||||||
content.replace("enabled: true", "enabled: false")
|
script_diff_for_field(|content| content.replace("enabled: true", "enabled: false"));
|
||||||
});
|
|
||||||
assert!(
|
assert!(
|
||||||
fields.contains(&"enabled".to_string()),
|
fields.contains(&"enabled".to_string()),
|
||||||
"expected 'enabled' in script diff fields, got: {fields:?}"
|
"expected 'enabled' in script diff fields, got: {fields:?}"
|
||||||
@@ -1006,9 +980,7 @@ fn test_diff_detects_script_enabled() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_diff_detects_script_version() {
|
fn test_diff_detects_script_version() {
|
||||||
let fields = script_diff_for_field(|content| {
|
let fields = script_diff_for_field(|content| content.replace("version: 1", "version: 42"));
|
||||||
content.replace("version: 1", "version: 42")
|
|
||||||
});
|
|
||||||
assert!(
|
assert!(
|
||||||
fields.contains(&"version".to_string()),
|
fields.contains(&"version".to_string()),
|
||||||
"expected 'version' in script diff fields, got: {fields:?}"
|
"expected 'version' in script diff fields, got: {fields:?}"
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use bds_core::db::Database;
|
||||||
use bds_core::db::queries::project::insert_project;
|
use bds_core::db::queries::project::insert_project;
|
||||||
use bds_core::db::queries::template::insert_template;
|
use bds_core::db::queries::template::insert_template;
|
||||||
use bds_core::db::Database;
|
|
||||||
use bds_core::engine::generation::{
|
use bds_core::engine::generation::{
|
||||||
PublishedPostSource, apply_validation_sections, generate_starter_site,
|
PublishedPostSource, apply_validation_sections, generate_starter_site,
|
||||||
sections_from_validation_report,
|
load_published_post_source, sections_from_validation_report,
|
||||||
};
|
};
|
||||||
use bds_core::engine::meta::write_category_meta_json;
|
use bds_core::engine::meta::write_category_meta_json;
|
||||||
use bds_core::engine::validate_site::validate_site;
|
use bds_core::engine::validate_site::validate_site;
|
||||||
use bds_core::model::{CategorySettings, Post, PostStatus, Project, ProjectMetadata, Template, TemplateKind, TemplateStatus};
|
use bds_core::model::{
|
||||||
|
CategorySettings, Post, PostStatus, Project, ProjectMetadata, Template, TemplateKind,
|
||||||
|
TemplateStatus,
|
||||||
|
};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn make_project() -> Project {
|
fn make_project() -> Project {
|
||||||
@@ -95,6 +98,24 @@ fn setup() -> (Database, TempDir) {
|
|||||||
(db, dir)
|
(db, dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reopened_draft_generation_uses_last_published_file() {
|
||||||
|
let (_db, dir) = setup();
|
||||||
|
let mut post = make_post("reopened", 1_710_000_000_000);
|
||||||
|
post.status = PostStatus::Draft;
|
||||||
|
post.content = Some("Unpublished draft body".into());
|
||||||
|
post.file_path = "posts/2024/03/reopened.md".into();
|
||||||
|
let frontmatter = bds_core::util::frontmatter::PostFrontmatter::from_post(&post).to_yaml();
|
||||||
|
let file = bds_core::util::frontmatter::format_frontmatter(&frontmatter, "Published body");
|
||||||
|
std::fs::create_dir_all(dir.path().join("posts/2024/03")).unwrap();
|
||||||
|
std::fs::write(dir.path().join(&post.file_path), file).unwrap();
|
||||||
|
|
||||||
|
let source = load_published_post_source(dir.path(), post)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(source.body_markdown, "Published body");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generation_engine_writes_core_and_single_post_artifacts() {
|
fn generation_engine_writes_core_and_single_post_artifacts() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
@@ -110,7 +131,8 @@ fn generation_engine_writes_core_and_single_post_artifacts() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let report = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
|
let report =
|
||||||
|
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
|
||||||
|
|
||||||
assert!(report.written_paths.contains(&"index.html".to_string()));
|
assert!(report.written_paths.contains(&"index.html".to_string()));
|
||||||
assert!(report.written_paths.contains(&"calendar.json".to_string()));
|
assert!(report.written_paths.contains(&"calendar.json".to_string()));
|
||||||
@@ -118,10 +140,26 @@ fn generation_engine_writes_core_and_single_post_artifacts() {
|
|||||||
assert!(report.written_paths.contains(&"feed.xml".to_string()));
|
assert!(report.written_paths.contains(&"feed.xml".to_string()));
|
||||||
assert!(report.written_paths.contains(&"atom.xml".to_string()));
|
assert!(report.written_paths.contains(&"atom.xml".to_string()));
|
||||||
assert!(report.written_paths.contains(&"sitemap.xml".to_string()));
|
assert!(report.written_paths.contains(&"sitemap.xml".to_string()));
|
||||||
assert!(report.written_paths.contains(&"assets/pico.min.css".to_string()));
|
assert!(
|
||||||
assert!(report.written_paths.contains(&"assets/tag-cloud.js".to_string()));
|
report
|
||||||
assert!(report.written_paths.contains(&"2024/03/09/hello/index.html".to_string()));
|
.written_paths
|
||||||
assert!(report.written_paths.contains(&"2024/03/10/next/index.html".to_string()));
|
.contains(&"assets/pico.min.css".to_string())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
report
|
||||||
|
.written_paths
|
||||||
|
.contains(&"assets/tag-cloud.js".to_string())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
report
|
||||||
|
.written_paths
|
||||||
|
.contains(&"2024/03/09/hello/index.html".to_string())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
report
|
||||||
|
.written_paths
|
||||||
|
.contains(&"2024/03/10/next/index.html".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
assert!(dir.path().join("index.html").exists());
|
assert!(dir.path().join("index.html").exists());
|
||||||
assert!(dir.path().join("rss.xml").exists());
|
assert!(dir.path().join("rss.xml").exists());
|
||||||
@@ -152,13 +190,17 @@ fn multilingual_generation_writes_language_aware_atom_and_sitemap_routes() {
|
|||||||
body_markdown: "Hallo Welt".into(),
|
body_markdown: "Hallo Welt".into(),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let report = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "de").unwrap();
|
let report =
|
||||||
|
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "de").unwrap();
|
||||||
|
|
||||||
assert!(report.written_paths.contains(&"en/atom.xml".to_string()));
|
assert!(report.written_paths.contains(&"en/atom.xml".to_string()));
|
||||||
assert!(report.written_paths.contains(&"en/sitemap.xml".to_string()));
|
assert!(report.written_paths.contains(&"en/sitemap.xml".to_string()));
|
||||||
|
|
||||||
let atom = std::fs::read_to_string(dir.path().join("en/atom.xml")).unwrap();
|
let atom = std::fs::read_to_string(dir.path().join("en/atom.xml")).unwrap();
|
||||||
assert!(atom.contains("<link href=\"https://example.com/en/\" rel=\"alternate\" />") || atom.contains("<link href=\"https://example.com/en\" rel=\"alternate\" />"));
|
assert!(
|
||||||
|
atom.contains("<link href=\"https://example.com/en/\" rel=\"alternate\" />")
|
||||||
|
|| atom.contains("<link href=\"https://example.com/en\" rel=\"alternate\" />")
|
||||||
|
);
|
||||||
assert!(atom.contains("<link href=\"https://example.com/en/atom.xml\" rel=\"self\" />"));
|
assert!(atom.contains("<link href=\"https://example.com/en/atom.xml\" rel=\"self\" />"));
|
||||||
|
|
||||||
let sitemap = std::fs::read_to_string(dir.path().join("sitemap.xml")).unwrap();
|
let sitemap = std::fs::read_to_string(dir.path().join("sitemap.xml")).unwrap();
|
||||||
@@ -223,7 +265,8 @@ fn generation_respects_category_list_settings_and_writes_bundled_images() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let report = generate_starter_site(db.conn(), dir.path(), "p1", &make_metadata(), &posts, "en").unwrap();
|
let report =
|
||||||
|
generate_starter_site(db.conn(), dir.path(), "p1", &make_metadata(), &posts, "en").unwrap();
|
||||||
|
|
||||||
for asset in [
|
for asset in [
|
||||||
"images/close.png",
|
"images/close.png",
|
||||||
@@ -232,17 +275,29 @@ fn generation_respects_category_list_settings_and_writes_bundled_images() {
|
|||||||
"images/prev.png",
|
"images/prev.png",
|
||||||
] {
|
] {
|
||||||
assert!(report.written_paths.contains(&asset.to_string()));
|
assert!(report.written_paths.contains(&asset.to_string()));
|
||||||
assert!(dir.path().join(asset).exists(), "missing bundled image {asset}");
|
assert!(
|
||||||
|
dir.path().join(asset).exists(),
|
||||||
|
"missing bundled image {asset}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
assert!(!report.written_paths.contains(&"category/hidden/index.html".to_string()));
|
assert!(
|
||||||
assert!(report.written_paths.contains(&"category/featured/index.html".to_string()));
|
!report
|
||||||
|
.written_paths
|
||||||
|
.contains(&"category/hidden/index.html".to_string())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
report
|
||||||
|
.written_paths
|
||||||
|
.contains(&"category/featured/index.html".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
let index_html = std::fs::read_to_string(dir.path().join("index.html")).unwrap();
|
let index_html = std::fs::read_to_string(dir.path().join("index.html")).unwrap();
|
||||||
assert!(!index_html.contains("Hidden Post"));
|
assert!(!index_html.contains("Hidden Post"));
|
||||||
assert!(index_html.contains("[Featured Post|false]"));
|
assert!(index_html.contains("[Featured Post|false]"));
|
||||||
|
|
||||||
let featured_html = std::fs::read_to_string(dir.path().join("category/featured/index.html")).unwrap();
|
let featured_html =
|
||||||
|
std::fs::read_to_string(dir.path().join("category/featured/index.html")).unwrap();
|
||||||
assert!(featured_html.contains("FEATURED:[Featured Post|false]"));
|
assert!(featured_html.contains("FEATURED:[Featured Post|false]"));
|
||||||
|
|
||||||
let feed = std::fs::read_to_string(dir.path().join("feed.xml")).unwrap();
|
let feed = std::fs::read_to_string(dir.path().join("feed.xml")).unwrap();
|
||||||
@@ -263,15 +318,21 @@ fn generation_engine_skips_unchanged_outputs_on_second_run() {
|
|||||||
body_markdown: "Hello **world**".into(),
|
body_markdown: "Hello **world**".into(),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let first = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
|
let first =
|
||||||
let second = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
|
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
|
||||||
|
let second =
|
||||||
|
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
|
||||||
|
|
||||||
assert!(!first.written_paths.is_empty());
|
assert!(!first.written_paths.is_empty());
|
||||||
assert!(second.skipped_paths.contains(&"index.html".to_string()));
|
assert!(second.skipped_paths.contains(&"index.html".to_string()));
|
||||||
assert!(second.skipped_paths.contains(&"calendar.json".to_string()));
|
assert!(second.skipped_paths.contains(&"calendar.json".to_string()));
|
||||||
assert!(second.skipped_paths.contains(&"rss.xml".to_string()));
|
assert!(second.skipped_paths.contains(&"rss.xml".to_string()));
|
||||||
assert!(second.skipped_paths.contains(&"feed.xml".to_string()));
|
assert!(second.skipped_paths.contains(&"feed.xml".to_string()));
|
||||||
assert!(second.skipped_paths.contains(&"assets/pico.min.css".to_string()));
|
assert!(
|
||||||
|
second
|
||||||
|
.skipped_paths
|
||||||
|
.contains(&"assets/pico.min.css".to_string())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -313,7 +374,9 @@ fn apply_validation_repairs_core_section_outputs() {
|
|||||||
|
|
||||||
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
|
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
|
||||||
let sections = sections_from_validation_report(&report);
|
let sections = sections_from_validation_report(&report);
|
||||||
let apply_report = apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, §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();
|
let repaired = validate_site(db.conn(), dir.path(), "p1").unwrap();
|
||||||
|
|
||||||
assert!(!apply_report.written_paths.is_empty() || !apply_report.skipped_paths.is_empty());
|
assert!(!apply_report.written_paths.is_empty() || !apply_report.skipped_paths.is_empty());
|
||||||
@@ -339,12 +402,22 @@ fn apply_validation_removes_extra_section_outputs() {
|
|||||||
std::fs::write(extra_dir.join("index.html"), "ghost").unwrap();
|
std::fs::write(extra_dir.join("index.html"), "ghost").unwrap();
|
||||||
|
|
||||||
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
|
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
|
||||||
assert!(report.extra_pages.contains(&"tag/ghost/index.html".to_string()));
|
assert!(
|
||||||
|
report
|
||||||
|
.extra_pages
|
||||||
|
.contains(&"tag/ghost/index.html".to_string())
|
||||||
|
);
|
||||||
let sections = sections_from_validation_report(&report);
|
let sections = sections_from_validation_report(&report);
|
||||||
let apply_report = apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, §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();
|
let repaired = validate_site(db.conn(), dir.path(), "p1").unwrap();
|
||||||
|
|
||||||
assert!(apply_report.deleted_paths.contains(&"tag/ghost/index.html".to_string()));
|
assert!(
|
||||||
|
apply_report
|
||||||
|
.deleted_paths
|
||||||
|
.contains(&"tag/ghost/index.html".to_string())
|
||||||
|
);
|
||||||
assert!(repaired.extra_pages.is_empty());
|
assert!(repaired.extra_pages.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
|
use bds_core::db::Database;
|
||||||
use bds_core::db::queries::generated_file_hash::get_generated_file_hash;
|
use bds_core::db::queries::generated_file_hash::get_generated_file_hash;
|
||||||
use bds_core::db::queries::project::insert_project;
|
use bds_core::db::queries::project::insert_project;
|
||||||
use bds_core::db::Database;
|
|
||||||
use bds_core::model::{Post, PostStatus, Project};
|
use bds_core::model::{Post, PostStatus, Project};
|
||||||
use bds_core::render::{
|
use bds_core::render::{
|
||||||
GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths,
|
GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths, write_generated_file,
|
||||||
write_generated_file,
|
|
||||||
};
|
};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
@@ -72,7 +71,11 @@ fn generated_write_skips_unchanged_content() {
|
|||||||
|
|
||||||
let stored = get_generated_file_hash(db.conn(), "p1", "index.html").unwrap();
|
let stored = get_generated_file_hash(db.conn(), "p1", "index.html").unwrap();
|
||||||
assert_eq!(stored.relative_path, "index.html");
|
assert_eq!(stored.relative_path, "index.html");
|
||||||
assert!(fs::read_to_string(dir.path().join("index.html")).unwrap().contains("changed"));
|
assert!(
|
||||||
|
fs::read_to_string(dir.path().join("index.html"))
|
||||||
|
.unwrap()
|
||||||
|
.contains("changed")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -85,7 +88,10 @@ fn generated_write_rewrites_missing_file_even_when_hash_matches() {
|
|||||||
|
|
||||||
assert_eq!(first, GeneratedWriteOutcome::Written);
|
assert_eq!(first, GeneratedWriteOutcome::Written);
|
||||||
assert_eq!(second, GeneratedWriteOutcome::Written);
|
assert_eq!(second, GeneratedWriteOutcome::Written);
|
||||||
assert_eq!(fs::read_to_string(dir.path().join("index.html")).unwrap(), "hello");
|
assert_eq!(
|
||||||
|
fs::read_to_string(dir.path().join("index.html")).unwrap(),
|
||||||
|
"hello"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -98,7 +104,10 @@ fn generated_write_rewrites_stale_file_even_when_db_hash_matches() {
|
|||||||
|
|
||||||
assert_eq!(first, GeneratedWriteOutcome::Written);
|
assert_eq!(first, GeneratedWriteOutcome::Written);
|
||||||
assert_eq!(second, GeneratedWriteOutcome::Written);
|
assert_eq!(second, GeneratedWriteOutcome::Written);
|
||||||
assert_eq!(fs::read_to_string(dir.path().join("index.html")).unwrap(), "hello");
|
assert_eq!(
|
||||||
|
fs::read_to_string(dir.path().join("index.html")).unwrap(),
|
||||||
|
"hello"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ fn markdown_filter_expands_builtin_macros_from_runtime_context() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn markdown_filter_marks_unsupported_macros_visibly() {
|
fn markdown_filter_leaves_unknown_macros_verbatim() {
|
||||||
let template = "{{ body | markdown: nil, nil, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}";
|
let template = "{{ body | markdown: nil, nil, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}";
|
||||||
let partials = HashMap::new();
|
let partials = HashMap::new();
|
||||||
let context = serde_json::json!({
|
let context = serde_json::json!({
|
||||||
@@ -104,9 +104,8 @@ fn markdown_filter_marks_unsupported_macros_visibly() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let rendered = render_liquid_template(template, &partials, &context).unwrap();
|
let rendered = render_liquid_template(template, &partials, &context).unwrap();
|
||||||
assert!(rendered.contains("macro-unsupported"));
|
assert!(rendered.contains("[[custom_block foo=bar]]"));
|
||||||
assert!(rendered.contains("Unsupported macro"));
|
assert!(!rendered.contains("macro-unsupported"));
|
||||||
assert!(rendered.contains("custom_block"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -123,11 +122,13 @@ fn starter_single_post_template_renders_with_partials() {
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"partials/language-switcher".to_string(),
|
"partials/language-switcher".to_string(),
|
||||||
include_str!("../../../assets/starter-templates/partials/language-switcher.liquid").to_string(),
|
include_str!("../../../assets/starter-templates/partials/language-switcher.liquid")
|
||||||
|
.to_string(),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"partials/menu-items".to_string(),
|
"partials/menu-items".to_string(),
|
||||||
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}".to_string(),
|
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}"
|
||||||
|
.to_string(),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use bds_core::model::{Post, PostStatus, Tag, Template, TemplateKind, TemplateStatus};
|
use bds_core::model::{Post, PostStatus, Tag, Template, TemplateKind, TemplateStatus};
|
||||||
use bds_core::render::{
|
use bds_core::render::{
|
||||||
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError,
|
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError, render_markdown_to_html,
|
||||||
render_markdown_to_html, resolve_post_template,
|
resolve_post_template,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn make_post() -> Post {
|
fn make_post() -> Post {
|
||||||
@@ -70,7 +70,11 @@ fn template_lookup_prefers_post_specific_template() {
|
|||||||
post.tags = vec!["rust".into()];
|
post.tags = vec!["rust".into()];
|
||||||
post.categories = vec!["article".into()];
|
post.categories = vec!["article".into()];
|
||||||
|
|
||||||
let templates = vec![make_template("post"), make_template("tag-template"), make_template("custom")];
|
let templates = vec![
|
||||||
|
make_template("post"),
|
||||||
|
make_template("tag-template"),
|
||||||
|
make_template("custom"),
|
||||||
|
];
|
||||||
let tags = vec![make_tag("rust", Some("tag-template"))];
|
let tags = vec![make_tag("rust", Some("tag-template"))];
|
||||||
let mut categories = HashMap::new();
|
let mut categories = HashMap::new();
|
||||||
categories.insert(
|
categories.insert(
|
||||||
@@ -163,7 +167,10 @@ fn template_lookup_errors_when_explicit_template_missing() {
|
|||||||
})
|
})
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert_eq!(err, TemplateLookupError::MissingExplicitTemplate("missing".into()));
|
assert_eq!(
|
||||||
|
err,
|
||||||
|
TemplateLookupError::MissingExplicitTemplate("missing".into())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -25,9 +25,18 @@ fn post_status_serde_matches_db_values() {
|
|||||||
// spec: status: draft | published | archived
|
// spec: status: draft | published | archived
|
||||||
use bds_core::model::PostStatus;
|
use bds_core::model::PostStatus;
|
||||||
|
|
||||||
assert_eq!(serde_json::to_string(&PostStatus::Draft).unwrap(), "\"draft\"");
|
assert_eq!(
|
||||||
assert_eq!(serde_json::to_string(&PostStatus::Published).unwrap(), "\"published\"");
|
serde_json::to_string(&PostStatus::Draft).unwrap(),
|
||||||
assert_eq!(serde_json::to_string(&PostStatus::Archived).unwrap(), "\"archived\"");
|
"\"draft\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&PostStatus::Published).unwrap(),
|
||||||
|
"\"published\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&PostStatus::Archived).unwrap(),
|
||||||
|
"\"archived\""
|
||||||
|
);
|
||||||
|
|
||||||
// round-trip
|
// round-trip
|
||||||
let d: PostStatus = serde_json::from_str("\"draft\"").unwrap();
|
let d: PostStatus = serde_json::from_str("\"draft\"").unwrap();
|
||||||
@@ -44,10 +53,22 @@ fn post_status_serde_matches_db_values() {
|
|||||||
fn template_kind_serde_matches_db_values() {
|
fn template_kind_serde_matches_db_values() {
|
||||||
use bds_core::model::TemplateKind;
|
use bds_core::model::TemplateKind;
|
||||||
|
|
||||||
assert_eq!(serde_json::to_string(&TemplateKind::Post).unwrap(), "\"post\"");
|
assert_eq!(
|
||||||
assert_eq!(serde_json::to_string(&TemplateKind::List).unwrap(), "\"list\"");
|
serde_json::to_string(&TemplateKind::Post).unwrap(),
|
||||||
assert_eq!(serde_json::to_string(&TemplateKind::NotFound).unwrap(), "\"not_found\"");
|
"\"post\""
|
||||||
assert_eq!(serde_json::to_string(&TemplateKind::Partial).unwrap(), "\"partial\"");
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&TemplateKind::List).unwrap(),
|
||||||
|
"\"list\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&TemplateKind::NotFound).unwrap(),
|
||||||
|
"\"not_found\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&TemplateKind::Partial).unwrap(),
|
||||||
|
"\"partial\""
|
||||||
|
);
|
||||||
|
|
||||||
let nf: TemplateKind = serde_json::from_str("\"not_found\"").unwrap();
|
let nf: TemplateKind = serde_json::from_str("\"not_found\"").unwrap();
|
||||||
assert_eq!(nf, TemplateKind::NotFound);
|
assert_eq!(nf, TemplateKind::NotFound);
|
||||||
@@ -59,8 +80,14 @@ fn template_kind_serde_matches_db_values() {
|
|||||||
fn template_status_serde_matches_db_values() {
|
fn template_status_serde_matches_db_values() {
|
||||||
use bds_core::model::TemplateStatus;
|
use bds_core::model::TemplateStatus;
|
||||||
|
|
||||||
assert_eq!(serde_json::to_string(&TemplateStatus::Draft).unwrap(), "\"draft\"");
|
assert_eq!(
|
||||||
assert_eq!(serde_json::to_string(&TemplateStatus::Published).unwrap(), "\"published\"");
|
serde_json::to_string(&TemplateStatus::Draft).unwrap(),
|
||||||
|
"\"draft\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&TemplateStatus::Published).unwrap(),
|
||||||
|
"\"published\""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── spec: schema.allium — Script kind: macro | utility | transform ──
|
// ── spec: schema.allium — Script kind: macro | utility | transform ──
|
||||||
@@ -69,9 +96,18 @@ fn template_status_serde_matches_db_values() {
|
|||||||
fn script_kind_serde_matches_db_values() {
|
fn script_kind_serde_matches_db_values() {
|
||||||
use bds_core::model::ScriptKind;
|
use bds_core::model::ScriptKind;
|
||||||
|
|
||||||
assert_eq!(serde_json::to_string(&ScriptKind::Macro).unwrap(), "\"macro\"");
|
assert_eq!(
|
||||||
assert_eq!(serde_json::to_string(&ScriptKind::Utility).unwrap(), "\"utility\"");
|
serde_json::to_string(&ScriptKind::Macro).unwrap(),
|
||||||
assert_eq!(serde_json::to_string(&ScriptKind::Transform).unwrap(), "\"transform\"");
|
"\"macro\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&ScriptKind::Utility).unwrap(),
|
||||||
|
"\"utility\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&ScriptKind::Transform).unwrap(),
|
||||||
|
"\"transform\""
|
||||||
|
);
|
||||||
|
|
||||||
let t: ScriptKind = serde_json::from_str("\"transform\"").unwrap();
|
let t: ScriptKind = serde_json::from_str("\"transform\"").unwrap();
|
||||||
assert_eq!(t, ScriptKind::Transform);
|
assert_eq!(t, ScriptKind::Transform);
|
||||||
@@ -83,8 +119,14 @@ fn script_kind_serde_matches_db_values() {
|
|||||||
fn script_status_serde_matches_db_values() {
|
fn script_status_serde_matches_db_values() {
|
||||||
use bds_core::model::ScriptStatus;
|
use bds_core::model::ScriptStatus;
|
||||||
|
|
||||||
assert_eq!(serde_json::to_string(&ScriptStatus::Draft).unwrap(), "\"draft\"");
|
assert_eq!(
|
||||||
assert_eq!(serde_json::to_string(&ScriptStatus::Published).unwrap(), "\"published\"");
|
serde_json::to_string(&ScriptStatus::Draft).unwrap(),
|
||||||
|
"\"draft\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&ScriptStatus::Published).unwrap(),
|
||||||
|
"\"published\""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── spec: post.allium — content_location invariant ──
|
// ── spec: post.allium — content_location invariant ──
|
||||||
@@ -105,7 +147,10 @@ fn content_location_published_posts_null_content_in_fixture() {
|
|||||||
|
|
||||||
assert!(!rows.is_empty());
|
assert!(!rows.is_empty());
|
||||||
for (slug, content) in &rows {
|
for (slug, content) in &rows {
|
||||||
assert!(content.is_none(), "spec: published post '{slug}' must have NULL content in DB");
|
assert!(
|
||||||
|
content.is_none(),
|
||||||
|
"spec: published post '{slug}' must have NULL content in DB"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +168,10 @@ fn content_location_draft_posts_have_content_in_fixture() {
|
|||||||
|
|
||||||
assert!(!rows.is_empty());
|
assert!(!rows.is_empty());
|
||||||
for (slug, content) in &rows {
|
for (slug, content) in &rows {
|
||||||
assert!(content.is_some(), "spec: draft post '{slug}' must have content in DB");
|
assert!(
|
||||||
|
content.is_some(),
|
||||||
|
"spec: draft post '{slug}' must have content in DB"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +186,8 @@ fn post_status_transitions_all_valid() {
|
|||||||
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
|
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
|
||||||
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at) \
|
"INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at) \
|
||||||
VALUES ('post1', 'p1', 'Test', 'test', 'draft', '', 1000, 1000)",
|
VALUES ('post1', 'p1', 'Test', 'test', 'draft', '', 1000, 1000)",
|
||||||
@@ -157,11 +206,15 @@ fn post_status_transitions_all_valid() {
|
|||||||
|
|
||||||
for (from, to) in transitions {
|
for (from, to) in transitions {
|
||||||
// Set to 'from' state first
|
// Set to 'from' state first
|
||||||
conn.execute("UPDATE posts SET status = ?1 WHERE id = 'post1'", [from]).unwrap();
|
conn.execute("UPDATE posts SET status = ?1 WHERE id = 'post1'", [from])
|
||||||
|
.unwrap();
|
||||||
// Transition to 'to' state
|
// Transition to 'to' state
|
||||||
conn.execute("UPDATE posts SET status = ?1 WHERE id = 'post1'", [to]).unwrap();
|
conn.execute("UPDATE posts SET status = ?1 WHERE id = 'post1'", [to])
|
||||||
|
.unwrap();
|
||||||
let status: String = conn
|
let status: String = conn
|
||||||
.query_row("SELECT status FROM posts WHERE id = 'post1'", [], |r| r.get(0))
|
.query_row("SELECT status FROM posts WHERE id = 'post1'", [], |r| {
|
||||||
|
r.get(0)
|
||||||
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(status, to, "transition {from} -> {to} failed");
|
assert_eq!(status, to, "transition {from} -> {to} failed");
|
||||||
}
|
}
|
||||||
@@ -177,7 +230,8 @@ fn slug_frozen_after_publish_semantics() {
|
|||||||
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
|
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
|
||||||
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at, published_at) \
|
"INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at, published_at) \
|
||||||
VALUES ('post1', 'p1', 'Test', 'test', 'published', 'posts/2024/01/test.md', 1000, 1000, 1000)",
|
VALUES ('post1', 'p1', 'Test', 'test', 'published', 'posts/2024/01/test.md', 1000, 1000, 1000)",
|
||||||
@@ -186,9 +240,16 @@ fn slug_frozen_after_publish_semantics() {
|
|||||||
|
|
||||||
// published_at is set — slug should be considered frozen
|
// published_at is set — slug should be considered frozen
|
||||||
let published_at: Option<i64> = conn
|
let published_at: Option<i64> = conn
|
||||||
.query_row("SELECT published_at FROM posts WHERE id = 'post1'", [], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT published_at FROM posts WHERE id = 'post1'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(published_at.is_some(), "spec: is_slug_frozen = published_at != null");
|
assert!(
|
||||||
|
published_at.is_some(),
|
||||||
|
"spec: is_slug_frozen = published_at != null"
|
||||||
|
);
|
||||||
|
|
||||||
// A never-published draft has no published_at — slug is mutable
|
// A never-published draft has no published_at — slug is mutable
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -197,9 +258,16 @@ fn slug_frozen_after_publish_semantics() {
|
|||||||
[],
|
[],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
let draft_published_at: Option<i64> = conn
|
let draft_published_at: Option<i64> = conn
|
||||||
.query_row("SELECT published_at FROM posts WHERE id = 'post2'", [], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT published_at FROM posts WHERE id = 'post2'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(draft_published_at.is_none(), "spec: unpublished draft has no published_at");
|
assert!(
|
||||||
|
draft_published_at.is_none(),
|
||||||
|
"spec: unpublished draft has no published_at"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── spec: project.allium — SingleActiveProject invariant ──
|
// ── spec: project.allium — SingleActiveProject invariant ──
|
||||||
@@ -209,7 +277,11 @@ fn slug_frozen_after_publish_semantics() {
|
|||||||
fn single_active_project_in_fixture() {
|
fn single_active_project_in_fixture() {
|
||||||
let conn = fixture_db();
|
let conn = fixture_db();
|
||||||
let active_count: i64 = conn
|
let active_count: i64 = conn
|
||||||
.query_row("SELECT COUNT(*) FROM projects WHERE is_active = 1", [], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM projects WHERE is_active = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(active_count, 1, "spec: exactly one project is active");
|
assert_eq!(active_count, 1, "spec: exactly one project is active");
|
||||||
}
|
}
|
||||||
@@ -247,7 +319,10 @@ fn unique_translation_per_post_language_in_fixture() {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.map(|r| r.unwrap())
|
.map(|r| r.unwrap())
|
||||||
.collect();
|
.collect();
|
||||||
assert!(dupes.is_empty(), "spec: translation unique per (post, language)");
|
assert!(
|
||||||
|
dupes.is_empty(),
|
||||||
|
"spec: translation unique per (post, language)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── spec: schema.allium — Post defaults ──
|
// ── spec: schema.allium — Post defaults ──
|
||||||
@@ -260,13 +335,15 @@ fn post_defaults_match_spec() {
|
|||||||
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
|
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
|
||||||
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
// Insert with minimal columns to test defaults
|
// Insert with minimal columns to test defaults
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO posts (id, project_id, title, slug, created_at, updated_at) \
|
"INSERT INTO posts (id, project_id, title, slug, created_at, updated_at) \
|
||||||
VALUES ('min1', 'p1', 'Minimal', 'minimal', 1000, 1000)",
|
VALUES ('min1', 'p1', 'Minimal', 'minimal', 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let (status, do_not_translate, file_path): (String, bool, String) = conn
|
let (status, do_not_translate, file_path): (String, bool, String) = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -291,12 +368,14 @@ fn script_defaults_match_spec() {
|
|||||||
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
|
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
|
||||||
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO scripts (id, project_id, slug, title, file_path, created_at, updated_at) \
|
"INSERT INTO scripts (id, project_id, slug, title, file_path, created_at, updated_at) \
|
||||||
VALUES ('s1', 'p1', 'test', 'Test', 'scripts/test.lua', 1000, 1000)",
|
VALUES ('s1', 'p1', 'test', 'Test', 'scripts/test.lua', 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let (kind, entrypoint, enabled, version, status): (String, String, bool, i32, String) = conn
|
let (kind, entrypoint, enabled, version, status): (String, String, bool, i32, String) = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -322,12 +401,14 @@ fn template_defaults_match_spec() {
|
|||||||
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
|
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
|
||||||
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO templates (id, project_id, slug, title, file_path, created_at, updated_at) \
|
"INSERT INTO templates (id, project_id, slug, title, file_path, created_at, updated_at) \
|
||||||
VALUES ('t1', 'p1', 'test', 'Test', 'templates/test.liquid', 1000, 1000)",
|
VALUES ('t1', 'p1', 'test', 'Test', 'templates/test.liquid', 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let (kind, enabled, version, status): (String, bool, i32, String) = conn
|
let (kind, enabled, version, status): (String, bool, i32, String) = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -351,12 +432,14 @@ fn tag_unique_name_per_project_enforced() {
|
|||||||
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
|
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
|
||||||
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO tags (id, project_id, name, created_at, updated_at) \
|
"INSERT INTO tags (id, project_id, name, created_at, updated_at) \
|
||||||
VALUES ('t1', 'p1', 'rust', 1000, 1000)",
|
VALUES ('t1', 'p1', 'rust', 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Same name, same project — must fail
|
// Same name, same project — must fail
|
||||||
let result = conn.execute(
|
let result = conn.execute(
|
||||||
@@ -364,7 +447,10 @@ fn tag_unique_name_per_project_enforced() {
|
|||||||
VALUES ('t2', 'p1', 'rust', 1000, 1000)",
|
VALUES ('t2', 'p1', 'rust', 1000, 1000)",
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
assert!(result.is_err(), "spec: duplicate tag name in same project must fail");
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"spec: duplicate tag name in same project must fail"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── spec: post.allium — Slug generation algorithm ──
|
// ── spec: post.allium — Slug generation algorithm ──
|
||||||
@@ -447,16 +533,37 @@ fn published_post_file_paths_follow_date_layout() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn notification_entity_serde_matches_db_values() {
|
fn notification_entity_serde_matches_db_values() {
|
||||||
use bds_core::model::{NotificationEntity, NotificationAction};
|
use bds_core::model::{NotificationAction, NotificationEntity};
|
||||||
|
|
||||||
assert_eq!(serde_json::to_string(&NotificationEntity::Post).unwrap(), "\"post\"");
|
assert_eq!(
|
||||||
assert_eq!(serde_json::to_string(&NotificationEntity::Media).unwrap(), "\"media\"");
|
serde_json::to_string(&NotificationEntity::Post).unwrap(),
|
||||||
assert_eq!(serde_json::to_string(&NotificationEntity::Script).unwrap(), "\"script\"");
|
"\"post\""
|
||||||
assert_eq!(serde_json::to_string(&NotificationEntity::Template).unwrap(), "\"template\"");
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&NotificationEntity::Media).unwrap(),
|
||||||
|
"\"media\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&NotificationEntity::Script).unwrap(),
|
||||||
|
"\"script\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&NotificationEntity::Template).unwrap(),
|
||||||
|
"\"template\""
|
||||||
|
);
|
||||||
|
|
||||||
assert_eq!(serde_json::to_string(&NotificationAction::Created).unwrap(), "\"created\"");
|
assert_eq!(
|
||||||
assert_eq!(serde_json::to_string(&NotificationAction::Updated).unwrap(), "\"updated\"");
|
serde_json::to_string(&NotificationAction::Created).unwrap(),
|
||||||
assert_eq!(serde_json::to_string(&NotificationAction::Deleted).unwrap(), "\"deleted\"");
|
"\"created\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&NotificationAction::Updated).unwrap(),
|
||||||
|
"\"updated\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&NotificationAction::Deleted).unwrap(),
|
||||||
|
"\"deleted\""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── spec: generation.allium / publishing.allium — SshMode values ──
|
// ── spec: generation.allium / publishing.allium — SshMode values ──
|
||||||
@@ -493,7 +600,11 @@ fn fts5_tables_exist_in_fixture() {
|
|||||||
[],
|
[],
|
||||||
|r| r.get::<_, i64>(0),
|
|r| r.get::<_, i64>(0),
|
||||||
);
|
);
|
||||||
assert_eq!(result.unwrap(), 1, "spec: posts_fts virtual table must exist");
|
assert_eq!(
|
||||||
|
result.unwrap(),
|
||||||
|
1,
|
||||||
|
"spec: posts_fts virtual table must exist"
|
||||||
|
);
|
||||||
|
|
||||||
// media_fts
|
// media_fts
|
||||||
let result = conn.query_row(
|
let result = conn.query_row(
|
||||||
@@ -501,5 +612,9 @@ fn fts5_tables_exist_in_fixture() {
|
|||||||
[],
|
[],
|
||||||
|r| r.get::<_, i64>(0),
|
|r| r.get::<_, i64>(0),
|
||||||
);
|
);
|
||||||
assert_eq!(result.unwrap(), 1, "spec: media_fts virtual table must exist");
|
assert_eq!(
|
||||||
|
result.unwrap(),
|
||||||
|
1,
|
||||||
|
"spec: media_fts virtual table must exist"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -127,7 +127,13 @@ impl EditorBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set selection explicitly (for double-click word select, etc).
|
/// Set selection explicitly (for double-click word select, etc).
|
||||||
pub fn set_selection(&mut self, anchor_line: usize, anchor_col: usize, head_line: usize, head_col: usize) {
|
pub fn set_selection(
|
||||||
|
&mut self,
|
||||||
|
anchor_line: usize,
|
||||||
|
anchor_col: usize,
|
||||||
|
head_line: usize,
|
||||||
|
head_col: usize,
|
||||||
|
) {
|
||||||
self.selection = Some(Selection {
|
self.selection = Some(Selection {
|
||||||
anchor_line,
|
anchor_line,
|
||||||
anchor_col,
|
anchor_col,
|
||||||
@@ -176,7 +182,7 @@ impl EditorBuffer {
|
|||||||
|
|
||||||
/// Insert text at the current cursor position. Deletes selection first if any.
|
/// Insert text at the current cursor position. Deletes selection first if any.
|
||||||
pub fn insert(&mut self, text: &str) {
|
pub fn insert(&mut self, text: &str) {
|
||||||
if self.selection.is_some() && self.selection.as_ref().map_or(false, |s| !s.is_empty()) {
|
if self.selection.is_some() && self.selection.as_ref().is_some_and(|s| !s.is_empty()) {
|
||||||
self.delete_selection();
|
self.delete_selection();
|
||||||
}
|
}
|
||||||
let char_idx = self.cursor_char_idx();
|
let char_idx = self.cursor_char_idx();
|
||||||
@@ -198,7 +204,7 @@ impl EditorBuffer {
|
|||||||
|
|
||||||
/// Delete one character before the cursor (backspace).
|
/// Delete one character before the cursor (backspace).
|
||||||
pub fn backspace(&mut self) {
|
pub fn backspace(&mut self) {
|
||||||
if self.selection.as_ref().map_or(false, |s| !s.is_empty()) {
|
if self.selection.as_ref().is_some_and(|s| !s.is_empty()) {
|
||||||
self.delete_selection();
|
self.delete_selection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -229,7 +235,7 @@ impl EditorBuffer {
|
|||||||
|
|
||||||
/// Delete one character after the cursor (delete key).
|
/// Delete one character after the cursor (delete key).
|
||||||
pub fn delete_forward(&mut self) {
|
pub fn delete_forward(&mut self) {
|
||||||
if self.selection.as_ref().map_or(false, |s| !s.is_empty()) {
|
if self.selection.as_ref().is_some_and(|s| !s.is_empty()) {
|
||||||
self.delete_selection();
|
self.delete_selection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -497,7 +503,12 @@ impl EditorBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure cursor is visible using visual line index (for word-wrap aware scrolling).
|
/// Ensure cursor is visible using visual line index (for word-wrap aware scrolling).
|
||||||
pub fn ensure_visual_line_visible(&mut self, visual_line: usize, visible_lines: usize, max_visual: usize) {
|
pub fn ensure_visual_line_visible(
|
||||||
|
&mut self,
|
||||||
|
visual_line: usize,
|
||||||
|
visible_lines: usize,
|
||||||
|
max_visual: usize,
|
||||||
|
) {
|
||||||
if visible_lines == 0 {
|
if visible_lines == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ pub struct UndoHistory {
|
|||||||
last_group_boundary: usize,
|
last_group_boundary: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for UndoHistory {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl UndoHistory {
|
impl UndoHistory {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ pub mod history;
|
|||||||
mod widget;
|
mod widget;
|
||||||
|
|
||||||
pub use buffer::{EditorBuffer, Selection};
|
pub use buffer::{EditorBuffer, Selection};
|
||||||
pub use highlight::{highlighter, Highlighter};
|
pub use highlight::{Highlighter, highlighter};
|
||||||
pub use widget::{CodeEditor, EditorMessage, mono_metrics};
|
pub use widget::{CodeEditor, EditorMessage, mono_metrics};
|
||||||
+137
-61
@@ -62,12 +62,11 @@ pub fn mono_metrics() -> &'static MonoMetrics {
|
|||||||
let mut char_width = FONT_SIZE * 0.6; // fallback
|
let mut char_width = FONT_SIZE * 0.6; // fallback
|
||||||
let mut line_height = (FONT_SIZE * 1.43).ceil(); // fallback
|
let mut line_height = (FONT_SIZE * 1.43).ceil(); // fallback
|
||||||
|
|
||||||
for run in buffer.layout_runs() {
|
if let Some(run) = buffer.layout_runs().next() {
|
||||||
if let Some(glyph) = run.glyphs.first() {
|
if let Some(glyph) = run.glyphs.first() {
|
||||||
char_width = glyph.w;
|
char_width = glyph.w;
|
||||||
}
|
}
|
||||||
line_height = run.line_height;
|
line_height = run.line_height;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MonoMetrics {
|
MonoMetrics {
|
||||||
@@ -92,7 +91,7 @@ const SCROLLBAR_THUMB: Color = Color::from_rgba(0.50, 0.53, 0.60, 0.7);
|
|||||||
const SCROLLBAR_THUMB_HOVER: Color = Color::from_rgba(0.60, 0.63, 0.70, 0.9);
|
const SCROLLBAR_THUMB_HOVER: Color = Color::from_rgba(0.60, 0.63, 0.70, 0.9);
|
||||||
const MIN_THUMB_HEIGHT: f32 = 20.0;
|
const MIN_THUMB_HEIGHT: f32 = 20.0;
|
||||||
|
|
||||||
fn committed_text_input<'a>(text: Option<&'a str>, is_command_shortcut: bool) -> Option<&'a str> {
|
fn committed_text_input(text: Option<&str>, is_command_shortcut: bool) -> Option<&str> {
|
||||||
if is_command_shortcut {
|
if is_command_shortcut {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -156,7 +155,11 @@ fn line_text_for(buf: &EditorBuffer, line_idx: usize) -> String {
|
|||||||
buf.line(line_idx)
|
buf.line(line_idx)
|
||||||
.map(|l| {
|
.map(|l| {
|
||||||
let s: String = l.chars().collect();
|
let s: String = l.chars().collect();
|
||||||
if s.ends_with('\n') { s[..s.len() - 1].to_string() } else { s }
|
if s.ends_with('\n') {
|
||||||
|
s[..s.len() - 1].to_string()
|
||||||
|
} else {
|
||||||
|
s
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
@@ -186,7 +189,11 @@ fn logical_to_visual(buf: &EditorBuffer, line: usize, col: usize, max_chars: usi
|
|||||||
if idx == line {
|
if idx == line {
|
||||||
// Find which sub-line the column falls on
|
// Find which sub-line the column falls on
|
||||||
for (i, &start) in breaks.iter().enumerate() {
|
for (i, &start) in breaks.iter().enumerate() {
|
||||||
let end = if i + 1 < breaks.len() { breaks[i + 1] } else { usize::MAX };
|
let end = if i + 1 < breaks.len() {
|
||||||
|
breaks[i + 1]
|
||||||
|
} else {
|
||||||
|
usize::MAX
|
||||||
|
};
|
||||||
if (col >= start && col < end) || i + 1 == breaks.len() {
|
if (col >= start && col < end) || i + 1 == breaks.len() {
|
||||||
return vis + i;
|
return vis + i;
|
||||||
}
|
}
|
||||||
@@ -209,7 +216,11 @@ fn visual_to_logical(
|
|||||||
if max_chars == 0 {
|
if max_chars == 0 {
|
||||||
// No wrapping — direct 1:1 mapping
|
// No wrapping — direct 1:1 mapping
|
||||||
let line = scroll + visual_row;
|
let line = scroll + visual_row;
|
||||||
return if line < buf.line_count() { Some((line, 0)) } else { None };
|
return if line < buf.line_count() {
|
||||||
|
Some((line, 0))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
}
|
}
|
||||||
let target = scroll + visual_row;
|
let target = scroll + visual_row;
|
||||||
let mut vis_count = 0usize;
|
let mut vis_count = 0usize;
|
||||||
@@ -357,7 +368,11 @@ where
|
|||||||
let line_len = text.chars().count();
|
let line_len = text.chars().count();
|
||||||
|
|
||||||
for (w, &start) in breaks.iter().enumerate() {
|
for (w, &start) in breaks.iter().enumerate() {
|
||||||
let end = if w + 1 < breaks.len() { breaks[w + 1] } else { line_len };
|
let end = if w + 1 < breaks.len() {
|
||||||
|
breaks[w + 1]
|
||||||
|
} else {
|
||||||
|
line_len
|
||||||
|
};
|
||||||
if vis_skip < scroll {
|
if vis_skip < scroll {
|
||||||
vis_skip += 1;
|
vis_skip += 1;
|
||||||
} else if visual_rows.len() < visible_lines {
|
} else if visual_rows.len() < visible_lines {
|
||||||
@@ -370,27 +385,37 @@ where
|
|||||||
let text_x = bounds.x + GUTTER_WIDTH + 8.0;
|
let text_x = bounds.x + GUTTER_WIDTH + 8.0;
|
||||||
|
|
||||||
// Render visual rows
|
// Render visual rows
|
||||||
for (vis_idx, &(line_idx, char_start, char_end, is_first)) in visual_rows.iter().enumerate() {
|
for (vis_idx, &(line_idx, char_start, char_end, is_first)) in visual_rows.iter().enumerate()
|
||||||
|
{
|
||||||
let y = bounds.y + vis_idx as f32 * metrics.line_height;
|
let y = bounds.y + vis_idx as f32 * metrics.line_height;
|
||||||
if y + metrics.line_height < bounds.y || y > bounds.y + bounds.height {
|
if y + metrics.line_height < bounds.y || y > bounds.y + bounds.height {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw selection highlight for this visual line
|
// Draw selection highlight for this visual line
|
||||||
if let Some(sel) = selection {
|
if let Some(sel) = selection
|
||||||
if !sel.is_empty() {
|
&& !sel.is_empty()
|
||||||
|
{
|
||||||
let (start, end) = sel.ordered();
|
let (start, end) = sel.ordered();
|
||||||
let line_len = buf
|
let line_len = buf
|
||||||
.line(line_idx)
|
.line(line_idx)
|
||||||
.map(|l| {
|
.map(|l| {
|
||||||
let len = l.len_chars();
|
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);
|
.unwrap_or(0);
|
||||||
|
|
||||||
if line_idx >= start.0 && line_idx <= end.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_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
|
// Clip selection to current visual line range
|
||||||
let s = sel_start_col.max(char_start);
|
let s = sel_start_col.max(char_start);
|
||||||
@@ -400,7 +425,12 @@ where
|
|||||||
let sel_w = (e - s) as f32 * metrics.char_width;
|
let sel_w = (e - s) as f32 * metrics.char_width;
|
||||||
renderer.fill_quad(
|
renderer.fill_quad(
|
||||||
renderer::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(),
|
border: iced::Border::default(),
|
||||||
shadow: iced::Shadow::default(),
|
shadow: iced::Shadow::default(),
|
||||||
},
|
},
|
||||||
@@ -409,18 +439,23 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Line number (only on first visual line of each logical line)
|
// Line number (only on first visual line of each logical line)
|
||||||
if is_first {
|
if is_first {
|
||||||
let line_num = format!("{:>4}", line_idx + 1);
|
let line_num = format!("{:>4}", line_idx + 1);
|
||||||
let num_color = if line_idx == cursor_line { ACTIVE_LINE_NUM } else { GUTTER_TEXT };
|
let num_color = if line_idx == cursor_line {
|
||||||
|
ACTIVE_LINE_NUM
|
||||||
|
} else {
|
||||||
|
GUTTER_TEXT
|
||||||
|
};
|
||||||
renderer.fill_text(
|
renderer.fill_text(
|
||||||
text::Text {
|
text::Text {
|
||||||
content: line_num,
|
content: line_num,
|
||||||
bounds: Size::new(GUTTER_WIDTH - 8.0, metrics.line_height),
|
bounds: Size::new(GUTTER_WIDTH - 8.0, metrics.line_height),
|
||||||
size: Pixels(FONT_SIZE),
|
size: Pixels(FONT_SIZE),
|
||||||
line_height: iced::widget::text::LineHeight::Absolute(Pixels(metrics.line_height)),
|
line_height: iced::widget::text::LineHeight::Absolute(Pixels(
|
||||||
|
metrics.line_height,
|
||||||
|
)),
|
||||||
font,
|
font,
|
||||||
horizontal_alignment: iced::alignment::Horizontal::Right,
|
horizontal_alignment: iced::alignment::Horizontal::Right,
|
||||||
vertical_alignment: iced::alignment::Vertical::Top,
|
vertical_alignment: iced::alignment::Vertical::Top,
|
||||||
@@ -441,7 +476,9 @@ where
|
|||||||
for (style, span_text) in spans {
|
for (style, span_text) in spans {
|
||||||
let color = syntect_to_iced(style.foreground);
|
let color = syntect_to_iced(style.foreground);
|
||||||
for ch in span_text.chars() {
|
for ch in span_text.chars() {
|
||||||
if ch == '\n' { continue; }
|
if ch == '\n' {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
chars_with_color.push((ch, color));
|
chars_with_color.push((ch, color));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -463,14 +500,19 @@ where
|
|||||||
while span_end < slice.len() && slice[span_end].1 == color {
|
while span_end < slice.len() && slice[span_end].1 == color {
|
||||||
span_end += 1;
|
span_end += 1;
|
||||||
}
|
}
|
||||||
let display_text: String = slice[span_start..span_end].iter().map(|(c, _)| *c).collect();
|
let display_text: String = slice[span_start..span_end]
|
||||||
|
.iter()
|
||||||
|
.map(|(c, _)| *c)
|
||||||
|
.collect();
|
||||||
let span_width = display_text.len() as f32 * metrics.char_width;
|
let span_width = display_text.len() as f32 * metrics.char_width;
|
||||||
renderer.fill_text(
|
renderer.fill_text(
|
||||||
text::Text {
|
text::Text {
|
||||||
content: display_text,
|
content: display_text,
|
||||||
bounds: Size::new(span_width + metrics.char_width, metrics.line_height),
|
bounds: Size::new(span_width + metrics.char_width, metrics.line_height),
|
||||||
size: Pixels(FONT_SIZE),
|
size: Pixels(FONT_SIZE),
|
||||||
line_height: iced::widget::text::LineHeight::Absolute(Pixels(metrics.line_height)),
|
line_height: iced::widget::text::LineHeight::Absolute(Pixels(
|
||||||
|
metrics.line_height,
|
||||||
|
)),
|
||||||
font,
|
font,
|
||||||
horizontal_alignment: iced::alignment::Horizontal::Left,
|
horizontal_alignment: iced::alignment::Horizontal::Left,
|
||||||
vertical_alignment: iced::alignment::Vertical::Top,
|
vertical_alignment: iced::alignment::Vertical::Top,
|
||||||
@@ -499,7 +541,9 @@ where
|
|||||||
content: display_text.to_string(),
|
content: display_text.to_string(),
|
||||||
bounds: Size::new(text_area_width, metrics.line_height),
|
bounds: Size::new(text_area_width, metrics.line_height),
|
||||||
size: Pixels(FONT_SIZE),
|
size: Pixels(FONT_SIZE),
|
||||||
line_height: iced::widget::text::LineHeight::Absolute(Pixels(metrics.line_height)),
|
line_height: iced::widget::text::LineHeight::Absolute(Pixels(
|
||||||
|
metrics.line_height,
|
||||||
|
)),
|
||||||
font,
|
font,
|
||||||
horizontal_alignment: iced::alignment::Horizontal::Left,
|
horizontal_alignment: iced::alignment::Horizontal::Left,
|
||||||
vertical_alignment: iced::alignment::Vertical::Top,
|
vertical_alignment: iced::alignment::Vertical::Top,
|
||||||
@@ -520,7 +564,10 @@ where
|
|||||||
renderer.fill_quad(
|
renderer.fill_quad(
|
||||||
renderer::Quad {
|
renderer::Quad {
|
||||||
bounds: Rectangle {
|
bounds: Rectangle {
|
||||||
x: cursor_x, y, width: 2.0, height: metrics.line_height,
|
x: cursor_x,
|
||||||
|
y,
|
||||||
|
width: 2.0,
|
||||||
|
height: metrics.line_height,
|
||||||
},
|
},
|
||||||
border: iced::Border::default(),
|
border: iced::Border::default(),
|
||||||
shadow: iced::Shadow::default(),
|
shadow: iced::Shadow::default(),
|
||||||
@@ -529,12 +576,17 @@ where
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Also draw cursor at end of last visual line segment
|
// Also draw cursor at end of last visual line segment
|
||||||
if cursor_col == char_end && char_end == line_text_for(&buf, line_idx).chars().count() {
|
if cursor_col == char_end
|
||||||
|
&& char_end == line_text_for(&buf, line_idx).chars().count()
|
||||||
|
{
|
||||||
let cursor_x = text_x + (char_end - char_start) as f32 * metrics.char_width;
|
let cursor_x = text_x + (char_end - char_start) as f32 * metrics.char_width;
|
||||||
renderer.fill_quad(
|
renderer.fill_quad(
|
||||||
renderer::Quad {
|
renderer::Quad {
|
||||||
bounds: Rectangle {
|
bounds: Rectangle {
|
||||||
x: cursor_x, y, width: 2.0, height: metrics.line_height,
|
x: cursor_x,
|
||||||
|
y,
|
||||||
|
width: 2.0,
|
||||||
|
height: metrics.line_height,
|
||||||
},
|
},
|
||||||
border: iced::Border::default(),
|
border: iced::Border::default(),
|
||||||
shadow: iced::Shadow::default(),
|
shadow: iced::Shadow::default(),
|
||||||
@@ -553,14 +605,23 @@ where
|
|||||||
let thumb_ratio = viewport_lines as f32 / total_vis as f32;
|
let thumb_ratio = viewport_lines as f32 / total_vis as f32;
|
||||||
let thumb_height = (track_height * thumb_ratio).max(MIN_THUMB_HEIGHT);
|
let thumb_height = (track_height * thumb_ratio).max(MIN_THUMB_HEIGHT);
|
||||||
let max_scroll = total_vis.saturating_sub(viewport_lines);
|
let max_scroll = total_vis.saturating_sub(viewport_lines);
|
||||||
let scroll_ratio = if max_scroll > 0 { scroll as f32 / max_scroll as f32 } else { 0.0 };
|
let scroll_ratio = if max_scroll > 0 {
|
||||||
|
scroll as f32 / max_scroll as f32
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
let thumb_y = bounds.y + scroll_ratio * (track_height - thumb_height);
|
let thumb_y = bounds.y + scroll_ratio * (track_height - thumb_height);
|
||||||
let track_x = bounds.x + bounds.width - SCROLLBAR_WIDTH;
|
let track_x = bounds.x + bounds.width - SCROLLBAR_WIDTH;
|
||||||
|
|
||||||
// Track
|
// Track
|
||||||
renderer.fill_quad(
|
renderer.fill_quad(
|
||||||
renderer::Quad {
|
renderer::Quad {
|
||||||
bounds: Rectangle { x: track_x, y: bounds.y, width: SCROLLBAR_WIDTH, height: track_height },
|
bounds: Rectangle {
|
||||||
|
x: track_x,
|
||||||
|
y: bounds.y,
|
||||||
|
width: SCROLLBAR_WIDTH,
|
||||||
|
height: track_height,
|
||||||
|
},
|
||||||
border: iced::Border::default(),
|
border: iced::Border::default(),
|
||||||
shadow: iced::Shadow::default(),
|
shadow: iced::Shadow::default(),
|
||||||
},
|
},
|
||||||
@@ -582,7 +643,11 @@ where
|
|||||||
},
|
},
|
||||||
shadow: iced::Shadow::default(),
|
shadow: iced::Shadow::default(),
|
||||||
},
|
},
|
||||||
if is_hover { SCROLLBAR_THUMB_HOVER } else { SCROLLBAR_THUMB },
|
if is_hover {
|
||||||
|
SCROLLBAR_THUMB_HOVER
|
||||||
|
} else {
|
||||||
|
SCROLLBAR_THUMB
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -629,7 +694,11 @@ where
|
|||||||
if let Some(pos) = cursor.position() {
|
if let Some(pos) = cursor.position() {
|
||||||
let local_y = pos.y - bounds.y - grab_offset;
|
let local_y = pos.y - bounds.y - grab_offset;
|
||||||
let scroll_range = track_height - thumb_height;
|
let scroll_range = track_height - thumb_height;
|
||||||
let ratio = if scroll_range > 0.0 { (local_y / scroll_range).clamp(0.0, 1.0) } else { 0.0 };
|
let ratio = if scroll_range > 0.0 {
|
||||||
|
(local_y / scroll_range).clamp(0.0, 1.0)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
let new_scroll = (ratio * max_scroll as f32).round() as usize;
|
let new_scroll = (ratio * max_scroll as f32).round() as usize;
|
||||||
self.buffer.borrow_mut().set_scroll(new_scroll, max_scroll);
|
self.buffer.borrow_mut().set_scroll(new_scroll, max_scroll);
|
||||||
}
|
}
|
||||||
@@ -644,13 +713,19 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start scrollbar drag
|
// Start scrollbar drag
|
||||||
if let Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) = event {
|
if let Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) = event
|
||||||
if let Some(pos) = cursor.position() {
|
&& let Some(pos) = cursor.position()
|
||||||
if pos.x >= track_x && pos.x <= bounds.x + bounds.width
|
&& pos.x >= track_x
|
||||||
&& pos.y >= bounds.y && pos.y <= bounds.y + bounds.height
|
&& pos.x <= bounds.x + bounds.width
|
||||||
|
&& pos.y >= bounds.y
|
||||||
|
&& pos.y <= bounds.y + bounds.height
|
||||||
{
|
{
|
||||||
let current_scroll = self.buffer.borrow().scroll_offset();
|
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);
|
let thumb_y = bounds.y + scroll_ratio * (track_height - thumb_height);
|
||||||
|
|
||||||
if pos.y >= thumb_y && pos.y <= thumb_y + thumb_height {
|
if pos.y >= thumb_y && pos.y <= thumb_y + thumb_height {
|
||||||
@@ -667,8 +742,6 @@ where
|
|||||||
return Status::Captured;
|
return Status::Captured;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match event {
|
match event {
|
||||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||||
@@ -679,8 +752,10 @@ where
|
|||||||
if let Some(pos) = cursor.position_in(bounds) {
|
if let Some(pos) = cursor.position_in(bounds) {
|
||||||
let mut buf = self.buffer.borrow_mut();
|
let mut buf = self.buffer.borrow_mut();
|
||||||
let vis_row = (pos.y / metrics.line_height) as usize;
|
let vis_row = (pos.y / metrics.line_height) as usize;
|
||||||
let raw_col = ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize;
|
let raw_col =
|
||||||
let (line, char_off) = visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl)
|
((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize;
|
||||||
|
let (line, char_off) =
|
||||||
|
visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl)
|
||||||
.unwrap_or((buf.line_count().saturating_sub(1), 0));
|
.unwrap_or((buf.line_count().saturating_sub(1), 0));
|
||||||
let col = char_off + raw_col;
|
let col = char_off + raw_col;
|
||||||
|
|
||||||
@@ -717,16 +792,17 @@ where
|
|||||||
if let Some(pos) = cursor.position_in(bounds) {
|
if let Some(pos) = cursor.position_in(bounds) {
|
||||||
let mut buf = self.buffer.borrow_mut();
|
let mut buf = self.buffer.borrow_mut();
|
||||||
let vis_row = (pos.y / metrics.line_height) as usize;
|
let vis_row = (pos.y / metrics.line_height) as usize;
|
||||||
let raw_col = ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize;
|
let raw_col =
|
||||||
let (line, char_off) = visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl)
|
((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize;
|
||||||
|
let (line, char_off) =
|
||||||
|
visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl)
|
||||||
.unwrap_or((buf.line_count().saturating_sub(1), 0));
|
.unwrap_or((buf.line_count().saturating_sub(1), 0));
|
||||||
let col = char_off + raw_col;
|
let col = char_off + raw_col;
|
||||||
// Extend selection by simulating shift+movement
|
// Extend selection by simulating shift+movement
|
||||||
let clamped_line = line.min(buf.line_count().saturating_sub(1));
|
let clamped_line = line.min(buf.line_count().saturating_sub(1));
|
||||||
let clamped_col = if clamped_line < buf.line_count() {
|
let clamped_col = if clamped_line < buf.line_count() {
|
||||||
col.min(
|
col.min(
|
||||||
buf
|
buf.line(clamped_line)
|
||||||
.line(clamped_line)
|
|
||||||
.map(|l| {
|
.map(|l| {
|
||||||
let len = l.len_chars();
|
let len = l.len_chars();
|
||||||
if len > 0 && l.char(len - 1) == '\n' {
|
if len > 0 && l.char(len - 1) == '\n' {
|
||||||
@@ -748,12 +824,7 @@ where
|
|||||||
.selection()
|
.selection()
|
||||||
.map(|s| s.anchor_col)
|
.map(|s| s.anchor_col)
|
||||||
.unwrap_or(buf.cursor().1);
|
.unwrap_or(buf.cursor().1);
|
||||||
buf.set_selection(
|
buf.set_selection(anchor_line, anchor_col, clamped_line, clamped_col);
|
||||||
anchor_line,
|
|
||||||
anchor_col,
|
|
||||||
clamped_line,
|
|
||||||
clamped_col,
|
|
||||||
);
|
|
||||||
buf.set_cursor(clamped_line, clamped_col);
|
buf.set_cursor(clamped_line, clamped_col);
|
||||||
}
|
}
|
||||||
return Status::Captured;
|
return Status::Captured;
|
||||||
@@ -762,9 +833,7 @@ where
|
|||||||
Event::Mouse(mouse::Event::WheelScrolled { delta }) if cursor.is_over(bounds) => {
|
Event::Mouse(mouse::Event::WheelScrolled { delta }) if cursor.is_over(bounds) => {
|
||||||
let lines = match delta {
|
let lines = match delta {
|
||||||
mouse::ScrollDelta::Lines { y, .. } => -(y * 3.0) as isize,
|
mouse::ScrollDelta::Lines { y, .. } => -(y * 3.0) as isize,
|
||||||
mouse::ScrollDelta::Pixels { y, .. } => {
|
mouse::ScrollDelta::Pixels { y, .. } => -(y / metrics.line_height) as isize,
|
||||||
-(y / metrics.line_height) as isize
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let mut buf = self.buffer.borrow_mut();
|
let mut buf = self.buffer.borrow_mut();
|
||||||
let vis = (bounds.height / metrics.line_height) as usize;
|
let vis = (bounds.height / metrics.line_height) as usize;
|
||||||
@@ -774,7 +843,10 @@ where
|
|||||||
return Status::Captured;
|
return Status::Captured;
|
||||||
}
|
}
|
||||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||||
key, modifiers, text, ..
|
key,
|
||||||
|
modifiers,
|
||||||
|
text,
|
||||||
|
..
|
||||||
}) if state.is_focused => {
|
}) if state.is_focused => {
|
||||||
let vis = (bounds.height / metrics.line_height) as usize;
|
let vis = (bounds.height / metrics.line_height) as usize;
|
||||||
let is_cmd = modifiers.command();
|
let is_cmd = modifiers.command();
|
||||||
@@ -799,7 +871,11 @@ where
|
|||||||
keyboard::Key::Character(ref c) if is_cmd && c.as_str() == "z" => {
|
keyboard::Key::Character(ref c) if is_cmd && c.as_str() == "z" => {
|
||||||
{
|
{
|
||||||
let mut buf = self.buffer.borrow_mut();
|
let mut buf = self.buffer.borrow_mut();
|
||||||
if is_shift { buf.redo(); } else { buf.undo(); }
|
if is_shift {
|
||||||
|
buf.redo();
|
||||||
|
} else {
|
||||||
|
buf.undo();
|
||||||
|
}
|
||||||
ensure_vis!(buf);
|
ensure_vis!(buf);
|
||||||
}
|
}
|
||||||
self.emit_change(shell);
|
self.emit_change(shell);
|
||||||
@@ -994,6 +1070,16 @@ impl<'a, Message> CodeEditor<'a, Message> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'a, Message, Renderer> From<CodeEditor<'a, Message>> for Element<'a, Message, Theme, Renderer>
|
||||||
|
where
|
||||||
|
Renderer: renderer::Renderer + text::Renderer<Font = iced::Font> + 'a,
|
||||||
|
Message: 'a,
|
||||||
|
{
|
||||||
|
fn from(editor: CodeEditor<'a, Message>) -> Self {
|
||||||
|
Self::new(editor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::committed_text_input;
|
use super::committed_text_input;
|
||||||
@@ -1013,13 +1099,3 @@ mod tests {
|
|||||||
assert_eq!(committed_text_input(None, false), None);
|
assert_eq!(committed_text_input(None, false), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, Message, Renderer> From<CodeEditor<'a, Message>> for Element<'a, Message, Theme, Renderer>
|
|
||||||
where
|
|
||||||
Renderer: renderer::Renderer + text::Renderer<Font = iced::Font> + 'a,
|
|
||||||
Message: 'a,
|
|
||||||
{
|
|
||||||
fn from(editor: CodeEditor<'a, Message>) -> Self {
|
|
||||||
Self::new(editor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2619
-1661
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::text::Shaping;
|
||||||
|
use iced::widget::{Space, button, checkbox, column, container, pick_list, row, text, text_input};
|
||||||
use iced::{Alignment, Background, Color, Element, Length, Theme};
|
use iced::{Alignment, Background, Color, Element, Length, Theme};
|
||||||
|
|
||||||
/// Standard form field label color.
|
/// Standard form field label color.
|
||||||
@@ -19,7 +19,10 @@ pub fn labeled_input<'a, Message: Clone + 'a>(
|
|||||||
on_change: impl Fn(String) -> Message + 'a,
|
on_change: impl Fn(String) -> Message + 'a,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
column![
|
column![
|
||||||
text(label.to_string()).size(12).color(LABEL_COLOR).shaping(Shaping::Advanced),
|
text(label.to_string())
|
||||||
|
.size(12)
|
||||||
|
.color(LABEL_COLOR)
|
||||||
|
.shaping(Shaping::Advanced),
|
||||||
text_input(placeholder, value).on_input(on_change).size(14),
|
text_input(placeholder, value).on_input(on_change).size(14),
|
||||||
]
|
]
|
||||||
.spacing(4)
|
.spacing(4)
|
||||||
@@ -39,7 +42,10 @@ where
|
|||||||
{
|
{
|
||||||
let list: Vec<T> = options.to_vec();
|
let list: Vec<T> = options.to_vec();
|
||||||
column![
|
column![
|
||||||
text(label.to_string()).size(12).color(LABEL_COLOR).shaping(Shaping::Advanced),
|
text(label.to_string())
|
||||||
|
.size(12)
|
||||||
|
.color(LABEL_COLOR)
|
||||||
|
.shaping(Shaping::Advanced),
|
||||||
pick_list(list, selected.cloned(), on_select),
|
pick_list(list, selected.cloned(), on_select),
|
||||||
]
|
]
|
||||||
.spacing(4)
|
.spacing(4)
|
||||||
@@ -157,8 +163,14 @@ pub fn toolbar<'a, Message: 'a>(
|
|||||||
pub fn date_label<'a, Message: 'a>(label: &str, timestamp_ms: i64) -> Element<'a, Message> {
|
pub fn date_label<'a, Message: 'a>(label: &str, timestamp_ms: i64) -> Element<'a, Message> {
|
||||||
let date_str = format_timestamp(timestamp_ms);
|
let date_str = format_timestamp(timestamp_ms);
|
||||||
row![
|
row![
|
||||||
text(label.to_string()).size(12).color(LABEL_COLOR).shaping(Shaping::Advanced),
|
text(label.to_string())
|
||||||
text(date_str).size(12).color(Color::from_rgb(0.55, 0.58, 0.65)).shaping(Shaping::Advanced),
|
.size(12)
|
||||||
|
.color(LABEL_COLOR)
|
||||||
|
.shaping(Shaping::Advanced),
|
||||||
|
text(date_str)
|
||||||
|
.size(12)
|
||||||
|
.color(Color::from_rgb(0.55, 0.58, 0.65))
|
||||||
|
.shaping(Shaping::Advanced),
|
||||||
]
|
]
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
.into()
|
.into()
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ use std::collections::HashMap;
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
use iced::event;
|
|
||||||
use iced::advanced::layout::{self, Node};
|
use iced::advanced::layout::{self, Node};
|
||||||
use iced::advanced::renderer;
|
use iced::advanced::renderer;
|
||||||
use iced::advanced::widget::Tree;
|
use iced::advanced::widget::Tree;
|
||||||
use iced::advanced::{Clipboard, Layout, Shell, Widget};
|
use iced::advanced::{Clipboard, Layout, Shell, Widget};
|
||||||
|
use iced::event;
|
||||||
use iced::mouse;
|
use iced::mouse;
|
||||||
use iced::{Element, Event, Length, Rectangle, Size, Task, window};
|
use iced::{Element, Event, Length, Rectangle, Size, Task, window};
|
||||||
use wry::dpi::{LogicalPosition, LogicalSize};
|
use wry::dpi::{LogicalPosition, LogicalSize};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use bds_core::i18n::{translate, translate_with, UiLocale};
|
use bds_core::i18n::{UiLocale, translate, translate_with};
|
||||||
|
|
||||||
/// Shorthand for translate in view code.
|
/// Shorthand for translate in view code.
|
||||||
pub fn t(locale: UiLocale, key: &str) -> String {
|
pub fn t(locale: UiLocale, key: &str) -> String {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::sync::mpsc;
|
|||||||
|
|
||||||
use objc2::rc::Retained;
|
use objc2::rc::Retained;
|
||||||
use objc2::runtime::ProtocolObject;
|
use objc2::runtime::ProtocolObject;
|
||||||
use objc2::{define_class, msg_send, DefinedClass, MainThreadMarker, MainThreadOnly};
|
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send};
|
||||||
use objc2_app_kit::{NSApplication, NSApplicationDelegate};
|
use objc2_app_kit::{NSApplication, NSApplicationDelegate};
|
||||||
use objc2_foundation::{NSArray, NSNotification, NSObject, NSObjectProtocol, NSString, NSURL};
|
use objc2_foundation::{NSArray, NSNotification, NSObject, NSObjectProtocol, NSString, NSURL};
|
||||||
|
|
||||||
@@ -30,8 +30,11 @@ define_class!(
|
|||||||
#[ivars = DelegateIvars]
|
#[ivars = DelegateIvars]
|
||||||
pub struct BdsAppDelegate;
|
pub struct BdsAppDelegate;
|
||||||
|
|
||||||
|
// SAFETY: NSObjectProtocol declares no additional safety requirements.
|
||||||
unsafe impl NSObjectProtocol for BdsAppDelegate {}
|
unsafe impl NSObjectProtocol for BdsAppDelegate {}
|
||||||
|
|
||||||
|
// SAFETY: Every exported selector below uses the exact AppKit delegate signature and the
|
||||||
|
// class is main-thread-only, as required by NSApplicationDelegate.
|
||||||
unsafe impl NSApplicationDelegate for BdsAppDelegate {
|
unsafe impl NSApplicationDelegate for BdsAppDelegate {
|
||||||
#[unsafe(method(applicationDidFinishLaunching:))]
|
#[unsafe(method(applicationDidFinishLaunching:))]
|
||||||
fn did_finish_launching(&self, _notification: &NSNotification) {
|
fn did_finish_launching(&self, _notification: &NSNotification) {
|
||||||
@@ -49,7 +52,10 @@ define_class!(
|
|||||||
fn application_open_urls(&self, _sender: &NSApplication, urls: &NSArray<NSURL>) {
|
fn application_open_urls(&self, _sender: &NSApplication, urls: &NSArray<NSURL>) {
|
||||||
for i in 0..urls.len() {
|
for i in 0..urls.len() {
|
||||||
if let Some(url) = urls.objectAtIndex(i).absoluteString() {
|
if let Some(url) = urls.objectAtIndex(i).absoluteString() {
|
||||||
let _ = self.ivars().tx.send(LifecycleEvent::UrlOpen(url.to_string()));
|
let _ = self
|
||||||
|
.ivars()
|
||||||
|
.tx
|
||||||
|
.send(LifecycleEvent::UrlOpen(url.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,6 +66,8 @@ impl BdsAppDelegate {
|
|||||||
fn new(mtm: MainThreadMarker, tx: mpsc::Sender<LifecycleEvent>) -> Retained<Self> {
|
fn new(mtm: MainThreadMarker, tx: mpsc::Sender<LifecycleEvent>) -> Retained<Self> {
|
||||||
let this = Self::alloc(mtm);
|
let this = Self::alloc(mtm);
|
||||||
let this = this.set_ivars(DelegateIvars { tx });
|
let this = this.set_ivars(DelegateIvars { tx });
|
||||||
|
// SAFETY: `this` has initialized ivars and NSObject's `init` accepts ownership of the
|
||||||
|
// allocated receiver, returning the retained initialized object.
|
||||||
unsafe { msg_send![super(this), init] }
|
unsafe { msg_send![super(this), init] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,9 +94,7 @@ pub fn install_delegate(tx: mpsc::Sender<LifecycleEvent>) -> Option<Retained<Bds
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Poll the macOS lifecycle receiver for the next event and map to a Message.
|
/// Poll the macOS lifecycle receiver for the next event and map to a Message.
|
||||||
pub fn poll_lifecycle(
|
pub fn poll_lifecycle(receiver: &mpsc::Receiver<LifecycleEvent>) -> Option<Message> {
|
||||||
receiver: &mpsc::Receiver<LifecycleEvent>,
|
|
||||||
) -> Option<Message> {
|
|
||||||
match receiver.try_recv() {
|
match receiver.try_recv() {
|
||||||
Ok(LifecycleEvent::FileOpen(path)) => Some(Message::FileOpenRequested(path)),
|
Ok(LifecycleEvent::FileOpen(path)) => Some(Message::FileOpenRequested(path)),
|
||||||
Ok(LifecycleEvent::UrlOpen(url)) => Some(Message::UrlOpenRequested(url)),
|
Ok(LifecycleEvent::UrlOpen(url)) => Some(Message::UrlOpenRequested(url)),
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use iced::Subscription;
|
use iced::Subscription;
|
||||||
use muda::accelerator::{Accelerator, Code, Modifiers, CMD_OR_CTRL};
|
use muda::accelerator::{Accelerator, CMD_OR_CTRL, Code, Modifiers};
|
||||||
use muda::{Menu, MenuEvent, MenuItem, MenuId, PredefinedMenuItem, Submenu};
|
use muda::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu};
|
||||||
|
|
||||||
use bds_core::i18n::{translate, UiLocale};
|
|
||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
|
use bds_core::i18n::{UiLocale, translate};
|
||||||
|
|
||||||
/// Every custom menu item that the application handles.
|
/// Every custom menu item that the application handles.
|
||||||
///
|
///
|
||||||
@@ -210,12 +210,24 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
|
|||||||
|
|
||||||
// -- File --
|
// -- File --
|
||||||
let file_menu = Submenu::new(translate(locale, "menu.group.file"), true);
|
let file_menu = Submenu::new(translate(locale, "menu.group.file"), true);
|
||||||
let _ = file_menu.append(&item(&mut reg, MenuAction::NewPost, locale,
|
let _ = file_menu.append(&item(
|
||||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyN))));
|
&mut reg,
|
||||||
let _ = file_menu.append(&item(&mut reg, MenuAction::ImportMedia, locale,
|
MenuAction::NewPost,
|
||||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyI))));
|
locale,
|
||||||
let _ = file_menu.append(&item(&mut reg, MenuAction::Save, locale,
|
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyN)),
|
||||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyS))));
|
));
|
||||||
|
let _ = file_menu.append(&item(
|
||||||
|
&mut reg,
|
||||||
|
MenuAction::ImportMedia,
|
||||||
|
locale,
|
||||||
|
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyI)),
|
||||||
|
));
|
||||||
|
let _ = file_menu.append(&item(
|
||||||
|
&mut reg,
|
||||||
|
MenuAction::Save,
|
||||||
|
locale,
|
||||||
|
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyS)),
|
||||||
|
));
|
||||||
let _ = file_menu.append(&PredefinedMenuItem::separator());
|
let _ = file_menu.append(&PredefinedMenuItem::separator());
|
||||||
let _ = file_menu.append(&item(&mut reg, MenuAction::OpenInBrowser, locale, None));
|
let _ = file_menu.append(&item(&mut reg, MenuAction::OpenInBrowser, locale, None));
|
||||||
let _ = file_menu.append(&item(&mut reg, MenuAction::OpenDataFolder, locale, None));
|
let _ = file_menu.append(&item(&mut reg, MenuAction::OpenDataFolder, locale, None));
|
||||||
@@ -232,34 +244,76 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
|
|||||||
let _ = edit_menu.append(&PredefinedMenuItem::paste(None));
|
let _ = edit_menu.append(&PredefinedMenuItem::paste(None));
|
||||||
let _ = edit_menu.append(&PredefinedMenuItem::select_all(None));
|
let _ = edit_menu.append(&PredefinedMenuItem::select_all(None));
|
||||||
let _ = edit_menu.append(&PredefinedMenuItem::separator());
|
let _ = edit_menu.append(&PredefinedMenuItem::separator());
|
||||||
let _ = edit_menu.append(&item(&mut reg, MenuAction::Find, locale,
|
let _ = edit_menu.append(&item(
|
||||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyF))));
|
&mut reg,
|
||||||
let _ = edit_menu.append(&item(&mut reg, MenuAction::Replace, locale,
|
MenuAction::Find,
|
||||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyH))));
|
locale,
|
||||||
|
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyF)),
|
||||||
|
));
|
||||||
|
let _ = edit_menu.append(&item(
|
||||||
|
&mut reg,
|
||||||
|
MenuAction::Replace,
|
||||||
|
locale,
|
||||||
|
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyH)),
|
||||||
|
));
|
||||||
let _ = edit_menu.append(&PredefinedMenuItem::separator());
|
let _ = edit_menu.append(&PredefinedMenuItem::separator());
|
||||||
let _ = edit_menu.append(&item(&mut reg, MenuAction::EditPreferences, locale,
|
let _ = edit_menu.append(&item(
|
||||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Comma))));
|
&mut reg,
|
||||||
|
MenuAction::EditPreferences,
|
||||||
|
locale,
|
||||||
|
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Comma)),
|
||||||
|
));
|
||||||
|
|
||||||
// -- View --
|
// -- View --
|
||||||
let view_menu = Submenu::new(translate(locale, "menu.group.view"), true);
|
let view_menu = Submenu::new(translate(locale, "menu.group.view"), true);
|
||||||
let _ = view_menu.append(&item(&mut reg, MenuAction::ViewPosts, locale,
|
let _ = view_menu.append(&item(
|
||||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Digit1))));
|
&mut reg,
|
||||||
let _ = view_menu.append(&item(&mut reg, MenuAction::ViewMedia, locale,
|
MenuAction::ViewPosts,
|
||||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Digit2))));
|
locale,
|
||||||
|
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Digit1)),
|
||||||
|
));
|
||||||
|
let _ = view_menu.append(&item(
|
||||||
|
&mut reg,
|
||||||
|
MenuAction::ViewMedia,
|
||||||
|
locale,
|
||||||
|
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Digit2)),
|
||||||
|
));
|
||||||
let _ = view_menu.append(&PredefinedMenuItem::separator());
|
let _ = view_menu.append(&PredefinedMenuItem::separator());
|
||||||
let _ = view_menu.append(&item(&mut reg, MenuAction::ToggleSidebar, locale,
|
let _ = view_menu.append(&item(
|
||||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyB))));
|
&mut reg,
|
||||||
let _ = view_menu.append(&item(&mut reg, MenuAction::TogglePanel, locale,
|
MenuAction::ToggleSidebar,
|
||||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyJ))));
|
locale,
|
||||||
|
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyB)),
|
||||||
|
));
|
||||||
|
let _ = view_menu.append(&item(
|
||||||
|
&mut reg,
|
||||||
|
MenuAction::TogglePanel,
|
||||||
|
locale,
|
||||||
|
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyJ)),
|
||||||
|
));
|
||||||
let _ = view_menu.append(&PredefinedMenuItem::separator());
|
let _ = view_menu.append(&PredefinedMenuItem::separator());
|
||||||
let _ = view_menu.append(&PredefinedMenuItem::fullscreen(None));
|
let _ = view_menu.append(&PredefinedMenuItem::fullscreen(None));
|
||||||
|
|
||||||
// -- Blog --
|
// -- Blog --
|
||||||
let blog_menu = Submenu::new(translate(locale, "menu.group.blog"), true);
|
let blog_menu = Submenu::new(translate(locale, "menu.group.blog"), true);
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::PublishSelected, locale,
|
let _ = blog_menu.append(&item(
|
||||||
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyP))));
|
&mut reg,
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::PreviewPost, locale,
|
MenuAction::PublishSelected,
|
||||||
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyV))));
|
locale,
|
||||||
|
Some(Accelerator::new(
|
||||||
|
Some(CMD_OR_CTRL | Modifiers::SHIFT),
|
||||||
|
Code::KeyP,
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
let _ = blog_menu.append(&item(
|
||||||
|
&mut reg,
|
||||||
|
MenuAction::PreviewPost,
|
||||||
|
locale,
|
||||||
|
Some(Accelerator::new(
|
||||||
|
Some(CMD_OR_CTRL | Modifiers::SHIFT),
|
||||||
|
Code::KeyV,
|
||||||
|
)),
|
||||||
|
));
|
||||||
let _ = blog_menu.append(&PredefinedMenuItem::separator());
|
let _ = blog_menu.append(&PredefinedMenuItem::separator());
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::EditMenu, locale, None));
|
let _ = blog_menu.append(&item(&mut reg, MenuAction::EditMenu, locale, None));
|
||||||
let _ = blog_menu.append(&PredefinedMenuItem::separator());
|
let _ = blog_menu.append(&PredefinedMenuItem::separator());
|
||||||
@@ -267,16 +321,49 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
|
|||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::ReindexText, locale, None));
|
let _ = blog_menu.append(&item(&mut reg, MenuAction::ReindexText, locale, None));
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::MetadataDiff, locale, None));
|
let _ = blog_menu.append(&item(&mut reg, MenuAction::MetadataDiff, locale, None));
|
||||||
let _ = blog_menu.append(&PredefinedMenuItem::separator());
|
let _ = blog_menu.append(&PredefinedMenuItem::separator());
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::RegenerateCalendar, locale, None));
|
let _ = blog_menu.append(&item(
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::ValidateTranslations, locale, None));
|
&mut reg,
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::FillMissingTranslations, locale, None));
|
MenuAction::RegenerateCalendar,
|
||||||
|
locale,
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
let _ = blog_menu.append(&item(
|
||||||
|
&mut reg,
|
||||||
|
MenuAction::ValidateTranslations,
|
||||||
|
locale,
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
let _ = blog_menu.append(&item(
|
||||||
|
&mut reg,
|
||||||
|
MenuAction::FillMissingTranslations,
|
||||||
|
locale,
|
||||||
|
None,
|
||||||
|
));
|
||||||
let _ = blog_menu.append(&PredefinedMenuItem::separator());
|
let _ = blog_menu.append(&PredefinedMenuItem::separator());
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::GenerateSitemap, locale,
|
let _ = blog_menu.append(&item(
|
||||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyR))));
|
&mut reg,
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::ValidateSite, locale,
|
MenuAction::GenerateSitemap,
|
||||||
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyL))));
|
locale,
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::UploadSite, locale,
|
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyR)),
|
||||||
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyU))));
|
));
|
||||||
|
let _ = blog_menu.append(&item(
|
||||||
|
&mut reg,
|
||||||
|
MenuAction::ValidateSite,
|
||||||
|
locale,
|
||||||
|
Some(Accelerator::new(
|
||||||
|
Some(CMD_OR_CTRL | Modifiers::SHIFT),
|
||||||
|
Code::KeyL,
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
let _ = blog_menu.append(&item(
|
||||||
|
&mut reg,
|
||||||
|
MenuAction::UploadSite,
|
||||||
|
locale,
|
||||||
|
Some(Accelerator::new(
|
||||||
|
Some(CMD_OR_CTRL | Modifiers::SHIFT),
|
||||||
|
Code::KeyU,
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
|
||||||
// -- Help --
|
// -- Help --
|
||||||
let help_menu = Submenu::new(translate(locale, "menu.group.help"), true);
|
let help_menu = Submenu::new(translate(locale, "menu.group.help"), true);
|
||||||
@@ -303,7 +390,7 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
|
|||||||
/// init task or first update), not during `build_menu_bar`.
|
/// init task or first update), not during `build_menu_bar`.
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
pub fn init_menu_for_nsapp(menu: &Menu) {
|
pub fn init_menu_for_nsapp(menu: &Menu) {
|
||||||
let _ = menu.init_for_nsapp();
|
menu.init_for_nsapp();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-translate every registered menu item for a new locale.
|
/// Re-translate every registered menu item for a new locale.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
pub mod menu;
|
|
||||||
pub mod dialog;
|
pub mod dialog;
|
||||||
|
pub mod menu;
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
pub mod macos;
|
pub mod macos;
|
||||||
@@ -3,7 +3,7 @@ pub mod sidebar_filter;
|
|||||||
pub mod tabs;
|
pub mod tabs;
|
||||||
pub mod toast;
|
pub mod toast;
|
||||||
|
|
||||||
pub use navigation::{SidebarView, PanelTab, TaskSnapshot, OutputEntry};
|
pub use navigation::{OutputEntry, PanelTab, SidebarView, TaskSnapshot};
|
||||||
pub use sidebar_filter::{PostFilter, MediaFilter, CalendarFilter, CalendarYear, CalendarMonth};
|
pub use sidebar_filter::{CalendarFilter, CalendarMonth, CalendarYear, MediaFilter, PostFilter};
|
||||||
pub use tabs::{Tab, TabType};
|
pub use tabs::{Tab, TabType};
|
||||||
pub use toast::{Toast, ToastLevel};
|
pub use toast::{Toast, ToastLevel};
|
||||||
@@ -92,24 +92,21 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn toggle_off_when_same_and_visible() {
|
fn toggle_off_when_same_and_visible() {
|
||||||
let (view, visible) =
|
let (view, visible) = handle_activity_click(SidebarView::Posts, true, SidebarView::Posts);
|
||||||
handle_activity_click(SidebarView::Posts, true, SidebarView::Posts);
|
|
||||||
assert_eq!(view, SidebarView::Posts);
|
assert_eq!(view, SidebarView::Posts);
|
||||||
assert!(!visible);
|
assert!(!visible);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn toggle_on_when_same_and_hidden() {
|
fn toggle_on_when_same_and_hidden() {
|
||||||
let (view, visible) =
|
let (view, visible) = handle_activity_click(SidebarView::Posts, false, SidebarView::Posts);
|
||||||
handle_activity_click(SidebarView::Posts, false, SidebarView::Posts);
|
|
||||||
assert_eq!(view, SidebarView::Posts);
|
assert_eq!(view, SidebarView::Posts);
|
||||||
assert!(visible);
|
assert!(visible);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn switch_view_when_different() {
|
fn switch_view_when_different() {
|
||||||
let (view, visible) =
|
let (view, visible) = handle_activity_click(SidebarView::Posts, true, SidebarView::Media);
|
||||||
handle_activity_click(SidebarView::Posts, true, SidebarView::Media);
|
|
||||||
assert_eq!(view, SidebarView::Media);
|
assert_eq!(view, SidebarView::Media);
|
||||||
assert!(visible);
|
assert!(visible);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/// Sidebar filter state per sidebar_views.allium PostsView / MediaView.
|
//! Sidebar filter state per sidebar_views.allium PostsView / MediaView.
|
||||||
///
|
//!
|
||||||
/// Per ui_data_flow.allium SidebarFilterIsolation:
|
//! Per ui_data_flow.allium SidebarFilterIsolation:
|
||||||
/// "Sidebar search/filter state is local to the sidebar component.
|
//! "Sidebar search/filter state is local to the sidebar component.
|
||||||
/// Filtering never affects: active tab, editor content, selectedPostId.
|
//! Filtering never affects: active tab, editor content, selectedPostId.
|
||||||
/// Only the visible list of items changes."
|
//! Only the visible list of items changes."
|
||||||
|
|
||||||
/// Calendar year/month archive filter.
|
/// Calendar year/month archive filter.
|
||||||
/// Per sidebar_views.allium CalendarFilter.
|
/// Per sidebar_views.allium CalendarFilter.
|
||||||
|
|||||||
@@ -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)
|
// Singleton: only one instance allowed (catches id mismatch edge cases)
|
||||||
if new_tab.tab_type.is_singleton() {
|
if new_tab.tab_type.is_singleton()
|
||||||
if let Some(idx) = tabs.iter().position(|t| t.tab_type == new_tab.tab_type) {
|
&& let Some(idx) = tabs.iter().position(|t| t.tab_type == new_tab.tab_type)
|
||||||
|
{
|
||||||
return idx;
|
return idx;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Transient replacement: replace existing transient of same type
|
// Transient replacement: replace existing transient of same type
|
||||||
if new_tab.is_transient {
|
if new_tab.is_transient
|
||||||
if let Some(idx) = tabs
|
&& let Some(idx) = tabs
|
||||||
.iter()
|
.iter()
|
||||||
.position(|t| t.tab_type == new_tab.tab_type && t.is_transient)
|
.position(|t| t.tab_type == new_tab.tab_type && t.is_transient)
|
||||||
{
|
{
|
||||||
tabs[idx] = new_tab;
|
tabs[idx] = new_tab;
|
||||||
return idx;
|
return idx;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
tabs.push(new_tab);
|
tabs.push(new_tab);
|
||||||
tabs.len() - 1
|
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).
|
/// Mark a transient tab as permanent (non-transient).
|
||||||
pub fn pin_tab(tabs: &mut Vec<Tab>, id: &str) {
|
pub fn pin_tab(tabs: &mut [Tab], id: &str) {
|
||||||
if let Some(tab) = tabs.iter_mut().find(|t| t.id == id) {
|
if let Some(tab) = tabs.iter_mut().find(|t| t.id == id) {
|
||||||
tab.is_transient = false;
|
tab.is_transient = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
/// Toasts are ephemeral, auto-dismissing messages shown at the top of
|
/// Toasts are ephemeral, auto-dismissing messages shown at the top of
|
||||||
/// the workspace. Each toast has a severity level, a message, and a
|
/// the workspace. Each toast has a severity level, a message, and a
|
||||||
/// monotonically increasing id used for targeted dismissal.
|
/// monotonically increasing id used for targeted dismissal.
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
static NEXT_TOAST_ID: AtomicU64 = AtomicU64::new(1);
|
static NEXT_TOAST_ID: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use iced::widget::{button, column, container, svg, text, tooltip, Column, Space};
|
|
||||||
use iced::widget::text::Shaping;
|
use iced::widget::text::Shaping;
|
||||||
|
use iced::widget::{Column, Space, button, column, container, svg, text, tooltip};
|
||||||
use iced::{Background, Border, Color, Element, Length, Theme};
|
use iced::{Background, Border, Color, Element, Length, Theme};
|
||||||
|
|
||||||
use bds_core::i18n::UiLocale;
|
use bds_core::i18n::UiLocale;
|
||||||
@@ -40,10 +40,7 @@ const TOP_ACTIVITIES: &[SidebarView] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// Bottom group of activity items.
|
/// Bottom group of activity items.
|
||||||
const BOTTOM_ACTIVITIES: &[SidebarView] = &[
|
const BOTTOM_ACTIVITIES: &[SidebarView] = &[SidebarView::Git, SidebarView::Settings];
|
||||||
SidebarView::Git,
|
|
||||||
SidebarView::Settings,
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Styles — matching bDS ActivityBar.css
|
// Styles — matching bDS ActivityBar.css
|
||||||
@@ -108,7 +105,7 @@ pub fn view(
|
|||||||
let icon = svg(handle)
|
let icon = svg(handle)
|
||||||
.width(Length::Fixed(24.0))
|
.width(Length::Fixed(24.0))
|
||||||
.height(Length::Fixed(24.0))
|
.height(Length::Fixed(24.0))
|
||||||
.opacity(if is_active { 1.0 } else { 0.4 });
|
.opacity(if is_active { 1.0_f32 } else { 0.4_f32 });
|
||||||
|
|
||||||
let btn = button(
|
let btn = button(
|
||||||
container(icon)
|
container(icon)
|
||||||
@@ -119,7 +116,11 @@ pub fn view(
|
|||||||
.height(Length::Fixed(48.0))
|
.height(Length::Fixed(48.0))
|
||||||
.padding(0)
|
.padding(0)
|
||||||
.on_press(Message::SetActiveView(view))
|
.on_press(Message::SetActiveView(view))
|
||||||
.style(if is_active { active_button_style } else { inactive_button_style });
|
.style(if is_active {
|
||||||
|
active_button_style
|
||||||
|
} else {
|
||||||
|
inactive_button_style
|
||||||
|
});
|
||||||
|
|
||||||
// Active indicator: 2px left border (like bDS/VS Code)
|
// Active indicator: 2px left border (like bDS/VS Code)
|
||||||
let btn_row: Element<'static, Message> = if is_active {
|
let btn_row: Element<'static, Message> = if is_active {
|
||||||
@@ -138,30 +139,26 @@ pub fn view(
|
|||||||
|
|
||||||
// Wrap in tooltip per layout.allium ActivityButton.label_key
|
// Wrap in tooltip per layout.allium ActivityButton.label_key
|
||||||
let tip_text = t(locale, view.i18n_key());
|
let tip_text = t(locale, view.i18n_key());
|
||||||
tooltip(btn_row, text(tip_text).size(12).shaping(Shaping::Advanced), tooltip::Position::Right)
|
tooltip(
|
||||||
|
btn_row,
|
||||||
|
text(tip_text).size(12).shaping(Shaping::Advanced),
|
||||||
|
tooltip::Position::Right,
|
||||||
|
)
|
||||||
.gap(4)
|
.gap(4)
|
||||||
.into()
|
.into()
|
||||||
};
|
};
|
||||||
|
|
||||||
let top_items: Vec<Element<'static, Message>> = TOP_ACTIVITIES
|
let top_items: Vec<Element<'static, Message>> =
|
||||||
.iter()
|
TOP_ACTIVITIES.iter().map(|v| make_btn(*v)).collect();
|
||||||
.map(|v| make_btn(*v))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let bottom_items: Vec<Element<'static, Message>> = BOTTOM_ACTIVITIES
|
let bottom_items: Vec<Element<'static, Message>> =
|
||||||
.iter()
|
BOTTOM_ACTIVITIES.iter().map(|v| make_btn(*v)).collect();
|
||||||
.map(|v| make_btn(*v))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let top = Column::with_children(top_items).spacing(0);
|
let top = Column::with_children(top_items).spacing(0);
|
||||||
let bottom = Column::with_children(bottom_items).spacing(0);
|
let bottom = Column::with_children(bottom_items).spacing(0);
|
||||||
|
|
||||||
container(
|
container(
|
||||||
column![
|
column![top, Space::with_height(Length::Fill), bottom,]
|
||||||
top,
|
|
||||||
Space::with_height(Length::Fill),
|
|
||||||
bottom,
|
|
||||||
]
|
|
||||||
.width(Length::Fixed(50.0))
|
.width(Length::Fixed(50.0))
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
.padding([4, 0]),
|
.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 iced::{Alignment, Background, Color, Element, Length, Theme};
|
||||||
|
|
||||||
use bds_core::i18n::UiLocale;
|
use bds_core::i18n::UiLocale;
|
||||||
@@ -88,13 +88,8 @@ impl DashboardState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Render the dashboard overview.
|
/// Render the dashboard overview.
|
||||||
pub fn view<'a>(
|
pub fn view<'a>(state: &'a DashboardState, locale: UiLocale) -> Element<'a, Message> {
|
||||||
state: &'a DashboardState,
|
let header = text(state.title.clone()).size(24).color(Color::WHITE);
|
||||||
locale: UiLocale,
|
|
||||||
) -> Element<'a, Message> {
|
|
||||||
let header = text(state.title.clone())
|
|
||||||
.size(24)
|
|
||||||
.color(Color::WHITE);
|
|
||||||
|
|
||||||
let project_label = text(state.subtitle.clone())
|
let project_label = text(state.subtitle.clone())
|
||||||
.size(14)
|
.size(14)
|
||||||
@@ -106,11 +101,23 @@ pub fn view<'a>(
|
|||||||
state.stats.total_posts.to_string(),
|
state.stats.total_posts.to_string(),
|
||||||
{
|
{
|
||||||
let mut details = vec![
|
let mut details = vec![
|
||||||
format!("{} {}", state.stats.published_count, t(locale, "dashboard.published")),
|
format!(
|
||||||
format!("{} {}", state.stats.draft_count, t(locale, "dashboard.drafts")),
|
"{} {}",
|
||||||
|
state.stats.published_count,
|
||||||
|
t(locale, "dashboard.published")
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"{} {}",
|
||||||
|
state.stats.draft_count,
|
||||||
|
t(locale, "dashboard.drafts")
|
||||||
|
),
|
||||||
];
|
];
|
||||||
if state.stats.archived_count > 0 {
|
if state.stats.archived_count > 0 {
|
||||||
details.push(format!("{} {}", state.stats.archived_count, t(locale, "dashboard.archived")));
|
details.push(format!(
|
||||||
|
"{} {}",
|
||||||
|
state.stats.archived_count,
|
||||||
|
t(locale, "dashboard.archived")
|
||||||
|
));
|
||||||
}
|
}
|
||||||
details
|
details
|
||||||
},
|
},
|
||||||
@@ -119,14 +126,22 @@ pub fn view<'a>(
|
|||||||
&t(locale, "dashboard.media"),
|
&t(locale, "dashboard.media"),
|
||||||
state.stats.media_count.to_string(),
|
state.stats.media_count.to_string(),
|
||||||
vec![
|
vec![
|
||||||
format!("{} {}", state.stats.image_count, t(locale, "dashboard.images")),
|
format!(
|
||||||
|
"{} {}",
|
||||||
|
state.stats.image_count,
|
||||||
|
t(locale, "dashboard.images")
|
||||||
|
),
|
||||||
state.stats.total_media_size.clone(),
|
state.stats.total_media_size.clone(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
stat_card(
|
stat_card(
|
||||||
&t(locale, "dashboard.tags"),
|
&t(locale, "dashboard.tags"),
|
||||||
state.stats.tag_count.to_string(),
|
state.stats.tag_count.to_string(),
|
||||||
vec![format!("{} {}", state.stats.category_count, t(locale, "dashboard.categories"))],
|
vec![format!(
|
||||||
|
"{} {}",
|
||||||
|
state.stats.category_count,
|
||||||
|
t(locale, "dashboard.categories")
|
||||||
|
)],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
.spacing(16);
|
.spacing(16);
|
||||||
@@ -166,7 +181,12 @@ fn stat_card<'a>(label: &str, value: String, details: Vec<String>) -> Element<'a
|
|||||||
container(
|
container(
|
||||||
column(
|
column(
|
||||||
std::iter::once(text(value).size(28).color(Color::WHITE).into())
|
std::iter::once(text(value).size(28).color(Color::WHITE).into())
|
||||||
.chain(std::iter::once(text(label.to_string()).size(12).color(Color::from_rgb(0.55, 0.58, 0.65)).into()))
|
.chain(std::iter::once(
|
||||||
|
text(label.to_string())
|
||||||
|
.size(12)
|
||||||
|
.color(Color::from_rgb(0.55, 0.58, 0.65))
|
||||||
|
.into(),
|
||||||
|
))
|
||||||
.chain(details.into_iter().map(|detail| {
|
.chain(details.into_iter().map(|detail| {
|
||||||
text(detail)
|
text(detail)
|
||||||
.size(12)
|
.size(12)
|
||||||
@@ -191,10 +211,17 @@ fn stat_card<'a>(label: &str, value: String, details: Vec<String>) -> Element<'a
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn timeline_chart<'a>(months: &'a [DashboardTimelineMonth], _locale: UiLocale) -> Element<'a, Message> {
|
fn timeline_chart<'a>(
|
||||||
let max_count = months.iter().map(|month| month.count).max().unwrap_or(1).max(1);
|
months: &'a [DashboardTimelineMonth],
|
||||||
row(
|
_locale: UiLocale,
|
||||||
months
|
) -> Element<'a, Message> {
|
||||||
|
let max_count = months
|
||||||
|
.iter()
|
||||||
|
.map(|month| month.count)
|
||||||
|
.max()
|
||||||
|
.unwrap_or(1)
|
||||||
|
.max(1);
|
||||||
|
row(months
|
||||||
.iter()
|
.iter()
|
||||||
.map(|month| {
|
.map(|month| {
|
||||||
let height = if month.count == 0 {
|
let height = if month.count == 0 {
|
||||||
@@ -209,10 +236,15 @@ fn timeline_chart<'a>(months: &'a [DashboardTimelineMonth], _locale: UiLocale) -
|
|||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.style(|_: &Theme| container::Style {
|
.style(|_: &Theme| container::Style {
|
||||||
background: Some(Background::Color(Color::from_rgb(0.25, 0.48, 0.80))),
|
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()
|
..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)
|
.spacing(6)
|
||||||
.align_x(Alignment::Center),
|
.align_x(Alignment::Center),
|
||||||
@@ -220,34 +252,41 @@ fn timeline_chart<'a>(months: &'a [DashboardTimelineMonth], _locale: UiLocale) -
|
|||||||
.width(Length::FillPortion(1))
|
.width(Length::FillPortion(1))
|
||||||
.into()
|
.into()
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>())
|
||||||
)
|
|
||||||
.spacing(10)
|
.spacing(10)
|
||||||
.align_y(Alignment::End)
|
.align_y(Alignment::End)
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tag_cloud<'a>(tags: &'a [DashboardTag], overflow_count: usize, locale: UiLocale) -> Element<'a, Message> {
|
fn tag_cloud<'a>(
|
||||||
|
tags: &'a [DashboardTag],
|
||||||
|
overflow_count: usize,
|
||||||
|
locale: UiLocale,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
let cloud = if tags.is_empty() {
|
let cloud = if tags.is_empty() {
|
||||||
row![text(t(locale, "dashboard.noTags")).size(13).color(Color::from_rgb(0.6, 0.62, 0.68))]
|
row![
|
||||||
|
text(t(locale, "dashboard.noTags"))
|
||||||
|
.size(13)
|
||||||
|
.color(Color::from_rgb(0.6, 0.62, 0.68))
|
||||||
|
]
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
.wrap()
|
.wrap()
|
||||||
} else {
|
} else {
|
||||||
let words = tags
|
let words = tags
|
||||||
.iter()
|
.iter()
|
||||||
.map(|tag| {
|
.map(|tag| {
|
||||||
let bg = parse_color(tag.color.as_deref()).unwrap_or(Color::from_rgb(0.18, 0.21, 0.28));
|
let bg =
|
||||||
|
parse_color(tag.color.as_deref()).unwrap_or(Color::from_rgb(0.18, 0.21, 0.28));
|
||||||
let fg = contrast_color(bg);
|
let fg = contrast_color(bg);
|
||||||
tooltip(
|
tooltip(
|
||||||
container(
|
container(text(tag.name.clone()).size(scale_font(tag.count)).color(fg))
|
||||||
text(tag.name.clone())
|
|
||||||
.size(scale_font(tag.count))
|
|
||||||
.color(fg),
|
|
||||||
)
|
|
||||||
.padding([6, 10])
|
.padding([6, 10])
|
||||||
.style(move |_: &Theme| container::Style {
|
.style(move |_: &Theme| container::Style {
|
||||||
background: Some(Background::Color(bg)),
|
background: Some(Background::Color(bg)),
|
||||||
border: iced::Border { radius: 999.0.into(), ..iced::Border::default() },
|
border: iced::Border {
|
||||||
|
radius: 999.0.into(),
|
||||||
|
..iced::Border::default()
|
||||||
|
},
|
||||||
..container::Style::default()
|
..container::Style::default()
|
||||||
}),
|
}),
|
||||||
text(format!("{} {}", tag.name, tag.count)).size(12),
|
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)
|
.spacing(8)
|
||||||
} else {
|
} else {
|
||||||
column![inputs::section_header(&t(locale, "dashboard.tagCloud")), cloud].spacing(8)
|
column![
|
||||||
|
inputs::section_header(&t(locale, "dashboard.tagCloud")),
|
||||||
|
cloud
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
};
|
};
|
||||||
|
|
||||||
container(content).width(Length::FillPortion(1)).into()
|
container(content).width(Length::FillPortion(1)).into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn category_cloud<'a>(categories: &'a [DashboardCategory], locale: UiLocale) -> Element<'a, Message> {
|
fn category_cloud<'a>(
|
||||||
let items = categories.iter().fold(column![inputs::section_header(&t(locale, "dashboard.categoryCloud"))].spacing(8), |column, category| {
|
categories: &'a [DashboardCategory],
|
||||||
|
locale: UiLocale,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let items = categories.iter().fold(
|
||||||
|
column![inputs::section_header(&t(
|
||||||
|
locale,
|
||||||
|
"dashboard.categoryCloud"
|
||||||
|
))]
|
||||||
|
.spacing(8),
|
||||||
|
|column, category| {
|
||||||
column.push(
|
column.push(
|
||||||
container(
|
container(
|
||||||
row![
|
row![
|
||||||
text(category.name.clone()).size(13).color(Color::WHITE),
|
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),
|
.spacing(8),
|
||||||
)
|
)
|
||||||
.padding([6, 10])
|
.padding([6, 10])
|
||||||
.style(|_: &Theme| container::Style {
|
.style(|_: &Theme| container::Style {
|
||||||
background: Some(Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
|
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::Style::default()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
});
|
},
|
||||||
|
);
|
||||||
container(items).width(Length::FillPortion(1)).into()
|
container(items).width(Length::FillPortion(1)).into()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +371,9 @@ fn recent_posts<'a>(posts: &'a [DashboardRecentPost], locale: UiLocale) -> Eleme
|
|||||||
button(
|
button(
|
||||||
column![
|
column![
|
||||||
text(post.title.clone()).size(14).color(Color::WHITE),
|
text(post.title.clone()).size(14).color(Color::WHITE),
|
||||||
text(post.date.clone()).size(12).color(Color::from_rgb(0.6, 0.62, 0.68)),
|
text(post.date.clone())
|
||||||
|
.size(12)
|
||||||
|
.color(Color::from_rgb(0.6, 0.62, 0.68)),
|
||||||
]
|
]
|
||||||
.spacing(4),
|
.spacing(4),
|
||||||
)
|
)
|
||||||
@@ -342,7 +402,10 @@ fn recent_posts<'a>(posts: &'a [DashboardRecentPost], locale: UiLocale) -> Eleme
|
|||||||
.padding(12)
|
.padding(12)
|
||||||
.style(|_: &Theme| container::Style {
|
.style(|_: &Theme| container::Style {
|
||||||
background: Some(Background::Color(Color::from_rgb(0.13, 0.14, 0.18))),
|
background: Some(Background::Color(Color::from_rgb(0.13, 0.14, 0.18))),
|
||||||
border: iced::Border { radius: 8.0.into(), ..iced::Border::default() },
|
border: iced::Border {
|
||||||
|
radius: 8.0.into(),
|
||||||
|
..iced::Border::default()
|
||||||
|
},
|
||||||
..container::Style::default()
|
..container::Style::default()
|
||||||
})
|
})
|
||||||
.into()
|
.into()
|
||||||
@@ -363,7 +426,10 @@ fn status_badge<'a>(status: &str) -> Element<'a, Message> {
|
|||||||
.padding([4, 8])
|
.padding([4, 8])
|
||||||
.style(move |_: &Theme| container::Style {
|
.style(move |_: &Theme| container::Style {
|
||||||
background: Some(Background::Color(bg)),
|
background: Some(Background::Color(bg)),
|
||||||
border: iced::Border { radius: 999.0.into(), ..iced::Border::default() },
|
border: iced::Border {
|
||||||
|
radius: 999.0.into(),
|
||||||
|
..iced::Border::default()
|
||||||
|
},
|
||||||
..container::Style::default()
|
..container::Style::default()
|
||||||
})
|
})
|
||||||
.into()
|
.into()
|
||||||
@@ -387,5 +453,9 @@ fn parse_color(value: Option<&str>) -> Option<Color> {
|
|||||||
|
|
||||||
fn contrast_color(background: Color) -> Color {
|
fn contrast_color(background: Color) -> Color {
|
||||||
let luma = 0.299 * background.r + 0.587 * background.g + 0.114 * background.b;
|
let luma = 0.299 * background.r + 0.587 * background.g + 0.114 * background.b;
|
||||||
if luma > 0.55 { Color::BLACK } else { Color::WHITE }
|
if luma > 0.55 {
|
||||||
|
Color::BLACK
|
||||||
|
} else {
|
||||||
|
Color::WHITE
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use iced::widget::{button, column, container, image, row, scrollable, text, Space};
|
|
||||||
use iced::widget::text::Shaping;
|
use iced::widget::text::Shaping;
|
||||||
|
use iced::widget::{Space, button, column, container, image, row, scrollable, text};
|
||||||
use iced::{Color, Element, Length};
|
use iced::{Color, Element, Length};
|
||||||
|
|
||||||
use bds_core::i18n::{self, UiLocale};
|
use bds_core::i18n::{self, UiLocale};
|
||||||
@@ -72,12 +72,15 @@ impl MediaEditorState {
|
|||||||
|
|
||||||
let mut translation_drafts = HashMap::new();
|
let mut translation_drafts = HashMap::new();
|
||||||
for tr in translations {
|
for tr in translations {
|
||||||
translation_drafts.insert(tr.language.clone(), MediaTranslationDraft {
|
translation_drafts.insert(
|
||||||
|
tr.language.clone(),
|
||||||
|
MediaTranslationDraft {
|
||||||
title: tr.title.clone().unwrap_or_default(),
|
title: tr.title.clone().unwrap_or_default(),
|
||||||
alt: tr.alt.clone().unwrap_or_default(),
|
alt: tr.alt.clone().unwrap_or_default(),
|
||||||
caption: tr.caption.clone().unwrap_or_default(),
|
caption: tr.caption.clone().unwrap_or_default(),
|
||||||
is_dirty: false,
|
is_dirty: false,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -125,12 +128,15 @@ impl MediaEditorState {
|
|||||||
is_dirty: self.is_dirty,
|
is_dirty: self.is_dirty,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
self.translation_drafts.insert(self.active_language.clone(), MediaTranslationDraft {
|
self.translation_drafts.insert(
|
||||||
|
self.active_language.clone(),
|
||||||
|
MediaTranslationDraft {
|
||||||
title: self.title.clone(),
|
title: self.title.clone(),
|
||||||
alt: self.alt.clone(),
|
alt: self.alt.clone(),
|
||||||
caption: self.caption.clone(),
|
caption: self.caption.clone(),
|
||||||
is_dirty: self.is_dirty,
|
is_dirty: self.is_dirty,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Load target fields
|
// Load target fields
|
||||||
if target_lang == self.canonical_language {
|
if target_lang == self.canonical_language {
|
||||||
@@ -225,24 +231,28 @@ pub fn view<'a>(
|
|||||||
ai_enabled: bool,
|
ai_enabled: bool,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let header = inputs::toolbar(
|
let header = inputs::toolbar(
|
||||||
vec![
|
vec![text(state.original_name.clone()).size(18).into()],
|
||||||
text(state.original_name.clone()).size(18).into(),
|
|
||||||
],
|
|
||||||
vec![
|
vec![
|
||||||
if state.mime_type.starts_with("image/") {
|
if state.mime_type.starts_with("image/") {
|
||||||
button(text(t(locale, "editor.aiAnalyze")).size(13))
|
button(text(t(locale, "editor.aiAnalyze")).size(13))
|
||||||
.on_press_maybe(ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::AnalyzeWithAi)))
|
.on_press_maybe(
|
||||||
|
ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::AnalyzeWithAi)),
|
||||||
|
)
|
||||||
.padding([6, 16])
|
.padding([6, 16])
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
Space::new(0, 0).into()
|
Space::new(0, 0).into()
|
||||||
},
|
},
|
||||||
button(text(t(locale, "editor.detectLanguage")).size(13))
|
button(text(t(locale, "editor.detectLanguage")).size(13))
|
||||||
.on_press_maybe(ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::DetectLanguage)))
|
.on_press_maybe(
|
||||||
|
ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::DetectLanguage)),
|
||||||
|
)
|
||||||
.padding([6, 16])
|
.padding([6, 16])
|
||||||
.into(),
|
.into(),
|
||||||
button(text(t(locale, "editor.translate")).size(13))
|
button(text(t(locale, "editor.translate")).size(13))
|
||||||
.on_press_maybe(ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::TranslateMetadata)))
|
.on_press_maybe(
|
||||||
|
ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::TranslateMetadata)),
|
||||||
|
)
|
||||||
.padding([6, 16])
|
.padding([6, 16])
|
||||||
.into(),
|
.into(),
|
||||||
button(text(t(locale, "common.save")).size(13))
|
button(text(t(locale, "common.save")).size(13))
|
||||||
@@ -273,7 +283,9 @@ pub fn view<'a>(
|
|||||||
Color::from_rgb(0.55, 0.58, 0.65)
|
Color::from_rgb(0.55, 0.58, 0.65)
|
||||||
};
|
};
|
||||||
button(text(label).size(12).shaping(Shaping::Advanced).color(color))
|
button(text(label).size(12).shaping(Shaping::Advanced).color(color))
|
||||||
.on_press(Message::MediaEditor(MediaEditorMsg::SwitchLanguage(flag.language.clone())))
|
.on_press(Message::MediaEditor(MediaEditorMsg::SwitchLanguage(
|
||||||
|
flag.language.clone(),
|
||||||
|
)))
|
||||||
.padding([4, 8])
|
.padding([4, 8])
|
||||||
.style(|_: &iced::Theme, _| button::Style::default())
|
.style(|_: &iced::Theme, _| button::Style::default())
|
||||||
.into()
|
.into()
|
||||||
@@ -295,13 +307,13 @@ pub fn view<'a>(
|
|||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
no_preview()
|
no_preview(locale)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
no_preview()
|
no_preview(locale)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
no_preview()
|
no_preview(locale)
|
||||||
};
|
};
|
||||||
|
|
||||||
// File info
|
// File info
|
||||||
@@ -311,7 +323,10 @@ pub fn view<'a>(
|
|||||||
};
|
};
|
||||||
let size_str = format_file_size(state.size);
|
let size_str = format_file_size(state.size);
|
||||||
let info = row![
|
let info = row![
|
||||||
text(format!("{} • {} • {}", state.mime_type, size_str, dimensions))
|
text(format!(
|
||||||
|
"{} • {} • {}",
|
||||||
|
state.mime_type, size_str, dimensions
|
||||||
|
))
|
||||||
.size(12)
|
.size(12)
|
||||||
.color(Color::from_rgb(0.55, 0.58, 0.65)),
|
.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 meta_row1 = row![title_input, alt_input].spacing(16).width(Length::Fill);
|
||||||
|
|
||||||
let caption_input = inputs::labeled_input(
|
let caption_input =
|
||||||
&t(locale, "editor.caption"),
|
inputs::labeled_input(&t(locale, "editor.caption"), "", &state.caption, |s| {
|
||||||
"",
|
Message::MediaEditor(MediaEditorMsg::CaptionChanged(s))
|
||||||
&state.caption,
|
});
|
||||||
|s| Message::MediaEditor(MediaEditorMsg::CaptionChanged(s)),
|
let author_input = inputs::labeled_input(&t(locale, "editor.author"), "", &state.author, |s| {
|
||||||
);
|
Message::MediaEditor(MediaEditorMsg::AuthorChanged(s))
|
||||||
let author_input = inputs::labeled_input(
|
});
|
||||||
&t(locale, "editor.author"),
|
|
||||||
"",
|
|
||||||
&state.author,
|
|
||||||
|s| Message::MediaEditor(MediaEditorMsg::AuthorChanged(s)),
|
|
||||||
);
|
|
||||||
let tags_input = inputs::labeled_input(
|
let tags_input = inputs::labeled_input(
|
||||||
&t(locale, "editor.tags"),
|
&t(locale, "editor.tags"),
|
||||||
&t(locale, "editor.tagsPlaceholder"),
|
&t(locale, "editor.tagsPlaceholder"),
|
||||||
@@ -361,8 +371,12 @@ pub fn view<'a>(
|
|||||||
Some(&state.language),
|
Some(&state.language),
|
||||||
|lang| Message::MediaEditor(MediaEditorMsg::LanguageChanged(lang)),
|
|lang| Message::MediaEditor(MediaEditorMsg::LanguageChanged(lang)),
|
||||||
);
|
);
|
||||||
let meta_row2 = row![caption_input, author_input].spacing(16).width(Length::Fill);
|
let meta_row2 = row![caption_input, author_input]
|
||||||
let meta_row3 = row![tags_input, language_input].spacing(16).width(Length::Fill);
|
.spacing(16)
|
||||||
|
.width(Length::Fill);
|
||||||
|
let meta_row3 = row![tags_input, language_input]
|
||||||
|
.spacing(16)
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
let linked_posts_header = row![
|
let linked_posts_header = row![
|
||||||
text(t(locale, "editor.linkedPosts"))
|
text(t(locale, "editor.linkedPosts"))
|
||||||
@@ -396,14 +410,20 @@ pub fn view<'a>(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|post| {
|
.map(|post| {
|
||||||
button(text(post.title.clone()).size(12))
|
button(text(post.title.clone()).size(12))
|
||||||
.on_press(Message::MediaEditor(MediaEditorMsg::LinkPost(post.post_id.clone())))
|
.on_press(Message::MediaEditor(MediaEditorMsg::LinkPost(
|
||||||
|
post.post_id.clone(),
|
||||||
|
)))
|
||||||
.padding([4, 10])
|
.padding([4, 10])
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.into()
|
.into()
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
container(column![search, column(results).spacing(4)].spacing(8).padding(8))
|
container(
|
||||||
|
column![search, column(results).spacing(4)]
|
||||||
|
.spacing(8)
|
||||||
|
.padding(8),
|
||||||
|
)
|
||||||
.style(|_: &iced::Theme| iced::widget::container::Style {
|
.style(|_: &iced::Theme| iced::widget::container::Style {
|
||||||
background: Some(iced::Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
|
background: Some(iced::Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
|
||||||
border: iced::Border {
|
border: iced::Border {
|
||||||
@@ -430,11 +450,15 @@ pub fn view<'a>(
|
|||||||
.map(|post| {
|
.map(|post| {
|
||||||
row![
|
row![
|
||||||
button(text(post.title.clone()).size(12))
|
button(text(post.title.clone()).size(12))
|
||||||
.on_press(Message::MediaEditor(MediaEditorMsg::OpenLinkedPost(post.post_id.clone())))
|
.on_press(Message::MediaEditor(MediaEditorMsg::OpenLinkedPost(
|
||||||
|
post.post_id.clone()
|
||||||
|
)))
|
||||||
.padding([4, 0]),
|
.padding([4, 0]),
|
||||||
Space::with_width(Length::Fill),
|
Space::with_width(Length::Fill),
|
||||||
button(text(t(locale, "editor.unlinkMedia")).size(11))
|
button(text(t(locale, "editor.unlinkMedia")).size(11))
|
||||||
.on_press(Message::MediaEditor(MediaEditorMsg::UnlinkPost(post.post_id.clone())))
|
.on_press(Message::MediaEditor(MediaEditorMsg::UnlinkPost(
|
||||||
|
post.post_id.clone()
|
||||||
|
)))
|
||||||
.padding([3, 8])
|
.padding([3, 8])
|
||||||
.style(inputs::danger_button),
|
.style(inputs::danger_button),
|
||||||
]
|
]
|
||||||
@@ -471,7 +495,7 @@ pub fn view<'a>(
|
|||||||
]
|
]
|
||||||
.spacing(12)
|
.spacing(12)
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.width(Length::Fill)
|
.width(Length::Fill),
|
||||||
);
|
);
|
||||||
|
|
||||||
container(body)
|
container(body)
|
||||||
@@ -480,6 +504,29 @@ pub fn view<'a>(
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn no_preview<'a>(locale: UiLocale) -> Element<'a, Message> {
|
||||||
|
container(
|
||||||
|
text(t(locale, "editor.noPreviewAvailable"))
|
||||||
|
.size(14)
|
||||||
|
.color(Color::from_rgb(0.5, 0.5, 0.5)),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fixed(200.0))
|
||||||
|
.center_x(Length::Fill)
|
||||||
|
.center_y(Length::Fixed(200.0))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_file_size(bytes: i64) -> String {
|
||||||
|
if bytes < 1024 {
|
||||||
|
format!("{bytes} B")
|
||||||
|
} else if bytes < 1024 * 1024 {
|
||||||
|
format!("{:.1} KB", bytes as f64 / 1024.0)
|
||||||
|
} else {
|
||||||
|
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::MediaEditorState;
|
use super::MediaEditorState;
|
||||||
@@ -519,30 +566,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let flags = state.translation_flags();
|
let flags = state.translation_flags();
|
||||||
let languages = flags.into_iter().map(|flag| flag.language).collect::<Vec<_>>();
|
let languages = flags
|
||||||
|
.into_iter()
|
||||||
|
.map(|flag| flag.language)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
assert_eq!(languages, vec!["en".to_string(), "de".to_string()]);
|
assert_eq!(languages, vec!["en".to_string(), "de".to_string()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn no_preview<'a>() -> Element<'a, Message> {
|
|
||||||
container(
|
|
||||||
text("No preview available")
|
|
||||||
.size(14)
|
|
||||||
.color(Color::from_rgb(0.5, 0.5, 0.5)),
|
|
||||||
)
|
|
||||||
.width(Length::Fill)
|
|
||||||
.height(Length::Fixed(200.0))
|
|
||||||
.center_x(Length::Fill)
|
|
||||||
.center_y(Length::Fixed(200.0))
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_file_size(bytes: i64) -> String {
|
|
||||||
if bytes < 1024 {
|
|
||||||
format!("{bytes} B")
|
|
||||||
} else if bytes < 1024 * 1024 {
|
|
||||||
format!("{:.1} KB", bytes as f64 / 1024.0)
|
|
||||||
} else {
|
|
||||||
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
pub mod workspace;
|
|
||||||
pub mod activity_bar;
|
pub mod activity_bar;
|
||||||
pub mod sidebar;
|
pub mod dashboard;
|
||||||
pub mod tab_bar;
|
pub mod media_editor;
|
||||||
pub mod status_bar;
|
pub mod modal;
|
||||||
pub mod panel;
|
pub mod panel;
|
||||||
|
pub mod post_editor;
|
||||||
pub mod project_selector;
|
pub mod project_selector;
|
||||||
|
pub mod script_editor;
|
||||||
|
pub mod settings_view;
|
||||||
|
pub mod sidebar;
|
||||||
|
pub mod site_validation;
|
||||||
|
pub mod status_bar;
|
||||||
|
pub mod tab_bar;
|
||||||
|
pub mod tags_view;
|
||||||
|
pub mod template_editor;
|
||||||
pub mod toast;
|
pub mod toast;
|
||||||
pub mod welcome;
|
pub mod welcome;
|
||||||
pub mod modal;
|
pub mod workspace;
|
||||||
pub mod post_editor;
|
|
||||||
pub mod media_editor;
|
|
||||||
pub mod template_editor;
|
|
||||||
pub mod script_editor;
|
|
||||||
pub mod tags_view;
|
|
||||||
pub mod settings_view;
|
|
||||||
pub mod dashboard;
|
|
||||||
pub mod site_validation;
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use iced::widget::text::Shaping;
|
use iced::widget::text::Shaping;
|
||||||
use iced::widget::{button, checkbox, column, container, image, row, scrollable, text, text_input, Space};
|
use iced::widget::{
|
||||||
|
Space, button, checkbox, column, container, image, row, scrollable, text, text_input,
|
||||||
|
};
|
||||||
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
|
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
|
||||||
|
|
||||||
use bds_core::i18n::UiLocale;
|
use bds_core::i18n::UiLocale;
|
||||||
@@ -112,7 +114,10 @@ pub enum ConfirmAction {
|
|||||||
DeleteTemplate(String),
|
DeleteTemplate(String),
|
||||||
ForceDeleteTemplate(String),
|
ForceDeleteTemplate(String),
|
||||||
DeleteTag(String),
|
DeleteTag(String),
|
||||||
MergeTags { sources: Vec<String>, target: String },
|
MergeTags {
|
||||||
|
sources: Vec<String>,
|
||||||
|
target: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Modal backdrop style ──
|
// ── Modal backdrop style ──
|
||||||
@@ -231,7 +236,11 @@ fn gallery_step(current: usize, len: usize, delta: isize) -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Render the modal overlay.
|
/// Render the modal overlay.
|
||||||
pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Element<'static, Message> {
|
pub fn view(
|
||||||
|
state: ModalState,
|
||||||
|
locale: UiLocale,
|
||||||
|
data_dir: Option<&Path>,
|
||||||
|
) -> Element<'static, Message> {
|
||||||
let modal_content: Element<'static, Message> = match state {
|
let modal_content: Element<'static, Message> = match state {
|
||||||
ModalState::ConfirmDelete {
|
ModalState::ConfirmDelete {
|
||||||
entity_name,
|
entity_name,
|
||||||
@@ -452,14 +461,18 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.padding([3, 8])
|
.padding([3, 8])
|
||||||
.style(|_: &Theme| container::Style {
|
.style(|_: &Theme| {
|
||||||
background: Some(Background::Color(Color::from_rgb(0.18, 0.20, 0.25))),
|
container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb(
|
||||||
|
0.18, 0.20, 0.25,
|
||||||
|
))),
|
||||||
border: Border {
|
border: Border {
|
||||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||||
width: 1.0,
|
width: 1.0,
|
||||||
radius: 999.0.into(),
|
radius: 999.0.into(),
|
||||||
},
|
},
|
||||||
..container::Style::default()
|
..container::Style::default()
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
.spacing(12)
|
.spacing(12)
|
||||||
@@ -505,7 +518,9 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
|||||||
row![
|
row![
|
||||||
Space::with_width(Length::Fill),
|
Space::with_width(Length::Fill),
|
||||||
button(text(t(locale, "modal.postInsertLink.insert")))
|
button(text(t(locale, "modal.postInsertLink.insert")))
|
||||||
.on_press(Message::PostEditor(PostEditorMsg::PostInsertLinkExternalInsert))
|
.on_press(Message::PostEditor(
|
||||||
|
PostEditorMsg::PostInsertLinkExternalInsert
|
||||||
|
))
|
||||||
.padding([6, 16])
|
.padding([6, 16])
|
||||||
.style(confirm_button_style),
|
.style(confirm_button_style),
|
||||||
]
|
]
|
||||||
@@ -537,7 +552,8 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
|||||||
.size(13)
|
.size(13)
|
||||||
.shaping(Shaping::Advanced);
|
.shaping(Shaping::Advanced);
|
||||||
|
|
||||||
let trailing_button: Element<'static, Message> = if active_tab == PostInsertLinkTab::Internal {
|
let trailing_button: Element<'static, Message> =
|
||||||
|
if active_tab == PostInsertLinkTab::Internal {
|
||||||
create_post_btn
|
create_post_btn
|
||||||
} else {
|
} else {
|
||||||
Space::with_width(0.0).into()
|
Space::with_width(0.0).into()
|
||||||
@@ -597,14 +613,10 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|m| {
|
.map(|m| {
|
||||||
let is_image = m.mime_type.starts_with("image/");
|
let is_image = m.mime_type.starts_with("image/");
|
||||||
let title = m
|
let title = m.title.as_deref().unwrap_or("").to_string();
|
||||||
.title
|
|
||||||
.as_ref()
|
|
||||||
.map(|s| s.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
let original_name = m.original_name.clone();
|
let original_name = m.original_name.clone();
|
||||||
let thumb: Element<'static, Message> = if let Some(path) = resolve_thumbnail_file(data_dir, m) {
|
let thumb: Element<'static, Message> =
|
||||||
|
if let Some(path) = resolve_thumbnail_file(data_dir, m) {
|
||||||
image(path)
|
image(path)
|
||||||
.width(Length::Fixed(120.0))
|
.width(Length::Fixed(120.0))
|
||||||
.height(Length::Fixed(90.0))
|
.height(Length::Fixed(90.0))
|
||||||
@@ -716,10 +728,12 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
|||||||
.size(13)
|
.size(13)
|
||||||
.shaping(Shaping::Advanced);
|
.shaping(Shaping::Advanced);
|
||||||
|
|
||||||
let buttons = row![button(cancel_text)
|
let buttons = row![
|
||||||
|
button(cancel_text)
|
||||||
.on_press(Message::DismissModal)
|
.on_press(Message::DismissModal)
|
||||||
.padding([6, 16])
|
.padding([6, 16])
|
||||||
.style(cancel_button_style),];
|
.style(cancel_button_style),
|
||||||
|
];
|
||||||
|
|
||||||
let content = column![
|
let content = column![
|
||||||
title,
|
title,
|
||||||
@@ -754,13 +768,9 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
|||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(index, m)| {
|
.map(|(index, m)| {
|
||||||
let title = m
|
let title = m.title.as_deref().unwrap_or("").to_string();
|
||||||
.title
|
let thumb: Element<'static, Message> =
|
||||||
.as_ref()
|
if let Some(path) = resolve_thumbnail_file(data_dir, m) {
|
||||||
.map(|s| s.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
let thumb: Element<'static, Message> = if let Some(path) = resolve_thumbnail_file(data_dir, m) {
|
|
||||||
image(path)
|
image(path)
|
||||||
.width(Length::Fixed(180.0))
|
.width(Length::Fixed(180.0))
|
||||||
.height(Length::Fixed(120.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 {
|
let lightbox: Element<'static, Message> = if let Some(index) = selected_index {
|
||||||
if let Some(selected) = image_media.get(index) {
|
if let Some(selected) = image_media.get(index) {
|
||||||
let preview: Element<'static, Message> = if let Some(path) = resolve_media_file(data_dir, selected) {
|
let preview: Element<'static, Message> =
|
||||||
|
if let Some(path) = resolve_media_file(data_dir, selected) {
|
||||||
image(path)
|
image(path)
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(Length::Fixed(420.0))
|
.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),
|
.style(cancel_button_style),
|
||||||
Space::with_width(12.0),
|
Space::with_width(12.0),
|
||||||
button(text(t(locale, "modal.postGallery.backToGrid")))
|
button(text(t(locale, "modal.postGallery.backToGrid")))
|
||||||
.on_press(Message::PostEditor(PostEditorMsg::PostGalleryCloseLightbox))
|
.on_press(Message::PostEditor(
|
||||||
|
PostEditorMsg::PostGalleryCloseLightbox
|
||||||
|
))
|
||||||
.padding([6, 12])
|
.padding([6, 12])
|
||||||
.style(cancel_button_style),
|
.style(cancel_button_style),
|
||||||
]
|
]
|
||||||
@@ -915,23 +928,40 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
|||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced)
|
||||||
.color(Color::WHITE);
|
.color(Color::WHITE);
|
||||||
|
|
||||||
let rows = fields.iter().enumerate().map(|(index, field)| {
|
let rows = fields
|
||||||
let toggle = checkbox(field.label.clone(), field.accepted)
|
.iter()
|
||||||
.on_toggle_maybe((!field.locked)
|
.enumerate()
|
||||||
.then_some(move |value| Message::ToggleAiSuggestionField(index, value)))
|
.map(|(index, field)| {
|
||||||
|
let toggle =
|
||||||
|
checkbox(field.label.clone(), field.accepted)
|
||||||
|
.on_toggle_maybe((!field.locked).then_some(move |value| {
|
||||||
|
Message::ToggleAiSuggestionField(index, value)
|
||||||
|
}))
|
||||||
.size(16)
|
.size(16)
|
||||||
.text_size(13);
|
.text_size(13);
|
||||||
container(column![
|
container(
|
||||||
|
column![
|
||||||
toggle,
|
toggle,
|
||||||
row![
|
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)),
|
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)
|
.spacing(10)
|
||||||
.align_y(Alignment::Center),
|
.align_y(Alignment::Center),
|
||||||
]
|
]
|
||||||
.spacing(8))
|
.spacing(8),
|
||||||
|
)
|
||||||
.padding(10)
|
.padding(10)
|
||||||
.style(|_: &Theme| container::Style {
|
.style(|_: &Theme| container::Style {
|
||||||
background: Some(Background::Color(Color::from_rgb(0.18, 0.20, 0.25))),
|
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()
|
..container::Style::default()
|
||||||
})
|
})
|
||||||
.into()
|
.into()
|
||||||
}).collect::<Vec<Element<'static, Message>>>();
|
})
|
||||||
|
.collect::<Vec<Element<'static, Message>>>();
|
||||||
|
|
||||||
let buttons = row![
|
let buttons = row![
|
||||||
button(text(t(locale, "common.cancel")).size(13))
|
button(text(t(locale, "common.cancel")).size(13))
|
||||||
@@ -972,12 +1003,20 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
ModalState::LanguagePicker { target, source_language, available_targets } => {
|
ModalState::LanguagePicker {
|
||||||
|
target,
|
||||||
|
source_language,
|
||||||
|
available_targets,
|
||||||
|
} => {
|
||||||
let title = text(t(locale, "modal.languagePicker.title"))
|
let title = text(t(locale, "modal.languagePicker.title"))
|
||||||
.size(16)
|
.size(16)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced)
|
||||||
.color(Color::WHITE);
|
.color(Color::WHITE);
|
||||||
let subtitle = text(format!("{}: {}", t(locale, "editor.language"), source_language))
|
let subtitle = text(format!(
|
||||||
|
"{}: {}",
|
||||||
|
t(locale, "editor.language"),
|
||||||
|
source_language
|
||||||
|
))
|
||||||
.size(12)
|
.size(12)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced)
|
||||||
.color(Color::from_rgb(0.60, 0.60, 0.68));
|
.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() {
|
let rows = if available_targets.is_empty() {
|
||||||
vec![text(t(locale, "common.noResults")).size(12).into()]
|
vec![text(t(locale, "common.noResults")).size(12).into()]
|
||||||
} else {
|
} else {
|
||||||
available_targets.into_iter().map(|language| {
|
available_targets
|
||||||
|
.into_iter()
|
||||||
|
.map(|language| {
|
||||||
let message = match &target {
|
let message = match &target {
|
||||||
AiEntityTarget::Post(_) => Message::PostEditor(PostEditorMsg::TranslateTo(language.code.clone())),
|
AiEntityTarget::Post(_) => Message::PostEditor(
|
||||||
AiEntityTarget::Media(_) => Message::MediaEditor(crate::views::media_editor::MediaEditorMsg::TranslateTo(language.code.clone())),
|
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();
|
let status = language.existing_status.clone().unwrap_or_default();
|
||||||
button(row![
|
button(
|
||||||
text(format!("{} {}", language.flag_emoji, language.name)).size(13).color(Color::WHITE),
|
row![
|
||||||
|
text(format!("{} {}", language.flag_emoji, language.name))
|
||||||
|
.size(13)
|
||||||
|
.color(Color::WHITE),
|
||||||
Space::with_width(Length::Fill),
|
Space::with_width(Length::Fill),
|
||||||
text(status).size(11).color(Color::from_rgb(0.60, 0.60, 0.68)),
|
text(status)
|
||||||
].align_y(Alignment::Center))
|
.size(11)
|
||||||
|
.color(Color::from_rgb(0.60, 0.60, 0.68)),
|
||||||
|
]
|
||||||
|
.align_y(Alignment::Center),
|
||||||
|
)
|
||||||
.on_press(message)
|
.on_press(message)
|
||||||
.padding([8, 12])
|
.padding([8, 12])
|
||||||
.style(cancel_button_style)
|
.style(cancel_button_style)
|
||||||
.into()
|
.into()
|
||||||
}).collect::<Vec<Element<'static, Message>>>()
|
})
|
||||||
|
.collect::<Vec<Element<'static, Message>>>()
|
||||||
};
|
};
|
||||||
|
|
||||||
let content = column![
|
let content = column![
|
||||||
@@ -1042,7 +1097,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn external_link_markdown_requires_url() {
|
fn external_link_markdown_requires_url() {
|
||||||
assert_eq!(external_link_markdown("", "text"), None);
|
assert_eq!(external_link_markdown("", "text"), None);
|
||||||
assert_eq!(external_link_markdown("https://example.com", ""), Some("https://example.com".to_string()));
|
assert_eq!(
|
||||||
|
external_link_markdown("https://example.com", ""),
|
||||||
|
Some("https://example.com".to_string())
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
external_link_markdown("https://example.com", "Example"),
|
external_link_markdown("https://example.com", "Example"),
|
||||||
Some("[Example](https://example.com)".to_string())
|
Some("[Example](https://example.com)".to_string())
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use iced::widget::{button, column, container, scrollable, text, Space};
|
|
||||||
use iced::widget::text::Shaping;
|
use iced::widget::text::Shaping;
|
||||||
|
use iced::widget::{Space, button, column, container, scrollable, text};
|
||||||
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
|
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
|
||||||
|
|
||||||
use bds_core::i18n::UiLocale;
|
use bds_core::i18n::UiLocale;
|
||||||
@@ -64,6 +64,10 @@ fn close_btn_style(_theme: &Theme, status: button::Status) -> button::Style {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_arguments,
|
||||||
|
reason = "arguments are independent panel state slices"
|
||||||
|
)]
|
||||||
pub fn view(
|
pub fn view(
|
||||||
panel_tab: PanelTab,
|
panel_tab: PanelTab,
|
||||||
task_snapshots: &[TaskSnapshot],
|
task_snapshots: &[TaskSnapshot],
|
||||||
@@ -79,39 +83,68 @@ pub fn view(
|
|||||||
// Tab header — per layout.allium: tasks, output, post_links (only when
|
// Tab header — per layout.allium: tasks, output, post_links (only when
|
||||||
// active editor tab is a post), git_log (only when active tab is post or
|
// active editor tab is a post), git_log (only when active tab is post or
|
||||||
// media).
|
// media).
|
||||||
let tasks_btn = button(text(t(locale, "common.tasks")).size(12).shaping(Shaping::Advanced))
|
let tasks_btn = button(
|
||||||
|
text(t(locale, "common.tasks"))
|
||||||
|
.size(12)
|
||||||
|
.shaping(Shaping::Advanced),
|
||||||
|
)
|
||||||
.on_press(Message::SetPanelTab(PanelTab::Tasks))
|
.on_press(Message::SetPanelTab(PanelTab::Tasks))
|
||||||
.padding([4, 8])
|
.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))
|
.on_press(Message::SetPanelTab(PanelTab::Output))
|
||||||
.padding([4, 8])
|
.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))
|
let close_btn = button(text("\u{2715}").size(12).shaping(Shaping::Advanced))
|
||||||
.on_press(Message::TogglePanel)
|
.on_press(Message::TogglePanel)
|
||||||
.padding([4, 6])
|
.padding([4, 6])
|
||||||
.style(close_btn_style);
|
.style(close_btn_style);
|
||||||
|
|
||||||
let mut tab_row: Vec<Element<'static, Message>> = vec![
|
let mut tab_row: Vec<Element<'static, Message>> = vec![tasks_btn.into(), output_btn.into()];
|
||||||
tasks_btn.into(),
|
|
||||||
output_btn.into(),
|
|
||||||
];
|
|
||||||
|
|
||||||
if active_tab_is_post {
|
if active_tab_is_post {
|
||||||
let post_links_btn = button(text(t(locale, "panel.postLinks")).size(12).shaping(Shaping::Advanced))
|
let post_links_btn = button(
|
||||||
|
text(t(locale, "panel.postLinks"))
|
||||||
|
.size(12)
|
||||||
|
.shaping(Shaping::Advanced),
|
||||||
|
)
|
||||||
.on_press(Message::SetPanelTab(PanelTab::PostLinks))
|
.on_press(Message::SetPanelTab(PanelTab::PostLinks))
|
||||||
.padding([4, 8])
|
.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());
|
tab_row.push(post_links_btn.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
if active_tab_is_post_or_media {
|
if active_tab_is_post_or_media {
|
||||||
let git_log_btn = button(text(t(locale, "panel.gitLog")).size(12).shaping(Shaping::Advanced))
|
let git_log_btn = button(
|
||||||
|
text(t(locale, "panel.gitLog"))
|
||||||
|
.size(12)
|
||||||
|
.shaping(Shaping::Advanced),
|
||||||
|
)
|
||||||
.on_press(Message::SetPanelTab(PanelTab::GitLog))
|
.on_press(Message::SetPanelTab(PanelTab::GitLog))
|
||||||
.padding([4, 8])
|
.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());
|
tab_row.push(git_log_btn.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +160,12 @@ pub fn view(
|
|||||||
let content: Element<'static, Message> = match panel_tab {
|
let content: Element<'static, Message> = match panel_tab {
|
||||||
PanelTab::Tasks => {
|
PanelTab::Tasks => {
|
||||||
if task_snapshots.is_empty() {
|
if task_snapshots.is_empty() {
|
||||||
container(text(t(locale, "tasks.noActive")).size(12).shaping(Shaping::Advanced).color(muted))
|
container(
|
||||||
|
text(t(locale, "tasks.noActive"))
|
||||||
|
.size(12)
|
||||||
|
.shaping(Shaping::Advanced)
|
||||||
|
.color(muted),
|
||||||
|
)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
@@ -137,21 +175,24 @@ pub fn view(
|
|||||||
.rev()
|
.rev()
|
||||||
.take(10)
|
.take(10)
|
||||||
.map(|snap| {
|
.map(|snap| {
|
||||||
let progress_str = snap.progress
|
let progress_str = snap
|
||||||
|
.progress
|
||||||
.map(|p| format!(" ({:.0}%)", p * 100.0))
|
.map(|p| format!(" ({:.0}%)", p * 100.0))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let phase_str = snap.message
|
let phase_str = snap
|
||||||
|
.message
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|m| format!(" \u{2014} {m}"))
|
.map(|m| format!(" \u{2014} {m}"))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let status_text = format!(
|
let status_text = format!(
|
||||||
"{} \u{2014} {}{}{}",
|
"{} \u{2014} {}{}{}",
|
||||||
snap.label,
|
snap.label, snap.status, progress_str, phase_str,
|
||||||
snap.status,
|
|
||||||
progress_str,
|
|
||||||
phase_str,
|
|
||||||
);
|
);
|
||||||
text(status_text).size(11).shaping(Shaping::Advanced).color(Color::from_rgb(0.70, 0.70, 0.75)).into()
|
text(status_text)
|
||||||
|
.size(11)
|
||||||
|
.shaping(Shaping::Advanced)
|
||||||
|
.color(Color::from_rgb(0.70, 0.70, 0.75))
|
||||||
|
.into()
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
scrollable(
|
scrollable(
|
||||||
@@ -164,7 +205,12 @@ pub fn view(
|
|||||||
}
|
}
|
||||||
PanelTab::Output => {
|
PanelTab::Output => {
|
||||||
if output_entries.is_empty() {
|
if output_entries.is_empty() {
|
||||||
container(text(t(locale, "panel.noOutput")).size(12).shaping(Shaping::Advanced).color(muted))
|
container(
|
||||||
|
text(t(locale, "panel.noOutput"))
|
||||||
|
.size(12)
|
||||||
|
.shaping(Shaping::Advanced)
|
||||||
|
.color(muted),
|
||||||
|
)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
@@ -188,7 +234,12 @@ pub fn view(
|
|||||||
}
|
}
|
||||||
PanelTab::PostLinks => {
|
PanelTab::PostLinks => {
|
||||||
if post_outlinks.is_empty() && post_backlinks.is_empty() {
|
if post_outlinks.is_empty() && post_backlinks.is_empty() {
|
||||||
container(text(t(locale, "panel.postLinksPlaceholder")).size(12).shaping(Shaping::Advanced).color(muted))
|
container(
|
||||||
|
text(t(locale, "panel.postLinksPlaceholder"))
|
||||||
|
.size(12)
|
||||||
|
.shaping(Shaping::Advanced)
|
||||||
|
.color(muted),
|
||||||
|
)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
@@ -201,7 +252,12 @@ pub fn view(
|
|||||||
];
|
];
|
||||||
|
|
||||||
if post_outlinks.is_empty() {
|
if post_outlinks.is_empty() {
|
||||||
items.push(text(t(locale, "panel.postLinksPlaceholder")).size(11).color(muted).into());
|
items.push(
|
||||||
|
text(t(locale, "panel.postLinksPlaceholder"))
|
||||||
|
.size(11)
|
||||||
|
.color(muted)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
for link in post_outlinks {
|
for link in post_outlinks {
|
||||||
items.push(post_link_button(locale, link));
|
items.push(post_link_button(locale, link));
|
||||||
@@ -218,7 +274,12 @@ pub fn view(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if post_backlinks.is_empty() {
|
if post_backlinks.is_empty() {
|
||||||
items.push(text(t(locale, "panel.postLinksPlaceholder")).size(11).color(muted).into());
|
items.push(
|
||||||
|
text(t(locale, "panel.postLinksPlaceholder"))
|
||||||
|
.size(11)
|
||||||
|
.color(muted)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
for link in post_backlinks {
|
for link in post_backlinks {
|
||||||
items.push(post_link_button(locale, link));
|
items.push(post_link_button(locale, link));
|
||||||
@@ -235,15 +296,18 @@ pub fn view(
|
|||||||
}
|
}
|
||||||
PanelTab::GitLog => {
|
PanelTab::GitLog => {
|
||||||
// Git Log content populated in extension bucket A (git integration)
|
// Git Log content populated in extension bucket A (git integration)
|
||||||
container(text(t(locale, "panel.gitLogPlaceholder")).size(12).shaping(Shaping::Advanced).color(muted))
|
container(
|
||||||
|
text(t(locale, "panel.gitLogPlaceholder"))
|
||||||
|
.size(12)
|
||||||
|
.shaping(Shaping::Advanced)
|
||||||
|
.color(muted),
|
||||||
|
)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
container(
|
container(column![tab_header, content].spacing(0))
|
||||||
column![tab_header, content].spacing(0),
|
|
||||||
)
|
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(Length::Fixed(200.0))
|
.height(Length::Fixed(200.0))
|
||||||
.style(panel_style)
|
.style(panel_style)
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ use std::cell::RefCell;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use iced::widget::text::{Shaping, Wrapping};
|
use iced::widget::text::{Shaping, Wrapping};
|
||||||
use iced::widget::{button, column, container, row, scrollable, text, text_input, Column, Space};
|
use iced::widget::{Column, Space, button, column, container, row, scrollable, text, text_input};
|
||||||
use iced::{Color, Element, Length, Theme};
|
use iced::{Color, Element, Length, Theme};
|
||||||
|
|
||||||
use bds_core::i18n::{self, UiLocale};
|
use bds_core::i18n::{self, UiLocale};
|
||||||
use bds_core::model::{Post, PostStatus, PostTranslation};
|
use bds_core::model::{Post, PostStatus, PostTranslation};
|
||||||
use bds_editor::{highlighter, CodeEditor, EditorBuffer, EditorMessage};
|
use bds_editor::{CodeEditor, EditorBuffer, EditorMessage, highlighter};
|
||||||
|
|
||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
use crate::components::inputs;
|
use crate::components::inputs;
|
||||||
@@ -297,7 +297,11 @@ impl PostEditorState {
|
|||||||
langs.sort();
|
langs.sort();
|
||||||
for lang in langs {
|
for lang in langs {
|
||||||
let locale = i18n::normalize_language(&lang);
|
let locale = i18n::normalize_language(&lang);
|
||||||
let status = match self.translation_drafts.get(&lang).map(|draft| &draft.status) {
|
let status = match self
|
||||||
|
.translation_drafts
|
||||||
|
.get(&lang)
|
||||||
|
.map(|draft| &draft.status)
|
||||||
|
{
|
||||||
Some(PostStatus::Published) => "published",
|
Some(PostStatus::Published) => "published",
|
||||||
Some(_) => "draft",
|
Some(_) => "draft",
|
||||||
None => "missing",
|
None => "missing",
|
||||||
@@ -480,10 +484,30 @@ pub fn view<'a>(
|
|||||||
Space::with_width(Length::Fill),
|
Space::with_width(Length::Fill),
|
||||||
container(
|
container(
|
||||||
column![
|
column![
|
||||||
quick_action_item(locale, t(locale, "editor.aiAnalyze"), PostEditorMsg::AnalyzeWithAi, ai_enabled),
|
quick_action_item(
|
||||||
quick_action_item(locale, t(locale, "editor.suggestTaxonomy"), PostEditorMsg::AnalyzeTaxonomy, ai_enabled),
|
locale,
|
||||||
quick_action_item(locale, t(locale, "editor.translate"), PostEditorMsg::Translate, ai_enabled),
|
t(locale, "editor.aiAnalyze"),
|
||||||
quick_action_item(locale, t(locale, "editor.detectLanguage"), PostEditorMsg::DetectLanguage, ai_enabled),
|
PostEditorMsg::AnalyzeWithAi,
|
||||||
|
ai_enabled
|
||||||
|
),
|
||||||
|
quick_action_item(
|
||||||
|
locale,
|
||||||
|
t(locale, "editor.suggestTaxonomy"),
|
||||||
|
PostEditorMsg::AnalyzeTaxonomy,
|
||||||
|
ai_enabled
|
||||||
|
),
|
||||||
|
quick_action_item(
|
||||||
|
locale,
|
||||||
|
t(locale, "editor.translate"),
|
||||||
|
PostEditorMsg::Translate,
|
||||||
|
ai_enabled
|
||||||
|
),
|
||||||
|
quick_action_item(
|
||||||
|
locale,
|
||||||
|
t(locale, "editor.detectLanguage"),
|
||||||
|
PostEditorMsg::DetectLanguage,
|
||||||
|
ai_enabled
|
||||||
|
),
|
||||||
]
|
]
|
||||||
.spacing(4)
|
.spacing(4)
|
||||||
)
|
)
|
||||||
@@ -525,7 +549,7 @@ pub fn view<'a>(
|
|||||||
let mut flag_row = row![].spacing(2);
|
let mut flag_row = row![].spacing(2);
|
||||||
for flag in &flags {
|
for flag in &flags {
|
||||||
let lang = flag.language.clone();
|
let lang = flag.language.clone();
|
||||||
let label = format!("{}", flag.flag_emoji);
|
let label = flag.flag_emoji.to_string();
|
||||||
let btn = button(text(label).size(14).shaping(Shaping::Advanced))
|
let btn = button(text(label).size(14).shaping(Shaping::Advanced))
|
||||||
.on_press(Message::PostEditor(PostEditorMsg::SwitchLanguage(lang)))
|
.on_press(Message::PostEditor(PostEditorMsg::SwitchLanguage(lang)))
|
||||||
.padding([2, 4])
|
.padding([2, 4])
|
||||||
@@ -574,7 +598,11 @@ pub fn view<'a>(
|
|||||||
Some(&state.language),
|
Some(&state.language),
|
||||||
|lang| Message::PostEditor(PostEditorMsg::LanguageChanged(lang)),
|
|lang| Message::PostEditor(PostEditorMsg::LanguageChanged(lang)),
|
||||||
);
|
);
|
||||||
let detect_language = button(text(t(locale, "editor.detectLanguage")).size(12).shaping(Shaping::Advanced))
|
let detect_language = button(
|
||||||
|
text(t(locale, "editor.detectLanguage"))
|
||||||
|
.size(12)
|
||||||
|
.shaping(Shaping::Advanced),
|
||||||
|
)
|
||||||
.on_press_maybe(ai_enabled.then_some(Message::PostEditor(PostEditorMsg::DetectLanguage)))
|
.on_press_maybe(ai_enabled.then_some(Message::PostEditor(PostEditorMsg::DetectLanguage)))
|
||||||
.padding([6, 12]);
|
.padding([6, 12]);
|
||||||
let template_input = inputs::labeled_input(
|
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() {
|
let outlinks_section: Element<'a, Message> = if state.outlinks.is_empty() {
|
||||||
Space::new(0, 0).into()
|
Space::new(0, 0).into()
|
||||||
} else {
|
} else {
|
||||||
let mut items: Vec<Element<'a, Message>> = vec![text(t(locale, "editor.outlinks"))
|
let mut items: Vec<Element<'a, Message>> = vec![
|
||||||
|
text(t(locale, "editor.outlinks"))
|
||||||
.size(12)
|
.size(12)
|
||||||
.color(inputs::LABEL_COLOR)
|
.color(inputs::LABEL_COLOR)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced)
|
||||||
.into()];
|
.into(),
|
||||||
|
];
|
||||||
for link in &state.outlinks {
|
for link in &state.outlinks {
|
||||||
items.push(
|
items.push(
|
||||||
text(format!("\u{2192} {}", link.title))
|
text(format!("\u{2192} {}", link.title))
|
||||||
@@ -639,11 +669,13 @@ pub fn view<'a>(
|
|||||||
let backlinks_section: Element<'a, Message> = if state.backlinks.is_empty() {
|
let backlinks_section: Element<'a, Message> = if state.backlinks.is_empty() {
|
||||||
Space::new(0, 0).into()
|
Space::new(0, 0).into()
|
||||||
} else {
|
} else {
|
||||||
let mut items: Vec<Element<'a, Message>> = vec![text(t(locale, "editor.backlinks"))
|
let mut items: Vec<Element<'a, Message>> = vec![
|
||||||
|
text(t(locale, "editor.backlinks"))
|
||||||
.size(12)
|
.size(12)
|
||||||
.color(inputs::LABEL_COLOR)
|
.color(inputs::LABEL_COLOR)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced)
|
||||||
.into()];
|
.into(),
|
||||||
|
];
|
||||||
for link in &state.backlinks {
|
for link in &state.backlinks {
|
||||||
items.push(
|
items.push(
|
||||||
text(format!("\u{2190} {}", link.title))
|
text(format!("\u{2190} {}", link.title))
|
||||||
@@ -712,11 +744,21 @@ pub fn view<'a>(
|
|||||||
]
|
]
|
||||||
.spacing(2)
|
.spacing(2)
|
||||||
.width(Length::Fill),
|
.width(Length::Fill),
|
||||||
button(text(t(locale, "common.open")).size(11).shaping(Shaping::Advanced))
|
button(
|
||||||
|
text(t(locale, "common.open"))
|
||||||
|
.size(11)
|
||||||
|
.shaping(Shaping::Advanced)
|
||||||
|
)
|
||||||
.on_press(Message::PostEditor(PostEditorMsg::OpenLinkedMedia(open_id)))
|
.on_press(Message::PostEditor(PostEditorMsg::OpenLinkedMedia(open_id)))
|
||||||
.padding([4, 10]),
|
.padding([4, 10]),
|
||||||
button(text(t(locale, "editor.unlinkMedia")).size(11).shaping(Shaping::Advanced))
|
button(
|
||||||
.on_press(Message::PostEditor(PostEditorMsg::UnlinkLinkedMedia(media_id)))
|
text(t(locale, "editor.unlinkMedia"))
|
||||||
|
.size(11)
|
||||||
|
.shaping(Shaping::Advanced)
|
||||||
|
)
|
||||||
|
.on_press(Message::PostEditor(PostEditorMsg::UnlinkLinkedMedia(
|
||||||
|
media_id
|
||||||
|
)))
|
||||||
.padding([4, 10])
|
.padding([4, 10])
|
||||||
.style(inputs::danger_button),
|
.style(inputs::danger_button),
|
||||||
]
|
]
|
||||||
@@ -725,7 +767,9 @@ pub fn view<'a>(
|
|||||||
)
|
)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.style(|_: &Theme| container::Style {
|
.style(|_: &Theme| container::Style {
|
||||||
background: Some(iced::Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
|
background: Some(iced::Background::Color(Color::from_rgb(
|
||||||
|
0.16, 0.18, 0.22,
|
||||||
|
))),
|
||||||
border: iced::Border {
|
border: iced::Border {
|
||||||
color: Color::from_rgb(0.28, 0.28, 0.32),
|
color: Color::from_rgb(0.28, 0.28, 0.32),
|
||||||
width: 1.0,
|
width: 1.0,
|
||||||
@@ -788,13 +832,31 @@ pub fn view<'a>(
|
|||||||
// ── Content section (fills remaining space) ──
|
// ── Content section (fills remaining space) ──
|
||||||
let content_label = inputs::section_header(&t(locale, "editor.content"));
|
let content_label = inputs::section_header(&t(locale, "editor.content"));
|
||||||
let mode_toggle = row![
|
let mode_toggle = row![
|
||||||
mode_button(locale, &state.editor_mode, "markdown", Message::PostEditor(PostEditorMsg::SwitchEditorMode("markdown".to_string()))),
|
mode_button(
|
||||||
mode_button(locale, &state.editor_mode, "preview", Message::PostEditor(PostEditorMsg::SwitchEditorMode("preview".to_string()))),
|
locale,
|
||||||
|
&state.editor_mode,
|
||||||
|
"markdown",
|
||||||
|
Message::PostEditor(PostEditorMsg::SwitchEditorMode("markdown".to_string()))
|
||||||
|
),
|
||||||
|
mode_button(
|
||||||
|
locale,
|
||||||
|
&state.editor_mode,
|
||||||
|
"preview",
|
||||||
|
Message::PostEditor(PostEditorMsg::SwitchEditorMode("preview".to_string()))
|
||||||
|
),
|
||||||
]
|
]
|
||||||
.spacing(6)
|
.spacing(6)
|
||||||
.align_y(iced::Alignment::Center);
|
.align_y(iced::Alignment::Center);
|
||||||
let body_toolbar = inputs::toolbar(
|
let body_toolbar = inputs::toolbar(
|
||||||
vec![row![content_label, Space::with_width(Length::Fixed(12.0)), mode_toggle].align_y(iced::Alignment::Center).into()],
|
vec![
|
||||||
|
row![
|
||||||
|
content_label,
|
||||||
|
Space::with_width(Length::Fixed(12.0)),
|
||||||
|
mode_toggle
|
||||||
|
]
|
||||||
|
.align_y(iced::Alignment::Center)
|
||||||
|
.into(),
|
||||||
|
],
|
||||||
vec![
|
vec![
|
||||||
if state.editor_mode == "markdown" {
|
if state.editor_mode == "markdown" {
|
||||||
button(
|
button(
|
||||||
@@ -980,7 +1042,12 @@ fn mode_button<'a>(
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn quick_action_item<'a>(locale: UiLocale, label: String, msg: PostEditorMsg, enabled: bool) -> Element<'a, Message> {
|
fn quick_action_item<'a>(
|
||||||
|
locale: UiLocale,
|
||||||
|
label: String,
|
||||||
|
msg: PostEditorMsg,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
let _ = locale;
|
let _ = locale;
|
||||||
button(text(label).size(12).shaping(Shaping::Advanced))
|
button(text(label).size(12).shaping(Shaping::Advanced))
|
||||||
.on_press_maybe(enabled.then_some(Message::PostEditor(msg)))
|
.on_press_maybe(enabled.then_some(Message::PostEditor(msg)))
|
||||||
@@ -1138,5 +1205,4 @@ mod tests {
|
|||||||
state.set_editor_mode("preview");
|
state.set_editor_mode("preview");
|
||||||
assert_eq!(state.editor_mode, "preview");
|
assert_eq!(state.editor_mode, "preview");
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use iced::widget::{button, container, row, svg, text, Column, Space};
|
|
||||||
use iced::widget::text::Shaping;
|
use iced::widget::text::Shaping;
|
||||||
|
use iced::widget::{Column, Space, button, container, row, svg, text};
|
||||||
use iced::{Background, Border, Color, Element, Length, Theme};
|
use iced::{Background, Border, Color, Element, Length, Theme};
|
||||||
|
|
||||||
use bds_core::i18n::UiLocale;
|
use bds_core::i18n::UiLocale;
|
||||||
@@ -120,19 +120,32 @@ pub fn view(
|
|||||||
|
|
||||||
let label = if is_active {
|
let label = if is_active {
|
||||||
row![
|
row![
|
||||||
text("\u{2713}").size(12).shaping(Shaping::Advanced).color(Color::from_rgb(0.40, 0.80, 0.40)),
|
text("\u{2713}")
|
||||||
text(name).size(12).shaping(Shaping::Advanced).color(Color::WHITE),
|
.size(12)
|
||||||
|
.shaping(Shaping::Advanced)
|
||||||
|
.color(Color::from_rgb(0.40, 0.80, 0.40)),
|
||||||
|
text(name)
|
||||||
|
.size(12)
|
||||||
|
.shaping(Shaping::Advanced)
|
||||||
|
.color(Color::WHITE),
|
||||||
]
|
]
|
||||||
.spacing(6)
|
.spacing(6)
|
||||||
} else {
|
} else {
|
||||||
row![
|
row![
|
||||||
Space::with_width(Length::Fixed(14.0)),
|
Space::with_width(Length::Fixed(14.0)),
|
||||||
text(name).size(12).shaping(Shaping::Advanced).color(Color::from_rgb(0.80, 0.80, 0.85)),
|
text(name)
|
||||||
|
.size(12)
|
||||||
|
.shaping(Shaping::Advanced)
|
||||||
|
.color(Color::from_rgb(0.80, 0.80, 0.85)),
|
||||||
]
|
]
|
||||||
.spacing(6)
|
.spacing(6)
|
||||||
};
|
};
|
||||||
|
|
||||||
let style_fn = if is_active { project_item_active } else { project_item };
|
let style_fn = if is_active {
|
||||||
|
project_item_active
|
||||||
|
} else {
|
||||||
|
project_item
|
||||||
|
};
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
button(label)
|
button(label)
|
||||||
@@ -159,8 +172,13 @@ pub fn view(
|
|||||||
items.push(
|
items.push(
|
||||||
button(
|
button(
|
||||||
row![
|
row![
|
||||||
text("+").size(14).shaping(Shaping::Advanced).color(Color::from_rgb(0.55, 0.75, 0.95)),
|
text("+")
|
||||||
text(t(locale, "projectSelector.newProject")).size(12).shaping(Shaping::Advanced),
|
.size(14)
|
||||||
|
.shaping(Shaping::Advanced)
|
||||||
|
.color(Color::from_rgb(0.55, 0.75, 0.95)),
|
||||||
|
text(t(locale, "projectSelector.newProject"))
|
||||||
|
.size(12)
|
||||||
|
.shaping(Shaping::Advanced),
|
||||||
]
|
]
|
||||||
.spacing(6),
|
.spacing(6),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
|
||||||
use iced::widget::{button, column, container, row, scrollable, text, Space};
|
use iced::widget::{Space, button, column, container, row, scrollable, text};
|
||||||
use iced::{Color, Element, Length, Theme};
|
use iced::{Color, Element, Length, Theme};
|
||||||
|
|
||||||
use bds_core::i18n::UiLocale;
|
use bds_core::i18n::UiLocale;
|
||||||
@@ -118,10 +118,7 @@ pub enum ScriptEditorMsg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Render the script editor view.
|
/// Render the script editor view.
|
||||||
pub fn view<'a>(
|
pub fn view<'a>(state: &'a ScriptEditorState, locale: UiLocale) -> Element<'a, Message> {
|
||||||
state: &'a ScriptEditorState,
|
|
||||||
locale: UiLocale,
|
|
||||||
) -> Element<'a, Message> {
|
|
||||||
let header = inputs::toolbar(
|
let header = inputs::toolbar(
|
||||||
vec![
|
vec![
|
||||||
text(state.title.clone()).size(18).into(),
|
text(state.title.clone()).size(18).into(),
|
||||||
@@ -162,7 +159,9 @@ pub fn view<'a>(
|
|||||||
&state.slug,
|
&state.slug,
|
||||||
|s| Message::ScriptEditor(ScriptEditorMsg::SlugChanged(s)),
|
|s| Message::ScriptEditor(ScriptEditorMsg::SlugChanged(s)),
|
||||||
);
|
);
|
||||||
let meta_row1 = row![title_input, slug_input].spacing(16).width(Length::Fill);
|
let meta_row1 = row![title_input, slug_input]
|
||||||
|
.spacing(16)
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
// Metadata row 2: kind, entrypoint, enabled
|
// Metadata row 2: kind, entrypoint, enabled
|
||||||
let kind_options = vec![
|
let kind_options = vec![
|
||||||
@@ -179,7 +178,10 @@ pub fn view<'a>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut entrypoint_options = state.discovered_entrypoints.clone();
|
let mut entrypoint_options = state.discovered_entrypoints.clone();
|
||||||
if !entrypoint_options.iter().any(|entrypoint| entrypoint == &state.entrypoint) {
|
if !entrypoint_options
|
||||||
|
.iter()
|
||||||
|
.any(|entrypoint| entrypoint == &state.entrypoint)
|
||||||
|
{
|
||||||
entrypoint_options.push(state.entrypoint.clone());
|
entrypoint_options.push(state.entrypoint.clone());
|
||||||
}
|
}
|
||||||
let selected_entrypoint = entrypoint_options
|
let selected_entrypoint = entrypoint_options
|
||||||
@@ -192,28 +194,30 @@ pub fn view<'a>(
|
|||||||
|entrypoint| Message::ScriptEditor(ScriptEditorMsg::EntrypointChanged(entrypoint)),
|
|entrypoint| Message::ScriptEditor(ScriptEditorMsg::EntrypointChanged(entrypoint)),
|
||||||
);
|
);
|
||||||
|
|
||||||
let enabled_check = inputs::labeled_checkbox(
|
let enabled_check =
|
||||||
&t(locale, "editor.enabled"),
|
inputs::labeled_checkbox(&t(locale, "editor.enabled"), state.enabled, |b| {
|
||||||
state.enabled,
|
Message::ScriptEditor(ScriptEditorMsg::EnabledChanged(b))
|
||||||
|b| Message::ScriptEditor(ScriptEditorMsg::EnabledChanged(b)),
|
});
|
||||||
);
|
|
||||||
let meta_row2 = row![kind_select, entrypoint_input, enabled_check]
|
let meta_row2 = row![kind_select, entrypoint_input, enabled_check]
|
||||||
.spacing(16)
|
.spacing(16)
|
||||||
.width(Length::Fill);
|
.width(Length::Fill);
|
||||||
|
|
||||||
// Content editor (CodeEditor with syntax highlighting based on file extension)
|
// Content editor (CodeEditor with syntax highlighting based on file extension)
|
||||||
let syntax_ext = if state.file_path.ends_with(".py") { "py" } else { "lua" };
|
let syntax_ext = if state.file_path.ends_with(".py") {
|
||||||
|
"py"
|
||||||
|
} else {
|
||||||
|
"lua"
|
||||||
|
};
|
||||||
let content_section: Element<'a, Message> = column![
|
let content_section: Element<'a, Message> = column![
|
||||||
inputs::section_header(&t(locale, "editor.content")),
|
inputs::section_header(&t(locale, "editor.content")),
|
||||||
Element::from(
|
Element::from(
|
||||||
CodeEditor::new(
|
CodeEditor::new(&state.editor_buffer, highlighter(), syntax_ext,).on_change(|msg| {
|
||||||
&state.editor_buffer,
|
match msg {
|
||||||
highlighter(),
|
EditorMessage::ContentChanged(s) => {
|
||||||
syntax_ext,
|
Message::ScriptEditor(ScriptEditorMsg::ContentChanged(s))
|
||||||
)
|
}
|
||||||
.on_change(|msg| match msg {
|
|
||||||
EditorMessage::ContentChanged(s) => Message::ScriptEditor(ScriptEditorMsg::ContentChanged(s)),
|
|
||||||
EditorMessage::SaveRequested => Message::ScriptEditor(ScriptEditorMsg::Save),
|
EditorMessage::SaveRequested => Message::ScriptEditor(ScriptEditorMsg::Save),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -245,24 +249,15 @@ pub fn view<'a>(
|
|||||||
|
|
||||||
// Top pane: header + metadata (scrollable for overflow)
|
// Top pane: header + metadata (scrollable for overflow)
|
||||||
let top_pane = scrollable(
|
let top_pane = scrollable(
|
||||||
column![
|
column![header, meta_row1, meta_row2,]
|
||||||
header,
|
|
||||||
meta_row1,
|
|
||||||
meta_row2,
|
|
||||||
]
|
|
||||||
.spacing(12)
|
.spacing(12)
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.width(Length::Fill)
|
.width(Length::Fill),
|
||||||
)
|
)
|
||||||
.height(Length::Shrink);
|
.height(Length::Shrink);
|
||||||
|
|
||||||
// Full layout: top pane (shrink), content (fill), validation + footer (shrink)
|
// Full layout: top pane (shrink), content (fill), validation + footer (shrink)
|
||||||
column![
|
column![top_pane, content_section, validation, footer,]
|
||||||
top_pane,
|
|
||||||
content_section,
|
|
||||||
validation,
|
|
||||||
footer,
|
|
||||||
]
|
|
||||||
.spacing(4)
|
.spacing(4)
|
||||||
.padding([0, 16])
|
.padding([0, 16])
|
||||||
.width(Length::Fill)
|
.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::text::Shaping;
|
||||||
|
use iced::widget::{button, column, container, row, scrollable, text, text_editor, text_input};
|
||||||
use iced::{Alignment, Color, Element, Length, Theme};
|
use iced::{Alignment, Color, Element, Length, Theme};
|
||||||
|
|
||||||
use bds_core::i18n::UiLocale;
|
use bds_core::i18n::UiLocale;
|
||||||
@@ -209,7 +209,13 @@ impl Default for SettingsViewState {
|
|||||||
public_url: String::new(),
|
public_url: String::new(),
|
||||||
main_language: "en".to_string(),
|
main_language: "en".to_string(),
|
||||||
blog_languages: vec!["en".to_string()],
|
blog_languages: vec!["en".to_string()],
|
||||||
available_languages: vec!["en".to_string(), "de".to_string(), "fr".to_string(), "it".to_string(), "es".to_string()],
|
available_languages: vec![
|
||||||
|
"en".to_string(),
|
||||||
|
"de".to_string(),
|
||||||
|
"fr".to_string(),
|
||||||
|
"it".to_string(),
|
||||||
|
"es".to_string(),
|
||||||
|
],
|
||||||
default_author: String::new(),
|
default_author: String::new(),
|
||||||
max_posts_per_page: "50".to_string(),
|
max_posts_per_page: "50".to_string(),
|
||||||
blogmark_category: String::new(),
|
blogmark_category: String::new(),
|
||||||
@@ -255,12 +261,12 @@ impl SettingsViewState {
|
|||||||
|
|
||||||
fn ordered_sections(&self) -> Vec<SettingsSection> {
|
fn ordered_sections(&self) -> Vec<SettingsSection> {
|
||||||
let mut sections = SettingsSection::all().to_vec();
|
let mut sections = SettingsSection::all().to_vec();
|
||||||
if let Some(active) = &self.active_section {
|
if let Some(active) = &self.active_section
|
||||||
if let Some(index) = sections.iter().position(|section| section == active) {
|
&& let Some(index) = sections.iter().position(|section| section == active)
|
||||||
|
{
|
||||||
let focused = sections.remove(index);
|
let focused = sections.remove(index);
|
||||||
sections.insert(0, focused);
|
sections.insert(0, focused);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
sections
|
sections
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,10 +343,7 @@ pub enum SettingsMsg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Render the settings view.
|
/// Render the settings view.
|
||||||
pub fn view<'a>(
|
pub fn view<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
|
||||||
state: &'a SettingsViewState,
|
|
||||||
locale: UiLocale,
|
|
||||||
) -> Element<'a, Message> {
|
|
||||||
let search = text_input(&t(locale, "sidebar.filter.search"), &state.search_query)
|
let search = text_input(&t(locale, "sidebar.filter.search"), &state.search_query)
|
||||||
.on_input(|s| Message::Settings(SettingsMsg::SearchChanged(s)))
|
.on_input(|s| Message::Settings(SettingsMsg::SearchChanged(s)))
|
||||||
.size(14);
|
.size(14);
|
||||||
@@ -388,38 +391,6 @@ pub fn view<'a>(
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{SettingsSection, SettingsViewState};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn focus_section_collapses_other_sections_and_clears_search() {
|
|
||||||
let mut state = SettingsViewState {
|
|
||||||
search_query: "publish".to_string(),
|
|
||||||
..SettingsViewState::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
state.focus_section(SettingsSection::Publishing);
|
|
||||||
|
|
||||||
assert_eq!(state.active_section, Some(SettingsSection::Publishing));
|
|
||||||
assert!(state.search_query.is_empty());
|
|
||||||
assert!(!state.collapsed.contains(&SettingsSection::Publishing));
|
|
||||||
assert!(state.collapsed.contains(&SettingsSection::Project));
|
|
||||||
assert!(state.collapsed.contains(&SettingsSection::MCP));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn focused_section_is_rendered_first() {
|
|
||||||
let mut state = SettingsViewState::default();
|
|
||||||
state.focus_section(SettingsSection::Technology);
|
|
||||||
|
|
||||||
let ordered = state.ordered_sections();
|
|
||||||
|
|
||||||
assert_eq!(ordered.first(), Some(&SettingsSection::Technology));
|
|
||||||
assert_eq!(ordered.len(), SettingsSection::all().len());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_section<'a>(
|
fn render_section<'a>(
|
||||||
state: &'a SettingsViewState,
|
state: &'a SettingsViewState,
|
||||||
section: &SettingsSection,
|
section: &SettingsSection,
|
||||||
@@ -436,7 +407,9 @@ fn render_section<'a>(
|
|||||||
.spacing(8)
|
.spacing(8)
|
||||||
.align_y(Alignment::Center),
|
.align_y(Alignment::Center),
|
||||||
)
|
)
|
||||||
.on_press(Message::Settings(SettingsMsg::ToggleSection(section.clone())))
|
.on_press(Message::Settings(SettingsMsg::ToggleSection(
|
||||||
|
section.clone(),
|
||||||
|
)))
|
||||||
.padding([6, 8])
|
.padding([6, 8])
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.style(|_: &Theme, _| button::Style::default());
|
.style(|_: &Theme, _| button::Style::default());
|
||||||
@@ -456,9 +429,7 @@ fn render_section<'a>(
|
|||||||
SettingsSection::MCP => section_mcp(locale),
|
SettingsSection::MCP => section_mcp(locale),
|
||||||
};
|
};
|
||||||
|
|
||||||
column![header, content]
|
column![header, content].spacing(4).into()
|
||||||
.spacing(4)
|
|
||||||
.into()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
|
fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
|
||||||
@@ -469,7 +440,10 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
|||||||
|s| Message::Settings(SettingsMsg::ProjectNameChanged(s)),
|
|s| Message::Settings(SettingsMsg::ProjectNameChanged(s)),
|
||||||
);
|
);
|
||||||
let desc = column![
|
let desc = column![
|
||||||
text(t(locale, "settings.projectDescription")).size(12).color(inputs::LABEL_COLOR).shaping(Shaping::Advanced),
|
text(t(locale, "settings.projectDescription"))
|
||||||
|
.size(12)
|
||||||
|
.color(inputs::LABEL_COLOR)
|
||||||
|
.shaping(Shaping::Advanced),
|
||||||
text_editor(&state.project_description)
|
text_editor(&state.project_description)
|
||||||
.on_action(|a| Message::Settings(SettingsMsg::ProjectDescriptionAction(a)))
|
.on_action(|a| Message::Settings(SettingsMsg::ProjectDescriptionAction(a)))
|
||||||
.height(Length::Fixed(80.0))
|
.height(Length::Fixed(80.0))
|
||||||
@@ -477,12 +451,9 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
|||||||
]
|
]
|
||||||
.spacing(4);
|
.spacing(4);
|
||||||
let data_path = row![
|
let data_path = row![
|
||||||
inputs::labeled_input(
|
inputs::labeled_input(&t(locale, "settings.dataPath"), "", &state.data_path, |s| {
|
||||||
&t(locale, "settings.dataPath"),
|
Message::Settings(SettingsMsg::DataPathChanged(s))
|
||||||
"",
|
},),
|
||||||
&state.data_path,
|
|
||||||
|s| Message::Settings(SettingsMsg::DataPathChanged(s)),
|
|
||||||
),
|
|
||||||
button(text(t(locale, "settings.browse")).size(12))
|
button(text(t(locale, "settings.browse")).size(12))
|
||||||
.on_press(Message::Settings(SettingsMsg::BrowseDataPath))
|
.on_press(Message::Settings(SettingsMsg::BrowseDataPath))
|
||||||
.padding([6, 12]),
|
.padding([6, 12]),
|
||||||
@@ -512,14 +483,24 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|language| {
|
.map(|language| {
|
||||||
let label = if *language == state.main_language {
|
let label = if *language == state.main_language {
|
||||||
format!("{} ({})", language, t(locale, "settings.mainLanguageRequired"))
|
format!(
|
||||||
|
"{} ({})",
|
||||||
|
language,
|
||||||
|
t(locale, "settings.mainLanguageRequired")
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
language.clone()
|
language.clone()
|
||||||
};
|
};
|
||||||
inputs::labeled_checkbox(&label, state.blog_languages.iter().any(|item| item == language), {
|
inputs::labeled_checkbox(
|
||||||
|
&label,
|
||||||
|
state.blog_languages.iter().any(|item| item == language),
|
||||||
|
{
|
||||||
let language = language.clone();
|
let language = language.clone();
|
||||||
move |_| Message::Settings(SettingsMsg::ToggleBlogLanguage(language.clone()))
|
move |_| {
|
||||||
})
|
Message::Settings(SettingsMsg::ToggleBlogLanguage(language.clone()))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
)
|
)
|
||||||
@@ -539,7 +520,10 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
|||||||
let blogmark_category = inputs::labeled_select(
|
let blogmark_category = inputs::labeled_select(
|
||||||
&t(locale, "settings.blogmarkCategory"),
|
&t(locale, "settings.blogmarkCategory"),
|
||||||
&state.categories,
|
&state.categories,
|
||||||
state.categories.iter().find(|row| row.name == state.blogmark_category),
|
state
|
||||||
|
.categories
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.name == state.blogmark_category),
|
||||||
|row| Message::Settings(SettingsMsg::BlogmarkCategoryChanged(row.name)),
|
|row| Message::Settings(SettingsMsg::BlogmarkCategoryChanged(row.name)),
|
||||||
);
|
);
|
||||||
let save = button(text(t(locale, "common.save")).size(13))
|
let save = button(text(t(locale, "common.save")).size(13))
|
||||||
@@ -616,14 +600,23 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
|||||||
.chain(state.template_options.iter().cloned())
|
.chain(state.template_options.iter().cloned())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let category_rows = state.categories.iter().fold(column![].spacing(8), |column, category| {
|
let category_rows = state
|
||||||
|
.categories
|
||||||
|
.iter()
|
||||||
|
.fold(column![].spacing(8), |column, category| {
|
||||||
let title = inputs::labeled_input(
|
let title = inputs::labeled_input(
|
||||||
&tw(locale, "settings.categoryTitle", &[("category", &category.name)]),
|
&tw(
|
||||||
|
locale,
|
||||||
|
"settings.categoryTitle",
|
||||||
|
&[("category", &category.name)],
|
||||||
|
),
|
||||||
"",
|
"",
|
||||||
&category.title,
|
&category.title,
|
||||||
{
|
{
|
||||||
let name = category.name.clone();
|
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(
|
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),
|
Some(&category.post_template_slug),
|
||||||
{
|
{
|
||||||
let name = category.name.clone();
|
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(
|
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),
|
Some(&category.list_template_slug),
|
||||||
{
|
{
|
||||||
let name = category.name.clone();
|
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![
|
let toggles = column![
|
||||||
@@ -650,7 +653,12 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
|||||||
category.render_in_lists,
|
category.render_in_lists,
|
||||||
{
|
{
|
||||||
let name = category.name.clone();
|
let name = category.name.clone();
|
||||||
move |value| Message::Settings(SettingsMsg::CategoryRenderInListsChanged(name.clone(), value))
|
move |value| {
|
||||||
|
Message::Settings(SettingsMsg::CategoryRenderInListsChanged(
|
||||||
|
name.clone(),
|
||||||
|
value,
|
||||||
|
))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
inputs::labeled_checkbox(
|
inputs::labeled_checkbox(
|
||||||
@@ -658,18 +666,27 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
|||||||
category.show_title,
|
category.show_title,
|
||||||
{
|
{
|
||||||
let name = category.name.clone();
|
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);
|
.spacing(6);
|
||||||
let actions = row![
|
let actions = row![
|
||||||
button(text(t(locale, "common.save")).size(13))
|
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)
|
.style(inputs::primary_button)
|
||||||
.padding([6, 12]),
|
.padding([6, 12]),
|
||||||
button(text(t(locale, "common.remove")).size(13))
|
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)
|
.style(inputs::danger_button)
|
||||||
.padding([6, 12]),
|
.padding([6, 12]),
|
||||||
]
|
]
|
||||||
@@ -732,7 +749,12 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
|
|||||||
label: t(locale, "tags.noTemplate"),
|
label: t(locale, "tags.noTemplate"),
|
||||||
supports_vision: false,
|
supports_vision: false,
|
||||||
})
|
})
|
||||||
.chain(active_model_options.iter().filter(|option| option.supports_vision).cloned())
|
.chain(
|
||||||
|
active_model_options
|
||||||
|
.iter()
|
||||||
|
.filter(|option| option.supports_vision)
|
||||||
|
.cloned(),
|
||||||
|
)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let offline = inputs::labeled_checkbox(
|
let offline = inputs::labeled_checkbox(
|
||||||
@@ -786,23 +808,32 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
|
|||||||
let default_model = inputs::labeled_select(
|
let default_model = inputs::labeled_select(
|
||||||
&t(locale, "settings.defaultModel"),
|
&t(locale, "settings.defaultModel"),
|
||||||
&model_options,
|
&model_options,
|
||||||
model_options.iter().find(|option| option.id == state.default_model),
|
model_options
|
||||||
|
.iter()
|
||||||
|
.find(|option| option.id == state.default_model),
|
||||||
|option| Message::Settings(SettingsMsg::DefaultModelChanged(option.id)),
|
|option| Message::Settings(SettingsMsg::DefaultModelChanged(option.id)),
|
||||||
);
|
);
|
||||||
let title_model = inputs::labeled_select(
|
let title_model = inputs::labeled_select(
|
||||||
&t(locale, "settings.titleModel"),
|
&t(locale, "settings.titleModel"),
|
||||||
&model_options,
|
&model_options,
|
||||||
model_options.iter().find(|option| option.id == state.title_model),
|
model_options
|
||||||
|
.iter()
|
||||||
|
.find(|option| option.id == state.title_model),
|
||||||
|option| Message::Settings(SettingsMsg::TitleModelChanged(option.id)),
|
|option| Message::Settings(SettingsMsg::TitleModelChanged(option.id)),
|
||||||
);
|
);
|
||||||
let image_model = inputs::labeled_select(
|
let image_model = inputs::labeled_select(
|
||||||
&t(locale, "settings.imageAnalysisModel"),
|
&t(locale, "settings.imageAnalysisModel"),
|
||||||
&image_model_options,
|
&image_model_options,
|
||||||
image_model_options.iter().find(|option| option.id == state.image_model),
|
image_model_options
|
||||||
|
.iter()
|
||||||
|
.find(|option| option.id == state.image_model),
|
||||||
|option| Message::Settings(SettingsMsg::ImageModelChanged(option.id)),
|
|option| Message::Settings(SettingsMsg::ImageModelChanged(option.id)),
|
||||||
);
|
);
|
||||||
let prompt = column![
|
let prompt = column![
|
||||||
text(t(locale, "settings.systemPrompt")).size(12).color(inputs::LABEL_COLOR).shaping(Shaping::Advanced),
|
text(t(locale, "settings.systemPrompt"))
|
||||||
|
.size(12)
|
||||||
|
.color(inputs::LABEL_COLOR)
|
||||||
|
.shaping(Shaping::Advanced),
|
||||||
text_editor(&state.system_prompt)
|
text_editor(&state.system_prompt)
|
||||||
.on_action(|a| Message::Settings(SettingsMsg::SystemPromptAction(a)))
|
.on_action(|a| Message::Settings(SettingsMsg::SystemPromptAction(a)))
|
||||||
.height(Length::Fixed(200.0))
|
.height(Length::Fixed(200.0))
|
||||||
@@ -822,11 +853,15 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
|
|||||||
|
|
||||||
column![
|
column![
|
||||||
offline,
|
offline,
|
||||||
text(t(locale, "settings.onlineEndpointSection")).size(12).color(inputs::LABEL_COLOR),
|
text(t(locale, "settings.onlineEndpointSection"))
|
||||||
|
.size(12)
|
||||||
|
.color(inputs::LABEL_COLOR),
|
||||||
online_url,
|
online_url,
|
||||||
row![online_model, online_api_key].spacing(12),
|
row![online_model, online_api_key].spacing(12),
|
||||||
online_refresh,
|
online_refresh,
|
||||||
text(t(locale, "settings.airplaneEndpointSection")).size(12).color(inputs::LABEL_COLOR),
|
text(t(locale, "settings.airplaneEndpointSection"))
|
||||||
|
.size(12)
|
||||||
|
.color(inputs::LABEL_COLOR),
|
||||||
airplane_url,
|
airplane_url,
|
||||||
row![airplane_model, airplane_refresh].spacing(12),
|
row![airplane_model, airplane_refresh].spacing(12),
|
||||||
default_model,
|
default_model,
|
||||||
@@ -857,12 +892,9 @@ fn section_technology<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Ele
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn section_publishing<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
|
fn section_publishing<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
|
||||||
let host = inputs::labeled_input(
|
let host = inputs::labeled_input(&t(locale, "settings.sshHost"), "", &state.ssh_host, |s| {
|
||||||
&t(locale, "settings.sshHost"),
|
Message::Settings(SettingsMsg::SshHostChanged(s))
|
||||||
"",
|
});
|
||||||
&state.ssh_host,
|
|
||||||
|s| Message::Settings(SettingsMsg::SshHostChanged(s)),
|
|
||||||
);
|
|
||||||
let user = inputs::labeled_input(
|
let user = inputs::labeled_input(
|
||||||
&t(locale, "settings.sshUsername"),
|
&t(locale, "settings.sshUsername"),
|
||||||
"",
|
"",
|
||||||
@@ -939,3 +971,35 @@ fn section_mcp<'a>(locale: UiLocale) -> Element<'a, Message> {
|
|||||||
.color(Color::from_rgb(0.5, 0.5, 0.5))
|
.color(Color::from_rgb(0.5, 0.5, 0.5))
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{SettingsSection, SettingsViewState};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn focus_section_collapses_other_sections_and_clears_search() {
|
||||||
|
let mut state = SettingsViewState {
|
||||||
|
search_query: "publish".to_string(),
|
||||||
|
..SettingsViewState::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
state.focus_section(SettingsSection::Publishing);
|
||||||
|
|
||||||
|
assert_eq!(state.active_section, Some(SettingsSection::Publishing));
|
||||||
|
assert!(state.search_query.is_empty());
|
||||||
|
assert!(!state.collapsed.contains(&SettingsSection::Publishing));
|
||||||
|
assert!(state.collapsed.contains(&SettingsSection::Project));
|
||||||
|
assert!(state.collapsed.contains(&SettingsSection::MCP));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn focused_section_is_rendered_first() {
|
||||||
|
let mut state = SettingsViewState::default();
|
||||||
|
state.focus_section(SettingsSection::Technology);
|
||||||
|
|
||||||
|
let ordered = state.ordered_sections();
|
||||||
|
|
||||||
|
assert_eq!(ordered.first(), Some(&SettingsSection::Technology));
|
||||||
|
assert_eq!(ordered.len(), SettingsSection::all().len());
|
||||||
|
}
|
||||||
|
}
|
||||||
+132
-134
@@ -1,7 +1,7 @@
|
|||||||
use std::path::{Path, PathBuf};
|
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::text::Shaping;
|
||||||
|
use iced::widget::{Space, button, column, container, image, row, scrollable, text, text_input};
|
||||||
use iced::{Background, Border, Color, Element, Length, Theme};
|
use iced::{Background, Border, Color, Element, Length, Theme};
|
||||||
|
|
||||||
use bds_core::i18n::UiLocale;
|
use bds_core::i18n::UiLocale;
|
||||||
@@ -269,9 +269,18 @@ fn clear_filters_style(_theme: &Theme, status: button::Status) -> button::Style
|
|||||||
/// Month name abbreviation for calendar display.
|
/// Month name abbreviation for calendar display.
|
||||||
fn month_abbr(month: u32) -> &'static str {
|
fn month_abbr(month: u32) -> &'static str {
|
||||||
match month {
|
match month {
|
||||||
1 => "Jan", 2 => "Feb", 3 => "Mar", 4 => "Apr",
|
1 => "Jan",
|
||||||
5 => "May", 6 => "Jun", 7 => "Jul", 8 => "Aug",
|
2 => "Feb",
|
||||||
9 => "Sep", 10 => "Oct", 11 => "Nov", 12 => "Dec",
|
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 label = format!("{} ({})", cy.year, total);
|
||||||
let year_val = cy.year;
|
let year_val = cy.year;
|
||||||
let on_year_clone = on_year.clone();
|
let on_year_clone = on_year.clone();
|
||||||
let style_fn = if year_selected { calendar_selected_style } else { calendar_style };
|
let style_fn = if year_selected {
|
||||||
let year_btn = button(
|
calendar_selected_style
|
||||||
text(label).size(11).shaping(Shaping::Advanced)
|
} else {
|
||||||
)
|
calendar_style
|
||||||
|
};
|
||||||
|
let year_btn = button(text(label).size(11).shaping(Shaping::Advanced))
|
||||||
.on_press(if year_selected {
|
.on_press(if year_selected {
|
||||||
on_year_clone(None)
|
on_year_clone(None)
|
||||||
} else {
|
} else {
|
||||||
@@ -320,10 +331,12 @@ fn calendar_widget(
|
|||||||
let label = format!(" {} ({})", month_abbr(cm.month), cm.count);
|
let label = format!(" {} ({})", month_abbr(cm.month), cm.count);
|
||||||
let month_val = cm.month;
|
let month_val = cm.month;
|
||||||
let on_month_clone = on_month.clone();
|
let on_month_clone = on_month.clone();
|
||||||
let style_fn = if month_selected { calendar_selected_style } else { calendar_style };
|
let style_fn = if month_selected {
|
||||||
let month_btn = button(
|
calendar_selected_style
|
||||||
text(label).size(10).shaping(Shaping::Advanced)
|
} else {
|
||||||
)
|
calendar_style
|
||||||
|
};
|
||||||
|
let month_btn = button(text(label).size(10).shaping(Shaping::Advanced))
|
||||||
.on_press(if month_selected {
|
.on_press(if month_selected {
|
||||||
on_month_clone(None)
|
on_month_clone(None)
|
||||||
} else {
|
} else {
|
||||||
@@ -362,10 +375,12 @@ fn chip_selector(
|
|||||||
let is_selected = selected.contains(tag);
|
let is_selected = selected.contains(tag);
|
||||||
let tag_clone = tag.clone();
|
let tag_clone = tag.clone();
|
||||||
let on_toggle_clone = on_toggle.clone();
|
let on_toggle_clone = on_toggle.clone();
|
||||||
let style_fn = if is_selected { chip_selected_style } else { chip_style };
|
let style_fn = if is_selected {
|
||||||
let chip = button(
|
chip_selected_style
|
||||||
text(tag.clone()).size(10).shaping(Shaping::Advanced)
|
} else {
|
||||||
)
|
chip_style
|
||||||
|
};
|
||||||
|
let chip = button(text(tag.clone()).size(10).shaping(Shaping::Advanced))
|
||||||
.on_press(on_toggle_clone(tag_clone))
|
.on_press(on_toggle_clone(tag_clone))
|
||||||
.padding([2, 6])
|
.padding([2, 6])
|
||||||
.style(style_fn);
|
.style(style_fn);
|
||||||
@@ -377,9 +392,7 @@ fn chip_selector(
|
|||||||
while !collected.is_empty() {
|
while !collected.is_empty() {
|
||||||
let chunk_size = 3.min(collected.len());
|
let chunk_size = 3.min(collected.len());
|
||||||
let chunk: Vec<Element<'static, Message>> = collected.drain(..chunk_size).collect();
|
let chunk: Vec<Element<'static, Message>> = collected.drain(..chunk_size).collect();
|
||||||
items.push(
|
items.push(iced::widget::Row::with_children(chunk).spacing(4).into());
|
||||||
iced::widget::Row::with_children(chunk).spacing(4).into()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +421,11 @@ fn single_select_chip_selector(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|(value, display)| {
|
.map(|(value, display)| {
|
||||||
let is_selected = selected == Some(value.as_str());
|
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 value = value.clone();
|
||||||
let on_toggle = on_toggle.clone();
|
let on_toggle = on_toggle.clone();
|
||||||
button(text(display.clone()).size(10).shaping(Shaping::Advanced))
|
button(text(display.clone()).size(10).shaping(Shaping::Advanced))
|
||||||
@@ -473,8 +490,8 @@ fn post_filter_panel(
|
|||||||
&filter.calendar_years,
|
&filter.calendar_years,
|
||||||
filter.calendar.selected_year,
|
filter.calendar.selected_year,
|
||||||
filter.calendar.selected_month,
|
filter.calendar.selected_month,
|
||||||
|y| Message::SetPostCalendarYear(y),
|
Message::SetPostCalendarYear,
|
||||||
|m| Message::SetPostCalendarMonth(m),
|
Message::SetPostCalendarMonth,
|
||||||
locale,
|
locale,
|
||||||
));
|
));
|
||||||
sections.push(Space::with_height(4.0).into());
|
sections.push(Space::with_height(4.0).into());
|
||||||
@@ -486,7 +503,7 @@ fn post_filter_panel(
|
|||||||
"sidebar.filter.tags",
|
"sidebar.filter.tags",
|
||||||
&filter.available_tags,
|
&filter.available_tags,
|
||||||
&filter.tag_filter,
|
&filter.tag_filter,
|
||||||
|tag| Message::TogglePostTagFilter(tag),
|
Message::TogglePostTagFilter,
|
||||||
locale,
|
locale,
|
||||||
));
|
));
|
||||||
sections.push(Space::with_height(4.0).into());
|
sections.push(Space::with_height(4.0).into());
|
||||||
@@ -498,7 +515,7 @@ fn post_filter_panel(
|
|||||||
"sidebar.filter.categories",
|
"sidebar.filter.categories",
|
||||||
&filter.available_categories,
|
&filter.available_categories,
|
||||||
&filter.category_filter,
|
&filter.category_filter,
|
||||||
|cat| Message::TogglePostCategoryFilter(cat),
|
Message::TogglePostCategoryFilter,
|
||||||
locale,
|
locale,
|
||||||
));
|
));
|
||||||
sections.push(Space::with_height(4.0).into());
|
sections.push(Space::with_height(4.0).into());
|
||||||
@@ -537,23 +554,22 @@ fn post_filter_panel(
|
|||||||
button(
|
button(
|
||||||
text(t(locale, "sidebar.filter.clearAll"))
|
text(t(locale, "sidebar.filter.clearAll"))
|
||||||
.size(10)
|
.size(10)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced),
|
||||||
)
|
)
|
||||||
.on_press(Message::ClearPostFilters)
|
.on_press(Message::ClearPostFilters)
|
||||||
.padding([2, 4])
|
.padding([2, 4])
|
||||||
.style(clear_filters_style)
|
.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.
|
/// Build the filter panel for Media view.
|
||||||
fn media_filter_panel(
|
fn media_filter_panel(filter: &MediaFilter, locale: UiLocale) -> Element<'static, Message> {
|
||||||
filter: &MediaFilter,
|
|
||||||
locale: UiLocale,
|
|
||||||
) -> Element<'static, Message> {
|
|
||||||
let mut sections: Vec<Element<'static, Message>> = Vec::new();
|
let mut sections: Vec<Element<'static, Message>> = Vec::new();
|
||||||
|
|
||||||
if !filter.calendar_years.is_empty() {
|
if !filter.calendar_years.is_empty() {
|
||||||
@@ -561,8 +577,8 @@ fn media_filter_panel(
|
|||||||
&filter.calendar_years,
|
&filter.calendar_years,
|
||||||
filter.calendar.selected_year,
|
filter.calendar.selected_year,
|
||||||
filter.calendar.selected_month,
|
filter.calendar.selected_month,
|
||||||
|y| Message::SetMediaCalendarYear(y),
|
Message::SetMediaCalendarYear,
|
||||||
|m| Message::SetMediaCalendarMonth(m),
|
Message::SetMediaCalendarMonth,
|
||||||
locale,
|
locale,
|
||||||
));
|
));
|
||||||
sections.push(Space::with_height(4.0).into());
|
sections.push(Space::with_height(4.0).into());
|
||||||
@@ -573,7 +589,7 @@ fn media_filter_panel(
|
|||||||
"sidebar.filter.tags",
|
"sidebar.filter.tags",
|
||||||
&filter.available_tags,
|
&filter.available_tags,
|
||||||
&filter.tag_filter,
|
&filter.tag_filter,
|
||||||
|tag| Message::ToggleMediaTagFilter(tag),
|
Message::ToggleMediaTagFilter,
|
||||||
locale,
|
locale,
|
||||||
));
|
));
|
||||||
sections.push(Space::with_height(4.0).into());
|
sections.push(Space::with_height(4.0).into());
|
||||||
@@ -584,18 +600,24 @@ fn media_filter_panel(
|
|||||||
button(
|
button(
|
||||||
text(t(locale, "sidebar.filter.clearAll"))
|
text(t(locale, "sidebar.filter.clearAll"))
|
||||||
.size(10)
|
.size(10)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced),
|
||||||
)
|
)
|
||||||
.on_press(Message::ClearMediaFilters)
|
.on_press(Message::ClearMediaFilters)
|
||||||
.padding([2, 4])
|
.padding([2, 4])
|
||||||
.style(clear_filters_style)
|
.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(
|
pub fn view(
|
||||||
sidebar_view: SidebarView,
|
sidebar_view: SidebarView,
|
||||||
posts: &[Post],
|
posts: &[Post],
|
||||||
@@ -633,7 +655,11 @@ pub fn view(
|
|||||||
row![
|
row![
|
||||||
header,
|
header,
|
||||||
Space::with_width(Length::Fill),
|
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)
|
.on_press(action)
|
||||||
.padding([3, 8])
|
.padding([3, 8])
|
||||||
.style(filter_button_style),
|
.style(filter_button_style),
|
||||||
@@ -652,10 +678,7 @@ pub fn view(
|
|||||||
let mut top_items: Vec<Element<'static, Message>> = Vec::new();
|
let mut top_items: Vec<Element<'static, Message>> = Vec::new();
|
||||||
|
|
||||||
// Search input + filter toggle row
|
// Search input + filter toggle row
|
||||||
let search = text_input(
|
let search = text_input(&t(locale, "sidebar.filter.search"), &filter.search_query)
|
||||||
&t(locale, "sidebar.filter.search"),
|
|
||||||
&filter.search_query,
|
|
||||||
)
|
|
||||||
.on_input(Message::PostSearchChanged)
|
.on_input(Message::PostSearchChanged)
|
||||||
.size(11)
|
.size(11)
|
||||||
.padding([4, 6])
|
.padding([4, 6])
|
||||||
@@ -673,16 +696,12 @@ pub fn view(
|
|||||||
} else {
|
} else {
|
||||||
"\u{25BC}" // ▼ toggle icon
|
"\u{25BC}" // ▼ toggle icon
|
||||||
};
|
};
|
||||||
let filter_toggle = button(
|
let filter_toggle = button(text(toggle_label).size(11).shaping(Shaping::Advanced))
|
||||||
text(toggle_label).size(11).shaping(Shaping::Advanced)
|
|
||||||
)
|
|
||||||
.on_press(Message::TogglePostFilterPanel)
|
.on_press(Message::TogglePostFilterPanel)
|
||||||
.padding([4, 6])
|
.padding([4, 6])
|
||||||
.style(toggle_style);
|
.style(toggle_style);
|
||||||
|
|
||||||
top_items.push(
|
top_items.push(row![search, filter_toggle].spacing(4).into());
|
||||||
row![search, filter_toggle].spacing(4).into()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Filter panel (collapsible)
|
// Filter panel (collapsible)
|
||||||
if filter.filter_panel_visible {
|
if filter.filter_panel_visible {
|
||||||
@@ -703,7 +722,7 @@ pub fn view(
|
|||||||
.size(12)
|
.size(12)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced)
|
||||||
.color(muted)
|
.color(muted)
|
||||||
.into()
|
.into(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
let section_header = |label: &str| -> Element<'static, Message> {
|
let section_header = |label: &str| -> Element<'static, Message> {
|
||||||
@@ -724,7 +743,8 @@ pub fn view(
|
|||||||
};
|
};
|
||||||
let date = format_post_date(p.created_at);
|
let date = format_post_date(p.created_at);
|
||||||
let prefix_chars: usize = 5;
|
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);
|
- (prefix_chars as f32 * AVG_CHAR_WIDTH_PX);
|
||||||
let display_title = truncate_to_fit(&p.title, text_px);
|
let display_title = truncate_to_fit(&p.title, text_px);
|
||||||
let label = format!("{icon} {status_indicator} {display_title}");
|
let label = format!("{icon} {status_indicator} {display_title}");
|
||||||
@@ -736,11 +756,15 @@ pub fn view(
|
|||||||
.size(10)
|
.size(10)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced)
|
||||||
.color(Color::from_rgb(0.50, 0.50, 0.55));
|
.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(
|
button(
|
||||||
container(column![label_text, date_text].spacing(1))
|
container(column![label_text, date_text].spacing(1))
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.clip(true)
|
.clip(true),
|
||||||
)
|
)
|
||||||
.on_press(Message::OpenTab(Tab {
|
.on_press(Message::OpenTab(Tab {
|
||||||
id: p.id.clone(),
|
id: p.id.clone(),
|
||||||
@@ -756,7 +780,10 @@ pub fn view(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Draft section
|
// 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() {
|
if !drafts.is_empty() {
|
||||||
top_items.push(section_header(&t(locale, "sidebar.drafts")));
|
top_items.push(section_header(&t(locale, "sidebar.drafts")));
|
||||||
for p in &drafts {
|
for p in &drafts {
|
||||||
@@ -766,7 +793,10 @@ pub fn view(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Published section
|
// 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() {
|
if !published.is_empty() {
|
||||||
top_items.push(section_header(&t(locale, "sidebar.published")));
|
top_items.push(section_header(&t(locale, "sidebar.published")));
|
||||||
for p in &published {
|
for p in &published {
|
||||||
@@ -776,7 +806,10 @@ pub fn view(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Archived section
|
// 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() {
|
if !archived.is_empty() {
|
||||||
top_items.push(section_header(&t(locale, "sidebar.archived")));
|
top_items.push(section_header(&t(locale, "sidebar.archived")));
|
||||||
for p in &archived {
|
for p in &archived {
|
||||||
@@ -791,13 +824,13 @@ pub fn view(
|
|||||||
button(
|
button(
|
||||||
text(t(locale, "sidebar.loadMore"))
|
text(t(locale, "sidebar.loadMore"))
|
||||||
.size(11)
|
.size(11)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced),
|
||||||
)
|
)
|
||||||
.on_press(Message::LoadMorePosts)
|
.on_press(Message::LoadMorePosts)
|
||||||
.padding([4, 8])
|
.padding([4, 8])
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.style(filter_button_style)
|
.style(filter_button_style)
|
||||||
.into()
|
.into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -811,10 +844,7 @@ pub fn view(
|
|||||||
let mut top_items: Vec<Element<'static, Message>> = Vec::new();
|
let mut top_items: Vec<Element<'static, Message>> = Vec::new();
|
||||||
|
|
||||||
// Search input + filter toggle row
|
// Search input + filter toggle row
|
||||||
let search = text_input(
|
let search = text_input(&t(locale, "sidebar.filter.search"), &filter.search_query)
|
||||||
&t(locale, "sidebar.filter.search"),
|
|
||||||
&filter.search_query,
|
|
||||||
)
|
|
||||||
.on_input(Message::MediaSearchChanged)
|
.on_input(Message::MediaSearchChanged)
|
||||||
.size(11)
|
.size(11)
|
||||||
.padding([4, 6])
|
.padding([4, 6])
|
||||||
@@ -832,16 +862,12 @@ pub fn view(
|
|||||||
} else {
|
} else {
|
||||||
"\u{25BC}" // ▼ toggle icon
|
"\u{25BC}" // ▼ toggle icon
|
||||||
};
|
};
|
||||||
let filter_toggle = button(
|
let filter_toggle = button(text(toggle_label).size(11).shaping(Shaping::Advanced))
|
||||||
text(toggle_label).size(11).shaping(Shaping::Advanced)
|
|
||||||
)
|
|
||||||
.on_press(Message::ToggleMediaFilterPanel)
|
.on_press(Message::ToggleMediaFilterPanel)
|
||||||
.padding([4, 6])
|
.padding([4, 6])
|
||||||
.style(toggle_style);
|
.style(toggle_style);
|
||||||
|
|
||||||
top_items.push(
|
top_items.push(row![search, filter_toggle].spacing(4).into());
|
||||||
row![search, filter_toggle].spacing(4).into()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Filter panel (collapsible)
|
// Filter panel (collapsible)
|
||||||
if filter.filter_panel_visible {
|
if filter.filter_panel_visible {
|
||||||
@@ -861,7 +887,7 @@ pub fn view(
|
|||||||
.size(12)
|
.size(12)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced)
|
||||||
.color(muted)
|
.color(muted)
|
||||||
.into()
|
.into(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
let items: Vec<Element<'static, Message>> = media
|
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 text_px = width - SIDEBAR_TEXT_OVERHEAD_PX - 48.0;
|
||||||
let display_name = truncate_to_fit(&display_name, text_px);
|
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)
|
// Left: 40x40 thumbnail or file icon (pre-resolved, no filesystem I/O)
|
||||||
let thumb_path = media_thumbs
|
let thumb_path = media_thumbs.get(&m.id).and_then(|opt| opt.as_ref());
|
||||||
.get(&m.id)
|
|
||||||
.and_then(|opt| opt.as_ref());
|
|
||||||
let left: Element<'static, Message> = if let Some(path) = thumb_path {
|
let left: Element<'static, Message> = if let Some(path) = thumb_path {
|
||||||
container(
|
container(
|
||||||
image(path.to_string_lossy().to_string())
|
image(path.to_string_lossy().to_string())
|
||||||
.width(Length::Fixed(40.0))
|
.width(Length::Fixed(40.0))
|
||||||
.height(Length::Fixed(40.0))
|
.height(Length::Fixed(40.0))
|
||||||
.content_fit(iced::ContentFit::Cover)
|
.content_fit(iced::ContentFit::Cover),
|
||||||
)
|
)
|
||||||
.width(Length::Fixed(40.0))
|
.width(Length::Fixed(40.0))
|
||||||
.height(Length::Fixed(40.0))
|
.height(Length::Fixed(40.0))
|
||||||
@@ -896,7 +924,7 @@ pub fn view(
|
|||||||
container(
|
container(
|
||||||
text("\u{1F4C4}") // 📄 generic file icon
|
text("\u{1F4C4}") // 📄 generic file icon
|
||||||
.size(20)
|
.size(20)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced),
|
||||||
)
|
)
|
||||||
.width(Length::Fixed(40.0))
|
.width(Length::Fixed(40.0))
|
||||||
.height(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 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(
|
button(container(content).width(Length::Fill).clip(true))
|
||||||
container(content)
|
|
||||||
.width(Length::Fill)
|
|
||||||
.clip(true)
|
|
||||||
)
|
|
||||||
.on_press(Message::OpenTab(Tab {
|
.on_press(Message::OpenTab(Tab {
|
||||||
id: m.id.clone(),
|
id: m.id.clone(),
|
||||||
tab_type: TabType::Media,
|
tab_type: TabType::Media,
|
||||||
@@ -953,13 +979,13 @@ pub fn view(
|
|||||||
button(
|
button(
|
||||||
text(t(locale, "sidebar.loadMore"))
|
text(t(locale, "sidebar.loadMore"))
|
||||||
.size(11)
|
.size(11)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced),
|
||||||
)
|
)
|
||||||
.on_press(Message::LoadMoreMedia)
|
.on_press(Message::LoadMoreMedia)
|
||||||
.padding([4, 8])
|
.padding([4, 8])
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.style(filter_button_style)
|
.style(filter_button_style)
|
||||||
.into()
|
.into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -986,12 +1012,12 @@ pub fn view(
|
|||||||
.size(12)
|
.size(12)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced)
|
||||||
.wrapping(iced::widget::text::Wrapping::None);
|
.wrapping(iced::widget::text::Wrapping::None);
|
||||||
let style_fn = if is_active { item_active_style } else { item_style };
|
let style_fn = if is_active {
|
||||||
button(
|
item_active_style
|
||||||
container(label_text)
|
} else {
|
||||||
.width(Length::Fill)
|
item_style
|
||||||
.clip(true)
|
};
|
||||||
)
|
button(container(label_text).width(Length::Fill).clip(true))
|
||||||
.on_press(Message::OpenTab(Tab {
|
.on_press(Message::OpenTab(Tab {
|
||||||
id: s.id.clone(),
|
id: s.id.clone(),
|
||||||
tab_type: TabType::Scripts,
|
tab_type: TabType::Scripts,
|
||||||
@@ -1005,9 +1031,7 @@ pub fn view(
|
|||||||
.into()
|
.into()
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
iced::widget::Column::with_children(items)
|
iced::widget::Column::with_children(items).spacing(1).into()
|
||||||
.spacing(1)
|
|
||||||
.into()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SidebarView::Templates => {
|
SidebarView::Templates => {
|
||||||
@@ -1028,12 +1052,12 @@ pub fn view(
|
|||||||
.size(12)
|
.size(12)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced)
|
||||||
.wrapping(iced::widget::text::Wrapping::None);
|
.wrapping(iced::widget::text::Wrapping::None);
|
||||||
let style_fn = if is_active { item_active_style } else { item_style };
|
let style_fn = if is_active {
|
||||||
button(
|
item_active_style
|
||||||
container(label_text)
|
} else {
|
||||||
.width(Length::Fill)
|
item_style
|
||||||
.clip(true)
|
};
|
||||||
)
|
button(container(label_text).width(Length::Fill).clip(true))
|
||||||
.on_press(Message::OpenTab(Tab {
|
.on_press(Message::OpenTab(Tab {
|
||||||
id: tmpl.id.clone(),
|
id: tmpl.id.clone(),
|
||||||
tab_type: TabType::Templates,
|
tab_type: TabType::Templates,
|
||||||
@@ -1047,9 +1071,7 @@ pub fn view(
|
|||||||
.into()
|
.into()
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
iced::widget::Column::with_children(items)
|
iced::widget::Column::with_children(items).spacing(1).into()
|
||||||
.spacing(1)
|
|
||||||
.into()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SidebarView::Settings => {
|
SidebarView::Settings => {
|
||||||
@@ -1070,9 +1092,7 @@ pub fn view(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|(key, section_opt)| {
|
.map(|(key, section_opt)| {
|
||||||
let label = t(locale, key);
|
let label = t(locale, key);
|
||||||
let label_text = text(label)
|
let label_text = text(label).size(12).shaping(Shaping::Advanced);
|
||||||
.size(12)
|
|
||||||
.shaping(Shaping::Advanced);
|
|
||||||
let msg = if let Some(section) = section_opt {
|
let msg = if let Some(section) = section_opt {
|
||||||
Message::OpenSettingsSection(section.clone())
|
Message::OpenSettingsSection(section.clone())
|
||||||
} else {
|
} else {
|
||||||
@@ -1084,10 +1104,7 @@ pub fn view(
|
|||||||
is_dirty: false,
|
is_dirty: false,
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
button(
|
button(container(label_text).width(Length::Fill))
|
||||||
container(label_text)
|
|
||||||
.width(Length::Fill)
|
|
||||||
)
|
|
||||||
.on_press(msg)
|
.on_press(msg)
|
||||||
.padding([3, 6])
|
.padding([3, 6])
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
@@ -1095,28 +1112,17 @@ pub fn view(
|
|||||||
.into()
|
.into()
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
iced::widget::Column::with_children(items)
|
iced::widget::Column::with_children(items).spacing(1).into()
|
||||||
.spacing(1)
|
|
||||||
.into()
|
|
||||||
}
|
}
|
||||||
SidebarView::Tags => {
|
SidebarView::Tags => {
|
||||||
// Per sidebar_views.allium TagsNav: 3 fixed-order sections
|
// Per sidebar_views.allium TagsNav: 3 fixed-order sections
|
||||||
let sections = [
|
let sections = ["tags.nav.cloud", "tags.nav.manage", "tags.nav.merge"];
|
||||||
"tags.nav.cloud",
|
|
||||||
"tags.nav.manage",
|
|
||||||
"tags.nav.merge",
|
|
||||||
];
|
|
||||||
let items: Vec<Element<'static, Message>> = sections
|
let items: Vec<Element<'static, Message>> = sections
|
||||||
.iter()
|
.iter()
|
||||||
.map(|key| {
|
.map(|key| {
|
||||||
let label = t(locale, key);
|
let label = t(locale, key);
|
||||||
let label_text = text(label)
|
let label_text = text(label).size(12).shaping(Shaping::Advanced);
|
||||||
.size(12)
|
button(container(label_text).width(Length::Fill))
|
||||||
.shaping(Shaping::Advanced);
|
|
||||||
button(
|
|
||||||
container(label_text)
|
|
||||||
.width(Length::Fill)
|
|
||||||
)
|
|
||||||
.on_press(Message::OpenTab(Tab {
|
.on_press(Message::OpenTab(Tab {
|
||||||
id: "tags".to_string(),
|
id: "tags".to_string(),
|
||||||
tab_type: TabType::Tags,
|
tab_type: TabType::Tags,
|
||||||
@@ -1130,24 +1136,16 @@ pub fn view(
|
|||||||
.into()
|
.into()
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
iced::widget::Column::with_children(items)
|
iced::widget::Column::with_children(items).spacing(1).into()
|
||||||
.spacing(1)
|
|
||||||
.into()
|
|
||||||
}
|
}
|
||||||
_ => {
|
_ => text(t(locale, placeholder_key(sidebar_view)))
|
||||||
text(t(locale, placeholder_key(sidebar_view)))
|
|
||||||
.size(12)
|
.size(12)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced)
|
||||||
.color(muted)
|
.color(muted)
|
||||||
.into()
|
.into(),
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let content = column![
|
let content = column![header_row, Space::with_height(8.0), body,]
|
||||||
header_row,
|
|
||||||
Space::with_height(8.0),
|
|
||||||
body,
|
|
||||||
]
|
|
||||||
.spacing(4)
|
.spacing(4)
|
||||||
.padding(12);
|
.padding(12);
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user