chore: cleanups and refactorings for cleaner code
This commit is contained in:
276
CLEANUP.md
276
CLEANUP.md
@@ -1,276 +0,0 @@
|
|||||||
# Cleanup Backlog
|
|
||||||
|
|
||||||
Findings from a repo-wide audit (2026-07-18) for over-engineering, dead code, and
|
|
||||||
idiomatic-Rust issues beyond what clippy/rustfmt catch. Ordered by priority —
|
|
||||||
the first item is a real bug, the rest are cleanup. Each item is self-contained.
|
|
||||||
|
|
||||||
Ground rules (from AGENTS.md): red/green TDD, run build + tests after each item,
|
|
||||||
remove unused code instead of keeping it, verify behaviour against the allium
|
|
||||||
specs in `specs/` and the Elixir baseline in `../bDS2` when in doubt.
|
|
||||||
|
|
||||||
## 1. BUG: temp-file collision in atomic_write
|
|
||||||
|
|
||||||
`crates/bds-core/src/util/atomic_write.rs` uses `path.with_extension("tmp")`,
|
|
||||||
which *replaces* the last extension. Sibling files that differ only in their
|
|
||||||
final extension map to the same temp path:
|
|
||||||
|
|
||||||
- `esmeralda.en.md` → `esmeralda.en.tmp`
|
|
||||||
- `esmeralda.en.meta` → `esmeralda.en.tmp`
|
|
||||||
|
|
||||||
Concurrent writes (e.g. publishing writes post file + sidecars in parallel) can
|
|
||||||
interleave on the shared temp file and corrupt or cross-write content. It also
|
|
||||||
clobbers any real file named `*.tmp`.
|
|
||||||
|
|
||||||
**Fix:** append a unique suffix to the full filename instead of replacing the
|
|
||||||
extension, e.g. `format!("{}.{}.tmp", file_name, std::process::id())` or a
|
|
||||||
counter/uuid. Add a regression test writing `a.en.md` and `a.en.meta`
|
|
||||||
concurrently (or at least assert the two temp paths differ).
|
|
||||||
|
|
||||||
## 2. Parallel record-struct layer in the DB glue (~450 lines)
|
|
||||||
|
|
||||||
`crates/bds-core/src/db/from_row.rs` defines a `*Record` twin struct for all 12
|
|
||||||
tables plus hand-written `From`/`TryFrom` conversions in both directions. Most
|
|
||||||
fields are copied 1:1; only a handful need conversion (status/kind enums,
|
|
||||||
`i32` bools, JSON string-lists).
|
|
||||||
|
|
||||||
**Fix:** implement diesel `ToSql`/`FromSql` (or `serialize`/`deserialize`
|
|
||||||
wrappers) for `PostStatus`, `TemplateKind`, `TemplateStatus`, `ScriptKind`,
|
|
||||||
`ScriptStatus`, `NotificationEntity`, `NotificationAction`, plus a newtype for
|
|
||||||
JSON-serialized `Vec<String>` (tags/categories) and bool-as-i32, then derive
|
|
||||||
`Queryable`/`Selectable`/`Insertable`/`AsChangeset` on the domain models in
|
|
||||||
`crates/bds-core/src/model/` directly. Delete the record structs and the
|
|
||||||
`convert`/`convert_all` helpers. Keep behaviour identical: tags serialize as
|
|
||||||
JSON arrays, unknown enum strings must still error, `treat_none_as_null` must
|
|
||||||
be preserved.
|
|
||||||
|
|
||||||
## 3. Split the god file: bds-ui/src/app.rs (9,090 lines)
|
|
||||||
|
|
||||||
`update()` alone spans ~2,250 lines (line 982–3238). The fix pattern already
|
|
||||||
exists in the same file: `handle_tags_msg` and `handle_settings_msg` delegate a
|
|
||||||
message sub-enum to a dedicated handler.
|
|
||||||
|
|
||||||
**Fix:** apply the same pattern to the remaining domains — post editor,
|
|
||||||
media editor, template/script editors, preview/embedded webview, AI actions,
|
|
||||||
translation flows, sidebar/filtering. Move each handler group into its own
|
|
||||||
module (e.g. `crates/bds-ui/src/app/posts.rs`), keeping `Message` routing thin.
|
|
||||||
Pure refactor: no behaviour change, existing tests must stay green.
|
|
||||||
|
|
||||||
## 4. Dead task machinery in engine/task.rs (~130 lines)
|
|
||||||
|
|
||||||
Zero production call sites in the workspace for:
|
|
||||||
|
|
||||||
- `ProgressThrottle` (whole struct + its 2 tests) — its logic is already
|
|
||||||
duplicated inside `TaskManager::report_progress`
|
|
||||||
- `TaskProgress` struct
|
|
||||||
- `try_start`
|
|
||||||
- `submit_grouped` and the `group_id`/`group_name` fields it fills (carried
|
|
||||||
through `TaskEntry` and `TaskSnapshot`, never read by the UI)
|
|
||||||
- `label()`, `message()` accessors
|
|
||||||
- `next_queued` (test-only)
|
|
||||||
|
|
||||||
**Fix:** delete them and their tests.
|
|
||||||
|
|
||||||
**Related wiring gap (do NOT delete, investigate):** `evict_expired()` and
|
|
||||||
`is_cancelled()` are also never called from production code. That means
|
|
||||||
finished tasks are never evicted at runtime and cooperative cancellation never
|
|
||||||
actually interrupts running work. Either wire them up in `bds-ui/src/app.rs`
|
|
||||||
(evict on the task-snapshot refresh tick; check `is_cancelled` inside
|
|
||||||
long-running engine loops) or confirm against the allium spec what the intended
|
|
||||||
behaviour is.
|
|
||||||
|
|
||||||
## 5. Dead public functions (~250 lines)
|
|
||||||
|
|
||||||
Zero call sites anywhere in the workspace (verified by sweep):
|
|
||||||
|
|
||||||
- `crates/bds-core/src/engine/validate_translations.rs`:
|
|
||||||
`validate_translations` — only `validate_translations_with_progress` is used
|
|
||||||
- `crates/bds-core/src/engine/meta.rs`: `update_blog_languages`,
|
|
||||||
`update_project_metadata`
|
|
||||||
- `crates/bds-core/src/db/queries/generated_file_hash.rs`:
|
|
||||||
`delete_generated_file_hash`, `list_generated_file_hashes_by_project`
|
|
||||||
- `crates/bds-core/src/db/queries/post.rs`: `list_posts_by_project_limited`
|
|
||||||
- `crates/bds-core/src/db/queries/media.rs`: `list_media_by_project_limited`
|
|
||||||
- `crates/bds-core/src/db/queries/post_link.rs`: `list_post_backlinks`,
|
|
||||||
`list_post_outlinks`
|
|
||||||
- `crates/bds-core/src/db/from_row.rs`: `notification_entity_to_str`,
|
|
||||||
`notification_action_to_str`
|
|
||||||
|
|
||||||
**Fix:** delete, along with any tests that exist only to exercise them.
|
|
||||||
|
|
||||||
**Special case:** `crates/bds-core/src/db/fts.rs::search_media` has only
|
|
||||||
test callers. AGENTS.md requires functionality to be tied to UI — check the
|
|
||||||
allium spec: if media search is specced, wire it into the UI; if not, delete.
|
|
||||||
|
|
||||||
## 6. Delete unused EngineContext
|
|
||||||
|
|
||||||
`crates/bds-core/src/engine/context.rs` — a "shared context passed to engine
|
|
||||||
operations" that zero engine operations take (every engine fn takes
|
|
||||||
`conn`/`project_id`/`data_dir` individually). Its only test asserts that struct
|
|
||||||
fields hold assigned values.
|
|
||||||
|
|
||||||
**Fix:** delete the file, its `pub use` in `crates/bds-core/src/engine/mod.rs`.
|
|
||||||
|
|
||||||
## 7. Derive EngineError with thiserror
|
|
||||||
|
|
||||||
`crates/bds-core/src/engine/error.rs` hand-writes `Display`, `Error::source`,
|
|
||||||
and four `From` impls (~85 lines). `DatabaseError` in
|
|
||||||
`crates/bds-core/src/db/connection.rs` already uses
|
|
||||||
`#[derive(thiserror::Error)]` — the crate is inconsistent.
|
|
||||||
|
|
||||||
**Fix:** convert `EngineError` to a thiserror derive (`#[error(...)]`,
|
|
||||||
`#[from]`, `#[source]`). Keep the `From<DatabaseError>` mapping and the
|
|
||||||
reqwest/serde_json/serde_yaml → `Parse` conversions (thiserror `#[from]` can't
|
|
||||||
map two sources into one variant with `.to_string()`, so those three stay as
|
|
||||||
small manual impls or become dedicated variants).
|
|
||||||
|
|
||||||
## 8. Single source of truth for status/kind enum strings
|
|
||||||
|
|
||||||
`PostStatus` ↔ string mapping exists three times:
|
|
||||||
|
|
||||||
1. serde `#[serde(rename_all = "lowercase")]` on the enum
|
|
||||||
(`crates/bds-core/src/model/post.rs`)
|
|
||||||
2. `post_status_to_str` / `post_status` in `crates/bds-core/src/db/from_row.rs`
|
|
||||||
3. the `serde_json::to_string(&status).trim_matches('"')` hack in
|
|
||||||
`crates/bds-core/src/util/frontmatter.rs` (`PostFrontmatter::from_post`,
|
|
||||||
`TranslationFrontmatter::from_translation`)
|
|
||||||
|
|
||||||
**Fix:** give each enum (`PostStatus`, `TemplateKind`, `TemplateStatus`,
|
|
||||||
`ScriptKind`, `ScriptStatus`) an `as_str()` + `FromStr` impl next to its
|
|
||||||
definition, use it from the DB layer and frontmatter, delete the duplicates and
|
|
||||||
the serde_json hack. (Combines well with item 2.)
|
|
||||||
|
|
||||||
## 9. Derive frontmatter deserialization (~120 lines)
|
|
||||||
|
|
||||||
`crates/bds-core/src/util/frontmatter.rs`: the four `from_yaml` impls each
|
|
||||||
hand-roll near-identical `get_str`/`get_string_list` closures over
|
|
||||||
`serde_yaml::Value`.
|
|
||||||
|
|
||||||
**Fix:** replace with `#[derive(Deserialize)]` +
|
|
||||||
`#[serde(rename_all = "camelCase")]` structs and one shared
|
|
||||||
`deserialize_with` helper for ISO-8601 → unix-ms timestamps. Preserve current
|
|
||||||
lenient behaviour (numbers/bools coerced to strings where fields expect
|
|
||||||
strings; missing optional fields default).
|
|
||||||
|
|
||||||
**Do NOT touch the serialization side** (`to_yaml`): it is deliberately
|
|
||||||
hand-rolled for byte-identical golden output against the gray-matter format —
|
|
||||||
the `golden_output_*` tests assert exact equality with fixture files.
|
|
||||||
|
|
||||||
## 10. Minor shrinks
|
|
||||||
|
|
||||||
- `crates/bds-core/src/util/checksum.rs`: replace the inline `hex` module with
|
|
||||||
`bytes.iter().map(|b| format!("{b:02x}")).collect::<String>()`. (Correctly
|
|
||||||
avoids adding a `hex` crate dep — keep it that way.)
|
|
||||||
- `crates/bds-core/src/i18n/mod.rs`: `RENDER_KEYS` hardcodes 34 keys that must
|
|
||||||
be kept in sync with the `.ftl` catalogs by hand. Enumerate the parsed
|
|
||||||
`FluentResource` entries of the English render catalog instead, so new keys
|
|
||||||
can't be silently missed.
|
|
||||||
- `crates/bds-core/src/i18n/mod.rs`: `locale_index` can be `locale as usize`
|
|
||||||
with explicit discriminants on `UiLocale`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Test Suite Cleanup
|
|
||||||
|
|
||||||
Findings from a full test-suite review (2026-07-18). The suite is largely
|
|
||||||
healthy: engine/query tests run against real in-memory SQLite, AI tests run the
|
|
||||||
real HTTP client against a local TCP test server, golden-output tests pin
|
|
||||||
byte-compatibility with the bDS2 baseline fixtures. No mock-object testing
|
|
||||||
anti-pattern exists. The problems are hot-air tests (assertions the compiler
|
|
||||||
already guarantees or that test a literal against itself) and duplication.
|
|
||||||
|
|
||||||
## T1. Delete bds-ui/tests/app_smoke.rs entirely
|
|
||||||
|
|
||||||
Every test in the file is compile-time noise:
|
|
||||||
|
|
||||||
- `message_variants_constructable`, `new_message_variants_constructable`
|
|
||||||
(~80 lines): construct `Message` enum variants and discard them — the
|
|
||||||
compiler already guarantees this
|
|
||||||
- `message_clone_works`: tests `#[derive(Clone)]`
|
|
||||||
- `bds_app_type_is_public`: an inner function that is never called; purely a
|
|
||||||
type assertion with no runtime assertion
|
|
||||||
|
|
||||||
The file's own header admits `BdsApp::new()` cannot be tested here. The real
|
|
||||||
smoke coverage lives in `app.rs`'s inline tests (which drive `update()` against
|
|
||||||
a real in-memory DB via `new_for_tests`). Delete the file.
|
|
||||||
|
|
||||||
## T2. Hot air in bds-ui/tests/m2_validation.rs
|
|
||||||
|
|
||||||
- `toast_level_variants`, `toast_preserves_message`: assert that a constructor
|
|
||||||
stores its arguments. Delete.
|
|
||||||
- `toast_ids_are_monotonically_increasing`, `fresh_toast_is_not_expired`:
|
|
||||||
duplicate the inline tests in `crates/bds-ui/src/state/toast.rs`
|
|
||||||
(`toast_ids_are_unique`, `fresh_toast_not_expired`). Keep one location
|
|
||||||
(suggest inline), delete the other.
|
|
||||||
- `menu_actions_that_need_project` / `menu_actions_that_need_tab` /
|
|
||||||
`menu_actions_gated_by_offline`: their own comment admits they don't test the
|
|
||||||
gating logic — they only re-verify i18n keys (already covered for ALL actions
|
|
||||||
in `menu_routing.rs::all_menu_actions_translate_in_all_locales`) and then
|
|
||||||
`assert_eq!(array.len(), N)` on the literal array declared five lines above.
|
|
||||||
Delete. **Real gap:** the actual enable/disable rules in
|
|
||||||
`BdsApp::sync_menu_state` (app.rs) are untested — write a test that drives
|
|
||||||
the real method (or extract its decision logic into a testable function).
|
|
||||||
- `accelerator_actions_match_spec`: asserts a locally declared array has 15
|
|
||||||
elements and that i18n keys are non-empty; never touches the real
|
|
||||||
accelerator registration in `platform/menu.rs`. Delete, or rewrite to
|
|
||||||
inspect the actual menu construction.
|
|
||||||
|
|
||||||
## T3. Hot air in bds-core/tests/spec_claims.rs
|
|
||||||
|
|
||||||
- `remaining_value_specs_match` contains
|
|
||||||
`assert_eq!(["en", "de", "fr", "it", "es"].len(), 5)` — a literal tested
|
|
||||||
against itself. Drop that line (the serde asserts around it are fine).
|
|
||||||
- `slug_frozen_after_publish_semantics`: the name promises the slug-freeze
|
|
||||||
invariant; the body only asserts `published_at` is Some/None after insert
|
|
||||||
(already covered by roundtrip tests elsewhere). Either write the real test —
|
|
||||||
attempt a slug change on a published post through the engine API and assert
|
|
||||||
it's rejected — or delete.
|
|
||||||
|
|
||||||
## T4. Vacuous inline tests in bds-core/src/i18n/mod.rs
|
|
||||||
|
|
||||||
- `ui_locale_is_independent_type`: asserts `"de" != "fr"`. Delete.
|
|
||||||
- `translate_falls_back_to_english`: identical call and assertion as
|
|
||||||
`translate_menu_labels`, and it does NOT test fallback — the key it uses has
|
|
||||||
a German translation. Replace with a key that exists only in en.ftl (that
|
|
||||||
actually exercises `add_resource_overriding` fallback), or delete.
|
|
||||||
- `detect_os_locale_does_not_panic`: marginal smoke; keep or drop, zero cost.
|
|
||||||
|
|
||||||
## T5. Tests that only exist to cover dead code
|
|
||||||
|
|
||||||
Deleted together with their subjects (see items 4–6 above):
|
|
||||||
|
|
||||||
- `engine/task.rs`: `progress_throttle_initial_reports`,
|
|
||||||
`progress_throttle_suppresses_rapid` (test the dead `ProgressThrottle`)
|
|
||||||
- `engine/context.rs`: `context_holds_references` (asserts struct fields hold
|
|
||||||
assigned values)
|
|
||||||
- `engine/error.rs`: `display_variants`, `from_io_error` become
|
|
||||||
derive-testing once EngineError moves to thiserror; delete with item 7
|
|
||||||
|
|
||||||
## T6. Change-detector test in bds-ui/tests/menu_routing.rs
|
|
||||||
|
|
||||||
`menu_action_count_matches_spec` asserts `MenuAction::ALL.len() == 28`. It must
|
|
||||||
be hand-bumped on every menu change and only ever catches forgetting to update
|
|
||||||
itself; the useful properties (every action has a key, keys unique, all locales
|
|
||||||
translate) are covered by the other three tests in the file. Delete.
|
|
||||||
|
|
||||||
## Explicitly fine (do not "clean up")
|
|
||||||
|
|
||||||
- `tests/orm_boundary.rs`, `tests/i18n_completeness.rs`,
|
|
||||||
`tests/packaging_assets.rs`: lints-as-tests. They don't test runtime
|
|
||||||
behaviour but mechanically enforce AGENTS.md rules (no raw SQL outside the
|
|
||||||
backend boundary, no untranslated keys, packaging config integrity). Keep.
|
|
||||||
- `fixture_readability.rs::fixture_database_files_are_not_modified_by_tests`:
|
|
||||||
meta-test guarding fixture integrity for the whole compat suite. Keep.
|
|
||||||
- `m1_validation.rs`: the per-field diff-detection matrix looks repetitive but
|
|
||||||
each test pins one metadata-diff field required by the spec. Keep.
|
|
||||||
- AI engine tests spawn a real local TCP server — fake at the network
|
|
||||||
boundary, real production client code exercised. This is the right pattern.
|
|
||||||
- `util/timestamp.rs::now_is_recent` looks trivial but guards
|
|
||||||
milliseconds-vs-seconds confusion. Keep.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Explicitly out of scope
|
|
||||||
|
|
||||||
- `crates/bds-cli` being a stub: intentional, part of upcoming extensions.
|
|
||||||
- The hand-rolled YAML *serialization* in frontmatter.rs and sidecar.rs:
|
|
||||||
required for byte-compatibility with the baseline output.
|
|
||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -802,6 +802,7 @@ dependencies = [
|
|||||||
"diesel",
|
"diesel",
|
||||||
"diesel_migrations",
|
"diesel_migrations",
|
||||||
"fluent-bundle",
|
"fluent-bundle",
|
||||||
|
"fluent-syntax",
|
||||||
"image 0.25.10",
|
"image 0.25.10",
|
||||||
"keyring",
|
"keyring",
|
||||||
"libsqlite3-sys",
|
"libsqlite3-sys",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ image = "0.25"
|
|||||||
rust-stemmers = "1"
|
rust-stemmers = "1"
|
||||||
sys-locale = "0.3"
|
sys-locale = "0.3"
|
||||||
fluent-bundle = "0.16"
|
fluent-bundle = "0.16"
|
||||||
|
fluent-syntax = "0.12"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
open = "5"
|
open = "5"
|
||||||
pulldown-cmark = "0.13"
|
pulldown-cmark = "0.13"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ image = { workspace = true }
|
|||||||
rust-stemmers = { workspace = true }
|
rust-stemmers = { workspace = true }
|
||||||
sys-locale = { workspace = true }
|
sys-locale = { workspace = true }
|
||||||
fluent-bundle = { workspace = true }
|
fluent-bundle = { workspace = true }
|
||||||
|
fluent-syntax = { workspace = true }
|
||||||
pulldown-cmark = { workspace = true }
|
pulldown-cmark = { workspace = true }
|
||||||
liquid = { workspace = true }
|
liquid = { workspace = true }
|
||||||
liquid-core = { workspace = true }
|
liquid-core = { workspace = true }
|
||||||
|
|||||||
@@ -1,759 +0,0 @@
|
|||||||
use diesel::prelude::*;
|
|
||||||
use diesel::sqlite::Sqlite;
|
|
||||||
|
|
||||||
use crate::db::schema;
|
|
||||||
use crate::model::{
|
|
||||||
DbNotification, GeneratedFileHash, Media, MediaTranslation, NotificationAction,
|
|
||||||
NotificationEntity, Post, PostLink, PostMedia, PostStatus, PostTranslation, Project, Script,
|
|
||||||
ScriptKind, ScriptStatus, Setting, Tag, Template, TemplateKind, TemplateStatus,
|
|
||||||
};
|
|
||||||
|
|
||||||
type ConversionError = Box<dyn std::error::Error + Send + Sync>;
|
|
||||||
|
|
||||||
fn invalid_value(kind: &str, value: &str) -> ConversionError {
|
|
||||||
format!("invalid {kind}: {value}").into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn json_strings(value: &str) -> Result<Vec<String>, ConversionError> {
|
|
||||||
Ok(serde_json::from_str(value)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn post_status_to_str(value: &PostStatus) -> &'static str {
|
|
||||||
match value {
|
|
||||||
PostStatus::Draft => "draft",
|
|
||||||
PostStatus::Published => "published",
|
|
||||||
PostStatus::Archived => "archived",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn post_status(value: &str) -> Result<PostStatus, ConversionError> {
|
|
||||||
match value {
|
|
||||||
"draft" => Ok(PostStatus::Draft),
|
|
||||||
"published" => Ok(PostStatus::Published),
|
|
||||||
"archived" => Ok(PostStatus::Archived),
|
|
||||||
_ => Err(invalid_value("PostStatus", value)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn template_kind_to_str(value: &TemplateKind) -> &'static str {
|
|
||||||
match value {
|
|
||||||
TemplateKind::Post => "post",
|
|
||||||
TemplateKind::List => "list",
|
|
||||||
TemplateKind::NotFound => "not_found",
|
|
||||||
TemplateKind::Partial => "partial",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn template_kind(value: &str) -> Result<TemplateKind, ConversionError> {
|
|
||||||
match value {
|
|
||||||
"post" => Ok(TemplateKind::Post),
|
|
||||||
"list" => Ok(TemplateKind::List),
|
|
||||||
"not_found" => Ok(TemplateKind::NotFound),
|
|
||||||
"partial" => Ok(TemplateKind::Partial),
|
|
||||||
_ => Err(invalid_value("TemplateKind", value)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn template_status_to_str(value: &TemplateStatus) -> &'static str {
|
|
||||||
match value {
|
|
||||||
TemplateStatus::Draft => "draft",
|
|
||||||
TemplateStatus::Published => "published",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn template_status(value: &str) -> Result<TemplateStatus, ConversionError> {
|
|
||||||
match value {
|
|
||||||
"draft" => Ok(TemplateStatus::Draft),
|
|
||||||
"published" => Ok(TemplateStatus::Published),
|
|
||||||
_ => Err(invalid_value("TemplateStatus", value)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn script_kind_to_str(value: &ScriptKind) -> &'static str {
|
|
||||||
match value {
|
|
||||||
ScriptKind::Macro => "macro",
|
|
||||||
ScriptKind::Utility => "utility",
|
|
||||||
ScriptKind::Transform => "transform",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn script_kind(value: &str) -> Result<ScriptKind, ConversionError> {
|
|
||||||
match value {
|
|
||||||
"macro" => Ok(ScriptKind::Macro),
|
|
||||||
"utility" => Ok(ScriptKind::Utility),
|
|
||||||
"transform" => Ok(ScriptKind::Transform),
|
|
||||||
_ => Err(invalid_value("ScriptKind", value)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn script_status_to_str(value: &ScriptStatus) -> &'static str {
|
|
||||||
match value {
|
|
||||||
ScriptStatus::Draft => "draft",
|
|
||||||
ScriptStatus::Published => "published",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn script_status(value: &str) -> Result<ScriptStatus, ConversionError> {
|
|
||||||
match value {
|
|
||||||
"draft" => Ok(ScriptStatus::Draft),
|
|
||||||
"published" => Ok(ScriptStatus::Published),
|
|
||||||
_ => Err(invalid_value("ScriptStatus", value)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn notification_entity_to_str(value: &NotificationEntity) -> &'static str {
|
|
||||||
match value {
|
|
||||||
NotificationEntity::Post => "post",
|
|
||||||
NotificationEntity::Media => "media",
|
|
||||||
NotificationEntity::Script => "script",
|
|
||||||
NotificationEntity::Template => "template",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn notification_entity(value: &str) -> Result<NotificationEntity, ConversionError> {
|
|
||||||
match value {
|
|
||||||
"post" => Ok(NotificationEntity::Post),
|
|
||||||
"media" => Ok(NotificationEntity::Media),
|
|
||||||
"script" => Ok(NotificationEntity::Script),
|
|
||||||
"template" => Ok(NotificationEntity::Template),
|
|
||||||
_ => Err(invalid_value("NotificationEntity", value)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn notification_action_to_str(value: &NotificationAction) -> &'static str {
|
|
||||||
match value {
|
|
||||||
NotificationAction::Created => "created",
|
|
||||||
NotificationAction::Updated => "updated",
|
|
||||||
NotificationAction::Deleted => "deleted",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn notification_action(value: &str) -> Result<NotificationAction, ConversionError> {
|
|
||||||
match value {
|
|
||||||
"created" => Ok(NotificationAction::Created),
|
|
||||||
"updated" => Ok(NotificationAction::Updated),
|
|
||||||
"deleted" => Ok(NotificationAction::Deleted),
|
|
||||||
_ => Err(invalid_value("NotificationAction", value)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Queryable, Selectable, Insertable, AsChangeset)]
|
|
||||||
#[diesel(table_name = schema::projects, check_for_backend(Sqlite))]
|
|
||||||
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
|
|
||||||
pub struct ProjectRecord {
|
|
||||||
pub id: String,
|
|
||||||
pub name: String,
|
|
||||||
pub slug: String,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub data_path: Option<String>,
|
|
||||||
pub is_active: i32,
|
|
||||||
pub created_at: i64,
|
|
||||||
pub updated_at: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&Project> for ProjectRecord {
|
|
||||||
fn from(value: &Project) -> Self {
|
|
||||||
Self {
|
|
||||||
id: value.id.clone(),
|
|
||||||
name: value.name.clone(),
|
|
||||||
slug: value.slug.clone(),
|
|
||||||
description: value.description.clone(),
|
|
||||||
data_path: value.data_path.clone(),
|
|
||||||
is_active: value.is_active as i32,
|
|
||||||
created_at: value.created_at,
|
|
||||||
updated_at: value.updated_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<ProjectRecord> for Project {
|
|
||||||
fn from(value: ProjectRecord) -> Self {
|
|
||||||
Self {
|
|
||||||
id: value.id,
|
|
||||||
name: value.name,
|
|
||||||
slug: value.slug,
|
|
||||||
description: value.description,
|
|
||||||
data_path: value.data_path,
|
|
||||||
is_active: value.is_active != 0,
|
|
||||||
created_at: value.created_at,
|
|
||||||
updated_at: value.updated_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Queryable, Selectable, Insertable, AsChangeset)]
|
|
||||||
#[diesel(table_name = schema::posts, check_for_backend(Sqlite))]
|
|
||||||
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
|
|
||||||
pub struct PostRecord {
|
|
||||||
pub id: String,
|
|
||||||
pub project_id: String,
|
|
||||||
pub title: String,
|
|
||||||
pub slug: String,
|
|
||||||
pub excerpt: Option<String>,
|
|
||||||
pub content: Option<String>,
|
|
||||||
pub status: String,
|
|
||||||
pub author: Option<String>,
|
|
||||||
pub created_at: i64,
|
|
||||||
pub updated_at: i64,
|
|
||||||
pub published_at: Option<i64>,
|
|
||||||
pub file_path: String,
|
|
||||||
pub checksum: Option<String>,
|
|
||||||
pub tags: String,
|
|
||||||
pub categories: String,
|
|
||||||
pub template_slug: Option<String>,
|
|
||||||
pub language: Option<String>,
|
|
||||||
pub do_not_translate: i32,
|
|
||||||
pub published_title: Option<String>,
|
|
||||||
pub published_content: Option<String>,
|
|
||||||
pub published_tags: Option<String>,
|
|
||||||
pub published_categories: Option<String>,
|
|
||||||
pub published_excerpt: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&Post> for PostRecord {
|
|
||||||
fn from(value: &Post) -> Self {
|
|
||||||
Self {
|
|
||||||
id: value.id.clone(),
|
|
||||||
project_id: value.project_id.clone(),
|
|
||||||
title: value.title.clone(),
|
|
||||||
slug: value.slug.clone(),
|
|
||||||
excerpt: value.excerpt.clone(),
|
|
||||||
content: value.content.clone(),
|
|
||||||
status: post_status_to_str(&value.status).into(),
|
|
||||||
author: value.author.clone(),
|
|
||||||
created_at: value.created_at,
|
|
||||||
updated_at: value.updated_at,
|
|
||||||
published_at: value.published_at,
|
|
||||||
file_path: value.file_path.clone(),
|
|
||||||
checksum: value.checksum.clone(),
|
|
||||||
tags: serde_json::to_string(&value.tags).unwrap_or_else(|_| "[]".into()),
|
|
||||||
categories: serde_json::to_string(&value.categories).unwrap_or_else(|_| "[]".into()),
|
|
||||||
template_slug: value.template_slug.clone(),
|
|
||||||
language: value.language.clone(),
|
|
||||||
do_not_translate: value.do_not_translate as i32,
|
|
||||||
published_title: value.published_title.clone(),
|
|
||||||
published_content: value.published_content.clone(),
|
|
||||||
published_tags: value.published_tags.clone(),
|
|
||||||
published_categories: value.published_categories.clone(),
|
|
||||||
published_excerpt: value.published_excerpt.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<PostRecord> for Post {
|
|
||||||
type Error = ConversionError;
|
|
||||||
fn try_from(value: PostRecord) -> Result<Self, Self::Error> {
|
|
||||||
Ok(Self {
|
|
||||||
id: value.id,
|
|
||||||
project_id: value.project_id,
|
|
||||||
title: value.title,
|
|
||||||
slug: value.slug,
|
|
||||||
excerpt: value.excerpt,
|
|
||||||
content: value.content,
|
|
||||||
status: post_status(&value.status)?,
|
|
||||||
author: value.author,
|
|
||||||
language: value.language,
|
|
||||||
do_not_translate: value.do_not_translate != 0,
|
|
||||||
template_slug: value.template_slug,
|
|
||||||
file_path: value.file_path,
|
|
||||||
checksum: value.checksum,
|
|
||||||
tags: json_strings(&value.tags)?,
|
|
||||||
categories: json_strings(&value.categories)?,
|
|
||||||
published_title: value.published_title,
|
|
||||||
published_content: value.published_content,
|
|
||||||
published_tags: value.published_tags,
|
|
||||||
published_categories: value.published_categories,
|
|
||||||
published_excerpt: value.published_excerpt,
|
|
||||||
created_at: value.created_at,
|
|
||||||
updated_at: value.updated_at,
|
|
||||||
published_at: value.published_at,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Queryable, Selectable, Insertable, AsChangeset)]
|
|
||||||
#[diesel(table_name = schema::post_translations, check_for_backend(Sqlite))]
|
|
||||||
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
|
|
||||||
pub struct PostTranslationRecord {
|
|
||||||
pub id: String,
|
|
||||||
pub project_id: String,
|
|
||||||
pub translation_for: String,
|
|
||||||
pub language: String,
|
|
||||||
pub title: String,
|
|
||||||
pub excerpt: Option<String>,
|
|
||||||
pub content: Option<String>,
|
|
||||||
pub status: String,
|
|
||||||
pub created_at: i64,
|
|
||||||
pub updated_at: i64,
|
|
||||||
pub published_at: Option<i64>,
|
|
||||||
pub file_path: String,
|
|
||||||
pub checksum: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&PostTranslation> for PostTranslationRecord {
|
|
||||||
fn from(v: &PostTranslation) -> Self {
|
|
||||||
Self {
|
|
||||||
id: v.id.clone(),
|
|
||||||
project_id: v.project_id.clone(),
|
|
||||||
translation_for: v.translation_for.clone(),
|
|
||||||
language: v.language.clone(),
|
|
||||||
title: v.title.clone(),
|
|
||||||
excerpt: v.excerpt.clone(),
|
|
||||||
content: v.content.clone(),
|
|
||||||
status: post_status_to_str(&v.status).into(),
|
|
||||||
created_at: v.created_at,
|
|
||||||
updated_at: v.updated_at,
|
|
||||||
published_at: v.published_at,
|
|
||||||
file_path: v.file_path.clone(),
|
|
||||||
checksum: v.checksum.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl TryFrom<PostTranslationRecord> for PostTranslation {
|
|
||||||
type Error = ConversionError;
|
|
||||||
fn try_from(v: PostTranslationRecord) -> Result<Self, Self::Error> {
|
|
||||||
Ok(Self {
|
|
||||||
id: v.id,
|
|
||||||
project_id: v.project_id,
|
|
||||||
translation_for: v.translation_for,
|
|
||||||
language: v.language,
|
|
||||||
title: v.title,
|
|
||||||
excerpt: v.excerpt,
|
|
||||||
content: v.content,
|
|
||||||
status: post_status(&v.status)?,
|
|
||||||
file_path: v.file_path,
|
|
||||||
checksum: v.checksum,
|
|
||||||
created_at: v.created_at,
|
|
||||||
updated_at: v.updated_at,
|
|
||||||
published_at: v.published_at,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Queryable, Selectable, Insertable)]
|
|
||||||
#[diesel(table_name = schema::post_links, check_for_backend(Sqlite))]
|
|
||||||
pub struct PostLinkRecord {
|
|
||||||
pub id: String,
|
|
||||||
pub source_post_id: String,
|
|
||||||
pub target_post_id: String,
|
|
||||||
pub link_text: Option<String>,
|
|
||||||
pub created_at: i64,
|
|
||||||
}
|
|
||||||
impl From<&PostLink> for PostLinkRecord {
|
|
||||||
fn from(v: &PostLink) -> Self {
|
|
||||||
Self {
|
|
||||||
id: v.id.clone(),
|
|
||||||
source_post_id: v.source_post_id.clone(),
|
|
||||||
target_post_id: v.target_post_id.clone(),
|
|
||||||
link_text: v.link_text.clone(),
|
|
||||||
created_at: v.created_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl From<PostLinkRecord> for PostLink {
|
|
||||||
fn from(v: PostLinkRecord) -> Self {
|
|
||||||
Self {
|
|
||||||
id: v.id,
|
|
||||||
source_post_id: v.source_post_id,
|
|
||||||
target_post_id: v.target_post_id,
|
|
||||||
link_text: v.link_text,
|
|
||||||
created_at: v.created_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Queryable, Selectable, Insertable)]
|
|
||||||
#[diesel(table_name = schema::post_media, check_for_backend(Sqlite))]
|
|
||||||
pub struct PostMediaRecord {
|
|
||||||
pub id: String,
|
|
||||||
pub project_id: String,
|
|
||||||
pub post_id: String,
|
|
||||||
pub media_id: String,
|
|
||||||
pub sort_order: i32,
|
|
||||||
pub created_at: i64,
|
|
||||||
}
|
|
||||||
impl From<&PostMedia> for PostMediaRecord {
|
|
||||||
fn from(v: &PostMedia) -> Self {
|
|
||||||
Self {
|
|
||||||
id: v.id.clone(),
|
|
||||||
project_id: v.project_id.clone(),
|
|
||||||
post_id: v.post_id.clone(),
|
|
||||||
media_id: v.media_id.clone(),
|
|
||||||
sort_order: v.sort_order,
|
|
||||||
created_at: v.created_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl From<PostMediaRecord> for PostMedia {
|
|
||||||
fn from(v: PostMediaRecord) -> Self {
|
|
||||||
Self {
|
|
||||||
id: v.id,
|
|
||||||
project_id: v.project_id,
|
|
||||||
post_id: v.post_id,
|
|
||||||
media_id: v.media_id,
|
|
||||||
sort_order: v.sort_order,
|
|
||||||
created_at: v.created_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Queryable, Selectable, Insertable, AsChangeset)]
|
|
||||||
#[diesel(table_name = schema::media, check_for_backend(Sqlite))]
|
|
||||||
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
|
|
||||||
pub struct MediaRecord {
|
|
||||||
pub id: String,
|
|
||||||
pub project_id: String,
|
|
||||||
pub filename: String,
|
|
||||||
pub original_name: String,
|
|
||||||
pub mime_type: String,
|
|
||||||
pub size: i64,
|
|
||||||
pub width: Option<i32>,
|
|
||||||
pub height: Option<i32>,
|
|
||||||
pub title: Option<String>,
|
|
||||||
pub alt: Option<String>,
|
|
||||||
pub caption: Option<String>,
|
|
||||||
pub author: Option<String>,
|
|
||||||
pub file_path: String,
|
|
||||||
pub sidecar_path: String,
|
|
||||||
pub created_at: i64,
|
|
||||||
pub updated_at: i64,
|
|
||||||
pub checksum: Option<String>,
|
|
||||||
pub tags: String,
|
|
||||||
pub language: Option<String>,
|
|
||||||
}
|
|
||||||
impl From<&Media> for MediaRecord {
|
|
||||||
fn from(v: &Media) -> Self {
|
|
||||||
Self {
|
|
||||||
id: v.id.clone(),
|
|
||||||
project_id: v.project_id.clone(),
|
|
||||||
filename: v.filename.clone(),
|
|
||||||
original_name: v.original_name.clone(),
|
|
||||||
mime_type: v.mime_type.clone(),
|
|
||||||
size: v.size,
|
|
||||||
width: v.width,
|
|
||||||
height: v.height,
|
|
||||||
title: v.title.clone(),
|
|
||||||
alt: v.alt.clone(),
|
|
||||||
caption: v.caption.clone(),
|
|
||||||
author: v.author.clone(),
|
|
||||||
file_path: v.file_path.clone(),
|
|
||||||
sidecar_path: v.sidecar_path.clone(),
|
|
||||||
created_at: v.created_at,
|
|
||||||
updated_at: v.updated_at,
|
|
||||||
checksum: v.checksum.clone(),
|
|
||||||
tags: serde_json::to_string(&v.tags).unwrap_or_else(|_| "[]".into()),
|
|
||||||
language: v.language.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl TryFrom<MediaRecord> for Media {
|
|
||||||
type Error = ConversionError;
|
|
||||||
fn try_from(v: MediaRecord) -> Result<Self, Self::Error> {
|
|
||||||
Ok(Self {
|
|
||||||
id: v.id,
|
|
||||||
project_id: v.project_id,
|
|
||||||
filename: v.filename,
|
|
||||||
original_name: v.original_name,
|
|
||||||
mime_type: v.mime_type,
|
|
||||||
size: v.size,
|
|
||||||
width: v.width,
|
|
||||||
height: v.height,
|
|
||||||
title: v.title,
|
|
||||||
alt: v.alt,
|
|
||||||
caption: v.caption,
|
|
||||||
author: v.author,
|
|
||||||
language: v.language,
|
|
||||||
file_path: v.file_path,
|
|
||||||
sidecar_path: v.sidecar_path,
|
|
||||||
checksum: v.checksum,
|
|
||||||
tags: json_strings(&v.tags)?,
|
|
||||||
created_at: v.created_at,
|
|
||||||
updated_at: v.updated_at,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Queryable, Selectable, Insertable, AsChangeset)]
|
|
||||||
#[diesel(table_name = schema::media_translations, check_for_backend(Sqlite))]
|
|
||||||
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
|
|
||||||
pub struct MediaTranslationRecord {
|
|
||||||
pub id: String,
|
|
||||||
pub project_id: String,
|
|
||||||
pub translation_for: String,
|
|
||||||
pub language: String,
|
|
||||||
pub title: Option<String>,
|
|
||||||
pub alt: Option<String>,
|
|
||||||
pub caption: Option<String>,
|
|
||||||
pub created_at: i64,
|
|
||||||
pub updated_at: i64,
|
|
||||||
}
|
|
||||||
impl From<&MediaTranslation> for MediaTranslationRecord {
|
|
||||||
fn from(v: &MediaTranslation) -> Self {
|
|
||||||
Self {
|
|
||||||
id: v.id.clone(),
|
|
||||||
project_id: v.project_id.clone(),
|
|
||||||
translation_for: v.translation_for.clone(),
|
|
||||||
language: v.language.clone(),
|
|
||||||
title: v.title.clone(),
|
|
||||||
alt: v.alt.clone(),
|
|
||||||
caption: v.caption.clone(),
|
|
||||||
created_at: v.created_at,
|
|
||||||
updated_at: v.updated_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl From<MediaTranslationRecord> for MediaTranslation {
|
|
||||||
fn from(v: MediaTranslationRecord) -> Self {
|
|
||||||
Self {
|
|
||||||
id: v.id,
|
|
||||||
project_id: v.project_id,
|
|
||||||
translation_for: v.translation_for,
|
|
||||||
language: v.language,
|
|
||||||
title: v.title,
|
|
||||||
alt: v.alt,
|
|
||||||
caption: v.caption,
|
|
||||||
created_at: v.created_at,
|
|
||||||
updated_at: v.updated_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Queryable, Selectable, Insertable, AsChangeset)]
|
|
||||||
#[diesel(table_name = schema::tags, check_for_backend(Sqlite))]
|
|
||||||
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
|
|
||||||
pub struct TagRecord {
|
|
||||||
pub id: String,
|
|
||||||
pub project_id: String,
|
|
||||||
pub name: String,
|
|
||||||
pub color: Option<String>,
|
|
||||||
pub post_template_slug: Option<String>,
|
|
||||||
pub created_at: i64,
|
|
||||||
pub updated_at: i64,
|
|
||||||
}
|
|
||||||
impl From<&Tag> for TagRecord {
|
|
||||||
fn from(v: &Tag) -> Self {
|
|
||||||
Self {
|
|
||||||
id: v.id.clone(),
|
|
||||||
project_id: v.project_id.clone(),
|
|
||||||
name: v.name.clone(),
|
|
||||||
color: v.color.clone(),
|
|
||||||
post_template_slug: v.post_template_slug.clone(),
|
|
||||||
created_at: v.created_at,
|
|
||||||
updated_at: v.updated_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl From<TagRecord> for Tag {
|
|
||||||
fn from(v: TagRecord) -> Self {
|
|
||||||
Self {
|
|
||||||
id: v.id,
|
|
||||||
project_id: v.project_id,
|
|
||||||
name: v.name,
|
|
||||||
color: v.color,
|
|
||||||
post_template_slug: v.post_template_slug,
|
|
||||||
created_at: v.created_at,
|
|
||||||
updated_at: v.updated_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Queryable, Selectable, Insertable, AsChangeset)]
|
|
||||||
#[diesel(table_name = schema::templates, check_for_backend(Sqlite))]
|
|
||||||
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
|
|
||||||
pub struct TemplateRecord {
|
|
||||||
pub id: String,
|
|
||||||
pub project_id: String,
|
|
||||||
pub slug: String,
|
|
||||||
pub title: String,
|
|
||||||
pub kind: String,
|
|
||||||
pub enabled: i32,
|
|
||||||
pub version: i32,
|
|
||||||
pub file_path: String,
|
|
||||||
pub status: String,
|
|
||||||
pub content: Option<String>,
|
|
||||||
pub created_at: i64,
|
|
||||||
pub updated_at: i64,
|
|
||||||
}
|
|
||||||
impl From<&Template> for TemplateRecord {
|
|
||||||
fn from(v: &Template) -> Self {
|
|
||||||
Self {
|
|
||||||
id: v.id.clone(),
|
|
||||||
project_id: v.project_id.clone(),
|
|
||||||
slug: v.slug.clone(),
|
|
||||||
title: v.title.clone(),
|
|
||||||
kind: template_kind_to_str(&v.kind).into(),
|
|
||||||
enabled: v.enabled as i32,
|
|
||||||
version: v.version,
|
|
||||||
file_path: v.file_path.clone(),
|
|
||||||
status: template_status_to_str(&v.status).into(),
|
|
||||||
content: v.content.clone(),
|
|
||||||
created_at: v.created_at,
|
|
||||||
updated_at: v.updated_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl TryFrom<TemplateRecord> for Template {
|
|
||||||
type Error = ConversionError;
|
|
||||||
fn try_from(v: TemplateRecord) -> Result<Self, Self::Error> {
|
|
||||||
Ok(Self {
|
|
||||||
id: v.id,
|
|
||||||
project_id: v.project_id,
|
|
||||||
slug: v.slug,
|
|
||||||
title: v.title,
|
|
||||||
kind: template_kind(&v.kind)?,
|
|
||||||
enabled: v.enabled != 0,
|
|
||||||
version: v.version,
|
|
||||||
file_path: v.file_path,
|
|
||||||
status: template_status(&v.status)?,
|
|
||||||
content: v.content,
|
|
||||||
created_at: v.created_at,
|
|
||||||
updated_at: v.updated_at,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Queryable, Selectable, Insertable, AsChangeset)]
|
|
||||||
#[diesel(table_name = schema::scripts, check_for_backend(Sqlite))]
|
|
||||||
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
|
|
||||||
pub struct ScriptRecord {
|
|
||||||
pub id: String,
|
|
||||||
pub project_id: String,
|
|
||||||
pub slug: String,
|
|
||||||
pub title: String,
|
|
||||||
pub kind: String,
|
|
||||||
pub entrypoint: String,
|
|
||||||
pub enabled: i32,
|
|
||||||
pub version: i32,
|
|
||||||
pub file_path: String,
|
|
||||||
pub status: String,
|
|
||||||
pub content: Option<String>,
|
|
||||||
pub created_at: i64,
|
|
||||||
pub updated_at: i64,
|
|
||||||
}
|
|
||||||
impl From<&Script> for ScriptRecord {
|
|
||||||
fn from(v: &Script) -> Self {
|
|
||||||
Self {
|
|
||||||
id: v.id.clone(),
|
|
||||||
project_id: v.project_id.clone(),
|
|
||||||
slug: v.slug.clone(),
|
|
||||||
title: v.title.clone(),
|
|
||||||
kind: script_kind_to_str(&v.kind).into(),
|
|
||||||
entrypoint: v.entrypoint.clone(),
|
|
||||||
enabled: v.enabled as i32,
|
|
||||||
version: v.version,
|
|
||||||
file_path: v.file_path.clone(),
|
|
||||||
status: script_status_to_str(&v.status).into(),
|
|
||||||
content: v.content.clone(),
|
|
||||||
created_at: v.created_at,
|
|
||||||
updated_at: v.updated_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl TryFrom<ScriptRecord> for Script {
|
|
||||||
type Error = ConversionError;
|
|
||||||
fn try_from(v: ScriptRecord) -> Result<Self, Self::Error> {
|
|
||||||
Ok(Self {
|
|
||||||
id: v.id,
|
|
||||||
project_id: v.project_id,
|
|
||||||
slug: v.slug,
|
|
||||||
title: v.title,
|
|
||||||
kind: script_kind(&v.kind)?,
|
|
||||||
entrypoint: v.entrypoint,
|
|
||||||
enabled: v.enabled != 0,
|
|
||||||
version: v.version,
|
|
||||||
file_path: v.file_path,
|
|
||||||
status: script_status(&v.status)?,
|
|
||||||
content: v.content,
|
|
||||||
created_at: v.created_at,
|
|
||||||
updated_at: v.updated_at,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Queryable, Selectable, Insertable, AsChangeset)]
|
|
||||||
#[diesel(table_name = schema::settings, check_for_backend(Sqlite))]
|
|
||||||
pub struct SettingRecord {
|
|
||||||
pub key: String,
|
|
||||||
pub value: String,
|
|
||||||
pub updated_at: i64,
|
|
||||||
}
|
|
||||||
impl From<SettingRecord> for Setting {
|
|
||||||
fn from(v: SettingRecord) -> Self {
|
|
||||||
Self {
|
|
||||||
key: v.key,
|
|
||||||
value: v.value,
|
|
||||||
updated_at: v.updated_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Queryable, Selectable, Insertable, AsChangeset)]
|
|
||||||
#[diesel(table_name = schema::generated_file_hashes, check_for_backend(Sqlite))]
|
|
||||||
pub struct GeneratedFileHashRecord {
|
|
||||||
pub project_id: String,
|
|
||||||
pub relative_path: String,
|
|
||||||
pub content_hash: String,
|
|
||||||
pub updated_at: i64,
|
|
||||||
}
|
|
||||||
impl From<&GeneratedFileHash> for GeneratedFileHashRecord {
|
|
||||||
fn from(v: &GeneratedFileHash) -> Self {
|
|
||||||
Self {
|
|
||||||
project_id: v.project_id.clone(),
|
|
||||||
relative_path: v.relative_path.clone(),
|
|
||||||
content_hash: v.content_hash.clone(),
|
|
||||||
updated_at: v.updated_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl From<GeneratedFileHashRecord> for GeneratedFileHash {
|
|
||||||
fn from(v: GeneratedFileHashRecord) -> Self {
|
|
||||||
Self {
|
|
||||||
project_id: v.project_id,
|
|
||||||
relative_path: v.relative_path,
|
|
||||||
content_hash: v.content_hash,
|
|
||||||
updated_at: v.updated_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Queryable, Selectable)]
|
|
||||||
#[diesel(table_name = schema::db_notifications, check_for_backend(Sqlite))]
|
|
||||||
pub struct DbNotificationRecord {
|
|
||||||
pub id: i32,
|
|
||||||
pub entity_type: String,
|
|
||||||
pub entity_id: String,
|
|
||||||
pub action: String,
|
|
||||||
pub from_cli: i32,
|
|
||||||
pub seen_at: Option<i64>,
|
|
||||||
pub created_at: i64,
|
|
||||||
}
|
|
||||||
impl TryFrom<DbNotificationRecord> for DbNotification {
|
|
||||||
type Error = ConversionError;
|
|
||||||
fn try_from(v: DbNotificationRecord) -> Result<Self, Self::Error> {
|
|
||||||
Ok(Self {
|
|
||||||
id: i64::from(v.id),
|
|
||||||
entity_type: notification_entity(&v.entity_type)?,
|
|
||||||
entity_id: v.entity_id,
|
|
||||||
action: notification_action(&v.action)?,
|
|
||||||
from_cli: v.from_cli != 0,
|
|
||||||
seen_at: v.seen_at,
|
|
||||||
created_at: v.created_at,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn convert<T, U>(value: T) -> diesel::QueryResult<U>
|
|
||||||
where
|
|
||||||
U: TryFrom<T, Error = ConversionError>,
|
|
||||||
{
|
|
||||||
value
|
|
||||||
.try_into()
|
|
||||||
.map_err(diesel::result::Error::DeserializationError)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn convert_all<T, U>(values: Vec<T>) -> diesel::QueryResult<Vec<U>>
|
|
||||||
where
|
|
||||||
U: TryFrom<T, Error = ConversionError>,
|
|
||||||
{
|
|
||||||
values.into_iter().map(convert).collect()
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ use diesel::sqlite::Sqlite;
|
|||||||
use rust_stemmers::{Algorithm, Stemmer};
|
use rust_stemmers::{Algorithm, Stemmer};
|
||||||
|
|
||||||
use crate::db::DbConnection as Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
use crate::db::schema::{post_translations, posts};
|
use crate::db::schema::{media, post_translations, posts};
|
||||||
use crate::util::calendar_range_unix_ms;
|
use crate::util::calendar_range_unix_ms;
|
||||||
|
|
||||||
diesel::define_sql_function!(fn instr(haystack: Text, needle: Text) -> diesel::sql_types::Integer);
|
diesel::define_sql_function!(fn instr(haystack: Text, needle: Text) -> diesel::sql_types::Integer);
|
||||||
@@ -457,11 +457,92 @@ pub fn search_media(conn: &Connection, query: &str, language: &str) -> QueryResu
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct MediaSearchFilters<'a> {
|
||||||
|
pub project_id: Option<&'a str>,
|
||||||
|
pub tags: Option<&'a [String]>,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
pub month: Option<u32>,
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
pub offset: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct MediaSearchResults {
|
||||||
|
pub media_ids: Vec<String>,
|
||||||
|
pub total: usize,
|
||||||
|
pub offset: usize,
|
||||||
|
pub limit: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn search_media_filtered(
|
||||||
|
conn: &Connection,
|
||||||
|
query: &str,
|
||||||
|
language: &str,
|
||||||
|
filters: &MediaSearchFilters<'_>,
|
||||||
|
) -> QueryResult<MediaSearchResults> {
|
||||||
|
let ranked_ids = search_media(conn, query, language)?;
|
||||||
|
let offset = filters.offset.unwrap_or(0);
|
||||||
|
if ranked_ids.is_empty() {
|
||||||
|
return Ok(MediaSearchResults {
|
||||||
|
media_ids: Vec::new(),
|
||||||
|
total: 0,
|
||||||
|
offset,
|
||||||
|
limit: filters.limit.unwrap_or(0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let matching_ids = conn.with(|connection| {
|
||||||
|
let mut filtered = media::table
|
||||||
|
.filter(media::id.eq_any(&ranked_ids))
|
||||||
|
.into_boxed();
|
||||||
|
if let Some(project_id) = filters.project_id {
|
||||||
|
filtered = filtered.filter(media::project_id.eq(project_id));
|
||||||
|
}
|
||||||
|
if let Some(year) = filters.year {
|
||||||
|
let (start, end) = calendar_range_unix_ms(year, filters.month).ok_or_else(|| {
|
||||||
|
diesel::result::Error::SerializationError("invalid calendar range".into())
|
||||||
|
})?;
|
||||||
|
filtered = filtered.filter(media::created_at.ge(start).and(media::created_at.lt(end)));
|
||||||
|
}
|
||||||
|
if let Some(tags) = filters.tags {
|
||||||
|
for tag in tags {
|
||||||
|
filtered = filtered.filter(
|
||||||
|
instr(
|
||||||
|
lower(media::tags),
|
||||||
|
serde_json::to_string(&tag.to_lowercase()).unwrap(),
|
||||||
|
)
|
||||||
|
.gt(0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filtered.select(media::id).load::<String>(connection)
|
||||||
|
})?;
|
||||||
|
let matching_ids = matching_ids
|
||||||
|
.into_iter()
|
||||||
|
.collect::<std::collections::HashSet<_>>();
|
||||||
|
let mut media_ids = ranked_ids
|
||||||
|
.into_iter()
|
||||||
|
.filter(|id| matching_ids.contains(id))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let total = media_ids.len();
|
||||||
|
let limit = filters.limit.unwrap_or(total);
|
||||||
|
media_ids = media_ids.into_iter().skip(offset).take(limit).collect();
|
||||||
|
Ok(MediaSearchResults {
|
||||||
|
media_ids,
|
||||||
|
total,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
use crate::db::queries::media::{insert_media, make_test_media};
|
||||||
use crate::db::queries::post::{insert_post, make_test_post};
|
use crate::db::queries::post::{insert_post, make_test_post};
|
||||||
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
use crate::model::PostStatus;
|
use crate::model::PostStatus;
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
@@ -652,6 +733,50 @@ mod tests {
|
|||||||
assert_eq!(results, vec!["media-1"]);
|
assert_eq!(results, vec!["media-1"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filtered_media_search_honors_project_tags_and_pagination() {
|
||||||
|
let db = setup();
|
||||||
|
insert_project(db.conn(), &make_test_project("p1", "one")).unwrap();
|
||||||
|
insert_project(db.conn(), &make_test_project("p2", "two")).unwrap();
|
||||||
|
for (id, project, tags) in [
|
||||||
|
("m1", "p1", vec!["nature".to_string()]),
|
||||||
|
("m2", "p1", vec!["city".to_string()]),
|
||||||
|
("m3", "p2", vec!["nature".to_string()]),
|
||||||
|
] {
|
||||||
|
let mut media = make_test_media(id, project);
|
||||||
|
media.tags = tags.clone();
|
||||||
|
insert_media(db.conn(), &media).unwrap();
|
||||||
|
index_media(
|
||||||
|
db.conn(),
|
||||||
|
id,
|
||||||
|
Some("Shared sunset"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&media.original_name,
|
||||||
|
&tags,
|
||||||
|
&[],
|
||||||
|
"en",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let tags = vec!["nature".to_string()];
|
||||||
|
let results = search_media_filtered(
|
||||||
|
db.conn(),
|
||||||
|
"sunset",
|
||||||
|
"en",
|
||||||
|
&MediaSearchFilters {
|
||||||
|
project_id: Some("p1"),
|
||||||
|
tags: Some(&tags),
|
||||||
|
limit: Some(1),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(results.total, 1);
|
||||||
|
assert_eq!(results.media_ids, ["m1"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn search_no_results() {
|
fn search_no_results() {
|
||||||
let db = setup();
|
let db = setup();
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
mod connection;
|
mod connection;
|
||||||
pub mod from_row;
|
|
||||||
pub mod fts;
|
pub mod fts;
|
||||||
mod migrations;
|
mod migrations;
|
||||||
pub mod queries;
|
pub mod queries;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
pub use connection::{Database, DatabaseError, DbConnection};
|
pub use connection::{Database, DatabaseError, DbConnection};
|
||||||
pub use diesel::result::Error as DbQueryError;
|
pub use diesel::result::Error as DbQueryError;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use crate::db::DbConnection;
|
use crate::db::DbConnection;
|
||||||
use crate::db::from_row::GeneratedFileHashRecord;
|
|
||||||
use crate::db::schema::generated_file_hashes;
|
use crate::db::schema::generated_file_hashes;
|
||||||
use crate::model::GeneratedFileHash;
|
use crate::model::GeneratedFileHash;
|
||||||
|
|
||||||
@@ -14,9 +13,8 @@ pub fn get_generated_file_hash(
|
|||||||
generated_file_hashes::table
|
generated_file_hashes::table
|
||||||
.filter(generated_file_hashes::project_id.eq(project_id))
|
.filter(generated_file_hashes::project_id.eq(project_id))
|
||||||
.filter(generated_file_hashes::relative_path.eq(relative_path))
|
.filter(generated_file_hashes::relative_path.eq(relative_path))
|
||||||
.select(GeneratedFileHashRecord::as_select())
|
.select(GeneratedFileHash::as_select())
|
||||||
.first(c)
|
.first(c)
|
||||||
.map(Into::into)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +24,7 @@ pub fn upsert_generated_file_hash(
|
|||||||
) -> QueryResult<()> {
|
) -> QueryResult<()> {
|
||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
diesel::insert_into(generated_file_hashes::table)
|
diesel::insert_into(generated_file_hashes::table)
|
||||||
.values(GeneratedFileHashRecord::from(hash))
|
.values(hash.clone())
|
||||||
.on_conflict((
|
.on_conflict((
|
||||||
generated_file_hashes::project_id,
|
generated_file_hashes::project_id,
|
||||||
generated_file_hashes::relative_path,
|
generated_file_hashes::relative_path,
|
||||||
@@ -41,36 +39,6 @@ pub fn upsert_generated_file_hash(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_generated_file_hash(
|
|
||||||
conn: &DbConnection,
|
|
||||||
project_id: &str,
|
|
||||||
relative_path: &str,
|
|
||||||
) -> QueryResult<()> {
|
|
||||||
conn.with(|c| {
|
|
||||||
diesel::delete(
|
|
||||||
generated_file_hashes::table
|
|
||||||
.filter(generated_file_hashes::project_id.eq(project_id))
|
|
||||||
.filter(generated_file_hashes::relative_path.eq(relative_path)),
|
|
||||||
)
|
|
||||||
.execute(c)
|
|
||||||
.map(|_| ())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_generated_file_hashes_by_project(
|
|
||||||
conn: &DbConnection,
|
|
||||||
project_id: &str,
|
|
||||||
) -> QueryResult<Vec<GeneratedFileHash>> {
|
|
||||||
conn.with(|c| {
|
|
||||||
generated_file_hashes::table
|
|
||||||
.filter(generated_file_hashes::project_id.eq(project_id))
|
|
||||||
.order(generated_file_hashes::relative_path)
|
|
||||||
.select(GeneratedFileHashRecord::as_select())
|
|
||||||
.load(c)
|
|
||||||
.map(|rows: Vec<GeneratedFileHashRecord>| rows.into_iter().map(Into::into).collect())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ use diesel::prelude::*;
|
|||||||
use diesel::sql_types::Text;
|
use diesel::sql_types::Text;
|
||||||
|
|
||||||
use crate::db::DbConnection;
|
use crate::db::DbConnection;
|
||||||
use crate::db::from_row::{MediaRecord, convert, convert_all};
|
|
||||||
use crate::db::schema::media;
|
use crate::db::schema::media;
|
||||||
use crate::model::Media;
|
use crate::model::Media;
|
||||||
use crate::util::calendar_range_unix_ms;
|
use crate::util::calendar_range_unix_ms;
|
||||||
@@ -13,7 +12,7 @@ diesel::define_sql_function!(fn instr(haystack: Text, needle: Text) -> Integer);
|
|||||||
pub fn insert_media(conn: &DbConnection, m: &Media) -> QueryResult<()> {
|
pub fn insert_media(conn: &DbConnection, m: &Media) -> QueryResult<()> {
|
||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
diesel::insert_into(media::table)
|
diesel::insert_into(media::table)
|
||||||
.values(MediaRecord::from(m))
|
.values(m.clone())
|
||||||
.execute(c)
|
.execute(c)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
})
|
})
|
||||||
@@ -23,9 +22,8 @@ pub fn get_media_by_id(conn: &DbConnection, id: &str) -> QueryResult<Media> {
|
|||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
media::table
|
media::table
|
||||||
.filter(media::id.eq(id))
|
.filter(media::id.eq(id))
|
||||||
.select(MediaRecord::as_select())
|
.select(Media::as_select())
|
||||||
.first(c)
|
.first(c)
|
||||||
.and_then(convert)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,27 +32,8 @@ pub fn list_media_by_project(conn: &DbConnection, project_id: &str) -> QueryResu
|
|||||||
media::table
|
media::table
|
||||||
.filter(media::project_id.eq(project_id))
|
.filter(media::project_id.eq(project_id))
|
||||||
.order(media::created_at.desc())
|
.order(media::created_at.desc())
|
||||||
.select(MediaRecord::as_select())
|
.select(Media::as_select())
|
||||||
.load(c)
|
.load(c)
|
||||||
.and_then(convert_all)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_media_by_project_limited(
|
|
||||||
conn: &DbConnection,
|
|
||||||
project_id: &str,
|
|
||||||
limit: i64,
|
|
||||||
offset: i64,
|
|
||||||
) -> QueryResult<Vec<Media>> {
|
|
||||||
conn.with(|c| {
|
|
||||||
media::table
|
|
||||||
.filter(media::project_id.eq(project_id))
|
|
||||||
.order(media::created_at.desc())
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset)
|
|
||||||
.select(MediaRecord::as_select())
|
|
||||||
.load(c)
|
|
||||||
.and_then(convert_all)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +49,7 @@ pub fn count_media_by_project(conn: &DbConnection, project_id: &str) -> QueryRes
|
|||||||
pub fn update_media(conn: &DbConnection, m: &Media) -> QueryResult<()> {
|
pub fn update_media(conn: &DbConnection, m: &Media) -> QueryResult<()> {
|
||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
diesel::update(media::table.filter(media::id.eq(&m.id)))
|
diesel::update(media::table.filter(media::id.eq(&m.id)))
|
||||||
.set(MediaRecord::from(m))
|
.set(m.clone())
|
||||||
.execute(c)
|
.execute(c)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
})
|
})
|
||||||
@@ -140,9 +119,8 @@ pub fn list_media_filtered(
|
|||||||
.order(media::created_at.desc())
|
.order(media::created_at.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.select(MediaRecord::as_select())
|
.select(Media::as_select())
|
||||||
.load(c)
|
.load(c)
|
||||||
.and_then(convert_all)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use crate::db::DbConnection;
|
use crate::db::DbConnection;
|
||||||
use crate::db::from_row::MediaTranslationRecord;
|
|
||||||
use crate::db::schema::media_translations;
|
use crate::db::schema::media_translations;
|
||||||
use crate::model::MediaTranslation;
|
use crate::model::MediaTranslation;
|
||||||
|
|
||||||
pub fn insert_media_translation(conn: &DbConnection, t: &MediaTranslation) -> QueryResult<()> {
|
pub fn insert_media_translation(conn: &DbConnection, t: &MediaTranslation) -> QueryResult<()> {
|
||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
diesel::insert_into(media_translations::table)
|
diesel::insert_into(media_translations::table)
|
||||||
.values(MediaTranslationRecord::from(t))
|
.values(t.clone())
|
||||||
.execute(c)
|
.execute(c)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
})
|
})
|
||||||
@@ -23,9 +22,8 @@ pub fn get_media_translation_by_media_and_language(
|
|||||||
media_translations::table
|
media_translations::table
|
||||||
.filter(media_translations::translation_for.eq(translation_for))
|
.filter(media_translations::translation_for.eq(translation_for))
|
||||||
.filter(media_translations::language.eq(language))
|
.filter(media_translations::language.eq(language))
|
||||||
.select(MediaTranslationRecord::as_select())
|
.select(MediaTranslation::as_select())
|
||||||
.first(c)
|
.first(c)
|
||||||
.map(Into::into)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,16 +35,15 @@ pub fn list_media_translations_by_media(
|
|||||||
media_translations::table
|
media_translations::table
|
||||||
.filter(media_translations::translation_for.eq(translation_for))
|
.filter(media_translations::translation_for.eq(translation_for))
|
||||||
.order(media_translations::language)
|
.order(media_translations::language)
|
||||||
.select(MediaTranslationRecord::as_select())
|
.select(MediaTranslation::as_select())
|
||||||
.load(c)
|
.load(c)
|
||||||
.map(|rows: Vec<MediaTranslationRecord>| rows.into_iter().map(Into::into).collect())
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn upsert_media_translation(conn: &DbConnection, t: &MediaTranslation) -> QueryResult<()> {
|
pub fn upsert_media_translation(conn: &DbConnection, t: &MediaTranslation) -> QueryResult<()> {
|
||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
diesel::insert_into(media_translations::table)
|
diesel::insert_into(media_translations::table)
|
||||||
.values(MediaTranslationRecord::from(t))
|
.values(t.clone())
|
||||||
.on_conflict((
|
.on_conflict((
|
||||||
media_translations::translation_for,
|
media_translations::translation_for,
|
||||||
media_translations::language,
|
media_translations::language,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ use diesel::sql_types::Text;
|
|||||||
use diesel::sqlite::Sqlite;
|
use diesel::sqlite::Sqlite;
|
||||||
|
|
||||||
use crate::db::DbConnection;
|
use crate::db::DbConnection;
|
||||||
use crate::db::from_row::{PostRecord, convert, convert_all, post_status_to_str};
|
|
||||||
use crate::db::schema::posts;
|
use crate::db::schema::posts;
|
||||||
use crate::model::{Post, PostStatus};
|
use crate::model::{Post, PostStatus};
|
||||||
use crate::util::calendar_range_unix_ms;
|
use crate::util::calendar_range_unix_ms;
|
||||||
@@ -15,7 +14,7 @@ diesel::define_sql_function!(fn lower(value: Text) -> Text);
|
|||||||
pub fn insert_post(conn: &DbConnection, post: &Post) -> QueryResult<()> {
|
pub fn insert_post(conn: &DbConnection, post: &Post) -> QueryResult<()> {
|
||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
diesel::insert_into(posts::table)
|
diesel::insert_into(posts::table)
|
||||||
.values(PostRecord::from(post))
|
.values(post.clone())
|
||||||
.execute(c)
|
.execute(c)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
})
|
})
|
||||||
@@ -25,9 +24,8 @@ pub fn get_post_by_id(conn: &DbConnection, id: &str) -> QueryResult<Post> {
|
|||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
posts::table
|
posts::table
|
||||||
.filter(posts::id.eq(id))
|
.filter(posts::id.eq(id))
|
||||||
.select(PostRecord::as_select())
|
.select(Post::as_select())
|
||||||
.first(c)
|
.first(c)
|
||||||
.and_then(convert)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,9 +38,8 @@ pub fn get_post_by_project_and_slug(
|
|||||||
posts::table
|
posts::table
|
||||||
.filter(posts::project_id.eq(project_id))
|
.filter(posts::project_id.eq(project_id))
|
||||||
.filter(posts::slug.eq(slug))
|
.filter(posts::slug.eq(slug))
|
||||||
.select(PostRecord::as_select())
|
.select(Post::as_select())
|
||||||
.first(c)
|
.first(c)
|
||||||
.and_then(convert)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,34 +48,15 @@ pub fn list_posts_by_project(conn: &DbConnection, project_id: &str) -> QueryResu
|
|||||||
posts::table
|
posts::table
|
||||||
.filter(posts::project_id.eq(project_id))
|
.filter(posts::project_id.eq(project_id))
|
||||||
.order(posts::created_at.desc())
|
.order(posts::created_at.desc())
|
||||||
.select(PostRecord::as_select())
|
.select(Post::as_select())
|
||||||
.load(c)
|
.load(c)
|
||||||
.and_then(convert_all)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_posts_by_project_limited(
|
|
||||||
conn: &DbConnection,
|
|
||||||
project_id: &str,
|
|
||||||
limit: i64,
|
|
||||||
offset: i64,
|
|
||||||
) -> QueryResult<Vec<Post>> {
|
|
||||||
conn.with(|c| {
|
|
||||||
posts::table
|
|
||||||
.filter(posts::project_id.eq(project_id))
|
|
||||||
.order(posts::created_at.desc())
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset)
|
|
||||||
.select(PostRecord::as_select())
|
|
||||||
.load(c)
|
|
||||||
.and_then(convert_all)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_post(conn: &DbConnection, post: &Post) -> QueryResult<()> {
|
pub fn update_post(conn: &DbConnection, post: &Post) -> QueryResult<()> {
|
||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
diesel::update(posts::table.filter(posts::id.eq(&post.id)))
|
diesel::update(posts::table.filter(posts::id.eq(&post.id)))
|
||||||
.set(PostRecord::from(post))
|
.set(post.clone())
|
||||||
.execute(c)
|
.execute(c)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
})
|
})
|
||||||
@@ -93,7 +71,7 @@ pub fn update_post_status(
|
|||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
diesel::update(posts::table.filter(posts::id.eq(id)))
|
diesel::update(posts::table.filter(posts::id.eq(id)))
|
||||||
.set((
|
.set((
|
||||||
posts::status.eq(post_status_to_str(status)),
|
posts::status.eq(status.as_str()),
|
||||||
posts::updated_at.eq(updated_at),
|
posts::updated_at.eq(updated_at),
|
||||||
))
|
))
|
||||||
.execute(c)
|
.execute(c)
|
||||||
@@ -307,17 +285,17 @@ pub fn list_posts_filtered(
|
|||||||
.order(posts::created_at.desc())
|
.order(posts::created_at.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.select(PostRecord::as_select())
|
.select(Post::as_select())
|
||||||
.load(c)?
|
.load(c)?
|
||||||
} else if content_filters {
|
} else if content_filters {
|
||||||
let mut records: Vec<PostRecord> = post_query(project_id, filters, false, false)
|
let mut records: Vec<Post> = post_query(project_id, filters, false, false)
|
||||||
.filter(posts::status.eq("draft"))
|
.filter(posts::status.eq("draft"))
|
||||||
.select(PostRecord::as_select())
|
.select(Post::as_select())
|
||||||
.load(c)?;
|
.load(c)?;
|
||||||
records.extend(
|
records.extend(
|
||||||
post_query(project_id, filters, true, true)
|
post_query(project_id, filters, true, true)
|
||||||
.select(PostRecord::as_select())
|
.select(Post::as_select())
|
||||||
.load::<PostRecord>(c)?,
|
.load::<Post>(c)?,
|
||||||
);
|
);
|
||||||
records.sort_unstable_by_key(|record| std::cmp::Reverse(record.created_at));
|
records.sort_unstable_by_key(|record| std::cmp::Reverse(record.created_at));
|
||||||
records
|
records
|
||||||
@@ -330,10 +308,10 @@ pub fn list_posts_filtered(
|
|||||||
.order(posts::created_at.desc())
|
.order(posts::created_at.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.select(PostRecord::as_select())
|
.select(Post::as_select())
|
||||||
.load(c)?
|
.load(c)?
|
||||||
};
|
};
|
||||||
convert_all(records)
|
Ok(records)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,6 +522,29 @@ mod tests {
|
|||||||
assert_eq!(fetched.updated_at, 5000);
|
assert_eq!(fetched.updated_at, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_persisted_types_fail_deserialization() {
|
||||||
|
let db = setup();
|
||||||
|
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
||||||
|
db.conn()
|
||||||
|
.with(|connection| {
|
||||||
|
diesel::update(posts::table.filter(posts::id.eq("x1")))
|
||||||
|
.set(posts::status.eq("unknown"))
|
||||||
|
.execute(connection)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(get_post_by_id(db.conn(), "x1").is_err());
|
||||||
|
|
||||||
|
db.conn()
|
||||||
|
.with(|connection| {
|
||||||
|
diesel::update(posts::table.filter(posts::id.eq("x1")))
|
||||||
|
.set((posts::status.eq("draft"), posts::tags.eq("not-json")))
|
||||||
|
.execute(connection)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(get_post_by_id(db.conn(), "x1").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clear_content_sets_null() {
|
fn clear_content_sets_null() {
|
||||||
let db = setup();
|
let db = setup();
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use crate::db::DbConnection;
|
use crate::db::DbConnection;
|
||||||
use crate::db::from_row::PostLinkRecord;
|
|
||||||
use crate::db::schema::post_links;
|
use crate::db::schema::post_links;
|
||||||
use crate::model::PostLink;
|
use crate::model::PostLink;
|
||||||
|
|
||||||
pub fn insert_post_link(conn: &DbConnection, link: &PostLink) -> QueryResult<()> {
|
pub fn insert_post_link(conn: &DbConnection, link: &PostLink) -> QueryResult<()> {
|
||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
diesel::insert_into(post_links::table)
|
diesel::insert_into(post_links::table)
|
||||||
.values(PostLinkRecord::from(link))
|
.values(link.clone())
|
||||||
.execute(c)
|
.execute(c)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
})
|
})
|
||||||
@@ -38,9 +37,8 @@ pub fn list_links_by_source(
|
|||||||
post_links::table
|
post_links::table
|
||||||
.filter(post_links::source_post_id.eq(source_post_id))
|
.filter(post_links::source_post_id.eq(source_post_id))
|
||||||
.order(post_links::created_at)
|
.order(post_links::created_at)
|
||||||
.select(PostLinkRecord::as_select())
|
.select(PostLink::as_select())
|
||||||
.load(c)
|
.load(c)
|
||||||
.map(|rows: Vec<PostLinkRecord>| rows.into_iter().map(Into::into).collect())
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,9 +50,8 @@ pub fn list_links_by_target(
|
|||||||
post_links::table
|
post_links::table
|
||||||
.filter(post_links::target_post_id.eq(target_post_id))
|
.filter(post_links::target_post_id.eq(target_post_id))
|
||||||
.order(post_links::created_at)
|
.order(post_links::created_at)
|
||||||
.select(PostLinkRecord::as_select())
|
.select(PostLink::as_select())
|
||||||
.load(c)
|
.load(c)
|
||||||
.map(|rows: Vec<PostLinkRecord>| rows.into_iter().map(Into::into).collect())
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use crate::db::DbConnection;
|
use crate::db::DbConnection;
|
||||||
use crate::db::from_row::PostMediaRecord;
|
|
||||||
use crate::db::schema::post_media;
|
use crate::db::schema::post_media;
|
||||||
use crate::model::PostMedia;
|
use crate::model::PostMedia;
|
||||||
|
|
||||||
pub fn link_media(conn: &DbConnection, pm: &PostMedia) -> QueryResult<()> {
|
pub fn link_media(conn: &DbConnection, pm: &PostMedia) -> QueryResult<()> {
|
||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
diesel::insert_into(post_media::table)
|
diesel::insert_into(post_media::table)
|
||||||
.values(PostMediaRecord::from(pm))
|
.values(pm.clone())
|
||||||
.execute(c)
|
.execute(c)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
})
|
})
|
||||||
@@ -39,9 +38,8 @@ pub fn list_post_media_by_post(conn: &DbConnection, post_id: &str) -> QueryResul
|
|||||||
post_media::table
|
post_media::table
|
||||||
.filter(post_media::post_id.eq(post_id))
|
.filter(post_media::post_id.eq(post_id))
|
||||||
.order(post_media::sort_order)
|
.order(post_media::sort_order)
|
||||||
.select(PostMediaRecord::as_select())
|
.select(PostMedia::as_select())
|
||||||
.load(c)
|
.load(c)
|
||||||
.map(|rows: Vec<PostMediaRecord>| rows.into_iter().map(Into::into).collect())
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,9 +51,8 @@ pub fn list_post_media_by_media(
|
|||||||
post_media::table
|
post_media::table
|
||||||
.filter(post_media::media_id.eq(media_id))
|
.filter(post_media::media_id.eq(media_id))
|
||||||
.order(post_media::created_at)
|
.order(post_media::created_at)
|
||||||
.select(PostMediaRecord::as_select())
|
.select(PostMedia::as_select())
|
||||||
.load(c)
|
.load(c)
|
||||||
.map(|rows: Vec<PostMediaRecord>| rows.into_iter().map(Into::into).collect())
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use crate::db::DbConnection;
|
use crate::db::DbConnection;
|
||||||
use crate::db::from_row::{PostTranslationRecord, convert, convert_all};
|
|
||||||
use crate::db::schema::post_translations;
|
use crate::db::schema::post_translations;
|
||||||
use crate::model::PostTranslation;
|
use crate::model::PostTranslation;
|
||||||
|
|
||||||
@@ -13,7 +12,7 @@ pub fn insert_post_translation(conn: &DbConnection, t: &PostTranslation) -> Quer
|
|||||||
}
|
}
|
||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
diesel::insert_into(post_translations::table)
|
diesel::insert_into(post_translations::table)
|
||||||
.values(PostTranslationRecord::from(t))
|
.values(t.clone())
|
||||||
.execute(c)
|
.execute(c)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
})
|
})
|
||||||
@@ -23,9 +22,8 @@ pub fn get_post_translation_by_id(conn: &DbConnection, id: &str) -> QueryResult<
|
|||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
post_translations::table
|
post_translations::table
|
||||||
.filter(post_translations::id.eq(id))
|
.filter(post_translations::id.eq(id))
|
||||||
.select(PostTranslationRecord::as_select())
|
.select(PostTranslation::as_select())
|
||||||
.first(c)
|
.first(c)
|
||||||
.and_then(convert)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,9 +36,8 @@ pub fn get_post_translation_by_post_and_language(
|
|||||||
post_translations::table
|
post_translations::table
|
||||||
.filter(post_translations::translation_for.eq(translation_for))
|
.filter(post_translations::translation_for.eq(translation_for))
|
||||||
.filter(post_translations::language.eq(language))
|
.filter(post_translations::language.eq(language))
|
||||||
.select(PostTranslationRecord::as_select())
|
.select(PostTranslation::as_select())
|
||||||
.first(c)
|
.first(c)
|
||||||
.and_then(convert)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,9 +49,8 @@ pub fn list_post_translations_by_post(
|
|||||||
post_translations::table
|
post_translations::table
|
||||||
.filter(post_translations::translation_for.eq(translation_for))
|
.filter(post_translations::translation_for.eq(translation_for))
|
||||||
.order(post_translations::language)
|
.order(post_translations::language)
|
||||||
.select(PostTranslationRecord::as_select())
|
.select(PostTranslation::as_select())
|
||||||
.load(c)
|
.load(c)
|
||||||
.and_then(convert_all)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +62,7 @@ pub fn update_post_translation(conn: &DbConnection, t: &PostTranslation) -> Quer
|
|||||||
}
|
}
|
||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
diesel::update(post_translations::table.filter(post_translations::id.eq(&t.id)))
|
diesel::update(post_translations::table.filter(post_translations::id.eq(&t.id)))
|
||||||
.set(PostTranslationRecord::from(t))
|
.set(t.clone())
|
||||||
.execute(c)
|
.execute(c)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use crate::db::DbConnection;
|
use crate::db::DbConnection;
|
||||||
use crate::db::from_row::ProjectRecord;
|
|
||||||
use crate::db::schema::projects;
|
use crate::db::schema::projects;
|
||||||
use crate::model::Project;
|
use crate::model::Project;
|
||||||
|
|
||||||
pub fn insert_project(conn: &DbConnection, project: &Project) -> QueryResult<()> {
|
pub fn insert_project(conn: &DbConnection, project: &Project) -> QueryResult<()> {
|
||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
diesel::insert_into(projects::table)
|
diesel::insert_into(projects::table)
|
||||||
.values(ProjectRecord::from(project))
|
.values(project.clone())
|
||||||
.execute(c)
|
.execute(c)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
})
|
})
|
||||||
@@ -18,9 +17,8 @@ pub fn get_project_by_id(conn: &DbConnection, id: &str) -> QueryResult<Project>
|
|||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
projects::table
|
projects::table
|
||||||
.filter(projects::id.eq(id))
|
.filter(projects::id.eq(id))
|
||||||
.select(ProjectRecord::as_select())
|
.select(Project::as_select())
|
||||||
.first(c)
|
.first(c)
|
||||||
.map(Into::into)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,9 +26,8 @@ pub fn get_project_by_slug(conn: &DbConnection, slug: &str) -> QueryResult<Proje
|
|||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
projects::table
|
projects::table
|
||||||
.filter(projects::slug.eq(slug))
|
.filter(projects::slug.eq(slug))
|
||||||
.select(ProjectRecord::as_select())
|
.select(Project::as_select())
|
||||||
.first(c)
|
.first(c)
|
||||||
.map(Into::into)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,9 +35,8 @@ pub fn get_active_project(conn: &DbConnection) -> QueryResult<Project> {
|
|||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
projects::table
|
projects::table
|
||||||
.filter(projects::is_active.eq(1))
|
.filter(projects::is_active.eq(1))
|
||||||
.select(ProjectRecord::as_select())
|
.select(Project::as_select())
|
||||||
.first(c)
|
.first(c)
|
||||||
.map(Into::into)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,16 +58,15 @@ pub fn list_projects(conn: &DbConnection) -> QueryResult<Vec<Project>> {
|
|||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
projects::table
|
projects::table
|
||||||
.order(projects::name)
|
.order(projects::name)
|
||||||
.select(ProjectRecord::as_select())
|
.select(Project::as_select())
|
||||||
.load(c)
|
.load(c)
|
||||||
.map(|rows: Vec<ProjectRecord>| rows.into_iter().map(Into::into).collect())
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_project(conn: &DbConnection, project: &Project) -> QueryResult<()> {
|
pub fn update_project(conn: &DbConnection, project: &Project) -> QueryResult<()> {
|
||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
diesel::update(projects::table.filter(projects::id.eq(&project.id)))
|
diesel::update(projects::table.filter(projects::id.eq(&project.id)))
|
||||||
.set(ProjectRecord::from(project))
|
.set(project.clone())
|
||||||
.execute(c)
|
.execute(c)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use crate::db::DbConnection;
|
use crate::db::DbConnection;
|
||||||
use crate::db::from_row::{ScriptRecord, convert, convert_all};
|
|
||||||
use crate::db::schema::scripts;
|
use crate::db::schema::scripts;
|
||||||
use crate::model::Script;
|
use crate::model::Script;
|
||||||
|
|
||||||
pub fn insert_script(conn: &DbConnection, s: &Script) -> QueryResult<()> {
|
pub fn insert_script(conn: &DbConnection, s: &Script) -> QueryResult<()> {
|
||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
diesel::insert_into(scripts::table)
|
diesel::insert_into(scripts::table)
|
||||||
.values(ScriptRecord::from(s))
|
.values(s.clone())
|
||||||
.execute(c)
|
.execute(c)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
})
|
})
|
||||||
@@ -18,9 +17,8 @@ pub fn get_script_by_id(conn: &DbConnection, id: &str) -> QueryResult<Script> {
|
|||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
scripts::table
|
scripts::table
|
||||||
.filter(scripts::id.eq(id))
|
.filter(scripts::id.eq(id))
|
||||||
.select(ScriptRecord::as_select())
|
.select(Script::as_select())
|
||||||
.first(c)
|
.first(c)
|
||||||
.and_then(convert)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,9 +31,8 @@ pub fn get_script_by_slug(
|
|||||||
scripts::table
|
scripts::table
|
||||||
.filter(scripts::project_id.eq(project_id))
|
.filter(scripts::project_id.eq(project_id))
|
||||||
.filter(scripts::slug.eq(slug))
|
.filter(scripts::slug.eq(slug))
|
||||||
.select(ScriptRecord::as_select())
|
.select(Script::as_select())
|
||||||
.first(c)
|
.first(c)
|
||||||
.and_then(convert)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,16 +41,15 @@ pub fn list_scripts_by_project(conn: &DbConnection, project_id: &str) -> QueryRe
|
|||||||
scripts::table
|
scripts::table
|
||||||
.filter(scripts::project_id.eq(project_id))
|
.filter(scripts::project_id.eq(project_id))
|
||||||
.order(scripts::title)
|
.order(scripts::title)
|
||||||
.select(ScriptRecord::as_select())
|
.select(Script::as_select())
|
||||||
.load(c)
|
.load(c)
|
||||||
.and_then(convert_all)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_script(conn: &DbConnection, s: &Script) -> QueryResult<()> {
|
pub fn update_script(conn: &DbConnection, s: &Script) -> QueryResult<()> {
|
||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
diesel::update(scripts::table.filter(scripts::id.eq(&s.id)))
|
diesel::update(scripts::table.filter(scripts::id.eq(&s.id)))
|
||||||
.set(ScriptRecord::from(s))
|
.set(s.clone())
|
||||||
.execute(c)
|
.execute(c)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use crate::db::DbConnection;
|
use crate::db::DbConnection;
|
||||||
use crate::db::from_row::SettingRecord;
|
|
||||||
use crate::db::schema::settings;
|
use crate::db::schema::settings;
|
||||||
use crate::model::Setting;
|
use crate::model::Setting;
|
||||||
|
|
||||||
@@ -9,9 +8,8 @@ pub fn get_setting_by_key(conn: &DbConnection, key: &str) -> QueryResult<Setting
|
|||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
settings::table
|
settings::table
|
||||||
.filter(settings::key.eq(key))
|
.filter(settings::key.eq(key))
|
||||||
.select(SettingRecord::as_select())
|
.select(Setting::as_select())
|
||||||
.first(c)
|
.first(c)
|
||||||
.map(Into::into)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,9 +41,8 @@ pub fn list_all_settings(conn: &DbConnection) -> QueryResult<Vec<Setting>> {
|
|||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
settings::table
|
settings::table
|
||||||
.order(settings::key)
|
.order(settings::key)
|
||||||
.select(SettingRecord::as_select())
|
.select(Setting::as_select())
|
||||||
.load(c)
|
.load(c)
|
||||||
.map(|rows: Vec<SettingRecord>| rows.into_iter().map(Into::into).collect())
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ use diesel::prelude::*;
|
|||||||
use diesel::sql_types::Text;
|
use diesel::sql_types::Text;
|
||||||
|
|
||||||
use crate::db::DbConnection;
|
use crate::db::DbConnection;
|
||||||
use crate::db::from_row::TagRecord;
|
|
||||||
use crate::db::schema::tags;
|
use crate::db::schema::tags;
|
||||||
use crate::model::Tag;
|
use crate::model::Tag;
|
||||||
|
|
||||||
@@ -11,7 +10,7 @@ diesel::define_sql_function!(fn lower(value: Text) -> Text);
|
|||||||
pub fn insert_tag(conn: &DbConnection, tag: &Tag) -> QueryResult<()> {
|
pub fn insert_tag(conn: &DbConnection, tag: &Tag) -> QueryResult<()> {
|
||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
diesel::insert_into(tags::table)
|
diesel::insert_into(tags::table)
|
||||||
.values(TagRecord::from(tag))
|
.values(tag.clone())
|
||||||
.execute(c)
|
.execute(c)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
})
|
})
|
||||||
@@ -21,9 +20,8 @@ pub fn get_tag_by_id(conn: &DbConnection, id: &str) -> QueryResult<Tag> {
|
|||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
tags::table
|
tags::table
|
||||||
.filter(tags::id.eq(id))
|
.filter(tags::id.eq(id))
|
||||||
.select(TagRecord::as_select())
|
.select(Tag::as_select())
|
||||||
.first(c)
|
.first(c)
|
||||||
.map(Into::into)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,9 +34,8 @@ pub fn get_tag_by_project_and_name(
|
|||||||
tags::table
|
tags::table
|
||||||
.filter(tags::project_id.eq(project_id))
|
.filter(tags::project_id.eq(project_id))
|
||||||
.filter(lower(tags::name).eq(name.to_lowercase()))
|
.filter(lower(tags::name).eq(name.to_lowercase()))
|
||||||
.select(TagRecord::as_select())
|
.select(Tag::as_select())
|
||||||
.first(c)
|
.first(c)
|
||||||
.map(Into::into)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,16 +44,15 @@ pub fn list_tags_by_project(conn: &DbConnection, project_id: &str) -> QueryResul
|
|||||||
tags::table
|
tags::table
|
||||||
.filter(tags::project_id.eq(project_id))
|
.filter(tags::project_id.eq(project_id))
|
||||||
.order(tags::name)
|
.order(tags::name)
|
||||||
.select(TagRecord::as_select())
|
.select(Tag::as_select())
|
||||||
.load(c)
|
.load(c)
|
||||||
.map(|rows: Vec<TagRecord>| rows.into_iter().map(Into::into).collect())
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_tag(conn: &DbConnection, tag: &Tag) -> QueryResult<()> {
|
pub fn update_tag(conn: &DbConnection, tag: &Tag) -> QueryResult<()> {
|
||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
diesel::update(tags::table.filter(tags::id.eq(&tag.id)))
|
diesel::update(tags::table.filter(tags::id.eq(&tag.id)))
|
||||||
.set(TagRecord::from(tag))
|
.set(tag.clone())
|
||||||
.execute(c)
|
.execute(c)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use crate::db::DbConnection;
|
use crate::db::DbConnection;
|
||||||
use crate::db::from_row::{TemplateRecord, convert, convert_all};
|
|
||||||
use crate::db::schema::templates;
|
use crate::db::schema::templates;
|
||||||
use crate::model::Template;
|
use crate::model::Template;
|
||||||
|
|
||||||
pub fn insert_template(conn: &DbConnection, t: &Template) -> QueryResult<()> {
|
pub fn insert_template(conn: &DbConnection, t: &Template) -> QueryResult<()> {
|
||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
diesel::insert_into(templates::table)
|
diesel::insert_into(templates::table)
|
||||||
.values(TemplateRecord::from(t))
|
.values(t.clone())
|
||||||
.execute(c)
|
.execute(c)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
})
|
})
|
||||||
@@ -18,9 +17,8 @@ pub fn get_template_by_id(conn: &DbConnection, id: &str) -> QueryResult<Template
|
|||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
templates::table
|
templates::table
|
||||||
.filter(templates::id.eq(id))
|
.filter(templates::id.eq(id))
|
||||||
.select(TemplateRecord::as_select())
|
.select(Template::as_select())
|
||||||
.first(c)
|
.first(c)
|
||||||
.and_then(convert)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,9 +31,8 @@ pub fn get_template_by_slug(
|
|||||||
templates::table
|
templates::table
|
||||||
.filter(templates::project_id.eq(project_id))
|
.filter(templates::project_id.eq(project_id))
|
||||||
.filter(templates::slug.eq(slug))
|
.filter(templates::slug.eq(slug))
|
||||||
.select(TemplateRecord::as_select())
|
.select(Template::as_select())
|
||||||
.first(c)
|
.first(c)
|
||||||
.and_then(convert)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,16 +44,15 @@ pub fn list_templates_by_project(
|
|||||||
templates::table
|
templates::table
|
||||||
.filter(templates::project_id.eq(project_id))
|
.filter(templates::project_id.eq(project_id))
|
||||||
.order(templates::title)
|
.order(templates::title)
|
||||||
.select(TemplateRecord::as_select())
|
.select(Template::as_select())
|
||||||
.load(c)
|
.load(c)
|
||||||
.and_then(convert_all)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_template(conn: &DbConnection, t: &Template) -> QueryResult<()> {
|
pub fn update_template(conn: &DbConnection, t: &Template) -> QueryResult<()> {
|
||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
diesel::update(templates::table.filter(templates::id.eq(&t.id)))
|
diesel::update(templates::table.filter(templates::id.eq(&t.id)))
|
||||||
.set(TemplateRecord::from(t))
|
.set(t.clone())
|
||||||
.execute(c)
|
.execute(c)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
})
|
})
|
||||||
|
|||||||
95
crates/bds-core/src/db/types.rs
Normal file
95
crates/bds-core/src/db/types.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
use diesel::deserialize::{self, FromSql, FromSqlRow};
|
||||||
|
use diesel::expression::AsExpression;
|
||||||
|
use diesel::serialize::{self, IsNull, Output, ToSql};
|
||||||
|
use diesel::sql_types::{Integer, Text};
|
||||||
|
use diesel::sqlite::{Sqlite, SqliteValue};
|
||||||
|
|
||||||
|
use crate::model::{
|
||||||
|
NotificationAction, NotificationEntity, PostStatus, ScriptKind, ScriptStatus, TemplateKind,
|
||||||
|
TemplateStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, AsExpression, FromSqlRow)]
|
||||||
|
#[diesel(sql_type = Integer)]
|
||||||
|
pub struct DbBool(bool);
|
||||||
|
|
||||||
|
impl From<bool> for DbBool {
|
||||||
|
fn from(value: bool) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DbBool> for bool {
|
||||||
|
fn from(value: DbBool) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToSql<Integer, Sqlite> for DbBool {
|
||||||
|
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Sqlite>) -> serialize::Result {
|
||||||
|
out.set_value(i32::from(self.0));
|
||||||
|
Ok(IsNull::No)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromSql<Integer, Sqlite> for DbBool {
|
||||||
|
fn from_sql(value: SqliteValue<'_, '_, '_>) -> deserialize::Result<Self> {
|
||||||
|
Ok(Self(i32::from_sql(value)? != 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, AsExpression, FromSqlRow)]
|
||||||
|
#[diesel(sql_type = Text)]
|
||||||
|
pub struct DbStringList(Vec<String>);
|
||||||
|
|
||||||
|
impl From<Vec<String>> for DbStringList {
|
||||||
|
fn from(value: Vec<String>) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DbStringList> for Vec<String> {
|
||||||
|
fn from(value: DbStringList) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToSql<Text, Sqlite> for DbStringList {
|
||||||
|
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Sqlite>) -> serialize::Result {
|
||||||
|
out.set_value(serde_json::to_string(&self.0)?);
|
||||||
|
Ok(IsNull::No)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromSql<Text, Sqlite> for DbStringList {
|
||||||
|
fn from_sql(value: SqliteValue<'_, '_, '_>) -> deserialize::Result<Self> {
|
||||||
|
let value = <String as FromSql<Text, Sqlite>>::from_sql(value)?;
|
||||||
|
Ok(Self(serde_json::from_str(&value)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! text_enum_sql {
|
||||||
|
($type:ty) => {
|
||||||
|
impl ToSql<Text, Sqlite> for $type {
|
||||||
|
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Sqlite>) -> serialize::Result {
|
||||||
|
out.set_value(self.as_str().to_owned());
|
||||||
|
Ok(IsNull::No)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromSql<Text, Sqlite> for $type {
|
||||||
|
fn from_sql(value: SqliteValue<'_, '_, '_>) -> deserialize::Result<Self> {
|
||||||
|
let value = <String as FromSql<Text, Sqlite>>::from_sql(value)?;
|
||||||
|
value.parse().map_err(Into::into)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
text_enum_sql!(PostStatus);
|
||||||
|
text_enum_sql!(TemplateKind);
|
||||||
|
text_enum_sql!(TemplateStatus);
|
||||||
|
text_enum_sql!(ScriptKind);
|
||||||
|
text_enum_sql!(ScriptStatus);
|
||||||
|
text_enum_sql!(NotificationEntity);
|
||||||
|
text_enum_sql!(NotificationAction);
|
||||||
@@ -64,7 +64,7 @@ pub fn fill_missing_translations(
|
|||||||
main_language: &str,
|
main_language: &str,
|
||||||
blog_languages: &[String],
|
blog_languages: &[String],
|
||||||
offline_mode: bool,
|
offline_mode: bool,
|
||||||
mut on_progress: impl FnMut(f32, &str),
|
mut on_progress: impl FnMut(f32, &str) -> bool,
|
||||||
) -> EngineResult<FillMissingTranslationsReport> {
|
) -> EngineResult<FillMissingTranslationsReport> {
|
||||||
fill_missing_translations_with(
|
fill_missing_translations_with(
|
||||||
conn,
|
conn,
|
||||||
@@ -90,7 +90,7 @@ fn fill_missing_translations_with(
|
|||||||
blog_languages: &[String],
|
blog_languages: &[String],
|
||||||
post_translator: &mut dyn FnMut(&Post, &str) -> EngineResult<TranslationResult>,
|
post_translator: &mut dyn FnMut(&Post, &str) -> EngineResult<TranslationResult>,
|
||||||
media_translator: &mut dyn FnMut(&Media, &str) -> EngineResult<MediaTranslationResult>,
|
media_translator: &mut dyn FnMut(&Media, &str) -> EngineResult<MediaTranslationResult>,
|
||||||
on_progress: &mut dyn FnMut(f32, &str),
|
on_progress: &mut dyn FnMut(f32, &str) -> bool,
|
||||||
) -> EngineResult<FillMissingTranslationsReport> {
|
) -> EngineResult<FillMissingTranslationsReport> {
|
||||||
let configured = configured_languages(main_language, blog_languages);
|
let configured = configured_languages(main_language, blog_languages);
|
||||||
if configured.len() <= 1 {
|
if configured.len() <= 1 {
|
||||||
@@ -100,12 +100,17 @@ fn fill_missing_translations_with(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
let posts = qp::list_posts_by_project(conn, project_id)?;
|
let posts = qp::list_posts_by_project(conn, project_id)?;
|
||||||
on_progress(0.0, "Scanning published posts");
|
if !on_progress(0.0, "Scanning published posts") {
|
||||||
|
return Err(EngineError::Validation("cancelled".to_string()));
|
||||||
|
}
|
||||||
let mut work = Vec::new();
|
let mut work = Vec::new();
|
||||||
for post in posts
|
for post in posts
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|post| post.status == PostStatus::Published && !post.do_not_translate)
|
.filter(|post| post.status == PostStatus::Published && !post.do_not_translate)
|
||||||
{
|
{
|
||||||
|
if !on_progress(0.0, "Scanning published posts") {
|
||||||
|
return Err(EngineError::Validation("cancelled".to_string()));
|
||||||
|
}
|
||||||
for language in missing_languages(conn, &post, &configured)? {
|
for language in missing_languages(conn, &post, &configured)? {
|
||||||
work.push((post.clone(), language));
|
work.push((post.clone(), language));
|
||||||
}
|
}
|
||||||
@@ -119,10 +124,12 @@ fn fill_missing_translations_with(
|
|||||||
|
|
||||||
let mut report = FillMissingTranslationsReport::default();
|
let mut report = FillMissingTranslationsReport::default();
|
||||||
for (index, (post, language)) in work.iter().enumerate() {
|
for (index, (post, language)) in work.iter().enumerate() {
|
||||||
on_progress(
|
if !on_progress(
|
||||||
0.15 + (index as f32 / work.len() as f32) * 0.85,
|
0.15 + (index as f32 / work.len() as f32) * 0.85,
|
||||||
&format!("{} → {language}", post.title),
|
&format!("{} → {language}", post.title),
|
||||||
);
|
) {
|
||||||
|
return Err(EngineError::Validation("cancelled".to_string()));
|
||||||
|
}
|
||||||
match translate_one_post(
|
match translate_one_post(
|
||||||
conn,
|
conn,
|
||||||
data_dir,
|
data_dir,
|
||||||
@@ -144,7 +151,9 @@ fn fill_missing_translations_with(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
on_progress(1.0, "Translation batch complete");
|
if !on_progress(1.0, "Translation batch complete") {
|
||||||
|
return Err(EngineError::Validation("cancelled".to_string()));
|
||||||
|
}
|
||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +165,7 @@ pub fn translate_missing_for_post(
|
|||||||
main_language: &str,
|
main_language: &str,
|
||||||
blog_languages: &[String],
|
blog_languages: &[String],
|
||||||
offline_mode: bool,
|
offline_mode: bool,
|
||||||
|
is_cancelled: impl Fn() -> bool,
|
||||||
) -> EngineResult<FillMissingTranslationsReport> {
|
) -> EngineResult<FillMissingTranslationsReport> {
|
||||||
let post = qp::get_post_by_id(conn, post_id)?;
|
let post = qp::get_post_by_id(conn, post_id)?;
|
||||||
let configured = configured_languages(main_language, blog_languages);
|
let configured = configured_languages(main_language, blog_languages);
|
||||||
@@ -165,6 +175,9 @@ pub fn translate_missing_for_post(
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
for language in targets {
|
for language in targets {
|
||||||
|
if is_cancelled() {
|
||||||
|
return Err(EngineError::Validation("cancelled".to_string()));
|
||||||
|
}
|
||||||
let result = translate_one_post(
|
let result = translate_one_post(
|
||||||
conn,
|
conn,
|
||||||
data_dir,
|
data_dir,
|
||||||
@@ -369,7 +382,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
&mut |_media, _language| unreachable!(),
|
&mut |_media, _language| unreachable!(),
|
||||||
&mut |_, _| {},
|
&mut |_, _| true,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -433,9 +446,30 @@ mod tests {
|
|||||||
&["de".into()],
|
&["de".into()],
|
||||||
&mut |_, _| panic!("translator must not run"),
|
&mut |_, _| panic!("translator must not run"),
|
||||||
&mut |_, _| panic!("translator must not run"),
|
&mut |_, _| panic!("translator must not run"),
|
||||||
&mut |_, _| {},
|
&mut |_, _| true,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(report.nothing_to_do);
|
assert!(report.nothing_to_do);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_stops_when_progress_callback_cancels() {
|
||||||
|
let db = Database::open_in_memory().unwrap();
|
||||||
|
db.migrate().unwrap();
|
||||||
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
let result = fill_missing_translations_with(
|
||||||
|
db.conn(),
|
||||||
|
dir.path(),
|
||||||
|
"p1",
|
||||||
|
"en",
|
||||||
|
&["de".into()],
|
||||||
|
&mut |_, _| panic!("translator must not run"),
|
||||||
|
&mut |_, _| panic!("translator must not run"),
|
||||||
|
&mut |_, _| false,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(matches!(result, Err(EngineError::Validation(message)) if message == "cancelled"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
/// Shared context passed to engine operations.
|
|
||||||
pub struct EngineContext<'a> {
|
|
||||||
pub conn: &'a crate::db::DbConnection,
|
|
||||||
pub project_id: String,
|
|
||||||
pub data_dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::db::Database;
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn context_holds_references() {
|
|
||||||
let db = Database::open_in_memory().unwrap();
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let ctx = EngineContext {
|
|
||||||
conn: db.conn(),
|
|
||||||
project_id: "p1".into(),
|
|
||||||
data_dir: dir.path().to_path_buf(),
|
|
||||||
};
|
|
||||||
assert_eq!(ctx.project_id, "p1");
|
|
||||||
assert!(ctx.data_dir.exists());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +1,22 @@
|
|||||||
use std::fmt;
|
|
||||||
|
|
||||||
/// Errors produced by engine operations.
|
/// Errors produced by engine operations.
|
||||||
#[derive(Debug)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum EngineError {
|
pub enum EngineError {
|
||||||
Db(diesel::result::Error),
|
#[error("database error: {0}")]
|
||||||
DbConnection(diesel::ConnectionError),
|
Db(#[from] diesel::result::Error),
|
||||||
Io(std::io::Error),
|
#[error("database connection error: {0}")]
|
||||||
|
DbConnection(#[from] diesel::ConnectionError),
|
||||||
|
#[error("I/O error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("parse error: {0}")]
|
||||||
Parse(String),
|
Parse(String),
|
||||||
|
#[error("not found: {0}")]
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
|
#[error("conflict: {0}")]
|
||||||
Conflict(String),
|
Conflict(String),
|
||||||
|
#[error("validation error: {0}")]
|
||||||
Validation(String),
|
Validation(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for EngineError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Db(e) => write!(f, "database error: {e}"),
|
|
||||||
Self::DbConnection(e) => write!(f, "database connection error: {e}"),
|
|
||||||
Self::Io(e) => write!(f, "I/O error: {e}"),
|
|
||||||
Self::Parse(msg) => write!(f, "parse error: {msg}"),
|
|
||||||
Self::NotFound(msg) => write!(f, "not found: {msg}"),
|
|
||||||
Self::Conflict(msg) => write!(f, "conflict: {msg}"),
|
|
||||||
Self::Validation(msg) => write!(f, "validation error: {msg}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for EngineError {
|
|
||||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
||||||
match self {
|
|
||||||
Self::Db(e) => Some(e),
|
|
||||||
Self::DbConnection(e) => Some(e),
|
|
||||||
Self::Io(e) => Some(e),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<diesel::result::Error> for EngineError {
|
|
||||||
fn from(e: diesel::result::Error) -> Self {
|
|
||||||
Self::Db(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<diesel::ConnectionError> for EngineError {
|
|
||||||
fn from(e: diesel::ConnectionError) -> Self {
|
|
||||||
Self::DbConnection(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<crate::db::DatabaseError> for EngineError {
|
impl From<crate::db::DatabaseError> for EngineError {
|
||||||
fn from(e: crate::db::DatabaseError) -> Self {
|
fn from(e: crate::db::DatabaseError) -> Self {
|
||||||
match e {
|
match e {
|
||||||
@@ -58,12 +26,6 @@ impl From<crate::db::DatabaseError> for EngineError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<std::io::Error> for EngineError {
|
|
||||||
fn from(e: std::io::Error) -> Self {
|
|
||||||
Self::Io(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<reqwest::Error> for EngineError {
|
impl From<reqwest::Error> for EngineError {
|
||||||
fn from(e: reqwest::Error) -> Self {
|
fn from(e: reqwest::Error) -> Self {
|
||||||
Self::Parse(e.to_string())
|
Self::Parse(e.to_string())
|
||||||
@@ -83,39 +45,3 @@ impl From<serde_yaml::Error> for EngineError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub type EngineResult<T> = Result<T, EngineError>;
|
pub type EngineResult<T> = Result<T, EngineError>;
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn display_variants() {
|
|
||||||
assert!(
|
|
||||||
EngineError::Parse("bad yaml".into())
|
|
||||||
.to_string()
|
|
||||||
.contains("parse error")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
EngineError::NotFound("post 123".into())
|
|
||||||
.to_string()
|
|
||||||
.contains("not found")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
EngineError::Conflict("slug taken".into())
|
|
||||||
.to_string()
|
|
||||||
.contains("conflict")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
EngineError::Validation("title empty".into())
|
|
||||||
.to_string()
|
|
||||||
.contains("validation")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn from_io_error() {
|
|
||||||
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
|
|
||||||
let engine_err = EngineError::from(io_err);
|
|
||||||
assert!(matches!(engine_err, EngineError::Io(_)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -164,75 +164,6 @@ pub fn remove_category(data_dir: &Path, category: &str) -> EngineResult<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── update helpers ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Update the blog_languages list in project.json.
|
|
||||||
/// Per metadata.allium UpdateProjectMetadata: writes ProjectJsonWritten.
|
|
||||||
pub fn update_blog_languages(data_dir: &Path, languages: Vec<String>) -> EngineResult<()> {
|
|
||||||
let mut meta = read_project_json(data_dir)?;
|
|
||||||
meta.blog_languages = languages;
|
|
||||||
write_project_json(data_dir, &meta)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update arbitrary fields of project metadata.
|
|
||||||
/// Per metadata.allium UpdateProjectMetadata rule.
|
|
||||||
pub fn update_project_metadata(data_dir: &Path, changes: &serde_json::Value) -> EngineResult<()> {
|
|
||||||
let mut meta = read_project_json(data_dir)?;
|
|
||||||
if let Some(name) = changes.get("name").and_then(|v| v.as_str()) {
|
|
||||||
meta.name = name.to_string();
|
|
||||||
}
|
|
||||||
if let Some(desc) = changes.get("description") {
|
|
||||||
meta.description = desc.as_str().map(|s| s.to_string());
|
|
||||||
}
|
|
||||||
if let Some(url) = changes.get("publicUrl") {
|
|
||||||
meta.public_url = url.as_str().map(|s| s.to_string());
|
|
||||||
}
|
|
||||||
if let Some(lang) = changes.get("mainLanguage") {
|
|
||||||
meta.main_language = lang.as_str().map(|s| s.to_string());
|
|
||||||
}
|
|
||||||
if let Some(author) = changes.get("defaultAuthor") {
|
|
||||||
meta.default_author = author.as_str().map(|s| s.to_string());
|
|
||||||
}
|
|
||||||
if let Some(max) = changes.get("maxPostsPerPage").and_then(|v| v.as_i64()) {
|
|
||||||
meta.max_posts_per_page = max as i32;
|
|
||||||
}
|
|
||||||
if let Some(concurrency) = changes.get("imageImportConcurrency") {
|
|
||||||
meta.image_import_concurrency = concurrency
|
|
||||||
.as_i64()
|
|
||||||
.map(|value| value as i32)
|
|
||||||
.or_else(|| {
|
|
||||||
concurrency
|
|
||||||
.as_str()
|
|
||||||
.and_then(|value| value.parse::<i32>().ok())
|
|
||||||
})
|
|
||||||
.unwrap_or(4)
|
|
||||||
.clamp(1, 8);
|
|
||||||
}
|
|
||||||
if let Some(cat) = changes.get("blogmarkCategory") {
|
|
||||||
meta.blogmark_category = cat.as_str().map(|s| s.to_string());
|
|
||||||
}
|
|
||||||
if let Some(theme) = changes.get("picoTheme") {
|
|
||||||
meta.pico_theme = theme.as_str().map(|s| s.to_string());
|
|
||||||
}
|
|
||||||
if let Some(enabled) = changes
|
|
||||||
.get("semanticSimilarityEnabled")
|
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
{
|
|
||||||
meta.semantic_similarity_enabled = enabled;
|
|
||||||
}
|
|
||||||
if let Some(langs) = changes.get("blogLanguages").and_then(|v| v.as_array()) {
|
|
||||||
meta.blog_languages = langs
|
|
||||||
.iter()
|
|
||||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
meta.validate()
|
|
||||||
.map_err(crate::engine::EngineError::Validation)?;
|
|
||||||
write_project_json(data_dir, &meta)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── startup sync ────────────────────────────────────────────────────
|
// ── startup sync ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Per metadata.allium StartupSync: loads metadata from filesystem, creating
|
/// Per metadata.allium StartupSync: loads metadata from filesystem, creating
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use std::path::Path;
|
|||||||
use crate::db::DbConnection as Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
use crate::db::from_row::{script_kind_to_str, template_kind_to_str};
|
|
||||||
use crate::db::queries::media as qm;
|
use crate::db::queries::media as qm;
|
||||||
use crate::db::queries::media_translation as qmt;
|
use crate::db::queries::media_translation as qmt;
|
||||||
use crate::db::queries::post as qp;
|
use crate::db::queries::post as qp;
|
||||||
@@ -14,7 +13,7 @@ use crate::db::queries::project as qproject;
|
|||||||
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::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult};
|
||||||
use crate::model::{Media, MediaTranslation, Post, PostStatus, PostTranslation, Script, Template};
|
use crate::model::{Media, MediaTranslation, Post, PostTranslation, Script, Template};
|
||||||
use crate::util::frontmatter::{
|
use crate::util::frontmatter::{
|
||||||
ScriptFrontmatter, TemplateFrontmatter, read_post_file, read_script_file, read_template_file,
|
ScriptFrontmatter, TemplateFrontmatter, read_post_file, read_script_file, read_template_file,
|
||||||
read_translation_file, write_post_file, write_script_file, write_template_file,
|
read_translation_file, write_post_file, write_script_file, write_template_file,
|
||||||
@@ -371,7 +370,7 @@ fn rewrite_script_from_database(
|
|||||||
project_id: Some(script.project_id),
|
project_id: Some(script.project_id),
|
||||||
slug: script.slug,
|
slug: script.slug,
|
||||||
title: script.title,
|
title: script.title,
|
||||||
kind: script_kind_to_str(&script.kind).to_owned(),
|
kind: script.kind.as_str().to_owned(),
|
||||||
entrypoint: script.entrypoint,
|
entrypoint: script.entrypoint,
|
||||||
enabled: script.enabled,
|
enabled: script.enabled,
|
||||||
version: script.version,
|
version: script.version,
|
||||||
@@ -399,7 +398,7 @@ fn rewrite_template_from_database(
|
|||||||
project_id: Some(template.project_id),
|
project_id: Some(template.project_id),
|
||||||
slug: template.slug,
|
slug: template.slug,
|
||||||
title: template.title,
|
title: template.title,
|
||||||
kind: template_kind_to_str(&template.kind).to_owned(),
|
kind: template.kind.as_str().to_owned(),
|
||||||
enabled: template.enabled,
|
enabled: template.enabled,
|
||||||
version: template.version,
|
version: template.version,
|
||||||
created_at: template.created_at,
|
created_at: template.created_at,
|
||||||
@@ -427,14 +426,6 @@ fn tags_to_json(tags: &[String]) -> String {
|
|||||||
serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string())
|
serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn status_to_str(s: &PostStatus) -> &'static str {
|
|
||||||
match s {
|
|
||||||
PostStatus::Draft => "draft",
|
|
||||||
PostStatus::Published => "published",
|
|
||||||
PostStatus::Archived => "archived",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compare_field(fields: &mut Vec<DiffField>, name: &str, db_val: &str, file_val: &str) {
|
fn compare_field(fields: &mut Vec<DiffField>, name: &str, db_val: &str, file_val: &str) {
|
||||||
if db_val != file_val {
|
if db_val != file_val {
|
||||||
fields.push(DiffField {
|
fields.push(DiffField {
|
||||||
@@ -459,12 +450,7 @@ fn diff_post(data_dir: &Path, post: &Post) -> EngineResult<Option<EntityDiff>> {
|
|||||||
|
|
||||||
compare_field(&mut fields, "title", &post.title, &fm.title);
|
compare_field(&mut fields, "title", &post.title, &fm.title);
|
||||||
compare_field(&mut fields, "slug", &post.slug, &fm.slug);
|
compare_field(&mut fields, "slug", &post.slug, &fm.slug);
|
||||||
compare_field(
|
compare_field(&mut fields, "status", post.status.as_str(), &fm.status);
|
||||||
&mut fields,
|
|
||||||
"status",
|
|
||||||
status_to_str(&post.status),
|
|
||||||
&fm.status,
|
|
||||||
);
|
|
||||||
compare_field(
|
compare_field(
|
||||||
&mut fields,
|
&mut fields,
|
||||||
"tags",
|
"tags",
|
||||||
@@ -700,12 +686,7 @@ fn diff_template(data_dir: &Path, tpl: &Template) -> EngineResult<Option<EntityD
|
|||||||
let mut fields = Vec::new();
|
let mut fields = Vec::new();
|
||||||
|
|
||||||
compare_field(&mut fields, "title", &tpl.title, &fm.title);
|
compare_field(&mut fields, "title", &tpl.title, &fm.title);
|
||||||
compare_field(
|
compare_field(&mut fields, "kind", tpl.kind.as_str(), &fm.kind);
|
||||||
&mut fields,
|
|
||||||
"kind",
|
|
||||||
template_kind_to_str(&tpl.kind),
|
|
||||||
&fm.kind,
|
|
||||||
);
|
|
||||||
compare_field(
|
compare_field(
|
||||||
&mut fields,
|
&mut fields,
|
||||||
"enabled",
|
"enabled",
|
||||||
@@ -743,12 +724,7 @@ fn diff_script(data_dir: &Path, script: &Script) -> EngineResult<Option<EntityDi
|
|||||||
let mut fields = Vec::new();
|
let mut fields = Vec::new();
|
||||||
|
|
||||||
compare_field(&mut fields, "title", &script.title, &fm.title);
|
compare_field(&mut fields, "title", &script.title, &fm.title);
|
||||||
compare_field(
|
compare_field(&mut fields, "kind", script.kind.as_str(), &fm.kind);
|
||||||
&mut fields,
|
|
||||||
"kind",
|
|
||||||
script_kind_to_str(&script.kind),
|
|
||||||
&fm.kind,
|
|
||||||
);
|
|
||||||
compare_field(
|
compare_field(
|
||||||
&mut fields,
|
&mut fields,
|
||||||
"entrypoint",
|
"entrypoint",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ pub mod ai;
|
|||||||
pub mod auto_translation;
|
pub mod auto_translation;
|
||||||
pub mod blogmark;
|
pub mod blogmark;
|
||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
pub mod context;
|
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod generation;
|
pub mod generation;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
@@ -27,5 +26,4 @@ pub mod validate_media;
|
|||||||
pub mod validate_site;
|
pub mod validate_site;
|
||||||
pub mod validate_translations;
|
pub mod validate_translations;
|
||||||
|
|
||||||
pub use context::EngineContext;
|
|
||||||
pub use error::{EngineError, EngineResult};
|
pub use error::{EngineError, EngineResult};
|
||||||
|
|||||||
@@ -1163,18 +1163,6 @@ pub fn post_insert_media(media_id: &str, is_image: bool, original_name: &str) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get posts linked from a given post (outlinks).
|
|
||||||
pub fn list_post_outlinks(conn: &Connection, post_id: &str) -> EngineResult<Vec<String>> {
|
|
||||||
let links = ql::list_links_by_source(conn, post_id)?;
|
|
||||||
Ok(links.into_iter().map(|l| l.target_post_id).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get posts linking to a given post (backlinks).
|
|
||||||
pub fn list_post_backlinks(conn: &Connection, post_id: &str) -> EngineResult<Vec<String>> {
|
|
||||||
let links = ql::list_links_by_target(conn, post_id)?;
|
|
||||||
Ok(links.into_iter().map(|l| l.source_post_id).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use walkdir::WalkDir;
|
|||||||
|
|
||||||
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, ScriptStatus};
|
||||||
use crate::util::frontmatter::read_script_file;
|
use crate::util::frontmatter::read_script_file;
|
||||||
use crate::util::now_unix_ms;
|
use crate::util::now_unix_ms;
|
||||||
|
|
||||||
@@ -18,16 +18,6 @@ pub struct ScriptRebuildReport {
|
|||||||
pub errors: Vec<String>,
|
pub errors: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a script kind string from frontmatter into a `ScriptKind`.
|
|
||||||
fn parse_script_kind(s: &str) -> Result<ScriptKind, String> {
|
|
||||||
match s {
|
|
||||||
"macro" => Ok(ScriptKind::Macro),
|
|
||||||
"utility" => Ok(ScriptKind::Utility),
|
|
||||||
"transform" => Ok(ScriptKind::Transform),
|
|
||||||
other => Err(format!("unknown script kind: '{other}'")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rebuild scripts from the filesystem into the database.
|
/// Rebuild scripts from the filesystem into the database.
|
||||||
///
|
///
|
||||||
/// Walks the `scripts/` directory for `*.lua` files, parses each
|
/// Walks the `scripts/` directory for `*.lua` files, parses each
|
||||||
@@ -92,7 +82,7 @@ pub(crate) fn rebuild_single_script(
|
|||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let kind = parse_script_kind(&fm.kind).map_err(EngineError::Parse)?;
|
let kind = fm.kind.parse().map_err(EngineError::Parse)?;
|
||||||
let now = now_unix_ms();
|
let now = now_unix_ms();
|
||||||
|
|
||||||
// File exists on disk -> Published; content is None in DB
|
// File exists on disk -> Published; content is None in DB
|
||||||
@@ -142,6 +132,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
|
use crate::model::ScriptKind;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Condvar, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
/// Unique task identifier.
|
/// Unique task identifier.
|
||||||
@@ -15,14 +15,6 @@ pub enum TaskStatus {
|
|||||||
Cancelled,
|
Cancelled,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Progress update for a task.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TaskProgress {
|
|
||||||
pub task_id: TaskId,
|
|
||||||
pub message: String,
|
|
||||||
pub percent: Option<f32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Immutable task state exposed to UI consumers.
|
/// Immutable task state exposed to UI consumers.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct TaskSnapshot {
|
pub struct TaskSnapshot {
|
||||||
@@ -57,6 +49,7 @@ pub struct TaskManager {
|
|||||||
max_concurrent: usize,
|
max_concurrent: usize,
|
||||||
next_id: Mutex<TaskId>,
|
next_id: Mutex<TaskId>,
|
||||||
tasks: Mutex<Vec<TaskEntry>>,
|
tasks: Mutex<Vec<TaskEntry>>,
|
||||||
|
state_changed: Condvar,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TaskManager {
|
impl TaskManager {
|
||||||
@@ -66,6 +59,7 @@ impl TaskManager {
|
|||||||
max_concurrent,
|
max_concurrent,
|
||||||
next_id: Mutex::new(1),
|
next_id: Mutex::new(1),
|
||||||
tasks: Mutex::new(Vec::new()),
|
tasks: Mutex::new(Vec::new()),
|
||||||
|
state_changed: Condvar::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,24 +111,20 @@ impl TaskManager {
|
|||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to start a queued task. Returns true if the task was moved to
|
/// Block a worker until its task may run. Returns false if cancelled.
|
||||||
/// Running, false if concurrency is at capacity or the task is not Queued.
|
pub fn wait_until_runnable(&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
|
loop {
|
||||||
|
match tasks
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|t| t.status == TaskStatus::Running)
|
.find(|task| task.id == task_id)
|
||||||
.count();
|
.map(|task| &task.status)
|
||||||
if running >= self.max_concurrent {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
|
|
||||||
&& entry.status == TaskStatus::Pending
|
|
||||||
{
|
{
|
||||||
entry.status = TaskStatus::Running;
|
Some(TaskStatus::Running) => return true,
|
||||||
return true;
|
Some(TaskStatus::Pending) => tasks = self.state_changed.wait(tasks).unwrap(),
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark a task as completed.
|
/// Mark a task as completed.
|
||||||
@@ -148,6 +138,7 @@ impl TaskManager {
|
|||||||
entry.finished_at = Some(Instant::now());
|
entry.finished_at = Some(Instant::now());
|
||||||
}
|
}
|
||||||
Self::promote_next(&mut tasks, self.max_concurrent);
|
Self::promote_next(&mut tasks, self.max_concurrent);
|
||||||
|
self.state_changed.notify_all();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark a task as failed with an error message.
|
/// Mark a task as failed with an error message.
|
||||||
@@ -161,6 +152,7 @@ impl TaskManager {
|
|||||||
entry.finished_at = Some(Instant::now());
|
entry.finished_at = Some(Instant::now());
|
||||||
}
|
}
|
||||||
Self::promote_next(&mut tasks, self.max_concurrent);
|
Self::promote_next(&mut tasks, self.max_concurrent);
|
||||||
|
self.state_changed.notify_all();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel a task by setting its cancel flag and status.
|
/// Cancel a task by setting its cancel flag and status.
|
||||||
@@ -174,6 +166,7 @@ impl TaskManager {
|
|||||||
entry.finished_at = Some(Instant::now());
|
entry.finished_at = Some(Instant::now());
|
||||||
}
|
}
|
||||||
Self::promote_next(&mut tasks, self.max_concurrent);
|
Self::promote_next(&mut tasks, self.max_concurrent);
|
||||||
|
self.state_changed.notify_all();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check whether a task has been cancelled.
|
/// Check whether a task has been cancelled.
|
||||||
@@ -223,24 +216,6 @@ impl TaskManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the label of a task.
|
|
||||||
pub fn label(&self, task_id: TaskId) -> Option<String> {
|
|
||||||
let tasks = self.tasks.lock().unwrap();
|
|
||||||
tasks
|
|
||||||
.iter()
|
|
||||||
.find(|t| t.id == task_id)
|
|
||||||
.map(|t| t.label.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the id of the first queued task (FIFO order).
|
|
||||||
pub fn next_queued(&self) -> Option<TaskId> {
|
|
||||||
let tasks = self.tasks.lock().unwrap();
|
|
||||||
tasks
|
|
||||||
.iter()
|
|
||||||
.find(|t| t.status == TaskStatus::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();
|
||||||
@@ -269,15 +244,6 @@ impl TaskManager {
|
|||||||
.and_then(|t| t.progress)
|
.and_then(|t| t.progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the current message of a task.
|
|
||||||
pub fn message(&self, task_id: TaskId) -> Option<String> {
|
|
||||||
let tasks = self.tasks.lock().unwrap();
|
|
||||||
tasks
|
|
||||||
.iter()
|
|
||||||
.find(|t| t.id == task_id)
|
|
||||||
.and_then(|t| t.message.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return a snapshot of all tasks for UI display.
|
/// Return a snapshot of all tasks for UI display.
|
||||||
pub fn snapshots(&self) -> Vec<TaskSnapshot> {
|
pub fn snapshots(&self) -> Vec<TaskSnapshot> {
|
||||||
let tasks = self.tasks.lock().unwrap();
|
let tasks = self.tasks.lock().unwrap();
|
||||||
@@ -331,35 +297,6 @@ pub const PROGRESS_THROTTLE_MS: u64 = 250;
|
|||||||
pub const RECENT_FINISHED_LIMIT: usize = 10;
|
pub const RECENT_FINISHED_LIMIT: usize = 10;
|
||||||
pub const FINISHED_TASK_TTL: Duration = Duration::from_secs(60 * 60);
|
pub const FINISHED_TASK_TTL: Duration = Duration::from_secs(60 * 60);
|
||||||
|
|
||||||
/// Throttles progress reporting to avoid flooding.
|
|
||||||
pub struct ProgressThrottle {
|
|
||||||
interval_ms: u64,
|
|
||||||
last_report: Mutex<Option<Instant>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProgressThrottle {
|
|
||||||
/// Create a throttle with the given interval in milliseconds.
|
|
||||||
pub fn new(interval_ms: u64) -> Self {
|
|
||||||
Self {
|
|
||||||
interval_ms,
|
|
||||||
last_report: Mutex::new(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if enough time has elapsed since the last report.
|
|
||||||
pub fn should_report(&self) -> bool {
|
|
||||||
let mut last = self.last_report.lock().unwrap();
|
|
||||||
let now = Instant::now();
|
|
||||||
match *last {
|
|
||||||
Some(prev) if now.duration_since(prev).as_millis() < self.interval_ms as u128 => false,
|
|
||||||
_ => {
|
|
||||||
*last = Some(now);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -390,11 +327,9 @@ mod tests {
|
|||||||
let c = mgr.submit("third"); // queued
|
let c = mgr.submit("third"); // queued
|
||||||
|
|
||||||
assert_eq!(mgr.status(a), Some(TaskStatus::Running));
|
assert_eq!(mgr.status(a), Some(TaskStatus::Running));
|
||||||
assert_eq!(mgr.next_queued(), Some(b));
|
|
||||||
|
|
||||||
mgr.complete(a); // should auto-promote b
|
mgr.complete(a); // should auto-promote b
|
||||||
assert_eq!(mgr.status(b), Some(TaskStatus::Running));
|
assert_eq!(mgr.status(b), Some(TaskStatus::Running));
|
||||||
assert_eq!(mgr.next_queued(), Some(c));
|
assert_eq!(mgr.status(c), Some(TaskStatus::Pending));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -481,6 +416,35 @@ mod tests {
|
|||||||
assert_eq!(mgr.status(b), Some(TaskStatus::Running));
|
assert_eq!(mgr.status(b), Some(TaskStatus::Running));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queued_task_waits_for_a_slot() {
|
||||||
|
let mgr = std::sync::Arc::new(TaskManager::new(1));
|
||||||
|
let running = mgr.submit("running");
|
||||||
|
let queued = mgr.submit("queued");
|
||||||
|
let waiter = {
|
||||||
|
let mgr = mgr.clone();
|
||||||
|
std::thread::spawn(move || mgr.wait_until_runnable(queued))
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!waiter.is_finished());
|
||||||
|
mgr.complete(running);
|
||||||
|
assert!(waiter.join().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancelling_queued_task_stops_its_waiter() {
|
||||||
|
let mgr = std::sync::Arc::new(TaskManager::new(1));
|
||||||
|
let _running = mgr.submit("running");
|
||||||
|
let queued = mgr.submit("queued");
|
||||||
|
let waiter = {
|
||||||
|
let mgr = mgr.clone();
|
||||||
|
std::thread::spawn(move || mgr.wait_until_runnable(queued))
|
||||||
|
};
|
||||||
|
|
||||||
|
mgr.cancel(queued);
|
||||||
|
assert!(!waiter.join().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cancel_precondition_ignores_completed() {
|
fn cancel_precondition_ignores_completed() {
|
||||||
let mgr = TaskManager::default();
|
let mgr = TaskManager::default();
|
||||||
@@ -496,19 +460,6 @@ mod tests {
|
|||||||
let id = mgr.submit("upload");
|
let id = mgr.submit("upload");
|
||||||
mgr.report_progress(id, Some(0.5), Some("halfway".into()));
|
mgr.report_progress(id, Some(0.5), Some("halfway".into()));
|
||||||
assert_eq!(mgr.progress(id), Some(0.5));
|
assert_eq!(mgr.progress(id), Some(0.5));
|
||||||
assert_eq!(mgr.message(id), Some("halfway".into()));
|
assert_eq!(mgr.snapshots()[0].message.as_deref(), Some("halfway"));
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn progress_throttle_initial_reports() {
|
|
||||||
let throttle = ProgressThrottle::new(PROGRESS_THROTTLE_MS);
|
|
||||||
assert!(throttle.should_report());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn progress_throttle_suppresses_rapid() {
|
|
||||||
let throttle = ProgressThrottle::new(PROGRESS_THROTTLE_MS);
|
|
||||||
assert!(throttle.should_report());
|
|
||||||
assert!(!throttle.should_report());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use walkdir::WalkDir;
|
|||||||
|
|
||||||
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, TemplateStatus};
|
||||||
use crate::util::frontmatter::read_template_file;
|
use crate::util::frontmatter::read_template_file;
|
||||||
use crate::util::now_unix_ms;
|
use crate::util::now_unix_ms;
|
||||||
|
|
||||||
@@ -18,17 +18,6 @@ pub struct TemplateRebuildReport {
|
|||||||
pub errors: Vec<String>,
|
pub errors: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a template kind string from frontmatter into a `TemplateKind`.
|
|
||||||
fn parse_template_kind(s: &str) -> Result<TemplateKind, String> {
|
|
||||||
match s {
|
|
||||||
"post" => Ok(TemplateKind::Post),
|
|
||||||
"list" => Ok(TemplateKind::List),
|
|
||||||
"not_found" | "notFound" | "not-found" => Ok(TemplateKind::NotFound),
|
|
||||||
"partial" => Ok(TemplateKind::Partial),
|
|
||||||
other => Err(format!("unknown template kind: '{other}'")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rebuild templates from the filesystem into the database.
|
/// Rebuild templates from the filesystem into the database.
|
||||||
///
|
///
|
||||||
/// Walks the `templates/` directory for `*.liquid` files, parses each via
|
/// Walks the `templates/` directory for `*.liquid` files, parses each via
|
||||||
@@ -93,7 +82,7 @@ pub(crate) fn rebuild_single_template(
|
|||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let kind = parse_template_kind(&fm.kind).map_err(EngineError::Parse)?;
|
let kind = fm.kind.parse().map_err(EngineError::Parse)?;
|
||||||
let now = now_unix_ms();
|
let now = now_unix_ms();
|
||||||
|
|
||||||
// File exists on disk -> Published; content is None in DB
|
// File exists on disk -> Published; content is None in DB
|
||||||
@@ -141,6 +130,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
|
use crate::model::TemplateKind;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
|
|||||||
@@ -55,25 +55,7 @@ pub struct TranslationValidationReport {
|
|||||||
/// Per-item progress callback: (current_item, total_items, item_description).
|
/// Per-item progress callback: (current_item, total_items, item_description).
|
||||||
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
|
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
|
||||||
|
|
||||||
/// Validate all translations in a project against consistency rules.
|
/// Validate all translations with optional per-item progress.
|
||||||
pub fn validate_translations(
|
|
||||||
conn: &Connection,
|
|
||||||
data_dir: &Path,
|
|
||||||
project_id: &str,
|
|
||||||
blog_languages: &[String],
|
|
||||||
main_language: &str,
|
|
||||||
) -> EngineResult<TranslationValidationReport> {
|
|
||||||
validate_translations_with_progress(
|
|
||||||
conn,
|
|
||||||
data_dir,
|
|
||||||
project_id,
|
|
||||||
blog_languages,
|
|
||||||
main_language,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like `validate_translations` but with optional per-item progress.
|
|
||||||
pub fn validate_translations_with_progress(
|
pub fn validate_translations_with_progress(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ use std::sync::LazyLock;
|
|||||||
|
|
||||||
/// Supported UI locales, matching the i18n.allium SupportedLanguage spec.
|
/// Supported UI locales, matching the i18n.allium SupportedLanguage spec.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
#[repr(usize)]
|
||||||
pub enum UiLocale {
|
pub enum UiLocale {
|
||||||
En,
|
En = 0,
|
||||||
De,
|
De = 1,
|
||||||
Fr,
|
Fr = 2,
|
||||||
It,
|
It = 3,
|
||||||
Es,
|
Es = 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UiLocale {
|
impl UiLocale {
|
||||||
@@ -153,13 +154,7 @@ static RENDER_CATALOGS: LazyLock<[Bundle; 5]> = LazyLock::new(|| {
|
|||||||
});
|
});
|
||||||
|
|
||||||
fn locale_index(locale: UiLocale) -> usize {
|
fn locale_index(locale: UiLocale) -> usize {
|
||||||
match locale {
|
locale as usize
|
||||||
UiLocale::En => 0,
|
|
||||||
UiLocale::De => 1,
|
|
||||||
UiLocale::Fr => 2,
|
|
||||||
UiLocale::It => 3,
|
|
||||||
UiLocale::Es => 4,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format(bundle: &Bundle, key: &str, params: &[(&str, &str)]) -> Option<String> {
|
fn format(bundle: &Bundle, key: &str, params: &[(&str, &str)]) -> Option<String> {
|
||||||
@@ -215,50 +210,22 @@ pub fn get_render_translations(language: &str) -> &'static HashMap<String, Strin
|
|||||||
&RENDER_MAPS[locale_index(normalize_language(language))]
|
&RENDER_MAPS[locale_index(normalize_language(language))]
|
||||||
}
|
}
|
||||||
|
|
||||||
const RENDER_KEYS: &[&str] = &[
|
static RENDER_KEYS: LazyLock<Vec<String>> = LazyLock::new(|| {
|
||||||
"render.archive",
|
resource(RENDER_EN)
|
||||||
"render.pagination.label",
|
.entries()
|
||||||
"render.pagination.newer",
|
.filter_map(|entry| match entry {
|
||||||
"render.pagination.older",
|
fluent_syntax::ast::Entry::Message(message) => Some(message.id.name.replace('-', ".")),
|
||||||
"render.notFound.message",
|
_ => None,
|
||||||
"render.notFound.back",
|
})
|
||||||
"render.photoArchive.empty",
|
.collect()
|
||||||
"render.gallery.empty",
|
});
|
||||||
"render.tagCloud.empty",
|
|
||||||
"render.tagCloud.ariaLabel",
|
|
||||||
"render.calendar.open",
|
|
||||||
"render.calendar.close",
|
|
||||||
"render.calendar.title",
|
|
||||||
"render.calendar.loading",
|
|
||||||
"render.calendar.error",
|
|
||||||
"render.taxonomy.ariaLabel",
|
|
||||||
"render.backlinks.label",
|
|
||||||
"render.backlinks.ariaLabel",
|
|
||||||
"render.languageSwitcher.ariaLabel",
|
|
||||||
"render.video.youtubeTitle",
|
|
||||||
"render.video.vimeoTitle",
|
|
||||||
"render.month.1",
|
|
||||||
"render.month.2",
|
|
||||||
"render.month.3",
|
|
||||||
"render.month.4",
|
|
||||||
"render.month.5",
|
|
||||||
"render.month.6",
|
|
||||||
"render.month.7",
|
|
||||||
"render.month.8",
|
|
||||||
"render.month.9",
|
|
||||||
"render.month.10",
|
|
||||||
"render.month.11",
|
|
||||||
"render.month.12",
|
|
||||||
"render.search.placeholder",
|
|
||||||
"render.search.ariaLabel",
|
|
||||||
];
|
|
||||||
|
|
||||||
static RENDER_MAPS: LazyLock<[HashMap<String, String>; 5]> = LazyLock::new(|| {
|
static RENDER_MAPS: LazyLock<[HashMap<String, String>; 5]> = LazyLock::new(|| {
|
||||||
std::array::from_fn(|index| {
|
std::array::from_fn(|index| {
|
||||||
let locale = UiLocale::all()[index];
|
let locale = UiLocale::all()[index];
|
||||||
RENDER_KEYS
|
RENDER_KEYS
|
||||||
.iter()
|
.iter()
|
||||||
.map(|key| ((*key).to_owned(), translate_render(locale.code(), key)))
|
.map(|key| (key.clone(), translate_render(locale.code(), key)))
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -295,14 +262,6 @@ mod tests {
|
|||||||
assert_eq!(normalize_language("FR-fr"), UiLocale::Fr);
|
assert_eq!(normalize_language("FR-fr"), UiLocale::Fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// SplitLocalization invariant: UiLocale is independent of content language
|
|
||||||
#[test]
|
|
||||||
fn ui_locale_is_independent_type() {
|
|
||||||
let ui = UiLocale::De;
|
|
||||||
let content_lang = "fr";
|
|
||||||
assert_ne!(ui.code(), content_lang);
|
|
||||||
}
|
|
||||||
|
|
||||||
// MenuTranslations invariant: menu labels come from locale catalog
|
// MenuTranslations invariant: menu labels come from locale catalog
|
||||||
#[test]
|
#[test]
|
||||||
fn translate_menu_labels() {
|
fn translate_menu_labels() {
|
||||||
@@ -313,13 +272,6 @@ mod tests {
|
|||||||
assert_eq!(label, "Enregistrer");
|
assert_eq!(label, "Enregistrer");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn translate_falls_back_to_english() {
|
|
||||||
// Non-English locale falls back for missing keys
|
|
||||||
let result = translate(UiLocale::De, "menu.group.file");
|
|
||||||
assert_eq!(result, "Datei");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn translate_missing_key_returns_key() {
|
fn translate_missing_key_returns_key() {
|
||||||
let result = translate(UiLocale::En, "nonexistent.key.xyz");
|
let result = translate(UiLocale::En, "nonexistent.key.xyz");
|
||||||
|
|||||||
@@ -2,7 +2,20 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
/// Tracks content hashes of generated files to skip unchanged writes.
|
/// Tracks content hashes of generated files to skip unchanged writes.
|
||||||
/// Matches the `generated_file_hashes` table.
|
/// Matches the `generated_file_hashes` table.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(
|
||||||
|
Debug,
|
||||||
|
Clone,
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
diesel::Queryable,
|
||||||
|
diesel::Selectable,
|
||||||
|
diesel::Insertable,
|
||||||
|
diesel::AsChangeset,
|
||||||
|
)]
|
||||||
|
#[diesel(
|
||||||
|
table_name = crate::db::schema::generated_file_hashes,
|
||||||
|
check_for_backend(diesel::sqlite::Sqlite)
|
||||||
|
)]
|
||||||
pub struct GeneratedFileHash {
|
pub struct GeneratedFileHash {
|
||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
pub relative_path: String,
|
pub relative_path: String,
|
||||||
@@ -10,7 +23,10 @@ pub struct GeneratedFileHash {
|
|||||||
pub updated_at: i64,
|
pub updated_at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(
|
||||||
|
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, diesel::AsExpression, diesel::FromSqlRow,
|
||||||
|
)]
|
||||||
|
#[diesel(sql_type = diesel::sql_types::Text)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum NotificationEntity {
|
pub enum NotificationEntity {
|
||||||
Post,
|
Post,
|
||||||
@@ -19,7 +35,10 @@ pub enum NotificationEntity {
|
|||||||
Template,
|
Template,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(
|
||||||
|
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, diesel::AsExpression, diesel::FromSqlRow,
|
||||||
|
)]
|
||||||
|
#[diesel(sql_type = diesel::sql_types::Text)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum NotificationAction {
|
pub enum NotificationAction {
|
||||||
Created,
|
Created,
|
||||||
@@ -27,14 +46,68 @@ pub enum NotificationAction {
|
|||||||
Deleted,
|
Deleted,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for NotificationEntity {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
match value {
|
||||||
|
"post" => Ok(Self::Post),
|
||||||
|
"media" => Ok(Self::Media),
|
||||||
|
"script" => Ok(Self::Script),
|
||||||
|
"template" => Ok(Self::Template),
|
||||||
|
_ => Err(format!("invalid NotificationEntity: {value}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotificationEntity {
|
||||||
|
pub const fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Post => "post",
|
||||||
|
Self::Media => "media",
|
||||||
|
Self::Script => "script",
|
||||||
|
Self::Template => "template",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for NotificationAction {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
match value {
|
||||||
|
"created" => Ok(Self::Created),
|
||||||
|
"updated" => Ok(Self::Updated),
|
||||||
|
"deleted" => Ok(Self::Deleted),
|
||||||
|
_ => Err(format!("invalid NotificationAction: {value}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotificationAction {
|
||||||
|
pub const fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Created => "created",
|
||||||
|
Self::Updated => "updated",
|
||||||
|
Self::Deleted => "deleted",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Notification for CLI-to-app synchronization.
|
/// Notification for CLI-to-app synchronization.
|
||||||
/// Matches the `db_notifications` table.
|
/// Matches the `db_notifications` table.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, diesel::Queryable, diesel::Selectable)]
|
||||||
|
#[diesel(
|
||||||
|
table_name = crate::db::schema::db_notifications,
|
||||||
|
check_for_backend(diesel::sqlite::Sqlite)
|
||||||
|
)]
|
||||||
pub struct DbNotification {
|
pub struct DbNotification {
|
||||||
|
#[diesel(deserialize_as = i32)]
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub entity_type: NotificationEntity,
|
pub entity_type: NotificationEntity,
|
||||||
pub entity_id: String,
|
pub entity_id: String,
|
||||||
pub action: NotificationAction,
|
pub action: NotificationAction,
|
||||||
|
#[diesel(deserialize_as = crate::db::types::DbBool)]
|
||||||
pub from_cli: bool,
|
pub from_cli: bool,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub seen_at: Option<i64>,
|
pub seen_at: Option<i64>,
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
|
use diesel::ExpressionMethods;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// A media item (image, video, etc.).
|
/// A media item (image, video, etc.).
|
||||||
/// Matches the `media` table schema.
|
/// Matches the `media` table schema.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(
|
||||||
|
Debug,
|
||||||
|
Clone,
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
diesel::Queryable,
|
||||||
|
diesel::Selectable,
|
||||||
|
diesel::Insertable,
|
||||||
|
diesel::AsChangeset,
|
||||||
|
)]
|
||||||
|
#[diesel(table_name = crate::db::schema::media, check_for_backend(diesel::sqlite::Sqlite))]
|
||||||
|
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
|
||||||
pub struct Media {
|
pub struct Media {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
@@ -30,6 +42,10 @@ pub struct Media {
|
|||||||
pub checksum: Option<String>,
|
pub checksum: Option<String>,
|
||||||
/// JSON-serialized string array in DB.
|
/// JSON-serialized string array in DB.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[diesel(
|
||||||
|
deserialize_as = crate::db::types::DbStringList,
|
||||||
|
serialize_as = crate::db::types::DbStringList
|
||||||
|
)]
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
pub updated_at: i64,
|
pub updated_at: i64,
|
||||||
@@ -37,7 +53,21 @@ pub struct Media {
|
|||||||
|
|
||||||
/// A translation of media metadata into another language.
|
/// A translation of media metadata into another language.
|
||||||
/// Matches the `media_translations` table.
|
/// Matches the `media_translations` table.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(
|
||||||
|
Debug,
|
||||||
|
Clone,
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
diesel::Queryable,
|
||||||
|
diesel::Selectable,
|
||||||
|
diesel::Insertable,
|
||||||
|
diesel::AsChangeset,
|
||||||
|
)]
|
||||||
|
#[diesel(
|
||||||
|
table_name = crate::db::schema::media_translations,
|
||||||
|
check_for_backend(diesel::sqlite::Sqlite)
|
||||||
|
)]
|
||||||
|
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
|
||||||
pub struct MediaTranslation {
|
pub struct MediaTranslation {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
use diesel::ExpressionMethods;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(
|
||||||
|
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, diesel::AsExpression, diesel::FromSqlRow,
|
||||||
|
)]
|
||||||
|
#[diesel(sql_type = diesel::sql_types::Text)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum PostStatus {
|
pub enum PostStatus {
|
||||||
Draft,
|
Draft,
|
||||||
@@ -9,17 +13,49 @@ pub enum PostStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PostStatus {
|
impl PostStatus {
|
||||||
|
pub const fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Draft => "draft",
|
||||||
|
Self::Published => "published",
|
||||||
|
Self::Archived => "archived",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns true if this status is valid for a translation (draft or published only).
|
/// Returns true if this status is valid for a translation (draft or published only).
|
||||||
pub fn is_valid_for_translation(&self) -> bool {
|
pub fn is_valid_for_translation(&self) -> bool {
|
||||||
matches!(self, PostStatus::Draft | PostStatus::Published)
|
matches!(self, PostStatus::Draft | PostStatus::Published)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for PostStatus {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
match value {
|
||||||
|
"draft" => Ok(Self::Draft),
|
||||||
|
"published" => Ok(Self::Published),
|
||||||
|
"archived" => Ok(Self::Archived),
|
||||||
|
_ => Err(format!("invalid PostStatus: {value}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A blog post. Matches the `posts` table schema.
|
/// A blog post. Matches the `posts` table schema.
|
||||||
///
|
///
|
||||||
/// NOTE: `content` is null for published posts — body lives in the filesystem
|
/// NOTE: `content` is null for published posts — body lives in the filesystem
|
||||||
/// `.md` file only. Draft content is stored in DB.
|
/// `.md` file only. Draft content is stored in DB.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(
|
||||||
|
Debug,
|
||||||
|
Clone,
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
diesel::Queryable,
|
||||||
|
diesel::Selectable,
|
||||||
|
diesel::Insertable,
|
||||||
|
diesel::AsChangeset,
|
||||||
|
)]
|
||||||
|
#[diesel(table_name = crate::db::schema::posts, check_for_backend(diesel::sqlite::Sqlite))]
|
||||||
|
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
|
||||||
pub struct Post {
|
pub struct Post {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
@@ -36,6 +72,10 @@ pub struct Post {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[diesel(
|
||||||
|
deserialize_as = crate::db::types::DbBool,
|
||||||
|
serialize_as = crate::db::types::DbBool
|
||||||
|
)]
|
||||||
pub do_not_translate: bool,
|
pub do_not_translate: bool,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub template_slug: Option<String>,
|
pub template_slug: Option<String>,
|
||||||
@@ -44,9 +84,17 @@ pub struct Post {
|
|||||||
pub checksum: Option<String>,
|
pub checksum: Option<String>,
|
||||||
/// JSON-serialized string array in DB.
|
/// JSON-serialized string array in DB.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[diesel(
|
||||||
|
deserialize_as = crate::db::types::DbStringList,
|
||||||
|
serialize_as = crate::db::types::DbStringList
|
||||||
|
)]
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
/// JSON-serialized string array in DB.
|
/// JSON-serialized string array in DB.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[diesel(
|
||||||
|
deserialize_as = crate::db::types::DbStringList,
|
||||||
|
serialize_as = crate::db::types::DbStringList
|
||||||
|
)]
|
||||||
pub categories: Vec<String>,
|
pub categories: Vec<String>,
|
||||||
// Published snapshot fields (used for diff detection)
|
// Published snapshot fields (used for diff detection)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -68,7 +116,21 @@ pub struct Post {
|
|||||||
|
|
||||||
/// A translation of a post into another language.
|
/// A translation of a post into another language.
|
||||||
/// Matches the `post_translations` table.
|
/// Matches the `post_translations` table.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(
|
||||||
|
Debug,
|
||||||
|
Clone,
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
diesel::Queryable,
|
||||||
|
diesel::Selectable,
|
||||||
|
diesel::Insertable,
|
||||||
|
diesel::AsChangeset,
|
||||||
|
)]
|
||||||
|
#[diesel(
|
||||||
|
table_name = crate::db::schema::post_translations,
|
||||||
|
check_for_backend(diesel::sqlite::Sqlite)
|
||||||
|
)]
|
||||||
|
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
|
||||||
pub struct PostTranslation {
|
pub struct PostTranslation {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
@@ -91,7 +153,10 @@ pub struct PostTranslation {
|
|||||||
|
|
||||||
/// A link between two posts (tracked for backlinks).
|
/// A link between two posts (tracked for backlinks).
|
||||||
/// Matches the `post_links` table.
|
/// Matches the `post_links` table.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(
|
||||||
|
Debug, Clone, Serialize, Deserialize, diesel::Queryable, diesel::Selectable, diesel::Insertable,
|
||||||
|
)]
|
||||||
|
#[diesel(table_name = crate::db::schema::post_links, check_for_backend(diesel::sqlite::Sqlite))]
|
||||||
pub struct PostLink {
|
pub struct PostLink {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub source_post_id: String,
|
pub source_post_id: String,
|
||||||
@@ -103,7 +168,10 @@ pub struct PostLink {
|
|||||||
|
|
||||||
/// Association between a post and media item.
|
/// Association between a post and media item.
|
||||||
/// Matches the `post_media` table.
|
/// Matches the `post_media` table.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(
|
||||||
|
Debug, Clone, Serialize, Deserialize, diesel::Queryable, diesel::Selectable, diesel::Insertable,
|
||||||
|
)]
|
||||||
|
#[diesel(table_name = crate::db::schema::post_media, check_for_backend(diesel::sqlite::Sqlite))]
|
||||||
pub struct PostMedia {
|
pub struct PostMedia {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
|
use diesel::ExpressionMethods;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// A bDS project. Matches the `projects` table schema.
|
/// A bDS project. Matches the `projects` table schema.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(
|
||||||
|
Debug,
|
||||||
|
Clone,
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
diesel::Queryable,
|
||||||
|
diesel::Selectable,
|
||||||
|
diesel::Insertable,
|
||||||
|
diesel::AsChangeset,
|
||||||
|
)]
|
||||||
|
#[diesel(table_name = crate::db::schema::projects, check_for_backend(diesel::sqlite::Sqlite))]
|
||||||
|
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
|
||||||
pub struct Project {
|
pub struct Project {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -10,13 +22,27 @@ pub struct Project {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub data_path: Option<String>,
|
pub data_path: Option<String>,
|
||||||
|
#[diesel(
|
||||||
|
deserialize_as = crate::db::types::DbBool,
|
||||||
|
serialize_as = crate::db::types::DbBool
|
||||||
|
)]
|
||||||
pub is_active: bool,
|
pub is_active: bool,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
pub updated_at: i64,
|
pub updated_at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Key-value settings. Matches the `settings` table.
|
/// Key-value settings. Matches the `settings` table.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(
|
||||||
|
Debug,
|
||||||
|
Clone,
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
diesel::Queryable,
|
||||||
|
diesel::Selectable,
|
||||||
|
diesel::Insertable,
|
||||||
|
diesel::AsChangeset,
|
||||||
|
)]
|
||||||
|
#[diesel(table_name = crate::db::schema::settings, check_for_backend(diesel::sqlite::Sqlite))]
|
||||||
pub struct Setting {
|
pub struct Setting {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
pub value: String,
|
pub value: String,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
use diesel::ExpressionMethods;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(
|
||||||
|
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, diesel::AsExpression, diesel::FromSqlRow,
|
||||||
|
)]
|
||||||
|
#[diesel(sql_type = diesel::sql_types::Text)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum ScriptKind {
|
pub enum ScriptKind {
|
||||||
Macro,
|
Macro,
|
||||||
@@ -8,15 +12,73 @@ pub enum ScriptKind {
|
|||||||
Transform,
|
Transform,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(
|
||||||
|
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, diesel::AsExpression, diesel::FromSqlRow,
|
||||||
|
)]
|
||||||
|
#[diesel(sql_type = diesel::sql_types::Text)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum ScriptStatus {
|
pub enum ScriptStatus {
|
||||||
Draft,
|
Draft,
|
||||||
Published,
|
Published,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ScriptKind {
|
||||||
|
pub const fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Macro => "macro",
|
||||||
|
Self::Utility => "utility",
|
||||||
|
Self::Transform => "transform",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for ScriptKind {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
match value {
|
||||||
|
"macro" => Ok(Self::Macro),
|
||||||
|
"utility" => Ok(Self::Utility),
|
||||||
|
"transform" => Ok(Self::Transform),
|
||||||
|
_ => Err(format!("invalid ScriptKind: {value}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScriptStatus {
|
||||||
|
pub const fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Draft => "draft",
|
||||||
|
Self::Published => "published",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for ScriptStatus {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
match value {
|
||||||
|
"draft" => Ok(Self::Draft),
|
||||||
|
"published" => Ok(Self::Published),
|
||||||
|
_ => Err(format!("invalid ScriptStatus: {value}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A user-authored script. Matches the `scripts` table.
|
/// A user-authored script. Matches the `scripts` table.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(
|
||||||
|
Debug,
|
||||||
|
Clone,
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
diesel::Queryable,
|
||||||
|
diesel::Selectable,
|
||||||
|
diesel::Insertable,
|
||||||
|
diesel::AsChangeset,
|
||||||
|
)]
|
||||||
|
#[diesel(table_name = crate::db::schema::scripts, check_for_backend(diesel::sqlite::Sqlite))]
|
||||||
|
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
|
||||||
pub struct Script {
|
pub struct Script {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
@@ -24,6 +86,10 @@ pub struct Script {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
pub kind: ScriptKind,
|
pub kind: ScriptKind,
|
||||||
pub entrypoint: String,
|
pub entrypoint: String,
|
||||||
|
#[diesel(
|
||||||
|
deserialize_as = crate::db::types::DbBool,
|
||||||
|
serialize_as = crate::db::types::DbBool
|
||||||
|
)]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub version: i32,
|
pub version: i32,
|
||||||
pub file_path: String,
|
pub file_path: String,
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(
|
||||||
|
Debug,
|
||||||
|
Clone,
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
diesel::Queryable,
|
||||||
|
diesel::Selectable,
|
||||||
|
diesel::Insertable,
|
||||||
|
diesel::AsChangeset,
|
||||||
|
)]
|
||||||
|
#[diesel(table_name = crate::db::schema::tags, check_for_backend(diesel::sqlite::Sqlite))]
|
||||||
|
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
|
||||||
pub struct Tag {
|
pub struct Tag {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
use diesel::ExpressionMethods;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(
|
||||||
|
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, diesel::AsExpression, diesel::FromSqlRow,
|
||||||
|
)]
|
||||||
|
#[diesel(sql_type = diesel::sql_types::Text)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum TemplateKind {
|
pub enum TemplateKind {
|
||||||
Post,
|
Post,
|
||||||
@@ -9,21 +13,85 @@ pub enum TemplateKind {
|
|||||||
Partial,
|
Partial,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(
|
||||||
|
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, diesel::AsExpression, diesel::FromSqlRow,
|
||||||
|
)]
|
||||||
|
#[diesel(sql_type = diesel::sql_types::Text)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum TemplateStatus {
|
pub enum TemplateStatus {
|
||||||
Draft,
|
Draft,
|
||||||
Published,
|
Published,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TemplateKind {
|
||||||
|
pub const fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Post => "post",
|
||||||
|
Self::List => "list",
|
||||||
|
Self::NotFound => "not_found",
|
||||||
|
Self::Partial => "partial",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for TemplateKind {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
match value {
|
||||||
|
"post" => Ok(Self::Post),
|
||||||
|
"list" => Ok(Self::List),
|
||||||
|
"not_found" | "notFound" | "not-found" => Ok(Self::NotFound),
|
||||||
|
"partial" => Ok(Self::Partial),
|
||||||
|
_ => Err(format!("invalid TemplateKind: {value}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TemplateStatus {
|
||||||
|
pub const fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Draft => "draft",
|
||||||
|
Self::Published => "published",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for TemplateStatus {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
match value {
|
||||||
|
"draft" => Ok(Self::Draft),
|
||||||
|
"published" => Ok(Self::Published),
|
||||||
|
_ => Err(format!("invalid TemplateStatus: {value}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A Liquid template. Matches the `templates` table.
|
/// A Liquid template. Matches the `templates` table.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(
|
||||||
|
Debug,
|
||||||
|
Clone,
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
diesel::Queryable,
|
||||||
|
diesel::Selectable,
|
||||||
|
diesel::Insertable,
|
||||||
|
diesel::AsChangeset,
|
||||||
|
)]
|
||||||
|
#[diesel(table_name = crate::db::schema::templates, check_for_backend(diesel::sqlite::Sqlite))]
|
||||||
|
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
|
||||||
pub struct Template {
|
pub struct Template {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub kind: TemplateKind,
|
pub kind: TemplateKind,
|
||||||
|
#[diesel(
|
||||||
|
deserialize_as = crate::db::types::DbBool,
|
||||||
|
serialize_as = crate::db::types::DbBool
|
||||||
|
)]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub version: i32,
|
pub version: i32,
|
||||||
pub file_path: String,
|
pub file_path: String,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
use std::ffi::OsString;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
/// Write `content` to `path` atomically: write to a temp file in the same
|
/// Write `content` to `path` atomically: write to a temp file in the same
|
||||||
/// directory, then rename. Creates parent directories if missing.
|
/// directory, then rename. Creates parent directories if missing.
|
||||||
@@ -8,12 +12,36 @@ pub fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
|
|||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
let tmp_path = path.with_extension("tmp");
|
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
let mut file = fs::File::create(&tmp_path)?;
|
let file_name = path
|
||||||
|
.file_name()
|
||||||
|
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut temp_name = OsString::from(".");
|
||||||
|
temp_name.push(file_name);
|
||||||
|
temp_name.push(format!(
|
||||||
|
".{}.{}.tmp",
|
||||||
|
std::process::id(),
|
||||||
|
NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
let tmp_path = parent.join(temp_name);
|
||||||
|
let mut file = match fs::File::create_new(&tmp_path) {
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let result = (|| {
|
||||||
file.write_all(content)?;
|
file.write_all(content)?;
|
||||||
file.sync_all()?;
|
file.sync_all()?;
|
||||||
fs::rename(&tmp_path, path)?;
|
drop(file);
|
||||||
Ok(())
|
fs::rename(&tmp_path, path)
|
||||||
|
})();
|
||||||
|
if result.is_err() {
|
||||||
|
let _ = fs::remove_file(&tmp_path);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience wrapper for UTF-8 string content.
|
/// Convenience wrapper for UTF-8 string content.
|
||||||
@@ -50,4 +78,27 @@ mod tests {
|
|||||||
atomic_write_str(&path, "v2").unwrap();
|
atomic_write_str(&path, "v2").unwrap();
|
||||||
assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
|
assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_sibling_writes_do_not_share_a_temp_file() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let markdown = dir.path().join("a.en.md");
|
||||||
|
let metadata = dir.path().join("a.en.meta");
|
||||||
|
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
|
||||||
|
|
||||||
|
let write = |path: std::path::PathBuf, content: &'static str| {
|
||||||
|
let barrier = barrier.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
barrier.wait();
|
||||||
|
atomic_write_str(&path, content)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let first = write(markdown.clone(), "markdown");
|
||||||
|
let second = write(metadata.clone(), "metadata");
|
||||||
|
|
||||||
|
first.join().unwrap().unwrap();
|
||||||
|
second.join().unwrap().unwrap();
|
||||||
|
assert_eq!(fs::read_to_string(markdown).unwrap(), "markdown");
|
||||||
|
assert_eq!(fs::read_to_string(metadata).unwrap(), "metadata");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ pub fn content_hash(content: &[u8]) -> String {
|
|||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(content);
|
hasher.update(content);
|
||||||
let result = hasher.finalize();
|
let result = hasher.finalize();
|
||||||
hex::encode(result)
|
encode_hex(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute a hex-encoded SHA-256 hash of a file by streaming (8 KB chunks).
|
/// Compute a hex-encoded SHA-256 hash of a file by streaming (8 KB chunks).
|
||||||
@@ -22,22 +22,15 @@ pub fn file_hash(path: &Path) -> std::io::Result<String> {
|
|||||||
}
|
}
|
||||||
hasher.update(&buf[..n]);
|
hasher.update(&buf[..n]);
|
||||||
}
|
}
|
||||||
Ok(hex::encode(hasher.finalize()))
|
Ok(encode_hex(hasher.finalize()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// sha2 doesn't include hex encoding, so we use a tiny inline helper.
|
fn encode_hex(bytes: impl AsRef<[u8]>) -> String {
|
||||||
mod hex {
|
bytes
|
||||||
const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
|
.as_ref()
|
||||||
|
.iter()
|
||||||
pub fn encode(bytes: impl AsRef<[u8]>) -> String {
|
.map(|byte| format!("{byte:02x}"))
|
||||||
let bytes = bytes.as_ref();
|
.collect()
|
||||||
let mut s = String::with_capacity(bytes.len() * 2);
|
|
||||||
for &b in bytes {
|
|
||||||
s.push(HEX_CHARS[(b >> 4) as usize] as char);
|
|
||||||
s.push(HEX_CHARS[(b & 0xf) as usize] as char);
|
|
||||||
}
|
|
||||||
s
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,5 +1,149 @@
|
|||||||
use crate::model::{Post, PostTranslation};
|
use crate::model::{Post, PostTranslation};
|
||||||
use crate::util::timestamp::{iso_to_unix_ms, unix_ms_to_iso};
|
use crate::util::timestamp::{iso_to_unix_ms, unix_ms_to_iso};
|
||||||
|
use serde::{Deserialize, Deserializer};
|
||||||
|
|
||||||
|
fn scalar_string(value: serde_yaml::Value) -> Option<String> {
|
||||||
|
match value {
|
||||||
|
serde_yaml::Value::String(value) => Some(value),
|
||||||
|
serde_yaml::Value::Number(value) => Some(value.to_string()),
|
||||||
|
serde_yaml::Value::Bool(value) => Some(value.to_string()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_scalar_string<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
scalar_string(serde_yaml::Value::deserialize(deserializer)?)
|
||||||
|
.ok_or_else(|| serde::de::Error::custom("expected a scalar string"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_optional_scalar_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Ok(Option::<serde_yaml::Value>::deserialize(deserializer)?.and_then(scalar_string))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_optional_nonempty_scalar_string<'de, D>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<Option<String>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Ok(deserialize_optional_scalar_string(deserializer)?.filter(|value| !value.is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_optional_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Ok(Option::<serde_yaml::Value>::deserialize(deserializer)?
|
||||||
|
.and_then(|value| value.as_str().map(str::to_owned)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_string<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
serde_yaml::Value::deserialize(deserializer)?
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.ok_or_else(|| serde::de::Error::custom("expected a string"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_optional_nonempty_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Ok(deserialize_optional_string(deserializer)?.filter(|value| !value.is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_string_list<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Ok(match serde_yaml::Value::deserialize(deserializer)? {
|
||||||
|
serde_yaml::Value::Sequence(values) => values
|
||||||
|
.iter()
|
||||||
|
.filter_map(|value| value.as_str().map(str::to_owned))
|
||||||
|
.collect(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_timestamp<'de, D>(deserializer: D) -> Result<i64, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let value = deserialize_scalar_string(deserializer)?;
|
||||||
|
iso_to_unix_ms(&value).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_optional_timestamp<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Ok(deserialize_optional_scalar_string(deserializer)?
|
||||||
|
.and_then(|value| iso_to_unix_ms(&value).ok()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_optional_string_timestamp<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Ok(deserialize_optional_string(deserializer)?.and_then(|value| iso_to_unix_ms(&value).ok()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Ok(Option::<serde_yaml::Value>::deserialize(deserializer)?
|
||||||
|
.and_then(scalar_string)
|
||||||
|
.is_some_and(|value| value == "true"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_bool_default_true<'de, D>(deserializer: D) -> Result<bool, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Ok(Option::<serde_yaml::Value>::deserialize(deserializer)?
|
||||||
|
.and_then(scalar_string)
|
||||||
|
.is_none_or(|value| value == "true"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_version() -> i32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_entrypoint() -> String {
|
||||||
|
"render".to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_version<'de, D>(deserializer: D) -> Result<i32, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Ok(Option::<serde_yaml::Value>::deserialize(deserializer)?
|
||||||
|
.and_then(scalar_string)
|
||||||
|
.and_then(|value| value.parse().ok())
|
||||||
|
.unwrap_or(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_entrypoint<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Ok(Option::<serde_yaml::Value>::deserialize(deserializer)?
|
||||||
|
.and_then(scalar_string)
|
||||||
|
.unwrap_or_else(default_entrypoint))
|
||||||
|
}
|
||||||
|
|
||||||
/// 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 `---`.
|
||||||
@@ -25,21 +169,48 @@ pub fn format_frontmatter(yaml: &str, body: &str) -> String {
|
|||||||
// --- Post Frontmatter ---
|
// --- Post Frontmatter ---
|
||||||
|
|
||||||
/// Parsed post frontmatter fields (camelCase for YAML compatibility).
|
/// Parsed post frontmatter fields (camelCase for YAML compatibility).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PostFrontmatter {
|
pub struct PostFrontmatter {
|
||||||
|
#[serde(deserialize_with = "deserialize_scalar_string")]
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
#[serde(deserialize_with = "deserialize_scalar_string")]
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
#[serde(deserialize_with = "deserialize_scalar_string")]
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
|
#[serde(deserialize_with = "deserialize_scalar_string")]
|
||||||
pub status: String,
|
pub status: String,
|
||||||
|
#[serde(deserialize_with = "deserialize_timestamp")]
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
|
#[serde(deserialize_with = "deserialize_timestamp")]
|
||||||
pub updated_at: i64,
|
pub updated_at: i64,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_string_list")]
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_string_list")]
|
||||||
pub categories: Vec<String>,
|
pub categories: Vec<String>,
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
deserialize_with = "deserialize_optional_nonempty_scalar_string"
|
||||||
|
)]
|
||||||
pub excerpt: Option<String>,
|
pub excerpt: Option<String>,
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
deserialize_with = "deserialize_optional_nonempty_scalar_string"
|
||||||
|
)]
|
||||||
pub author: Option<String>,
|
pub author: Option<String>,
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
deserialize_with = "deserialize_optional_nonempty_scalar_string"
|
||||||
|
)]
|
||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
deserialize_with = "deserialize_optional_nonempty_scalar_string"
|
||||||
|
)]
|
||||||
pub template_slug: Option<String>,
|
pub template_slug: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_bool")]
|
||||||
pub do_not_translate: bool,
|
pub do_not_translate: bool,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_timestamp")]
|
||||||
pub published_at: Option<i64>,
|
pub published_at: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,10 +221,7 @@ impl PostFrontmatter {
|
|||||||
id: post.id.clone(),
|
id: post.id.clone(),
|
||||||
title: post.title.clone(),
|
title: post.title.clone(),
|
||||||
slug: post.slug.clone(),
|
slug: post.slug.clone(),
|
||||||
status: serde_json::to_string(&post.status)
|
status: post.status.as_str().to_owned(),
|
||||||
.unwrap_or_default()
|
|
||||||
.trim_matches('"')
|
|
||||||
.to_string(),
|
|
||||||
created_at: post.created_at,
|
created_at: post.created_at,
|
||||||
updated_at: post.updated_at,
|
updated_at: post.updated_at,
|
||||||
tags: post.tags.clone(),
|
tags: post.tags.clone(),
|
||||||
@@ -130,56 +298,7 @@ impl PostFrontmatter {
|
|||||||
|
|
||||||
/// Parse from a YAML string.
|
/// Parse from a YAML string.
|
||||||
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
|
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
|
||||||
let doc: serde_yaml::Value =
|
serde_yaml::from_str(yaml).map_err(|error| format!("YAML parse error: {error}"))
|
||||||
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
|
|
||||||
let map = doc
|
|
||||||
.as_mapping()
|
|
||||||
.ok_or("frontmatter is not a YAML mapping")?;
|
|
||||||
|
|
||||||
let get_str = |key: &str| -> Option<String> {
|
|
||||||
map.get(serde_yaml::Value::String(key.to_string()))
|
|
||||||
.and_then(|v| match v {
|
|
||||||
serde_yaml::Value::String(s) => Some(s.clone()),
|
|
||||||
serde_yaml::Value::Number(n) => Some(n.to_string()),
|
|
||||||
serde_yaml::Value::Bool(b) => Some(b.to_string()),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let get_string_list = |key: &str| -> Vec<String> {
|
|
||||||
map.get(serde_yaml::Value::String(key.to_string()))
|
|
||||||
.and_then(|v| v.as_sequence())
|
|
||||||
.map(|seq| {
|
|
||||||
seq.iter()
|
|
||||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let get_timestamp = |key: &str| -> Result<i64, String> {
|
|
||||||
let s = get_str(key).ok_or(format!("missing required field '{key}'"))?;
|
|
||||||
iso_to_unix_ms(&s)
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
id: get_str("id").ok_or("missing 'id'")?,
|
|
||||||
title: get_str("title").ok_or("missing 'title'")?,
|
|
||||||
slug: get_str("slug").ok_or("missing 'slug'")?,
|
|
||||||
status: get_str("status").ok_or("missing 'status'")?,
|
|
||||||
created_at: get_timestamp("createdAt")?,
|
|
||||||
updated_at: get_timestamp("updatedAt")?,
|
|
||||||
tags: get_string_list("tags"),
|
|
||||||
categories: get_string_list("categories"),
|
|
||||||
excerpt: get_str("excerpt").filter(|s| !s.is_empty()),
|
|
||||||
author: get_str("author").filter(|s| !s.is_empty()),
|
|
||||||
language: get_str("language").filter(|s| !s.is_empty()),
|
|
||||||
template_slug: get_str("templateSlug").filter(|s| !s.is_empty()),
|
|
||||||
do_not_translate: get_str("doNotTranslate")
|
|
||||||
.map(|s| s == "true")
|
|
||||||
.unwrap_or(false),
|
|
||||||
published_at: get_str("publishedAt").and_then(|s| iso_to_unix_ms(&s).ok()),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,16 +318,26 @@ pub fn read_post_file(content: &str) -> Result<(PostFrontmatter, String), String
|
|||||||
// --- Translation Frontmatter ---
|
// --- Translation Frontmatter ---
|
||||||
|
|
||||||
/// Parsed translation frontmatter.
|
/// Parsed translation frontmatter.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct TranslationFrontmatter {
|
pub struct TranslationFrontmatter {
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_string")]
|
||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
|
#[serde(deserialize_with = "deserialize_string")]
|
||||||
pub translation_for: String,
|
pub translation_for: String,
|
||||||
|
#[serde(deserialize_with = "deserialize_string")]
|
||||||
pub language: String,
|
pub language: String,
|
||||||
|
#[serde(deserialize_with = "deserialize_string")]
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_nonempty_string")]
|
||||||
pub excerpt: Option<String>,
|
pub excerpt: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_string")]
|
||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_string_timestamp")]
|
||||||
pub created_at: Option<i64>,
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_string_timestamp")]
|
||||||
pub updated_at: Option<i64>,
|
pub updated_at: Option<i64>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_string_timestamp")]
|
||||||
pub published_at: Option<i64>,
|
pub published_at: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,12 +349,7 @@ impl TranslationFrontmatter {
|
|||||||
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(
|
status: Some(t.status.as_str().to_owned()),
|
||||||
serde_json::to_string(&t.status)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim_matches('"')
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
created_at: Some(t.created_at),
|
created_at: Some(t.created_at),
|
||||||
updated_at: Some(t.updated_at),
|
updated_at: Some(t.updated_at),
|
||||||
published_at: t.published_at,
|
published_at: t.published_at,
|
||||||
@@ -261,29 +385,7 @@ impl TranslationFrontmatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
|
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
|
||||||
let doc: serde_yaml::Value =
|
serde_yaml::from_str(yaml).map_err(|error| format!("YAML parse error: {error}"))
|
||||||
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
|
|
||||||
let map = doc
|
|
||||||
.as_mapping()
|
|
||||||
.ok_or("frontmatter is not a YAML mapping")?;
|
|
||||||
|
|
||||||
let get_str = |key: &str| -> Option<String> {
|
|
||||||
map.get(serde_yaml::Value::String(key.to_string()))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
id: get_str("id"),
|
|
||||||
translation_for: get_str("translationFor").ok_or("missing 'translationFor'")?,
|
|
||||||
language: get_str("language").ok_or("missing 'language'")?,
|
|
||||||
title: get_str("title").ok_or("missing 'title'")?,
|
|
||||||
excerpt: get_str("excerpt").filter(|s| !s.is_empty()),
|
|
||||||
status: get_str("status"),
|
|
||||||
created_at: get_str("createdAt").and_then(|value| iso_to_unix_ms(&value).ok()),
|
|
||||||
updated_at: get_str("updatedAt").and_then(|value| iso_to_unix_ms(&value).ok()),
|
|
||||||
published_at: get_str("publishedAt").and_then(|value| iso_to_unix_ms(&value).ok()),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,16 +405,29 @@ pub fn read_translation_file(content: &str) -> Result<(TranslationFrontmatter, S
|
|||||||
// --- Template Frontmatter ---
|
// --- Template Frontmatter ---
|
||||||
|
|
||||||
/// Parsed template frontmatter (double-quoted strings, matching TypeScript output).
|
/// Parsed template frontmatter (double-quoted strings, matching TypeScript output).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct TemplateFrontmatter {
|
pub struct TemplateFrontmatter {
|
||||||
|
#[serde(deserialize_with = "deserialize_scalar_string")]
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_scalar_string")]
|
||||||
pub project_id: Option<String>,
|
pub project_id: Option<String>,
|
||||||
|
#[serde(deserialize_with = "deserialize_scalar_string")]
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
|
#[serde(deserialize_with = "deserialize_scalar_string")]
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
#[serde(deserialize_with = "deserialize_scalar_string")]
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
|
#[serde(
|
||||||
|
default = "default_true",
|
||||||
|
deserialize_with = "deserialize_bool_default_true"
|
||||||
|
)]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
#[serde(default = "default_version", deserialize_with = "deserialize_version")]
|
||||||
pub version: i32,
|
pub version: i32,
|
||||||
|
#[serde(deserialize_with = "deserialize_timestamp")]
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
|
#[serde(deserialize_with = "deserialize_timestamp")]
|
||||||
pub updated_at: i64,
|
pub updated_at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,37 +456,7 @@ impl TemplateFrontmatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
|
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
|
||||||
let doc: serde_yaml::Value =
|
serde_yaml::from_str(yaml).map_err(|error| format!("YAML parse error: {error}"))
|
||||||
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
|
|
||||||
let map = doc.as_mapping().ok_or("not a YAML mapping")?;
|
|
||||||
|
|
||||||
let get_str = |key: &str| -> Option<String> {
|
|
||||||
map.get(serde_yaml::Value::String(key.to_string()))
|
|
||||||
.and_then(|v| match v {
|
|
||||||
serde_yaml::Value::String(s) => Some(s.clone()),
|
|
||||||
serde_yaml::Value::Number(n) => Some(n.to_string()),
|
|
||||||
serde_yaml::Value::Bool(b) => Some(b.to_string()),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
id: get_str("id").ok_or("missing 'id'")?,
|
|
||||||
project_id: get_str("projectId"),
|
|
||||||
slug: get_str("slug").ok_or("missing 'slug'")?,
|
|
||||||
title: get_str("title").ok_or("missing 'title'")?,
|
|
||||||
kind: get_str("kind").ok_or("missing 'kind'")?,
|
|
||||||
enabled: get_str("enabled").map(|s| s == "true").unwrap_or(true),
|
|
||||||
version: get_str("version")
|
|
||||||
.and_then(|s| s.parse::<i32>().ok())
|
|
||||||
.unwrap_or(1),
|
|
||||||
created_at: get_str("createdAt")
|
|
||||||
.and_then(|s| iso_to_unix_ms(&s).ok())
|
|
||||||
.ok_or("missing 'createdAt'")?,
|
|
||||||
updated_at: get_str("updatedAt")
|
|
||||||
.and_then(|s| iso_to_unix_ms(&s).ok())
|
|
||||||
.ok_or("missing 'updatedAt'")?,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,17 +475,34 @@ pub fn write_template_file(fm: &TemplateFrontmatter, body: &str) -> String {
|
|||||||
// --- Script Frontmatter ---
|
// --- Script Frontmatter ---
|
||||||
|
|
||||||
/// Parsed script frontmatter (double-quoted strings like templates, plus entrypoint).
|
/// Parsed script frontmatter (double-quoted strings like templates, plus entrypoint).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ScriptFrontmatter {
|
pub struct ScriptFrontmatter {
|
||||||
|
#[serde(deserialize_with = "deserialize_scalar_string")]
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_scalar_string")]
|
||||||
pub project_id: Option<String>,
|
pub project_id: Option<String>,
|
||||||
|
#[serde(deserialize_with = "deserialize_scalar_string")]
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
|
#[serde(deserialize_with = "deserialize_scalar_string")]
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
#[serde(deserialize_with = "deserialize_scalar_string")]
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
|
#[serde(
|
||||||
|
default = "default_entrypoint",
|
||||||
|
deserialize_with = "deserialize_entrypoint"
|
||||||
|
)]
|
||||||
pub entrypoint: String,
|
pub entrypoint: String,
|
||||||
|
#[serde(
|
||||||
|
default = "default_true",
|
||||||
|
deserialize_with = "deserialize_bool_default_true"
|
||||||
|
)]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
#[serde(default = "default_version", deserialize_with = "deserialize_version")]
|
||||||
pub version: i32,
|
pub version: i32,
|
||||||
|
#[serde(deserialize_with = "deserialize_timestamp")]
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
|
#[serde(deserialize_with = "deserialize_timestamp")]
|
||||||
pub updated_at: i64,
|
pub updated_at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,38 +532,7 @@ impl ScriptFrontmatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
|
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
|
||||||
let doc: serde_yaml::Value =
|
serde_yaml::from_str(yaml).map_err(|error| format!("YAML parse error: {error}"))
|
||||||
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
|
|
||||||
let map = doc.as_mapping().ok_or("not a YAML mapping")?;
|
|
||||||
|
|
||||||
let get_str = |key: &str| -> Option<String> {
|
|
||||||
map.get(serde_yaml::Value::String(key.to_string()))
|
|
||||||
.and_then(|v| match v {
|
|
||||||
serde_yaml::Value::String(s) => Some(s.clone()),
|
|
||||||
serde_yaml::Value::Number(n) => Some(n.to_string()),
|
|
||||||
serde_yaml::Value::Bool(b) => Some(b.to_string()),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
id: get_str("id").ok_or("missing 'id'")?,
|
|
||||||
project_id: get_str("projectId"),
|
|
||||||
slug: get_str("slug").ok_or("missing 'slug'")?,
|
|
||||||
title: get_str("title").ok_or("missing 'title'")?,
|
|
||||||
kind: get_str("kind").ok_or("missing 'kind'")?,
|
|
||||||
entrypoint: get_str("entrypoint").unwrap_or_else(|| "render".to_string()),
|
|
||||||
enabled: get_str("enabled").map(|s| s == "true").unwrap_or(true),
|
|
||||||
version: get_str("version")
|
|
||||||
.and_then(|s| s.parse::<i32>().ok())
|
|
||||||
.unwrap_or(1),
|
|
||||||
created_at: get_str("createdAt")
|
|
||||||
.and_then(|s| iso_to_unix_ms(&s).ok())
|
|
||||||
.ok_or("missing 'createdAt'")?,
|
|
||||||
updated_at: get_str("updatedAt")
|
|
||||||
.and_then(|s| iso_to_unix_ms(&s).ok())
|
|
||||||
.ok_or("missing 'updatedAt'")?,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,6 +622,27 @@ mod tests {
|
|||||||
assert!(split_frontmatter("no frontmatter here").is_none());
|
assert!(split_frontmatter("no frontmatter here").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derived_deserialization_preserves_lenient_scalar_coercion() {
|
||||||
|
let yaml = "id: 42\ntitle: true\nslug: 7\nstatus: false\ncreatedAt: '2026-01-02T03:04:05.000Z'\nupdatedAt: '2026-01-02T03:04:05.000Z'\ntags: [rust, 2]\ncategories: invalid\nauthor: 9\ndoNotTranslate: true";
|
||||||
|
let parsed = PostFrontmatter::from_yaml(yaml).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(parsed.id, "42");
|
||||||
|
assert_eq!(parsed.title, "true");
|
||||||
|
assert_eq!(parsed.slug, "7");
|
||||||
|
assert_eq!(parsed.status, "false");
|
||||||
|
assert_eq!(parsed.tags, vec!["rust"]);
|
||||||
|
assert!(parsed.categories.is_empty());
|
||||||
|
assert_eq!(parsed.author.as_deref(), Some("9"));
|
||||||
|
assert!(parsed.do_not_translate);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn translation_frontmatter_remains_string_only() {
|
||||||
|
let yaml = "translationFor: 42\nlanguage: en\ntitle: Title";
|
||||||
|
assert!(TranslationFrontmatter::from_yaml(yaml).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_esmeralda() {
|
fn parse_esmeralda() {
|
||||||
let path = fixture_dir().join("posts/2005/11/esmeralda.md");
|
let path = fixture_dir().join("posts/2005/11/esmeralda.md");
|
||||||
|
|||||||
@@ -140,30 +140,6 @@ fn post_status_transitions_all_valid() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn slug_frozen_after_publish_semantics() {
|
|
||||||
let db = memory_db();
|
|
||||||
project_queries::insert_project(db.conn(), &project("p1", "test")).unwrap();
|
|
||||||
post_queries::insert_post(
|
|
||||||
db.conn(),
|
|
||||||
&post("published", PostStatus::Published, Some(1000)),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
post_queries::insert_post(db.conn(), &post("draft", PostStatus::Draft, None)).unwrap();
|
|
||||||
assert!(
|
|
||||||
post_queries::get_post_by_id(db.conn(), "published")
|
|
||||||
.unwrap()
|
|
||||||
.published_at
|
|
||||||
.is_some()
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
post_queries::get_post_by_id(db.conn(), "draft")
|
|
||||||
.unwrap()
|
|
||||||
.published_at
|
|
||||||
.is_none()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_and_translation_uniqueness_in_fixture() {
|
fn project_and_translation_uniqueness_in_fixture() {
|
||||||
let db = fixture_db();
|
let db = fixture_db();
|
||||||
@@ -339,7 +315,6 @@ fn remaining_value_specs_match() {
|
|||||||
"\"deleted\""
|
"\"deleted\""
|
||||||
);
|
);
|
||||||
assert_eq!(serde_json::to_string(&SshMode::Rsync).unwrap(), "\"rsync\"");
|
assert_eq!(serde_json::to_string(&SshMode::Rsync).unwrap(), "\"rsync\"");
|
||||||
assert_eq!(["en", "de", "fr", "it", "es"].len(), 5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ objc2-foundation = "0.3"
|
|||||||
objc2-app-kit = "0.3"
|
objc2-app-kit = "0.3"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
fluent-syntax = "0.12"
|
fluent-syntax = { workspace = true }
|
||||||
syn = { version = "2", features = ["full", "visit"] }
|
syn = { version = "2", features = ["full", "visit"] }
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
757
crates/bds-ui/src/app/editor_handlers.rs
Normal file
757
crates/bds-ui/src/app/editor_handlers.rs
Normal file
@@ -0,0 +1,757 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl BdsApp {
|
||||||
|
pub(super) fn handle_post_editor_msg(&mut self, msg: PostEditorMsg) -> Task<Message> {
|
||||||
|
enum DeferredPostAction {
|
||||||
|
None,
|
||||||
|
SyncEmbeddedPreview,
|
||||||
|
Analyze(String),
|
||||||
|
AnalyzeTaxonomy(String),
|
||||||
|
DetectLanguage(String),
|
||||||
|
OpenTranslate(String),
|
||||||
|
TranslateTo {
|
||||||
|
post_id: String,
|
||||||
|
target_language: String,
|
||||||
|
},
|
||||||
|
Save(String),
|
||||||
|
Publish(String),
|
||||||
|
Duplicate(String),
|
||||||
|
Discard(String),
|
||||||
|
ShowDelete {
|
||||||
|
tab_id: String,
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
OpenInsertLink(String),
|
||||||
|
OpenInsertMedia {
|
||||||
|
post_id: String,
|
||||||
|
link_only: bool,
|
||||||
|
},
|
||||||
|
OpenGallery(String),
|
||||||
|
OpenLinkedMedia(String),
|
||||||
|
UnlinkLinkedMedia {
|
||||||
|
post_id: String,
|
||||||
|
media_id: String,
|
||||||
|
},
|
||||||
|
InsertSelectedLink {
|
||||||
|
post_id: String,
|
||||||
|
linked_post_id: String,
|
||||||
|
},
|
||||||
|
CreateLinkedPost(String),
|
||||||
|
InsertSelectedMedia {
|
||||||
|
post_id: String,
|
||||||
|
media_id: String,
|
||||||
|
},
|
||||||
|
SetLinkTab(modal::PostInsertLinkTab),
|
||||||
|
SetLinkSearch(String),
|
||||||
|
SetExternalUrl(String),
|
||||||
|
SetExternalText(String),
|
||||||
|
InsertExternalLink,
|
||||||
|
SetMediaSearch(String),
|
||||||
|
SelectGalleryImage(usize),
|
||||||
|
GalleryPrevious,
|
||||||
|
GalleryNext,
|
||||||
|
GalleryCloseLightbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut deferred = DeferredPostAction::None;
|
||||||
|
let mut refresh_linked_media: Option<(String, String)> = None;
|
||||||
|
if let Some(tab_id) = self.active_tab.clone()
|
||||||
|
&& let Some(state) = self.post_editors.get_mut(&tab_id)
|
||||||
|
{
|
||||||
|
match msg {
|
||||||
|
PostEditorMsg::ToggleQuickActions => {
|
||||||
|
state.quick_actions_open = !state.quick_actions_open;
|
||||||
|
}
|
||||||
|
PostEditorMsg::AnalyzeWithAi => {
|
||||||
|
state.quick_actions_open = false;
|
||||||
|
deferred = DeferredPostAction::Analyze(tab_id.clone());
|
||||||
|
}
|
||||||
|
PostEditorMsg::AnalyzeTaxonomy => {
|
||||||
|
state.quick_actions_open = false;
|
||||||
|
deferred = DeferredPostAction::AnalyzeTaxonomy(tab_id.clone());
|
||||||
|
}
|
||||||
|
PostEditorMsg::SwitchEditorMode(mode) => {
|
||||||
|
state.set_editor_mode(&mode);
|
||||||
|
deferred = DeferredPostAction::SyncEmbeddedPreview;
|
||||||
|
}
|
||||||
|
PostEditorMsg::DetectLanguage => {
|
||||||
|
state.quick_actions_open = false;
|
||||||
|
deferred = DeferredPostAction::DetectLanguage(tab_id.clone());
|
||||||
|
}
|
||||||
|
PostEditorMsg::Translate => {
|
||||||
|
state.quick_actions_open = false;
|
||||||
|
deferred = DeferredPostAction::OpenTranslate(tab_id.clone());
|
||||||
|
}
|
||||||
|
PostEditorMsg::TranslateTo(target_language) => {
|
||||||
|
deferred = DeferredPostAction::TranslateTo {
|
||||||
|
post_id: tab_id.clone(),
|
||||||
|
target_language,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
PostEditorMsg::TitleChanged(s) => {
|
||||||
|
state.title = s;
|
||||||
|
state.mark_dirty();
|
||||||
|
}
|
||||||
|
PostEditorMsg::SlugChanged(s) => {
|
||||||
|
state.slug = s;
|
||||||
|
state.mark_dirty();
|
||||||
|
}
|
||||||
|
PostEditorMsg::ExcerptChanged(s) => {
|
||||||
|
state.excerpt = s;
|
||||||
|
state.mark_dirty();
|
||||||
|
}
|
||||||
|
PostEditorMsg::ContentChanged(new_text) => {
|
||||||
|
state.content = new_text;
|
||||||
|
state.mark_dirty();
|
||||||
|
refresh_linked_media = Some((state.post_id.clone(), state.content.clone()));
|
||||||
|
}
|
||||||
|
PostEditorMsg::AuthorChanged(s) => {
|
||||||
|
state.author = s;
|
||||||
|
state.mark_dirty();
|
||||||
|
}
|
||||||
|
PostEditorMsg::LanguageChanged(s) => {
|
||||||
|
state.language = s;
|
||||||
|
state.mark_dirty();
|
||||||
|
}
|
||||||
|
PostEditorMsg::TemplateSlugChanged(s) => {
|
||||||
|
state.template_slug = s;
|
||||||
|
state.mark_dirty();
|
||||||
|
}
|
||||||
|
PostEditorMsg::ToggleDoNotTranslate(b) => {
|
||||||
|
state.do_not_translate = b;
|
||||||
|
state.mark_dirty();
|
||||||
|
}
|
||||||
|
PostEditorMsg::ToggleMetadata => {
|
||||||
|
state.metadata_expanded = !state.metadata_expanded;
|
||||||
|
}
|
||||||
|
PostEditorMsg::ToggleExcerpt => {
|
||||||
|
state.excerpt_expanded = !state.excerpt_expanded;
|
||||||
|
}
|
||||||
|
PostEditorMsg::SwitchLanguage(lang) => {
|
||||||
|
state.switch_language(&lang);
|
||||||
|
if state.editor_mode == "preview" {
|
||||||
|
deferred = DeferredPostAction::SyncEmbeddedPreview;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PostEditorMsg::TagsInputChanged(s) => {
|
||||||
|
state.tags_input = s;
|
||||||
|
}
|
||||||
|
PostEditorMsg::TagsInputSubmit => {
|
||||||
|
let tag = state.tags_input.trim().to_string();
|
||||||
|
if !tag.is_empty() && !state.tags.contains(&tag) {
|
||||||
|
state.tags.push(tag);
|
||||||
|
state.mark_dirty();
|
||||||
|
}
|
||||||
|
state.tags_input.clear();
|
||||||
|
}
|
||||||
|
PostEditorMsg::RemoveTag(tag) => {
|
||||||
|
state.tags.retain(|t| t != &tag);
|
||||||
|
state.mark_dirty();
|
||||||
|
}
|
||||||
|
PostEditorMsg::CategoriesInputChanged(s) => {
|
||||||
|
state.categories_input = s;
|
||||||
|
}
|
||||||
|
PostEditorMsg::CategoriesInputSubmit => {
|
||||||
|
let cat = state.categories_input.trim().to_string();
|
||||||
|
if !cat.is_empty() && !state.categories.contains(&cat) {
|
||||||
|
state.categories.push(cat);
|
||||||
|
state.mark_dirty();
|
||||||
|
}
|
||||||
|
state.categories_input.clear();
|
||||||
|
}
|
||||||
|
PostEditorMsg::RemoveCategory(cat) => {
|
||||||
|
state.categories.retain(|c| c != &cat);
|
||||||
|
state.mark_dirty();
|
||||||
|
}
|
||||||
|
PostEditorMsg::Save => {
|
||||||
|
deferred = DeferredPostAction::Save(tab_id.clone());
|
||||||
|
}
|
||||||
|
PostEditorMsg::Publish => {
|
||||||
|
deferred = DeferredPostAction::Publish(tab_id.clone());
|
||||||
|
}
|
||||||
|
PostEditorMsg::Duplicate => {
|
||||||
|
deferred = DeferredPostAction::Duplicate(tab_id.clone());
|
||||||
|
}
|
||||||
|
PostEditorMsg::Discard => {
|
||||||
|
deferred = DeferredPostAction::Discard(tab_id.clone());
|
||||||
|
}
|
||||||
|
PostEditorMsg::Delete => {
|
||||||
|
deferred = DeferredPostAction::ShowDelete {
|
||||||
|
tab_id: tab_id.clone(),
|
||||||
|
name: state.title.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
PostEditorMsg::InsertLink => {
|
||||||
|
deferred = DeferredPostAction::OpenInsertLink(state.post_id.clone());
|
||||||
|
}
|
||||||
|
PostEditorMsg::InsertMedia => {
|
||||||
|
deferred = DeferredPostAction::OpenInsertMedia {
|
||||||
|
post_id: state.post_id.clone(),
|
||||||
|
link_only: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
PostEditorMsg::Gallery => {
|
||||||
|
deferred = DeferredPostAction::OpenGallery(state.post_id.clone());
|
||||||
|
}
|
||||||
|
PostEditorMsg::LinkExistingMedia => {
|
||||||
|
deferred = DeferredPostAction::OpenInsertMedia {
|
||||||
|
post_id: state.post_id.clone(),
|
||||||
|
link_only: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
PostEditorMsg::OpenLinkedMedia(media_id) => {
|
||||||
|
deferred = DeferredPostAction::OpenLinkedMedia(media_id);
|
||||||
|
}
|
||||||
|
PostEditorMsg::UnlinkLinkedMedia(media_id) => {
|
||||||
|
deferred = DeferredPostAction::UnlinkLinkedMedia {
|
||||||
|
post_id: state.post_id.clone(),
|
||||||
|
media_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
PostEditorMsg::PostInsertLinkSelected(linked_post_id) => {
|
||||||
|
deferred = DeferredPostAction::InsertSelectedLink {
|
||||||
|
post_id: state.post_id.clone(),
|
||||||
|
linked_post_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
PostEditorMsg::PostInsertLinkCreate => {
|
||||||
|
deferred = DeferredPostAction::CreateLinkedPost(state.post_id.clone());
|
||||||
|
}
|
||||||
|
PostEditorMsg::PostInsertMediaSelected(media_id) => {
|
||||||
|
deferred = DeferredPostAction::InsertSelectedMedia {
|
||||||
|
post_id: state.post_id.clone(),
|
||||||
|
media_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
PostEditorMsg::PostGalleryImageSelected(index) => {
|
||||||
|
deferred = DeferredPostAction::SelectGalleryImage(index);
|
||||||
|
}
|
||||||
|
PostEditorMsg::PostInsertLinkTabSwitch(tab) => {
|
||||||
|
deferred = DeferredPostAction::SetLinkTab(tab);
|
||||||
|
}
|
||||||
|
PostEditorMsg::PostInsertLinkSearch(query) => {
|
||||||
|
deferred = DeferredPostAction::SetLinkSearch(query);
|
||||||
|
}
|
||||||
|
PostEditorMsg::PostInsertLinkUrlChanged(url) => {
|
||||||
|
deferred = DeferredPostAction::SetExternalUrl(url);
|
||||||
|
}
|
||||||
|
PostEditorMsg::PostInsertLinkTextChanged(text) => {
|
||||||
|
deferred = DeferredPostAction::SetExternalText(text);
|
||||||
|
}
|
||||||
|
PostEditorMsg::PostInsertLinkExternalInsert => {
|
||||||
|
deferred = DeferredPostAction::InsertExternalLink;
|
||||||
|
}
|
||||||
|
PostEditorMsg::PostInsertMediaSearch(query) => {
|
||||||
|
deferred = DeferredPostAction::SetMediaSearch(query);
|
||||||
|
}
|
||||||
|
PostEditorMsg::PostGalleryPrevious => {
|
||||||
|
deferred = DeferredPostAction::GalleryPrevious;
|
||||||
|
}
|
||||||
|
PostEditorMsg::PostGalleryNext => {
|
||||||
|
deferred = DeferredPostAction::GalleryNext;
|
||||||
|
}
|
||||||
|
PostEditorMsg::PostGalleryCloseLightbox => {
|
||||||
|
deferred = DeferredPostAction::GalleryCloseLightbox;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == state.post_id) {
|
||||||
|
tab.is_dirty = state.is_dirty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((post_id, content)) = refresh_linked_media {
|
||||||
|
let linked_media = self.load_post_media_items(&post_id, Some(&content));
|
||||||
|
if let Some(state) = self.post_editors.get_mut(&post_id) {
|
||||||
|
state.linked_media = linked_media;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match deferred {
|
||||||
|
DeferredPostAction::None => Task::none(),
|
||||||
|
DeferredPostAction::SyncEmbeddedPreview => self.sync_embedded_preview_for_active_post(),
|
||||||
|
DeferredPostAction::Analyze(tab_id) => self.run_post_ai_analysis(&tab_id),
|
||||||
|
DeferredPostAction::AnalyzeTaxonomy(tab_id) => self.run_post_taxonomy_analysis(&tab_id),
|
||||||
|
DeferredPostAction::DetectLanguage(tab_id) => self.detect_post_language(&tab_id),
|
||||||
|
DeferredPostAction::OpenTranslate(tab_id) => self.open_post_translation_modal(&tab_id),
|
||||||
|
DeferredPostAction::TranslateTo {
|
||||||
|
post_id,
|
||||||
|
target_language,
|
||||||
|
} => self.translate_post_to(&post_id, &target_language),
|
||||||
|
DeferredPostAction::Save(tab_id) => self.save_post_editor(&tab_id),
|
||||||
|
DeferredPostAction::Publish(tab_id) => self.publish_post_editor(&tab_id),
|
||||||
|
DeferredPostAction::Duplicate(tab_id) => self.duplicate_post_editor(&tab_id),
|
||||||
|
DeferredPostAction::Discard(tab_id) => self.discard_post_editor(&tab_id),
|
||||||
|
DeferredPostAction::ShowDelete { tab_id, name } => {
|
||||||
|
Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete {
|
||||||
|
entity_name: name,
|
||||||
|
references: Vec::new(),
|
||||||
|
on_confirm: modal::ConfirmAction::DeletePost(tab_id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
DeferredPostAction::OpenInsertLink(post_id) => self.insert_link_modal(&post_id),
|
||||||
|
DeferredPostAction::OpenInsertMedia { post_id, link_only } => {
|
||||||
|
self.insert_media_modal(&post_id, link_only)
|
||||||
|
}
|
||||||
|
DeferredPostAction::OpenGallery(post_id) => self.post_gallery(&post_id),
|
||||||
|
DeferredPostAction::OpenLinkedMedia(media_id) => {
|
||||||
|
let title = self
|
||||||
|
.db
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|db| {
|
||||||
|
bds_core::db::queries::media::get_media_by_id(db.conn(), &media_id)
|
||||||
|
.ok()
|
||||||
|
.map(|media| media.title.unwrap_or(media.original_name))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| media_id.clone());
|
||||||
|
Task::done(Message::OpenTab(Tab {
|
||||||
|
id: media_id,
|
||||||
|
tab_type: TabType::Media,
|
||||||
|
title,
|
||||||
|
is_transient: false,
|
||||||
|
is_dirty: false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
DeferredPostAction::UnlinkLinkedMedia { post_id, media_id } => {
|
||||||
|
if let (Some(db), Some(data_dir)) = (&self.db, &self.data_dir) {
|
||||||
|
if let Err(err) = engine::post_media::unlink_media_from_post(
|
||||||
|
db.conn(),
|
||||||
|
data_dir,
|
||||||
|
&post_id,
|
||||||
|
&media_id,
|
||||||
|
) {
|
||||||
|
self.notify_operation_failed("editor.unlinkMedia", err);
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
self.refresh_post_relationships(&post_id);
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
DeferredPostAction::InsertSelectedLink {
|
||||||
|
post_id,
|
||||||
|
linked_post_id,
|
||||||
|
} => self.insert_selected_post_link(&post_id, &linked_post_id),
|
||||||
|
DeferredPostAction::CreateLinkedPost(post_id) => {
|
||||||
|
self.insert_created_post_link(&post_id)
|
||||||
|
}
|
||||||
|
DeferredPostAction::InsertSelectedMedia { post_id, media_id } => {
|
||||||
|
self.insert_selected_media(&post_id, &media_id)
|
||||||
|
}
|
||||||
|
DeferredPostAction::SetLinkTab(tab) => {
|
||||||
|
self.refresh_post_insert_link_modal(Some(tab), None, None, None);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
DeferredPostAction::SetLinkSearch(query) => {
|
||||||
|
self.refresh_post_insert_link_modal(None, Some(query), None, None);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
DeferredPostAction::SetExternalUrl(url) => {
|
||||||
|
self.refresh_post_insert_link_modal(None, None, Some(url), None);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
DeferredPostAction::SetExternalText(text) => {
|
||||||
|
self.refresh_post_insert_link_modal(None, None, None, Some(text));
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
DeferredPostAction::InsertExternalLink => {
|
||||||
|
if let Some(modal::ModalState::PostInsertLink {
|
||||||
|
post_id,
|
||||||
|
external_url,
|
||||||
|
external_text,
|
||||||
|
..
|
||||||
|
}) = self.active_modal.clone()
|
||||||
|
{
|
||||||
|
if let Some(markdown) =
|
||||||
|
modal::external_link_markdown(&external_url, &external_text)
|
||||||
|
{
|
||||||
|
self.insert_markdown_into_post(&post_id, &markdown)
|
||||||
|
} else {
|
||||||
|
self.notify(
|
||||||
|
ToastLevel::Error,
|
||||||
|
&t(self.ui_locale, "modal.postInsertLink.urlRequired"),
|
||||||
|
);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DeferredPostAction::SetMediaSearch(query) => {
|
||||||
|
self.refresh_insert_media_modal(query);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
DeferredPostAction::SelectGalleryImage(index) => {
|
||||||
|
self.update_gallery_selection(Some(index));
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
DeferredPostAction::GalleryPrevious => {
|
||||||
|
self.step_gallery_selection(-1);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
DeferredPostAction::GalleryNext => {
|
||||||
|
self.step_gallery_selection(1);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
DeferredPostAction::GalleryCloseLightbox => {
|
||||||
|
self.update_gallery_selection(None);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_media_editor_msg(&mut self, msg: MediaEditorMsg) -> Task<Message> {
|
||||||
|
enum DeferredMediaAction {
|
||||||
|
None,
|
||||||
|
Analyze(String),
|
||||||
|
DetectLanguage(String),
|
||||||
|
OpenTranslate(String),
|
||||||
|
TranslateTo {
|
||||||
|
media_id: String,
|
||||||
|
target_language: String,
|
||||||
|
},
|
||||||
|
LinkPost {
|
||||||
|
media_id: String,
|
||||||
|
post_id: String,
|
||||||
|
},
|
||||||
|
OpenLinkedPost(String),
|
||||||
|
UnlinkPost {
|
||||||
|
media_id: String,
|
||||||
|
post_id: String,
|
||||||
|
},
|
||||||
|
Save(String),
|
||||||
|
Replace(String),
|
||||||
|
Delete {
|
||||||
|
tab_id: String,
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut deferred = DeferredMediaAction::None;
|
||||||
|
let mut picker_refresh: Option<(String, String)> = None;
|
||||||
|
if let Some(tab_id) = self.active_tab.clone()
|
||||||
|
&& let Some(state) = self.media_editors.get_mut(&tab_id)
|
||||||
|
{
|
||||||
|
match msg {
|
||||||
|
MediaEditorMsg::AnalyzeWithAi => {
|
||||||
|
deferred = DeferredMediaAction::Analyze(tab_id.clone());
|
||||||
|
}
|
||||||
|
MediaEditorMsg::DetectLanguage => {
|
||||||
|
deferred = DeferredMediaAction::DetectLanguage(tab_id.clone());
|
||||||
|
}
|
||||||
|
MediaEditorMsg::TranslateMetadata => {
|
||||||
|
deferred = DeferredMediaAction::OpenTranslate(tab_id.clone());
|
||||||
|
}
|
||||||
|
MediaEditorMsg::TranslateTo(target_language) => {
|
||||||
|
deferred = DeferredMediaAction::TranslateTo {
|
||||||
|
media_id: tab_id.clone(),
|
||||||
|
target_language,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
MediaEditorMsg::TitleChanged(s) => {
|
||||||
|
state.title = s;
|
||||||
|
state.is_dirty = true;
|
||||||
|
}
|
||||||
|
MediaEditorMsg::AltChanged(s) => {
|
||||||
|
state.alt = s;
|
||||||
|
state.is_dirty = true;
|
||||||
|
}
|
||||||
|
MediaEditorMsg::CaptionChanged(s) => {
|
||||||
|
state.caption = s;
|
||||||
|
state.is_dirty = true;
|
||||||
|
}
|
||||||
|
MediaEditorMsg::AuthorChanged(s) => {
|
||||||
|
state.author = s;
|
||||||
|
state.is_dirty = true;
|
||||||
|
}
|
||||||
|
MediaEditorMsg::LanguageChanged(s) => {
|
||||||
|
state.language = s;
|
||||||
|
state.is_dirty = true;
|
||||||
|
}
|
||||||
|
MediaEditorMsg::TagsChanged(s) => {
|
||||||
|
state.tags_input = s;
|
||||||
|
state.is_dirty = true;
|
||||||
|
}
|
||||||
|
MediaEditorMsg::SwitchLanguage(lang) => {
|
||||||
|
state.switch_language(&lang);
|
||||||
|
}
|
||||||
|
MediaEditorMsg::TogglePostPicker => {
|
||||||
|
let next_open = !state.post_picker_open;
|
||||||
|
state.post_picker_open = next_open;
|
||||||
|
if !next_open {
|
||||||
|
state.post_picker_results.clear();
|
||||||
|
} else {
|
||||||
|
picker_refresh =
|
||||||
|
Some((state.media_id.clone(), state.post_picker_search.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MediaEditorMsg::PostPickerSearchChanged(search) => {
|
||||||
|
state.post_picker_search = search;
|
||||||
|
if state.post_picker_open {
|
||||||
|
picker_refresh =
|
||||||
|
Some((state.media_id.clone(), state.post_picker_search.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MediaEditorMsg::LinkPost(post_id) => {
|
||||||
|
deferred = DeferredMediaAction::LinkPost {
|
||||||
|
media_id: state.media_id.clone(),
|
||||||
|
post_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
MediaEditorMsg::OpenLinkedPost(post_id) => {
|
||||||
|
deferred = DeferredMediaAction::OpenLinkedPost(post_id);
|
||||||
|
}
|
||||||
|
MediaEditorMsg::UnlinkPost(post_id) => {
|
||||||
|
deferred = DeferredMediaAction::UnlinkPost {
|
||||||
|
media_id: state.media_id.clone(),
|
||||||
|
post_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
MediaEditorMsg::Save => {
|
||||||
|
deferred = DeferredMediaAction::Save(tab_id.clone());
|
||||||
|
}
|
||||||
|
MediaEditorMsg::ReplaceFile => {
|
||||||
|
deferred = DeferredMediaAction::Replace(tab_id.clone());
|
||||||
|
}
|
||||||
|
MediaEditorMsg::Delete => {
|
||||||
|
deferred = DeferredMediaAction::Delete {
|
||||||
|
tab_id: tab_id.clone(),
|
||||||
|
name: state.title.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(tab) = self
|
||||||
|
.tabs
|
||||||
|
.iter_mut()
|
||||||
|
.find(|t| t.id == *state.media_id.as_str())
|
||||||
|
{
|
||||||
|
tab.is_dirty = state.is_dirty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((media_id, search)) = picker_refresh {
|
||||||
|
let results = self.query_media_post_picker_results(&media_id, &search);
|
||||||
|
if let Some(state) = self.media_editors.get_mut(&media_id) {
|
||||||
|
state.post_picker_results = results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match deferred {
|
||||||
|
DeferredMediaAction::None => Task::none(),
|
||||||
|
DeferredMediaAction::Analyze(media_id) => self.run_media_ai_analysis(&media_id),
|
||||||
|
DeferredMediaAction::DetectLanguage(media_id) => self.detect_media_language(&media_id),
|
||||||
|
DeferredMediaAction::OpenTranslate(media_id) => {
|
||||||
|
self.open_media_translation_modal(&media_id)
|
||||||
|
}
|
||||||
|
DeferredMediaAction::TranslateTo {
|
||||||
|
media_id,
|
||||||
|
target_language,
|
||||||
|
} => self.translate_media_to(&media_id, &target_language),
|
||||||
|
DeferredMediaAction::LinkPost { media_id, post_id } => {
|
||||||
|
self.link_media_to_post(&media_id, &post_id)
|
||||||
|
}
|
||||||
|
DeferredMediaAction::OpenLinkedPost(post_id) => {
|
||||||
|
let title = self
|
||||||
|
.db
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|db| {
|
||||||
|
bds_core::db::queries::post::get_post_by_id(db.conn(), &post_id).ok()
|
||||||
|
})
|
||||||
|
.map(|post| post.title)
|
||||||
|
.unwrap_or_else(|| post_id.clone());
|
||||||
|
Task::done(Message::OpenTab(Tab {
|
||||||
|
id: post_id,
|
||||||
|
tab_type: TabType::Post,
|
||||||
|
title,
|
||||||
|
is_transient: false,
|
||||||
|
is_dirty: false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
DeferredMediaAction::UnlinkPost { media_id, post_id } => {
|
||||||
|
self.unlink_media_from_post(&media_id, &post_id)
|
||||||
|
}
|
||||||
|
DeferredMediaAction::Save(tab_id) => self.save_media_editor(&tab_id),
|
||||||
|
DeferredMediaAction::Replace(media_id) => {
|
||||||
|
crate::platform::dialog::pick_media_replacement(
|
||||||
|
media_id,
|
||||||
|
t(self.ui_locale, "dialog.replaceMedia"),
|
||||||
|
t(self.ui_locale, "dialog.imageFilter"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DeferredMediaAction::Delete { tab_id, name } => {
|
||||||
|
Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete {
|
||||||
|
entity_name: name,
|
||||||
|
references: Vec::new(),
|
||||||
|
on_confirm: modal::ConfirmAction::DeleteMedia(tab_id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_template_editor_msg(&mut self, msg: TemplateEditorMsg) -> Task<Message> {
|
||||||
|
if let Some(tab_id) = self.active_tab.clone()
|
||||||
|
&& let Some(state) = self.template_editors.get_mut(&tab_id)
|
||||||
|
{
|
||||||
|
match msg {
|
||||||
|
TemplateEditorMsg::TitleChanged(s) => {
|
||||||
|
state.title = s;
|
||||||
|
state.is_dirty = true;
|
||||||
|
}
|
||||||
|
TemplateEditorMsg::SlugChanged(s) => {
|
||||||
|
state.slug = s;
|
||||||
|
state.is_dirty = true;
|
||||||
|
}
|
||||||
|
TemplateEditorMsg::KindChanged(k) => {
|
||||||
|
state.kind = k.0;
|
||||||
|
state.is_dirty = true;
|
||||||
|
}
|
||||||
|
TemplateEditorMsg::EnabledChanged(b) => {
|
||||||
|
state.enabled = b;
|
||||||
|
state.is_dirty = true;
|
||||||
|
}
|
||||||
|
TemplateEditorMsg::ContentChanged(new_text) => {
|
||||||
|
state.content = new_text;
|
||||||
|
state.is_dirty = true;
|
||||||
|
}
|
||||||
|
TemplateEditorMsg::Save => {
|
||||||
|
return self.save_template_editor(&tab_id);
|
||||||
|
}
|
||||||
|
TemplateEditorMsg::Validate => {
|
||||||
|
if let Some(st) = self.template_editors.get_mut(&tab_id) {
|
||||||
|
match engine::template::validate_template(&st.content) {
|
||||||
|
Ok(()) => {
|
||||||
|
st.validation_error = None;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
st.validation_error = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TemplateEditorMsg::Delete => {
|
||||||
|
return self.show_template_delete_confirmation(&tab_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(st) = self.template_editors.get(&tab_id)
|
||||||
|
&& let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id)
|
||||||
|
{
|
||||||
|
tab.is_dirty = st.is_dirty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_script_editor_msg(&mut self, msg: ScriptEditorMsg) -> Task<Message> {
|
||||||
|
let mut run_request = None;
|
||||||
|
if let Some(tab_id) = self.active_tab.clone()
|
||||||
|
&& let Some(state) = self.script_editors.get_mut(&tab_id)
|
||||||
|
{
|
||||||
|
match msg {
|
||||||
|
ScriptEditorMsg::TitleChanged(s) => {
|
||||||
|
state.title = s;
|
||||||
|
state.is_dirty = true;
|
||||||
|
}
|
||||||
|
ScriptEditorMsg::SlugChanged(s) => {
|
||||||
|
state.slug = s;
|
||||||
|
state.is_dirty = true;
|
||||||
|
}
|
||||||
|
ScriptEditorMsg::KindChanged(k) => {
|
||||||
|
state.kind = k.0;
|
||||||
|
state.is_dirty = true;
|
||||||
|
}
|
||||||
|
ScriptEditorMsg::EntrypointChanged(s) => {
|
||||||
|
state.entrypoint = s;
|
||||||
|
state.is_dirty = true;
|
||||||
|
}
|
||||||
|
ScriptEditorMsg::EnabledChanged(b) => {
|
||||||
|
state.enabled = b;
|
||||||
|
state.is_dirty = true;
|
||||||
|
}
|
||||||
|
ScriptEditorMsg::ContentChanged(new_text) => {
|
||||||
|
state.discovered_entrypoints = engine::script::discover_entrypoints(&new_text);
|
||||||
|
state.content = new_text;
|
||||||
|
state.is_dirty = true;
|
||||||
|
}
|
||||||
|
ScriptEditorMsg::Save => {
|
||||||
|
return self.save_script_editor(&tab_id);
|
||||||
|
}
|
||||||
|
ScriptEditorMsg::CheckSyntax => {
|
||||||
|
if let Some(st) = self.script_editors.get_mut(&tab_id) {
|
||||||
|
match engine::script::validate_script_syntax(&st.content) {
|
||||||
|
Ok(()) => {
|
||||||
|
st.validation_error = None;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
st.validation_error = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ScriptEditorMsg::Run => {
|
||||||
|
run_request = Some((
|
||||||
|
state.content.clone(),
|
||||||
|
state.entrypoint.clone(),
|
||||||
|
state.kind.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ScriptEditorMsg::Delete => {
|
||||||
|
let name = state.title.clone();
|
||||||
|
return Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete {
|
||||||
|
entity_name: name,
|
||||||
|
references: Vec::new(),
|
||||||
|
on_confirm: modal::ConfirmAction::DeleteScript(tab_id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(st) = self.script_editors.get(&tab_id)
|
||||||
|
&& let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id)
|
||||||
|
{
|
||||||
|
tab.is_dirty = st.is_dirty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((source, entrypoint, kind)) = run_request {
|
||||||
|
return self.spawn_engine_task(
|
||||||
|
"engine.runScript",
|
||||||
|
move |_db_path, _project_id, _data_dir, task_manager, task_id| {
|
||||||
|
let execution_kind = match kind {
|
||||||
|
bds_core::model::ScriptKind::Macro => {
|
||||||
|
bds_core::scripting::ExecutionKind::Macro
|
||||||
|
}
|
||||||
|
bds_core::model::ScriptKind::Utility => {
|
||||||
|
bds_core::scripting::ExecutionKind::Utility
|
||||||
|
}
|
||||||
|
bds_core::model::ScriptKind::Transform => {
|
||||||
|
bds_core::scripting::ExecutionKind::Transform
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let result = bds_core::scripting::execute(
|
||||||
|
&source,
|
||||||
|
&entrypoint,
|
||||||
|
&serde_json::json!({}),
|
||||||
|
execution_kind,
|
||||||
|
&bds_core::scripting::ExecutionControl::default(),
|
||||||
|
)?;
|
||||||
|
for progress in &result.progress {
|
||||||
|
let percent = progress.total.and_then(|total| {
|
||||||
|
(total > 0.0).then_some((progress.current / total) as f32)
|
||||||
|
});
|
||||||
|
task_manager.report_progress(task_id, percent, progress.message.clone());
|
||||||
|
}
|
||||||
|
let mut lines = result.output;
|
||||||
|
lines.extend(
|
||||||
|
result
|
||||||
|
.toasts
|
||||||
|
.into_iter()
|
||||||
|
.map(|message| format!("toast: {message}")),
|
||||||
|
);
|
||||||
|
if result.value != serde_json::Value::Null {
|
||||||
|
lines.push(result.value.to_string());
|
||||||
|
}
|
||||||
|
Ok(if lines.is_empty() {
|
||||||
|
"completed".to_string()
|
||||||
|
} else {
|
||||||
|
lines.join("\n")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
}
|
||||||
412
crates/bds-ui/src/app/engine_handlers.rs
Normal file
412
crates/bds-ui/src/app/engine_handlers.rs
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl BdsApp {
|
||||||
|
pub(super) fn handle_engine_message(&mut self, message: Message) -> Task<Message> {
|
||||||
|
match message {
|
||||||
|
Message::RebuildDatabase => self.spawn_engine_task(
|
||||||
|
"engine.rebuildStarted",
|
||||||
|
|db_path, project_id, data_dir, tm, tid| {
|
||||||
|
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
||||||
|
let on_progress: engine::rebuild::ProgressFn = Arc::new(move |pct, msg| {
|
||||||
|
tm.report_progress(tid, Some(pct), Some(msg.to_string()));
|
||||||
|
});
|
||||||
|
let report = engine::rebuild::rebuild_from_filesystem_with_progress(
|
||||||
|
db.conn(),
|
||||||
|
&data_dir,
|
||||||
|
&project_id,
|
||||||
|
Some(on_progress),
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let posts = report.posts_created + report.posts_updated;
|
||||||
|
let media = report.media_created + report.media_updated;
|
||||||
|
let templates = report.templates_created + report.templates_updated;
|
||||||
|
let scripts = report.scripts_created + report.scripts_updated;
|
||||||
|
Ok(format!(
|
||||||
|
"posts={posts}, media={media}, templates={templates}, scripts={scripts}"
|
||||||
|
))
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Message::ReindexText => {
|
||||||
|
if !self.search_index_rebuild_running {
|
||||||
|
self.active_modal = Some(modal::ModalState::SearchIndexRepair);
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::RegenerateCalendar => {
|
||||||
|
let locale = self.ui_locale;
|
||||||
|
self.spawn_engine_task(
|
||||||
|
"engine.calendarStarted",
|
||||||
|
move |db_path, project_id, data_dir, tm, tid| {
|
||||||
|
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
||||||
|
tm.report_progress(tid, Some(0.20), Some(t(locale, "engine.loadingPosts")));
|
||||||
|
engine::calendar::regenerate_calendar(db.conn(), &data_dir, &project_id)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
tm.report_progress(
|
||||||
|
tid,
|
||||||
|
Some(0.90),
|
||||||
|
Some(t(locale, "engine.writingCalendar")),
|
||||||
|
);
|
||||||
|
Ok("done".to_string())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Message::ValidateTranslations => {
|
||||||
|
self.open_singleton_tab(
|
||||||
|
TabType::TranslationValidation,
|
||||||
|
"tabBar.translationValidation",
|
||||||
|
);
|
||||||
|
let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
self.translation_validation_state.is_running = true;
|
||||||
|
self.translation_validation_state.error_message = None;
|
||||||
|
let db_path = self.db_path.clone();
|
||||||
|
let project_id = project.id.clone();
|
||||||
|
let data_dir = data_dir.clone();
|
||||||
|
Task::perform(
|
||||||
|
async move {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
||||||
|
let meta = engine::meta::read_project_json(&data_dir)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let main_lang = meta.main_language.as_deref().unwrap_or("en");
|
||||||
|
let blog_langs = meta.blog_languages.clone();
|
||||||
|
let on_item: engine::validate_translations::ItemProgressFn =
|
||||||
|
Box::new(move |_current, _total, _name| {});
|
||||||
|
engine::validate_translations::validate_translations_with_progress(
|
||||||
|
db.conn(),
|
||||||
|
&data_dir,
|
||||||
|
&project_id,
|
||||||
|
&blog_langs,
|
||||||
|
main_lang,
|
||||||
|
Some(on_item),
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
|
||||||
|
},
|
||||||
|
Message::TranslationValidationLoaded,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Message::TranslationValidationLoaded(result) => {
|
||||||
|
self.translation_validation_state.is_running = false;
|
||||||
|
match result {
|
||||||
|
Ok(report) => {
|
||||||
|
self.translation_validation_state.report = Some(report);
|
||||||
|
self.translation_validation_state.error_message = None;
|
||||||
|
}
|
||||||
|
Err(error) => self.translation_validation_state.error_message = Some(error),
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::ValidateMedia => {
|
||||||
|
let locale = self.ui_locale;
|
||||||
|
self.spawn_engine_task(
|
||||||
|
"engine.validateMediaStarted",
|
||||||
|
move |db_path, project_id, _data_dir, tm, tid| {
|
||||||
|
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
||||||
|
let on_item: engine::validate_media::ProgressFn =
|
||||||
|
Box::new(move |current, total, name| {
|
||||||
|
let pct = if total > 0 {
|
||||||
|
current as f32 / total as f32
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
let msg = tw(
|
||||||
|
locale,
|
||||||
|
"engine.checkingItem",
|
||||||
|
&[
|
||||||
|
("current", ¤t.to_string()),
|
||||||
|
("total", &total.to_string()),
|
||||||
|
("name", name),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
tm.report_progress(tid, Some(pct), Some(msg));
|
||||||
|
});
|
||||||
|
let report = engine::validate_media::validate_media(
|
||||||
|
db.conn(),
|
||||||
|
&_data_dir,
|
||||||
|
&project_id,
|
||||||
|
Some(on_item),
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(format!(
|
||||||
|
"checked={}, issues={}",
|
||||||
|
report.total_checked,
|
||||||
|
report.issues.len()
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Message::GenerateSite => {
|
||||||
|
let locale = self.ui_locale;
|
||||||
|
self.spawn_engine_task(
|
||||||
|
"engine.generateSiteStarted",
|
||||||
|
move |db_path, project_id, data_dir, tm, tid| {
|
||||||
|
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
||||||
|
let metadata = engine::meta::read_project_json(&data_dir)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if metadata
|
||||||
|
.public_url
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
|
return Err(
|
||||||
|
"public URL is required before generating the site".to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let main_language = metadata
|
||||||
|
.main_language
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "en".to_string());
|
||||||
|
let all_posts = bds_core::db::queries::post::list_posts_by_project(
|
||||||
|
db.conn(),
|
||||||
|
&project_id,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let published_posts = all_posts
|
||||||
|
.into_iter()
|
||||||
|
.filter(engine::generation::has_published_snapshot)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let total = published_posts.len().max(1) as f32;
|
||||||
|
let mut sources = Vec::new();
|
||||||
|
for (index, post) in published_posts.into_iter().enumerate() {
|
||||||
|
if tm.is_cancelled(tid) {
|
||||||
|
return Err("cancelled".to_string());
|
||||||
|
}
|
||||||
|
tm.report_progress(
|
||||||
|
tid,
|
||||||
|
Some(((index as f32) / total) * 0.7),
|
||||||
|
Some(tw(locale, "engine.renderingPost", &[("post", &post.slug)])),
|
||||||
|
);
|
||||||
|
if let Some(source) =
|
||||||
|
engine::generation::load_published_post_source(&data_dir, post)
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
{
|
||||||
|
sources.push(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let output_dir = data_dir.join("html");
|
||||||
|
std::fs::create_dir_all(&output_dir).map_err(|e| e.to_string())?;
|
||||||
|
tm.report_progress(
|
||||||
|
tid,
|
||||||
|
Some(0.85),
|
||||||
|
Some(t(locale, "engine.writingGeneratedFiles")),
|
||||||
|
);
|
||||||
|
let progress_start = 0.70_f32;
|
||||||
|
let progress_span = 0.28_f32;
|
||||||
|
let task_manager = Arc::clone(&tm);
|
||||||
|
let report = engine::generation::generate_starter_site_with_progress(
|
||||||
|
db.conn(),
|
||||||
|
&output_dir,
|
||||||
|
&project_id,
|
||||||
|
&metadata,
|
||||||
|
&sources,
|
||||||
|
&main_language,
|
||||||
|
move |current, total, path| {
|
||||||
|
let fraction = if total == 0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
current as f32 / total as f32
|
||||||
|
};
|
||||||
|
task_manager.report_progress(
|
||||||
|
tid,
|
||||||
|
Some(progress_start + fraction * progress_span),
|
||||||
|
Some(tw(
|
||||||
|
locale,
|
||||||
|
"engine.generatedPage",
|
||||||
|
&[
|
||||||
|
("url", path),
|
||||||
|
("current", ¤t.to_string()),
|
||||||
|
("total", &total.to_string()),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
tm.report_progress(
|
||||||
|
tid,
|
||||||
|
Some(1.0),
|
||||||
|
Some(t(locale, "engine.generationComplete")),
|
||||||
|
);
|
||||||
|
Ok(format!(
|
||||||
|
"written={}, skipped={}, output={}",
|
||||||
|
report.written_paths.len(),
|
||||||
|
report.skipped_paths.len(),
|
||||||
|
output_dir.display(),
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Message::RunMetadataDiff => {
|
||||||
|
self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff");
|
||||||
|
self.start_metadata_diff()
|
||||||
|
}
|
||||||
|
Message::MetadataDiffLoaded(result) => {
|
||||||
|
self.metadata_diff_state.is_running = false;
|
||||||
|
match result {
|
||||||
|
Ok(report) => {
|
||||||
|
self.metadata_diff_state.report = Some(report);
|
||||||
|
self.metadata_diff_state.error_message = None;
|
||||||
|
}
|
||||||
|
Err(error) => self.metadata_diff_state.error_message = Some(error),
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::RepairMetadataDiffItem { index, direction } => {
|
||||||
|
let Some(item) = self
|
||||||
|
.metadata_diff_state
|
||||||
|
.report
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|report| report.diffs.get(index))
|
||||||
|
.cloned()
|
||||||
|
else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
let (Some(project), Some(data_dir)) =
|
||||||
|
(self.active_project.as_ref(), self.data_dir.as_ref())
|
||||||
|
else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
self.metadata_diff_state.is_repairing = true;
|
||||||
|
let db_path = self.db_path.clone();
|
||||||
|
let project_id = project.id.clone();
|
||||||
|
let data_dir = data_dir.clone();
|
||||||
|
Task::perform(
|
||||||
|
async move {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
||||||
|
engine::metadata_diff::repair_metadata_diff_item(
|
||||||
|
db.conn(),
|
||||||
|
&data_dir,
|
||||||
|
&project_id,
|
||||||
|
direction,
|
||||||
|
&item,
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
},
|
||||||
|
Message::MetadataDiffItemRepaired,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Message::MetadataDiffItemRepaired(result) => {
|
||||||
|
self.metadata_diff_state.is_repairing = false;
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
self.notify(
|
||||||
|
ToastLevel::Success,
|
||||||
|
&t(self.ui_locale, "metadataDiff.repaired"),
|
||||||
|
);
|
||||||
|
self.start_metadata_diff()
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.notify(ToastLevel::Error, &error);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::RunSiteValidation => self.start_site_validation(),
|
||||||
|
Message::ApplySiteValidation => self.apply_site_validation(),
|
||||||
|
Message::EngineTaskDone {
|
||||||
|
task_id,
|
||||||
|
label,
|
||||||
|
result,
|
||||||
|
} => {
|
||||||
|
let search_rebuild_finished = self.search_index_rebuild_task_id == Some(task_id);
|
||||||
|
let cancelled = self.task_manager.status(task_id) == Some(TaskStatus::Cancelled);
|
||||||
|
match &result {
|
||||||
|
_ if cancelled => {}
|
||||||
|
Ok(detail) => {
|
||||||
|
self.task_manager.complete(task_id);
|
||||||
|
self.notify(ToastLevel::Success, &format!("{label}: {detail}"));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
self.task_manager.fail(task_id, err.clone());
|
||||||
|
let message = tw(
|
||||||
|
self.ui_locale,
|
||||||
|
"common.operationFailed",
|
||||||
|
&[("operation", &label), ("error", err)],
|
||||||
|
);
|
||||||
|
self.notify(ToastLevel::Error, &message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if search_rebuild_finished {
|
||||||
|
self.search_index_rebuild_running = false;
|
||||||
|
self.search_index_rebuild_task_id = None;
|
||||||
|
self.active_modal = None;
|
||||||
|
if result.is_ok() {
|
||||||
|
self.search_index_rebuild_required = false;
|
||||||
|
}
|
||||||
|
self.sync_menu_state();
|
||||||
|
}
|
||||||
|
let sidebar_task = self.refresh_counts();
|
||||||
|
self.refresh_task_snapshots();
|
||||||
|
sidebar_task
|
||||||
|
}
|
||||||
|
Message::SiteValidationLoaded(result) => {
|
||||||
|
self.site_validation_state.is_running = false;
|
||||||
|
self.site_validation_state.has_run = true;
|
||||||
|
match result {
|
||||||
|
Ok(report) => {
|
||||||
|
self.site_validation_state.error_message = None;
|
||||||
|
self.site_validation_state.missing_files = report.missing_pages;
|
||||||
|
self.site_validation_state.extra_files = report.extra_pages;
|
||||||
|
self.site_validation_state.stale_files = report.stale_pages;
|
||||||
|
self.notify(
|
||||||
|
ToastLevel::Success,
|
||||||
|
&tw(
|
||||||
|
self.ui_locale,
|
||||||
|
"siteValidation.summary",
|
||||||
|
&[
|
||||||
|
("label", &t(self.ui_locale, "tabBar.siteValidation")),
|
||||||
|
(
|
||||||
|
"missing",
|
||||||
|
&self.site_validation_state.missing_files.len().to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"extra",
|
||||||
|
&self.site_validation_state.extra_files.len().to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"stale",
|
||||||
|
&self.site_validation_state.stale_files.len().to_string(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.site_validation_state.error_message = Some(error.clone());
|
||||||
|
self.site_validation_state.missing_files.clear();
|
||||||
|
self.site_validation_state.extra_files.clear();
|
||||||
|
self.site_validation_state.stale_files.clear();
|
||||||
|
self.notify(ToastLevel::Error, &error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::SiteValidationApplied(result) => {
|
||||||
|
self.site_validation_state.is_applying = false;
|
||||||
|
match result {
|
||||||
|
Ok(detail) => {
|
||||||
|
self.site_validation_state.error_message = None;
|
||||||
|
self.notify(ToastLevel::Success, &detail);
|
||||||
|
self.start_site_validation()
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.site_validation_state.error_message = Some(error.clone());
|
||||||
|
self.notify(ToastLevel::Error, &error);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => unreachable!("non-engine message routed to engine handler"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
crates/bds-ui/src/app/preview_handlers.rs
Normal file
35
crates/bds-ui/src/app/preview_handlers.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl BdsApp {
|
||||||
|
pub(super) fn handle_preview_message(&mut self, message: Message) -> Task<Message> {
|
||||||
|
match message {
|
||||||
|
Message::MainWindowLoaded(window_id) => {
|
||||||
|
self.main_window_id = window_id;
|
||||||
|
if self.active_post_uses_embedded_preview() {
|
||||||
|
self.sync_embedded_preview_for_active_post()
|
||||||
|
} else {
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::EmbeddedPreviewReady(result) => {
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
let visible = self.active_post_uses_embedded_preview();
|
||||||
|
if let Some(preview) = &mut self.embedded_preview {
|
||||||
|
preview.controller.take_staged();
|
||||||
|
if let Some(url) = preview.current_url.as_deref() {
|
||||||
|
preview.controller.navigate(url);
|
||||||
|
}
|
||||||
|
preview.controller.set_visible(visible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.notify(ToastLevel::Error, &error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
_ => unreachable!("non-preview message routed to preview handler"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
584
crates/bds-ui/src/app/search.rs
Normal file
584
crates/bds-ui/src/app/search.rs
Normal file
@@ -0,0 +1,584 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl BdsApp {
|
||||||
|
/// Number of items to load per sidebar page.
|
||||||
|
/// Matches the TypeScript app's limit of 500 for initial load.
|
||||||
|
pub(super) const SIDEBAR_PAGE_SIZE: i64 = 500;
|
||||||
|
|
||||||
|
/// Refresh only sidebar posts using current filter state (async).
|
||||||
|
pub(super) fn refresh_sidebar_posts(&mut self) -> Task<Message> {
|
||||||
|
let (Some(_), Some(project)) = (&self.db, &self.active_project) else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
|
||||||
|
let db_path = self.db_path.clone();
|
||||||
|
let project_id = project.id.clone();
|
||||||
|
let content_language = self.content_language.clone();
|
||||||
|
let filter = match self.sidebar_view {
|
||||||
|
SidebarView::Pages => self.page_filter.clone(),
|
||||||
|
_ => self.post_filter.clone(),
|
||||||
|
};
|
||||||
|
let is_pages = self.sidebar_view == SidebarView::Pages;
|
||||||
|
|
||||||
|
Task::perform(
|
||||||
|
async move {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
Self::query_sidebar_posts_blocking(
|
||||||
|
&db_path,
|
||||||
|
&project_id,
|
||||||
|
&content_language,
|
||||||
|
&filter,
|
||||||
|
is_pages,
|
||||||
|
Self::SIDEBAR_PAGE_SIZE + 1,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
},
|
||||||
|
Message::SidebarPostsLoaded,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh only sidebar media using current filter state (async).
|
||||||
|
pub(super) fn refresh_sidebar_media(&mut self) -> Task<Message> {
|
||||||
|
let (Some(_), Some(project)) = (&self.db, &self.active_project) else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
|
||||||
|
let db_path = self.db_path.clone();
|
||||||
|
let project_id = project.id.clone();
|
||||||
|
let data_dir = self.data_dir.clone();
|
||||||
|
let content_language = self.content_language.clone();
|
||||||
|
let filter = self.media_filter.clone();
|
||||||
|
|
||||||
|
Task::perform(
|
||||||
|
async move {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let items = Self::query_sidebar_media_blocking(
|
||||||
|
&db_path,
|
||||||
|
&project_id,
|
||||||
|
&content_language,
|
||||||
|
&filter,
|
||||||
|
Self::SIDEBAR_PAGE_SIZE + 1,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pre-resolve thumbnail paths off the main thread
|
||||||
|
let thumbs: HashMap<String, Option<std::path::PathBuf>> = items
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
let thumb = data_dir.as_ref().and_then(|dir| {
|
||||||
|
if !m.mime_type.starts_with("image/") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let rel =
|
||||||
|
bds_core::util::paths::thumbnail_path(&m.id, "small", "webp");
|
||||||
|
let full = dir.join(&rel);
|
||||||
|
if full.exists() { Some(full) } else { None }
|
||||||
|
});
|
||||||
|
(m.id.clone(), thumb)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
(items, thumbs)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
},
|
||||||
|
|(items, thumbs)| Message::SidebarMediaLoaded { items, thumbs },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load more posts (append to existing sidebar data).
|
||||||
|
pub(super) fn load_more_sidebar_posts(&mut self) -> Task<Message> {
|
||||||
|
let (Some(_), Some(project)) = (&self.db, &self.active_project) else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
|
||||||
|
let db_path = self.db_path.clone();
|
||||||
|
let project_id = project.id.clone();
|
||||||
|
let content_language = self.content_language.clone();
|
||||||
|
let offset = self.sidebar_posts.len() as i64;
|
||||||
|
let filter = match self.sidebar_view {
|
||||||
|
SidebarView::Pages => self.page_filter.clone(),
|
||||||
|
_ => self.post_filter.clone(),
|
||||||
|
};
|
||||||
|
let is_pages = self.sidebar_view == SidebarView::Pages;
|
||||||
|
|
||||||
|
Task::perform(
|
||||||
|
async move {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
Self::query_sidebar_posts_blocking(
|
||||||
|
&db_path,
|
||||||
|
&project_id,
|
||||||
|
&content_language,
|
||||||
|
&filter,
|
||||||
|
is_pages,
|
||||||
|
Self::SIDEBAR_PAGE_SIZE + 1,
|
||||||
|
offset,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
},
|
||||||
|
Message::SidebarPostsAppended,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn parse_filter_date(input: &str, end_of_day: bool) -> Option<i64> {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let date = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d").ok()?;
|
||||||
|
let time = if end_of_day {
|
||||||
|
date.and_hms_opt(23, 59, 59)?
|
||||||
|
} else {
|
||||||
|
date.and_hms_opt(0, 0, 0)?
|
||||||
|
};
|
||||||
|
Some(time.and_utc().timestamp_millis())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_post_filter_params(
|
||||||
|
filter: &PostFilter,
|
||||||
|
is_pages: bool,
|
||||||
|
) -> bds_core::db::queries::post::PostFilterParams {
|
||||||
|
bds_core::db::queries::post::PostFilterParams {
|
||||||
|
search_query: filter.search_query.clone(),
|
||||||
|
status: filter.status_filter.clone(),
|
||||||
|
language: filter.language_filter.clone(),
|
||||||
|
year: filter.calendar.selected_year,
|
||||||
|
month: filter.calendar.selected_month,
|
||||||
|
from: Self::parse_filter_date(&filter.from_date, false),
|
||||||
|
to: Self::parse_filter_date(&filter.to_date, true),
|
||||||
|
tags: filter.tag_filter.clone(),
|
||||||
|
categories: filter.category_filter.clone(),
|
||||||
|
exclude_pages: !is_pages,
|
||||||
|
pages_only: is_pages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn query_sidebar_posts_blocking(
|
||||||
|
db_path: &Path,
|
||||||
|
project_id: &str,
|
||||||
|
content_language: &str,
|
||||||
|
filter: &PostFilter,
|
||||||
|
is_pages: bool,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Vec<Post> {
|
||||||
|
let Ok(db) = Database::open(db_path) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let params = Self::build_post_filter_params(filter, is_pages);
|
||||||
|
if filter.search_query.trim().is_empty() {
|
||||||
|
return bds_core::db::queries::post::list_posts_filtered(
|
||||||
|
db.conn(),
|
||||||
|
project_id,
|
||||||
|
¶ms,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
|
||||||
|
let fts_filters = bds_core::db::fts::PostSearchFilters {
|
||||||
|
status: params.status.as_deref(),
|
||||||
|
tags: (!params.tags.is_empty()).then_some(params.tags.as_slice()),
|
||||||
|
categories: (!params.categories.is_empty()).then_some(params.categories.as_slice()),
|
||||||
|
language: params.language.as_deref(),
|
||||||
|
year: params.year,
|
||||||
|
month: params.month,
|
||||||
|
from: params.from,
|
||||||
|
to: params.to,
|
||||||
|
limit: Some(limit as usize),
|
||||||
|
offset: Some(offset as usize),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let ids = bds_core::db::fts::search_posts_filtered(
|
||||||
|
db.conn(),
|
||||||
|
¶ms.search_query,
|
||||||
|
content_language,
|
||||||
|
&fts_filters,
|
||||||
|
)
|
||||||
|
.map(|results| results.post_ids)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
ids.into_iter()
|
||||||
|
.filter_map(|post_id| {
|
||||||
|
bds_core::db::queries::post::get_post_by_id(db.conn(), &post_id).ok()
|
||||||
|
})
|
||||||
|
.filter(|post| post.project_id == project_id)
|
||||||
|
.filter(|post| {
|
||||||
|
let is_page_post = post
|
||||||
|
.categories
|
||||||
|
.iter()
|
||||||
|
.any(|category| category.eq_ignore_ascii_case("page"));
|
||||||
|
if is_pages {
|
||||||
|
is_page_post
|
||||||
|
} else {
|
||||||
|
!is_page_post
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn query_sidebar_media_blocking(
|
||||||
|
db_path: &Path,
|
||||||
|
project_id: &str,
|
||||||
|
content_language: &str,
|
||||||
|
filter: &MediaFilter,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Vec<Media> {
|
||||||
|
let Ok(db) = Database::open(db_path) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let params = bds_core::db::queries::media::MediaFilterParams {
|
||||||
|
search_query: filter.search_query.clone(),
|
||||||
|
year: filter.calendar.selected_year,
|
||||||
|
month: filter.calendar.selected_month,
|
||||||
|
tags: filter.tag_filter.clone(),
|
||||||
|
};
|
||||||
|
if filter.search_query.trim().is_empty() {
|
||||||
|
return bds_core::db::queries::media::list_media_filtered(
|
||||||
|
db.conn(),
|
||||||
|
project_id,
|
||||||
|
¶ms,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
|
||||||
|
let filters = bds_core::db::fts::MediaSearchFilters {
|
||||||
|
project_id: Some(project_id),
|
||||||
|
tags: (!params.tags.is_empty()).then_some(params.tags.as_slice()),
|
||||||
|
year: params.year,
|
||||||
|
month: params.month,
|
||||||
|
limit: Some(limit as usize),
|
||||||
|
offset: Some(offset as usize),
|
||||||
|
};
|
||||||
|
bds_core::db::fts::search_media_filtered(
|
||||||
|
db.conn(),
|
||||||
|
¶ms.search_query,
|
||||||
|
content_language,
|
||||||
|
&filters,
|
||||||
|
)
|
||||||
|
.map(|results| results.media_ids)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|media_id| {
|
||||||
|
bds_core::db::queries::media::get_media_by_id(db.conn(), &media_id).ok()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn regenerate_project_thumbnails(
|
||||||
|
db: &Database,
|
||||||
|
data_dir: &Path,
|
||||||
|
project_id: &str,
|
||||||
|
is_cancelled: impl Fn() -> bool,
|
||||||
|
mut progress: impl FnMut(usize, usize, &str),
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
let media = bds_core::db::queries::media::list_media_by_project(db.conn(), project_id)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let total = media.len();
|
||||||
|
let thumbnails_dir = data_dir.join("thumbnails");
|
||||||
|
let mut regenerated = 0usize;
|
||||||
|
|
||||||
|
for (index, item) in media.iter().enumerate() {
|
||||||
|
if is_cancelled() {
|
||||||
|
return Err("cancelled".into());
|
||||||
|
}
|
||||||
|
progress(index, total, &item.original_name);
|
||||||
|
if !item.mime_type.starts_with("image/") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let source = data_dir.join(&item.file_path);
|
||||||
|
if !source.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
bds_core::util::thumbnail::generate_all_thumbnails(&source, &thumbnails_dir, &item.id)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
regenerated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(regenerated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load more media (append to existing sidebar data).
|
||||||
|
pub(super) fn load_more_sidebar_media(&mut self) -> Task<Message> {
|
||||||
|
let (Some(_), Some(project)) = (&self.db, &self.active_project) else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
|
||||||
|
let db_path = self.db_path.clone();
|
||||||
|
let project_id = project.id.clone();
|
||||||
|
let offset = self.sidebar_media.len() as i64;
|
||||||
|
let data_dir = self.data_dir.clone();
|
||||||
|
let content_language = self.content_language.clone();
|
||||||
|
let filter = self.media_filter.clone();
|
||||||
|
|
||||||
|
Task::perform(
|
||||||
|
async move {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let items = Self::query_sidebar_media_blocking(
|
||||||
|
&db_path,
|
||||||
|
&project_id,
|
||||||
|
&content_language,
|
||||||
|
&filter,
|
||||||
|
Self::SIDEBAR_PAGE_SIZE + 1,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
|
||||||
|
let thumbs: HashMap<String, Option<std::path::PathBuf>> = items
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
let thumb = data_dir.as_ref().and_then(|dir| {
|
||||||
|
if !m.mime_type.starts_with("image/") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let rel =
|
||||||
|
bds_core::util::paths::thumbnail_path(&m.id, "small", "webp");
|
||||||
|
let full = dir.join(&rel);
|
||||||
|
if full.exists() { Some(full) } else { None }
|
||||||
|
});
|
||||||
|
(m.id.clone(), thumb)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
(items, thumbs)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
},
|
||||||
|
|(items, thumbs)| Message::SidebarMediaAppended { items, thumbs },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh available tags, categories, and calendar data for filter widgets.
|
||||||
|
pub(super) fn refresh_filter_metadata(&mut self) {
|
||||||
|
if let (Some(db), Some(project)) = (&self.db, &self.active_project) {
|
||||||
|
use bds_core::db::queries::media;
|
||||||
|
use bds_core::db::queries::post;
|
||||||
|
|
||||||
|
// Post filter metadata
|
||||||
|
let all_tags = post::distinct_post_tags(db.conn(), &project.id).unwrap_or_default();
|
||||||
|
let all_cats =
|
||||||
|
post::distinct_post_categories(db.conn(), &project.id).unwrap_or_default();
|
||||||
|
|
||||||
|
// Calendar counts for posts (excluding pages)
|
||||||
|
let post_cal =
|
||||||
|
post::post_calendar_counts(db.conn(), &project.id, false, true).unwrap_or_default();
|
||||||
|
self.post_filter.available_tags = all_tags.clone();
|
||||||
|
self.post_filter.available_categories = all_cats.clone();
|
||||||
|
self.post_filter.available_languages = self.blog_languages.clone();
|
||||||
|
self.post_filter.calendar_years = Self::build_calendar_tree(&post_cal);
|
||||||
|
|
||||||
|
// Calendar counts for pages only
|
||||||
|
let page_cal =
|
||||||
|
post::post_calendar_counts(db.conn(), &project.id, true, false).unwrap_or_default();
|
||||||
|
self.page_filter.available_tags = all_tags;
|
||||||
|
self.page_filter.available_categories = all_cats;
|
||||||
|
self.page_filter.available_languages = self.blog_languages.clone();
|
||||||
|
self.page_filter.calendar_years = Self::build_calendar_tree(&page_cal);
|
||||||
|
|
||||||
|
// Media filter metadata
|
||||||
|
self.media_filter.available_tags =
|
||||||
|
media::distinct_media_tags(db.conn(), &project.id).unwrap_or_default();
|
||||||
|
let media_cal =
|
||||||
|
media::media_calendar_counts(db.conn(), &project.id).unwrap_or_default();
|
||||||
|
self.media_filter.calendar_years = Self::build_calendar_tree(&media_cal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert (year, month, count) tuples into CalendarYear/CalendarMonth tree.
|
||||||
|
pub(super) fn build_calendar_tree(data: &[(i32, u32, usize)]) -> Vec<CalendarYear> {
|
||||||
|
let mut years: Vec<CalendarYear> = Vec::new();
|
||||||
|
for &(y, m, c) in data {
|
||||||
|
if let Some(cy) = years.iter_mut().find(|cy| cy.year == y) {
|
||||||
|
cy.months.push(CalendarMonth { month: m, count: c });
|
||||||
|
} else {
|
||||||
|
years.push(CalendarYear {
|
||||||
|
year: y,
|
||||||
|
months: vec![CalendarMonth { month: m, count: c }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
years
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_sidebar_message(&mut self, message: Message) -> Task<Message> {
|
||||||
|
match message {
|
||||||
|
Message::PostSearchChanged(query) => {
|
||||||
|
if self.search_index_rebuild_required && !query.is_empty() {
|
||||||
|
self.active_modal = Some(modal::ModalState::SearchIndexRepair);
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
let filter = match self.sidebar_view {
|
||||||
|
SidebarView::Pages => &mut self.page_filter,
|
||||||
|
_ => &mut self.post_filter,
|
||||||
|
};
|
||||||
|
filter.search_query = query;
|
||||||
|
self.refresh_sidebar_posts()
|
||||||
|
}
|
||||||
|
Message::TogglePostFilterPanel => {
|
||||||
|
let filter = match self.sidebar_view {
|
||||||
|
SidebarView::Pages => &mut self.page_filter,
|
||||||
|
_ => &mut self.post_filter,
|
||||||
|
};
|
||||||
|
filter.filter_panel_visible = !filter.filter_panel_visible;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::SetPostStatusFilter(status) => {
|
||||||
|
let filter = match self.sidebar_view {
|
||||||
|
SidebarView::Pages => &mut self.page_filter,
|
||||||
|
_ => &mut self.post_filter,
|
||||||
|
};
|
||||||
|
filter.status_filter = status;
|
||||||
|
self.refresh_sidebar_posts()
|
||||||
|
}
|
||||||
|
Message::SetPostLanguageFilter(language) => {
|
||||||
|
let filter = match self.sidebar_view {
|
||||||
|
SidebarView::Pages => &mut self.page_filter,
|
||||||
|
_ => &mut self.post_filter,
|
||||||
|
};
|
||||||
|
filter.language_filter = language;
|
||||||
|
self.refresh_sidebar_posts()
|
||||||
|
}
|
||||||
|
Message::SetPostCalendarYear(year) => {
|
||||||
|
let filter = match self.sidebar_view {
|
||||||
|
SidebarView::Pages => &mut self.page_filter,
|
||||||
|
_ => &mut self.post_filter,
|
||||||
|
};
|
||||||
|
filter.calendar.selected_year = year;
|
||||||
|
filter.calendar.selected_month = None;
|
||||||
|
self.refresh_sidebar_posts()
|
||||||
|
}
|
||||||
|
Message::SetPostCalendarMonth(month) => {
|
||||||
|
let filter = match self.sidebar_view {
|
||||||
|
SidebarView::Pages => &mut self.page_filter,
|
||||||
|
_ => &mut self.post_filter,
|
||||||
|
};
|
||||||
|
filter.calendar.selected_month = month;
|
||||||
|
self.refresh_sidebar_posts()
|
||||||
|
}
|
||||||
|
Message::SetPostFromDate(value) => {
|
||||||
|
let filter = match self.sidebar_view {
|
||||||
|
SidebarView::Pages => &mut self.page_filter,
|
||||||
|
_ => &mut self.post_filter,
|
||||||
|
};
|
||||||
|
filter.from_date = value;
|
||||||
|
self.refresh_sidebar_posts()
|
||||||
|
}
|
||||||
|
Message::SetPostToDate(value) => {
|
||||||
|
let filter = match self.sidebar_view {
|
||||||
|
SidebarView::Pages => &mut self.page_filter,
|
||||||
|
_ => &mut self.post_filter,
|
||||||
|
};
|
||||||
|
filter.to_date = value;
|
||||||
|
self.refresh_sidebar_posts()
|
||||||
|
}
|
||||||
|
Message::TogglePostTagFilter(tag) => {
|
||||||
|
let filter = match self.sidebar_view {
|
||||||
|
SidebarView::Pages => &mut self.page_filter,
|
||||||
|
_ => &mut self.post_filter,
|
||||||
|
};
|
||||||
|
if let Some(pos) = filter.tag_filter.iter().position(|t| *t == tag) {
|
||||||
|
filter.tag_filter.remove(pos);
|
||||||
|
} else {
|
||||||
|
filter.tag_filter.push(tag);
|
||||||
|
}
|
||||||
|
self.refresh_sidebar_posts()
|
||||||
|
}
|
||||||
|
Message::TogglePostCategoryFilter(cat) => {
|
||||||
|
let filter = match self.sidebar_view {
|
||||||
|
SidebarView::Pages => &mut self.page_filter,
|
||||||
|
_ => &mut self.post_filter,
|
||||||
|
};
|
||||||
|
if let Some(pos) = filter.category_filter.iter().position(|c| *c == cat) {
|
||||||
|
filter.category_filter.remove(pos);
|
||||||
|
} else {
|
||||||
|
filter.category_filter.push(cat);
|
||||||
|
}
|
||||||
|
self.refresh_sidebar_posts()
|
||||||
|
}
|
||||||
|
Message::ClearPostFilters => {
|
||||||
|
let filter = match self.sidebar_view {
|
||||||
|
SidebarView::Pages => &mut self.page_filter,
|
||||||
|
_ => &mut self.post_filter,
|
||||||
|
};
|
||||||
|
filter.clear();
|
||||||
|
self.refresh_sidebar_posts()
|
||||||
|
}
|
||||||
|
Message::MediaSearchChanged(query) => {
|
||||||
|
if self.search_index_rebuild_required && !query.is_empty() {
|
||||||
|
self.active_modal = Some(modal::ModalState::SearchIndexRepair);
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
self.media_filter.search_query = query;
|
||||||
|
self.refresh_sidebar_media()
|
||||||
|
}
|
||||||
|
Message::ToggleMediaFilterPanel => {
|
||||||
|
self.media_filter.filter_panel_visible = !self.media_filter.filter_panel_visible;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::SetMediaCalendarYear(year) => {
|
||||||
|
self.media_filter.calendar.selected_year = year;
|
||||||
|
self.media_filter.calendar.selected_month = None;
|
||||||
|
self.refresh_sidebar_media()
|
||||||
|
}
|
||||||
|
Message::SetMediaCalendarMonth(month) => {
|
||||||
|
self.media_filter.calendar.selected_month = month;
|
||||||
|
self.refresh_sidebar_media()
|
||||||
|
}
|
||||||
|
Message::ToggleMediaTagFilter(tag) => {
|
||||||
|
if let Some(pos) = self.media_filter.tag_filter.iter().position(|t| *t == tag) {
|
||||||
|
self.media_filter.tag_filter.remove(pos);
|
||||||
|
} else {
|
||||||
|
self.media_filter.tag_filter.push(tag);
|
||||||
|
}
|
||||||
|
self.refresh_sidebar_media()
|
||||||
|
}
|
||||||
|
Message::ClearMediaFilters => {
|
||||||
|
self.media_filter.clear();
|
||||||
|
self.refresh_sidebar_media()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Async sidebar data ──
|
||||||
|
Message::SidebarPostsLoaded(mut posts) => {
|
||||||
|
self.sidebar_posts_has_more = posts.len() > Self::SIDEBAR_PAGE_SIZE as usize;
|
||||||
|
posts.truncate(Self::SIDEBAR_PAGE_SIZE as usize);
|
||||||
|
self.sidebar_posts = posts;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::SidebarMediaLoaded { mut items, thumbs } => {
|
||||||
|
self.sidebar_media_has_more = items.len() > Self::SIDEBAR_PAGE_SIZE as usize;
|
||||||
|
items.truncate(Self::SIDEBAR_PAGE_SIZE as usize);
|
||||||
|
self.sidebar_media = items;
|
||||||
|
self.sidebar_media_thumbs = thumbs;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::SidebarPostsAppended(mut posts) => {
|
||||||
|
self.sidebar_posts_has_more = posts.len() > Self::SIDEBAR_PAGE_SIZE as usize;
|
||||||
|
posts.truncate(Self::SIDEBAR_PAGE_SIZE as usize);
|
||||||
|
self.sidebar_posts.extend(posts);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::SidebarMediaAppended { mut items, thumbs } => {
|
||||||
|
self.sidebar_media_has_more = items.len() > Self::SIDEBAR_PAGE_SIZE as usize;
|
||||||
|
items.truncate(Self::SIDEBAR_PAGE_SIZE as usize);
|
||||||
|
self.sidebar_media.extend(items);
|
||||||
|
self.sidebar_media_thumbs.extend(thumbs);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::LoadMorePosts => self.load_more_sidebar_posts(),
|
||||||
|
Message::LoadMoreMedia => self.load_more_sidebar_media(),
|
||||||
|
_ => unreachable!("non-sidebar message routed to sidebar handler"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
187
crates/bds-ui/src/app/tasks.rs
Normal file
187
crates/bds-ui/src/app/tasks.rs
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl BdsApp {
|
||||||
|
pub(super) fn refresh_task_snapshots(&mut self) {
|
||||||
|
self.task_snapshots = self
|
||||||
|
.task_manager
|
||||||
|
.snapshots()
|
||||||
|
.into_iter()
|
||||||
|
.map(|snapshot| {
|
||||||
|
let status_str = match &snapshot.status {
|
||||||
|
TaskStatus::Pending => t(self.ui_locale, "tasks.statusPending"),
|
||||||
|
TaskStatus::Running => t(self.ui_locale, "tasks.statusRunning"),
|
||||||
|
TaskStatus::Completed => t(self.ui_locale, "tasks.statusCompleted"),
|
||||||
|
TaskStatus::Failed(error) => {
|
||||||
|
tw(self.ui_locale, "tasks.statusFailed", &[("error", error)])
|
||||||
|
}
|
||||||
|
TaskStatus::Cancelled => t(self.ui_locale, "tasks.statusCancelled"),
|
||||||
|
};
|
||||||
|
TaskSnapshot {
|
||||||
|
id: snapshot.id,
|
||||||
|
label: snapshot.label,
|
||||||
|
group_id: snapshot.group_id,
|
||||||
|
group_name: snapshot.group_name,
|
||||||
|
status: status_str,
|
||||||
|
progress: snapshot.progress,
|
||||||
|
message: snapshot.message,
|
||||||
|
is_cancellable: matches!(
|
||||||
|
snapshot.status,
|
||||||
|
TaskStatus::Pending | TaskStatus::Running
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
/// Rebuild the shared search index while the modal blocks editor writes.
|
||||||
|
pub(super) fn start_search_index_rebuild(&mut self) -> Task<Message> {
|
||||||
|
if self.db.is_none() || self.search_index_rebuild_running {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
if self.task_manager.running_count() > 0 || self.task_manager.pending_count() > 0 {
|
||||||
|
self.active_modal = Some(modal::ModalState::SearchIndexRepair);
|
||||||
|
self.notify(
|
||||||
|
ToastLevel::Warning,
|
||||||
|
&t(self.ui_locale, "searchIndexRepair.waitForTasks"),
|
||||||
|
);
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.flush_active_post_editor();
|
||||||
|
let locale = self.ui_locale;
|
||||||
|
let label = t(locale, "engine.reindexStarted");
|
||||||
|
self.add_output(&label);
|
||||||
|
let task_id = self.task_manager.submit(&label);
|
||||||
|
self.search_index_rebuild_running = true;
|
||||||
|
self.search_index_rebuild_task_id = Some(task_id);
|
||||||
|
self.active_modal = Some(modal::ModalState::SearchIndexRebuilding);
|
||||||
|
self.refresh_task_snapshots();
|
||||||
|
self.sync_menu_state();
|
||||||
|
|
||||||
|
let db_path = self.db_path.clone();
|
||||||
|
let label_for_message = label.clone();
|
||||||
|
let task_manager = Arc::clone(&self.task_manager);
|
||||||
|
Task::perform(
|
||||||
|
async move {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
if !task_manager.wait_until_runnable(task_id) {
|
||||||
|
return Err("cancelled".to_string());
|
||||||
|
}
|
||||||
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
||||||
|
let progress_manager = Arc::clone(&task_manager);
|
||||||
|
let on_item: engine::search::ItemProgressFn =
|
||||||
|
Box::new(move |current, total, name| {
|
||||||
|
let progress = if total > 0 {
|
||||||
|
current as f32 / total as f32
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
let message = tw(
|
||||||
|
locale,
|
||||||
|
"engine.indexingItem",
|
||||||
|
&[
|
||||||
|
("current", ¤t.to_string()),
|
||||||
|
("total", &total.to_string()),
|
||||||
|
("name", name),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
progress_manager.report_progress(
|
||||||
|
task_id,
|
||||||
|
Some(progress),
|
||||||
|
Some(message),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
let report = engine::search::rebuild_search_index(db.conn(), Some(on_item))
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
Ok(format!(
|
||||||
|
"posts={}, media={}",
|
||||||
|
report.posts_indexed, report.media_indexed
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
|
||||||
|
},
|
||||||
|
move |result| Message::EngineTaskDone {
|
||||||
|
task_id,
|
||||||
|
label: label_for_message.clone(),
|
||||||
|
result,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a blocking engine operation on a background thread via TaskManager.
|
||||||
|
pub(super) fn spawn_engine_task<F>(&mut self, label_key: &str, work: F) -> Task<Message>
|
||||||
|
where
|
||||||
|
F: FnOnce(PathBuf, String, PathBuf, Arc<TaskManager>, TaskId) -> Result<String, String>
|
||||||
|
+ Send
|
||||||
|
+ 'static,
|
||||||
|
{
|
||||||
|
self.spawn_engine_task_in_group(label_key, None, work)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn spawn_grouped_engine_task<F>(
|
||||||
|
&mut self,
|
||||||
|
label_key: &str,
|
||||||
|
group_name: &str,
|
||||||
|
work: F,
|
||||||
|
) -> Task<Message>
|
||||||
|
where
|
||||||
|
F: FnOnce(PathBuf, String, PathBuf, Arc<TaskManager>, TaskId) -> Result<String, String>
|
||||||
|
+ Send
|
||||||
|
+ 'static,
|
||||||
|
{
|
||||||
|
self.spawn_engine_task_in_group(label_key, Some(group_name), work)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn spawn_engine_task_in_group<F>(
|
||||||
|
&mut self,
|
||||||
|
label_key: &str,
|
||||||
|
group_name: Option<&str>,
|
||||||
|
work: F,
|
||||||
|
) -> Task<Message>
|
||||||
|
where
|
||||||
|
F: FnOnce(PathBuf, String, PathBuf, Arc<TaskManager>, TaskId) -> Result<String, String>
|
||||||
|
+ Send
|
||||||
|
+ 'static,
|
||||||
|
{
|
||||||
|
let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
|
||||||
|
let db_path = self.db_path.clone();
|
||||||
|
let project_id = project.id.clone();
|
||||||
|
let data_dir = data_dir.clone();
|
||||||
|
|
||||||
|
let label = t(self.ui_locale, label_key);
|
||||||
|
self.add_output(&label);
|
||||||
|
|
||||||
|
let task_id = group_name.map_or_else(
|
||||||
|
|| self.task_manager.submit(&label),
|
||||||
|
|name| {
|
||||||
|
self.task_manager
|
||||||
|
.submit_grouped(&label, &format!("{project_id}:{name}"), name)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.refresh_task_snapshots();
|
||||||
|
|
||||||
|
let label_for_msg = label.clone();
|
||||||
|
let tm = Arc::clone(&self.task_manager);
|
||||||
|
|
||||||
|
Task::perform(
|
||||||
|
async move {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
if !tm.wait_until_runnable(task_id) {
|
||||||
|
return Err("cancelled".to_string());
|
||||||
|
}
|
||||||
|
work(db_path, project_id, data_dir, tm, task_id)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| Err(format!("task panicked: {e}")))
|
||||||
|
},
|
||||||
|
move |result| Message::EngineTaskDone {
|
||||||
|
task_id,
|
||||||
|
label: label_for_msg.clone(),
|
||||||
|
result,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ use muda::accelerator::{Accelerator, CMD_OR_CTRL, Code, Modifiers};
|
|||||||
use muda::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu};
|
use muda::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu};
|
||||||
|
|
||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
|
use crate::state::tabs::TabType;
|
||||||
use bds_core::i18n::{UiLocale, translate};
|
use bds_core::i18n::{UiLocale, translate};
|
||||||
|
|
||||||
/// Every custom menu item that the application handles.
|
/// Every custom menu item that the application handles.
|
||||||
@@ -116,6 +117,70 @@ impl MenuAction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn action_enabled(
|
||||||
|
action: MenuAction,
|
||||||
|
has_project: bool,
|
||||||
|
active_tab: Option<&TabType>,
|
||||||
|
offline: bool,
|
||||||
|
interactions_enabled: bool,
|
||||||
|
) -> bool {
|
||||||
|
if !interactions_enabled {
|
||||||
|
return !matches!(
|
||||||
|
action,
|
||||||
|
MenuAction::NewPost
|
||||||
|
| MenuAction::ImportMedia
|
||||||
|
| MenuAction::Save
|
||||||
|
| MenuAction::OpenInBrowser
|
||||||
|
| MenuAction::OpenDataFolder
|
||||||
|
| MenuAction::Find
|
||||||
|
| MenuAction::Replace
|
||||||
|
| MenuAction::PublishSelected
|
||||||
|
| MenuAction::PreviewPost
|
||||||
|
| MenuAction::EditMenu
|
||||||
|
| MenuAction::RebuildDatabase
|
||||||
|
| MenuAction::ReindexText
|
||||||
|
| MenuAction::MetadataDiff
|
||||||
|
| MenuAction::RegenerateCalendar
|
||||||
|
| MenuAction::ValidateTranslations
|
||||||
|
| MenuAction::FillMissingTranslations
|
||||||
|
| MenuAction::GenerateSitemap
|
||||||
|
| MenuAction::ValidateSite
|
||||||
|
| MenuAction::UploadSite
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let post_tab = active_tab == Some(&TabType::Post);
|
||||||
|
let savable_tab = matches!(
|
||||||
|
active_tab,
|
||||||
|
Some(TabType::Post | TabType::Media | TabType::Templates | TabType::Scripts)
|
||||||
|
);
|
||||||
|
let text_editor = matches!(
|
||||||
|
active_tab,
|
||||||
|
Some(TabType::Post | TabType::Templates | TabType::Scripts)
|
||||||
|
);
|
||||||
|
match action {
|
||||||
|
MenuAction::Save => savable_tab,
|
||||||
|
MenuAction::Find | MenuAction::Replace => text_editor,
|
||||||
|
MenuAction::OpenInBrowser | MenuAction::PublishSelected | MenuAction::PreviewPost => {
|
||||||
|
has_project && post_tab
|
||||||
|
}
|
||||||
|
MenuAction::UploadSite => has_project && !offline,
|
||||||
|
MenuAction::NewPost
|
||||||
|
| MenuAction::ImportMedia
|
||||||
|
| MenuAction::OpenDataFolder
|
||||||
|
| MenuAction::EditMenu
|
||||||
|
| MenuAction::RebuildDatabase
|
||||||
|
| MenuAction::ReindexText
|
||||||
|
| MenuAction::MetadataDiff
|
||||||
|
| MenuAction::RegenerateCalendar
|
||||||
|
| MenuAction::ValidateTranslations
|
||||||
|
| MenuAction::FillMissingTranslations
|
||||||
|
| MenuAction::GenerateSitemap
|
||||||
|
| MenuAction::ValidateSite => has_project,
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Maps between muda `MenuId`s and application `MenuAction`s.
|
/// Maps between muda `MenuId`s and application `MenuAction`s.
|
||||||
///
|
///
|
||||||
/// Also holds clones of the `MenuItem` handles so that labels and
|
/// Also holds clones of the `MenuItem` handles so that labels and
|
||||||
@@ -164,11 +229,6 @@ impl MenuRegistry {
|
|||||||
item.set_text(text);
|
item.set_text(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Number of registered (action, MenuId) pairs.
|
|
||||||
pub fn action_count(&self) -> usize {
|
|
||||||
self.action_map.len()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -422,14 +482,6 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use bds_core::i18n::UiLocale;
|
use bds_core::i18n::UiLocale;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn all_variants_are_listed() {
|
|
||||||
// MenuAction::ALL must contain every variant.
|
|
||||||
// If someone adds a variant but forgets ALL, the i18n_key match
|
|
||||||
// will produce a compile error, but this also catches ALL length.
|
|
||||||
assert_eq!(MenuAction::ALL.len(), 28);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn i18n_keys_resolve_for_english() {
|
fn i18n_keys_resolve_for_english() {
|
||||||
for &action in MenuAction::ALL {
|
for &action in MenuAction::ALL {
|
||||||
@@ -446,7 +498,6 @@ mod tests {
|
|||||||
reg.register(MenuAction::Save, &mi);
|
reg.register(MenuAction::Save, &mi);
|
||||||
|
|
||||||
assert_eq!(reg.lookup(mi.id()), Some(MenuAction::Save));
|
assert_eq!(reg.lookup(mi.id()), Some(MenuAction::Save));
|
||||||
assert_eq!(reg.action_count(), 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -477,4 +528,44 @@ mod tests {
|
|||||||
assert!(seen.insert(key), "duplicate i18n key: {key}");
|
assert!(seen.insert(key), "duplicate i18n key: {key}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enabled_state_follows_application_rules() {
|
||||||
|
assert!(action_enabled(
|
||||||
|
MenuAction::Save,
|
||||||
|
true,
|
||||||
|
Some(&TabType::Media),
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!action_enabled(
|
||||||
|
MenuAction::Find,
|
||||||
|
true,
|
||||||
|
Some(&TabType::Media),
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(action_enabled(
|
||||||
|
MenuAction::FillMissingTranslations,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!action_enabled(
|
||||||
|
MenuAction::UploadSite,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!action_enabled(
|
||||||
|
MenuAction::ReindexText,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
));
|
||||||
|
assert!(action_enabled(MenuAction::About, true, None, false, false));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,9 +56,12 @@ pub enum PanelTab {
|
|||||||
pub struct TaskSnapshot {
|
pub struct TaskSnapshot {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
|
pub group_id: Option<String>,
|
||||||
|
pub group_name: Option<String>,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub progress: Option<f32>,
|
pub progress: Option<f32>,
|
||||||
pub message: Option<String>,
|
pub message: Option<String>,
|
||||||
|
pub is_cancellable: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single line of output shown in the panel.
|
/// A single line of output shown in the panel.
|
||||||
|
|||||||
@@ -64,13 +64,6 @@ mod tests {
|
|||||||
assert_ne!(a.id, b.id);
|
assert_ne!(a.id, b.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn toast_levels() {
|
|
||||||
let t = Toast::new(ToastLevel::Error, "oops".into());
|
|
||||||
assert_eq!(t.level, ToastLevel::Error);
|
|
||||||
assert!(!t.message.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fresh_toast_not_expired() {
|
fn fresh_toast_not_expired() {
|
||||||
let t = Toast::new(ToastLevel::Info, "test".into());
|
let t = Toast::new(ToastLevel::Info, "test".into());
|
||||||
|
|||||||
@@ -1,14 +1,45 @@
|
|||||||
use iced::widget::text::Shaping;
|
use iced::widget::text::Shaping;
|
||||||
use iced::widget::{Space, button, column, container, scrollable, text};
|
use iced::widget::{Space, button, column, container, row, 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;
|
||||||
|
|
||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
|
use crate::components::inputs;
|
||||||
use crate::i18n::t;
|
use crate::i18n::t;
|
||||||
use crate::state::navigation::{OutputEntry, PanelTab, TaskSnapshot};
|
use crate::state::navigation::{OutputEntry, PanelTab, TaskSnapshot};
|
||||||
use crate::state::tabs::{Tab, TabType};
|
use crate::state::tabs::{Tab, TabType};
|
||||||
use crate::views::post_editor::ResolvedPostLink;
|
use crate::views::post_editor::ResolvedPostLink;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
fn task_row(snapshot: &TaskSnapshot, locale: UiLocale) -> Element<'static, Message> {
|
||||||
|
let progress = snapshot
|
||||||
|
.progress
|
||||||
|
.map(|value| format!(" ({:.0}%)", value * 100.0))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let phase = snapshot
|
||||||
|
.message
|
||||||
|
.as_deref()
|
||||||
|
.map(|message| format!(" — {message}"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let label = text(format!(
|
||||||
|
"{} — {}{}{}",
|
||||||
|
snapshot.label, snapshot.status, progress, phase
|
||||||
|
))
|
||||||
|
.size(11)
|
||||||
|
.shaping(Shaping::Advanced)
|
||||||
|
.color(Color::from_rgb(0.70, 0.70, 0.75));
|
||||||
|
let mut content = row![label].align_y(Alignment::Center).spacing(8);
|
||||||
|
if snapshot.is_cancellable {
|
||||||
|
content = content.push(Space::with_width(Length::Fill)).push(
|
||||||
|
button(text(t(locale, "tasks.cancelTask")).size(11))
|
||||||
|
.on_press(Message::CancelTask(snapshot.id))
|
||||||
|
.padding([3, 8])
|
||||||
|
.style(inputs::secondary_button),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
content.into()
|
||||||
|
}
|
||||||
|
|
||||||
/// Panel background style.
|
/// Panel background style.
|
||||||
fn panel_style(_theme: &Theme) -> container::Style {
|
fn panel_style(_theme: &Theme) -> container::Style {
|
||||||
@@ -71,6 +102,7 @@ fn close_btn_style(_theme: &Theme, status: button::Status) -> button::Style {
|
|||||||
pub fn view(
|
pub fn view(
|
||||||
panel_tab: PanelTab,
|
panel_tab: PanelTab,
|
||||||
task_snapshots: &[TaskSnapshot],
|
task_snapshots: &[TaskSnapshot],
|
||||||
|
collapsed_task_groups: &HashSet<String>,
|
||||||
output_entries: &[OutputEntry],
|
output_entries: &[OutputEntry],
|
||||||
post_outlinks: &[ResolvedPostLink],
|
post_outlinks: &[ResolvedPostLink],
|
||||||
post_backlinks: &[ResolvedPostLink],
|
post_backlinks: &[ResolvedPostLink],
|
||||||
@@ -169,32 +201,54 @@ pub fn view(
|
|||||||
.padding(8)
|
.padding(8)
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
// Per layout.allium: last 10 tasks, newest first
|
let visible = task_snapshots.iter().rev().take(10).collect::<Vec<_>>();
|
||||||
let items: Vec<Element<'static, Message>> = task_snapshots
|
let mut rendered_groups = HashSet::new();
|
||||||
|
let mut items: Vec<Element<'static, Message>> = Vec::new();
|
||||||
|
for snapshot in &visible {
|
||||||
|
let Some(group_id) = snapshot.group_id.as_ref() else {
|
||||||
|
items.push(task_row(snapshot, locale));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !rendered_groups.insert(group_id.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let members = visible
|
||||||
.iter()
|
.iter()
|
||||||
.rev()
|
.copied()
|
||||||
.take(10)
|
.filter(|member| member.group_id.as_ref() == Some(group_id))
|
||||||
.map(|snap| {
|
.collect::<Vec<_>>();
|
||||||
let progress_str = snap
|
let progress_values = members
|
||||||
.progress
|
.iter()
|
||||||
.map(|p| format!(" ({:.0}%)", p * 100.0))
|
.filter_map(|member| member.progress)
|
||||||
.unwrap_or_default();
|
.collect::<Vec<_>>();
|
||||||
let phase_str = snap
|
let progress = (!progress_values.is_empty()).then(|| {
|
||||||
.message
|
format!(
|
||||||
.as_deref()
|
" ({:.0}%)",
|
||||||
.map(|m| format!(" \u{2014} {m}"))
|
progress_values.iter().sum::<f32>() / progress_values.len() as f32
|
||||||
.unwrap_or_default();
|
* 100.0
|
||||||
let status_text = format!(
|
)
|
||||||
"{} \u{2014} {}{}{}",
|
});
|
||||||
snap.label, snap.status, progress_str, phase_str,
|
let collapsed = collapsed_task_groups.contains(group_id);
|
||||||
);
|
let group_name = snapshot.group_name.as_deref().unwrap_or(group_id);
|
||||||
text(status_text)
|
items.push(
|
||||||
|
button(
|
||||||
|
row![
|
||||||
|
text(if collapsed { "\u{25b8}" } else { "\u{25be}" }).size(11),
|
||||||
|
text(format!("{}{}", group_name, progress.unwrap_or_default()))
|
||||||
.size(11)
|
.size(11)
|
||||||
.shaping(Shaping::Advanced)
|
]
|
||||||
.color(Color::from_rgb(0.70, 0.70, 0.75))
|
.spacing(6),
|
||||||
.into()
|
)
|
||||||
})
|
.on_press(Message::ToggleTaskGroup(group_id.clone()))
|
||||||
.collect();
|
.width(Length::Fill)
|
||||||
|
.padding([3, 6])
|
||||||
|
.style(inputs::disclosure_button)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
if !collapsed {
|
||||||
|
items.extend(members.into_iter().map(|member| task_row(member, locale)));
|
||||||
|
}
|
||||||
|
}
|
||||||
scrollable(
|
scrollable(
|
||||||
iced::widget::Column::with_children(items)
|
iced::widget::Column::with_children(items)
|
||||||
.spacing(4)
|
.spacing(4)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use iced::widget::text::Shaping;
|
use iced::widget::text::Shaping;
|
||||||
@@ -78,6 +78,7 @@ pub fn view<'a>(
|
|||||||
panel_visible: bool,
|
panel_visible: bool,
|
||||||
panel_tab: PanelTab,
|
panel_tab: PanelTab,
|
||||||
task_snapshots: &'a [TaskSnapshot],
|
task_snapshots: &'a [TaskSnapshot],
|
||||||
|
collapsed_task_groups: &'a HashSet<String>,
|
||||||
output_entries: &'a [OutputEntry],
|
output_entries: &'a [OutputEntry],
|
||||||
// Sidebar data
|
// Sidebar data
|
||||||
sidebar_posts: &'a [Post],
|
sidebar_posts: &'a [Post],
|
||||||
@@ -168,6 +169,7 @@ pub fn view<'a>(
|
|||||||
right_col = right_col.push(panel::view(
|
right_col = right_col.push(panel::view(
|
||||||
panel_tab,
|
panel_tab,
|
||||||
task_snapshots,
|
task_snapshots,
|
||||||
|
collapsed_task_groups,
|
||||||
output_entries,
|
output_entries,
|
||||||
post_outlinks,
|
post_outlinks,
|
||||||
post_backlinks,
|
post_backlinks,
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
//! App launch smoke tests.
|
|
||||||
//!
|
|
||||||
//! Validates that the core UI types can be constructed
|
|
||||||
//! and that the message routing works at the type level.
|
|
||||||
//!
|
|
||||||
//! NOTE: muda::Menu on macOS requires the actual main thread (not just
|
|
||||||
//! single-threaded test mode). Menu construction and BdsApp::new() cannot
|
|
||||||
//! be tested via `cargo test`. The full smoke test is launching the binary:
|
|
||||||
//! cargo run -p bds-ui
|
|
||||||
|
|
||||||
use bds_core::i18n::UiLocale;
|
|
||||||
use bds_ui::app::Message;
|
|
||||||
use bds_ui::state::navigation::{PanelTab, SidebarView};
|
|
||||||
use bds_ui::state::tabs::{Tab, TabType};
|
|
||||||
use bds_ui::state::toast::ToastLevel;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
// ── Smoke: Message enum is well-formed ──
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn message_variants_constructable() {
|
|
||||||
let _noop = Message::Noop;
|
|
||||||
let _menu = Message::MenuEvent(muda::MenuId::new("test"));
|
|
||||||
assert!(format!("{:?}", Message::Noop).contains("Noop"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn message_clone_works() {
|
|
||||||
let msg = Message::MenuEvent(muda::MenuId::new("file-open"));
|
|
||||||
let cloned = msg.clone();
|
|
||||||
assert!(format!("{cloned:?}").contains("MenuEvent"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_message_variants_constructable() {
|
|
||||||
// Navigation
|
|
||||||
let _view = Message::SetActiveView(SidebarView::Posts);
|
|
||||||
let _toggle_sb = Message::ToggleSidebar;
|
|
||||||
let _toggle_p = Message::TogglePanel;
|
|
||||||
|
|
||||||
// Tabs
|
|
||||||
let tab = Tab {
|
|
||||||
id: "test".to_string(),
|
|
||||||
tab_type: TabType::Post,
|
|
||||||
title: "Test".to_string(),
|
|
||||||
is_transient: false,
|
|
||||||
is_dirty: false,
|
|
||||||
};
|
|
||||||
let _open = Message::OpenTab(tab);
|
|
||||||
let _close = Message::CloseTab("test".into());
|
|
||||||
let _select = Message::SelectTab("test".into());
|
|
||||||
let _pin = Message::PinTab("test".into());
|
|
||||||
|
|
||||||
// Project
|
|
||||||
let _switch = Message::SwitchProject("id".into());
|
|
||||||
let _create = Message::CreateProject {
|
|
||||||
name: "X".into(),
|
|
||||||
data_path: None,
|
|
||||||
};
|
|
||||||
let _delete = Message::DeleteProject("id".into());
|
|
||||||
|
|
||||||
// Dialogs
|
|
||||||
let _folder = Message::FolderPicked(Some(PathBuf::from("/tmp")));
|
|
||||||
let _media = Message::MediaFilesPicked(None);
|
|
||||||
|
|
||||||
// Tasks
|
|
||||||
let _tick = Message::TaskTick;
|
|
||||||
|
|
||||||
// macOS lifecycle
|
|
||||||
let _file = Message::FileOpenRequested(PathBuf::from("/test"));
|
|
||||||
let _url = Message::UrlOpenRequested("bds://open".into());
|
|
||||||
|
|
||||||
// Panel
|
|
||||||
let _panel = Message::SetPanelTab(PanelTab::Output);
|
|
||||||
|
|
||||||
// Settings
|
|
||||||
let _offline = Message::SetOfflineMode(true);
|
|
||||||
let _locale = Message::SetUiLocale(UiLocale::De);
|
|
||||||
let _toggle_locale = Message::ToggleLocaleDropdown;
|
|
||||||
let _toggle_project = Message::ToggleProjectDropdown;
|
|
||||||
let _init_menu = Message::InitMenuBar;
|
|
||||||
|
|
||||||
// Blog actions
|
|
||||||
let _rebuild = Message::RebuildDatabase;
|
|
||||||
let _reindex = Message::ReindexText;
|
|
||||||
let _regen_cal = Message::RegenerateCalendar;
|
|
||||||
let _validate = Message::ValidateTranslations;
|
|
||||||
let _generate = Message::GenerateSite;
|
|
||||||
let _diff = Message::RunMetadataDiff;
|
|
||||||
let _finished = Message::EngineTaskDone {
|
|
||||||
task_id: 1,
|
|
||||||
label: "test".into(),
|
|
||||||
result: Ok("ok".into()),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Toast
|
|
||||||
let _show = Message::ShowToast(ToastLevel::Info, "hello".into());
|
|
||||||
let _dismiss = Message::DismissToast(1);
|
|
||||||
let _expire = Message::ExpireToasts;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Smoke: BdsApp type is accessible from integration tests ──
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bds_app_type_is_public() {
|
|
||||||
fn _assert_types() {
|
|
||||||
let _: fn() -> (bds_ui::BdsApp, iced::Task<Message>) = bds_ui::BdsApp::new;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,99 +4,6 @@
|
|||||||
//! and keyboard shortcut coverage.
|
//! and keyboard shortcut coverage.
|
||||||
|
|
||||||
use bds_core::i18n::{UiLocale, translate};
|
use bds_core::i18n::{UiLocale, translate};
|
||||||
use bds_ui::platform::menu::MenuAction;
|
|
||||||
use bds_ui::state::toast::{Toast, ToastLevel};
|
|
||||||
|
|
||||||
// ── Toast system ──
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn toast_ids_are_monotonically_increasing() {
|
|
||||||
let a = Toast::new(ToastLevel::Info, "first".into());
|
|
||||||
let b = Toast::new(ToastLevel::Warning, "second".into());
|
|
||||||
let c = Toast::new(ToastLevel::Error, "third".into());
|
|
||||||
assert!(b.id > a.id);
|
|
||||||
assert!(c.id > b.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn toast_level_variants() {
|
|
||||||
let info = Toast::new(ToastLevel::Info, "info".into());
|
|
||||||
let success = Toast::new(ToastLevel::Success, "ok".into());
|
|
||||||
let warning = Toast::new(ToastLevel::Warning, "warn".into());
|
|
||||||
let error = Toast::new(ToastLevel::Error, "err".into());
|
|
||||||
|
|
||||||
assert_eq!(info.level, ToastLevel::Info);
|
|
||||||
assert_eq!(success.level, ToastLevel::Success);
|
|
||||||
assert_eq!(warning.level, ToastLevel::Warning);
|
|
||||||
assert_eq!(error.level, ToastLevel::Error);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fresh_toast_is_not_expired() {
|
|
||||||
let t = Toast::new(ToastLevel::Info, "test".into());
|
|
||||||
assert!(!t.is_expired());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn toast_preserves_message() {
|
|
||||||
let t = Toast::new(ToastLevel::Error, "something failed".into());
|
|
||||||
assert_eq!(t.message, "something failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Menu enable/disable rules ──
|
|
||||||
// (These test the expected invariants, not the BdsApp method directly,
|
|
||||||
// since BdsApp::new() requires main thread for muda.)
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn menu_actions_that_need_project() {
|
|
||||||
let project_actions = [
|
|
||||||
MenuAction::NewPost,
|
|
||||||
MenuAction::ImportMedia,
|
|
||||||
MenuAction::OpenDataFolder,
|
|
||||||
MenuAction::EditMenu,
|
|
||||||
MenuAction::RebuildDatabase,
|
|
||||||
MenuAction::ReindexText,
|
|
||||||
MenuAction::MetadataDiff,
|
|
||||||
MenuAction::RegenerateCalendar,
|
|
||||||
MenuAction::ValidateTranslations,
|
|
||||||
MenuAction::GenerateSitemap,
|
|
||||||
MenuAction::ValidateSite,
|
|
||||||
];
|
|
||||||
// All should have i18n keys
|
|
||||||
for action in &project_actions {
|
|
||||||
let key = action.i18n_key();
|
|
||||||
let label = translate(UiLocale::En, key);
|
|
||||||
assert_ne!(label, key, "missing translation for {key}");
|
|
||||||
}
|
|
||||||
assert_eq!(project_actions.len(), 11);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn menu_actions_that_need_tab() {
|
|
||||||
let tab_actions = [
|
|
||||||
MenuAction::Save,
|
|
||||||
MenuAction::OpenInBrowser,
|
|
||||||
MenuAction::Find,
|
|
||||||
MenuAction::Replace,
|
|
||||||
];
|
|
||||||
for action in &tab_actions {
|
|
||||||
let key = action.i18n_key();
|
|
||||||
let label = translate(UiLocale::En, key);
|
|
||||||
assert_ne!(label, key, "missing translation for {key}");
|
|
||||||
}
|
|
||||||
assert_eq!(tab_actions.len(), 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn menu_actions_gated_by_offline() {
|
|
||||||
let online_only = [MenuAction::FillMissingTranslations, MenuAction::UploadSite];
|
|
||||||
for action in &online_only {
|
|
||||||
let key = action.i18n_key();
|
|
||||||
let label = translate(UiLocale::En, key);
|
|
||||||
assert_ne!(label, key, "missing translation for {key}");
|
|
||||||
}
|
|
||||||
assert_eq!(online_only.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Dialog i18n ──
|
// ── Dialog i18n ──
|
||||||
|
|
||||||
@@ -115,39 +22,6 @@ fn dialog_keys_exist_in_all_locales() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Keyboard shortcut coverage ──
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn accelerator_actions_match_spec() {
|
|
||||||
// M2 spec: these actions must have keyboard shortcuts
|
|
||||||
let accelerated = [
|
|
||||||
MenuAction::NewPost, // Cmd+N
|
|
||||||
MenuAction::ImportMedia, // Cmd+I
|
|
||||||
MenuAction::Save, // Cmd+S
|
|
||||||
MenuAction::Find, // Cmd+F
|
|
||||||
MenuAction::Replace, // Cmd+H
|
|
||||||
MenuAction::EditPreferences, // Cmd+,
|
|
||||||
MenuAction::ViewPosts, // Cmd+1
|
|
||||||
MenuAction::ViewMedia, // Cmd+2
|
|
||||||
MenuAction::ToggleSidebar, // Cmd+B
|
|
||||||
MenuAction::TogglePanel, // Cmd+J
|
|
||||||
MenuAction::PublishSelected, // Cmd+Shift+P
|
|
||||||
MenuAction::PreviewPost, // Cmd+Shift+V
|
|
||||||
MenuAction::GenerateSitemap, // Cmd+R
|
|
||||||
MenuAction::ValidateSite, // Cmd+Shift+L
|
|
||||||
MenuAction::UploadSite, // Cmd+Shift+U
|
|
||||||
];
|
|
||||||
// All must be valid MenuAction variants with i18n keys
|
|
||||||
for action in &accelerated {
|
|
||||||
assert!(!action.i18n_key().is_empty());
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
accelerated.len(),
|
|
||||||
15,
|
|
||||||
"M2 spec has 15 accelerator-bound actions"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Toast i18n keys ──
|
// ── Toast i18n keys ──
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -28,12 +28,6 @@ fn all_menu_actions_translate_in_all_locales() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn menu_action_count_matches_spec() {
|
|
||||||
// M2 spec: 28 custom menu actions
|
|
||||||
assert_eq!(MenuAction::ALL.len(), 28);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_duplicate_i18n_keys() {
|
fn no_duplicate_i18n_keys() {
|
||||||
let mut keys = std::collections::HashSet::new();
|
let mut keys = std::collections::HashSet::new();
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ tasks-backgroundTasks = Hintergrundaufgaben
|
|||||||
tasks-clearCompleted = Abgeschlossene löschen
|
tasks-clearCompleted = Abgeschlossene löschen
|
||||||
tasks-noActive = Keine aktiven Aufgaben
|
tasks-noActive = Keine aktiven Aufgaben
|
||||||
tasks-cancelTask = Aufgabe abbrechen
|
tasks-cancelTask = Aufgabe abbrechen
|
||||||
|
tasks-statusPending = Ausstehend
|
||||||
|
tasks-statusRunning = Laufend
|
||||||
|
tasks-statusCompleted = Abgeschlossen
|
||||||
|
tasks-statusFailed = Fehlgeschlagen: { $error }
|
||||||
|
tasks-statusCancelled = Abgebrochen
|
||||||
tasks-triggerTitle = { $running } laufend, { $pending } ausstehend
|
tasks-triggerTitle = { $running } laufend, { $pending } ausstehend
|
||||||
app-taskCompleted = Aufgabe abgeschlossen: { $message }
|
app-taskCompleted = Aufgabe abgeschlossen: { $message }
|
||||||
app-taskFailed = Aufgabe fehlgeschlagen: { $message }
|
app-taskFailed = Aufgabe fehlgeschlagen: { $message }
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ tasks-backgroundTasks = Background Tasks
|
|||||||
tasks-clearCompleted = Clear completed
|
tasks-clearCompleted = Clear completed
|
||||||
tasks-noActive = No active tasks
|
tasks-noActive = No active tasks
|
||||||
tasks-cancelTask = Cancel task
|
tasks-cancelTask = Cancel task
|
||||||
|
tasks-statusPending = Pending
|
||||||
|
tasks-statusRunning = Running
|
||||||
|
tasks-statusCompleted = Completed
|
||||||
|
tasks-statusFailed = Failed: { $error }
|
||||||
|
tasks-statusCancelled = Cancelled
|
||||||
tasks-triggerTitle = { $running } running, { $pending } pending
|
tasks-triggerTitle = { $running } running, { $pending } pending
|
||||||
app-taskCompleted = Task completed: { $message }
|
app-taskCompleted = Task completed: { $message }
|
||||||
app-taskFailed = Task failed: { $message }
|
app-taskFailed = Task failed: { $message }
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ tasks-backgroundTasks = Tareas en segundo plano
|
|||||||
tasks-clearCompleted = Limpiar completadas
|
tasks-clearCompleted = Limpiar completadas
|
||||||
tasks-noActive = No hay tareas activas
|
tasks-noActive = No hay tareas activas
|
||||||
tasks-cancelTask = Cancelar tarea
|
tasks-cancelTask = Cancelar tarea
|
||||||
|
tasks-statusPending = Pendiente
|
||||||
|
tasks-statusRunning = En ejecución
|
||||||
|
tasks-statusCompleted = Completada
|
||||||
|
tasks-statusFailed = Error: { $error }
|
||||||
|
tasks-statusCancelled = Cancelada
|
||||||
tasks-triggerTitle = { $running } en ejecución, { $pending } pendiente
|
tasks-triggerTitle = { $running } en ejecución, { $pending } pendiente
|
||||||
app-taskCompleted = Tarea completada: { $message }
|
app-taskCompleted = Tarea completada: { $message }
|
||||||
app-taskFailed = Tarea fallida: { $message }
|
app-taskFailed = Tarea fallida: { $message }
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ tasks-backgroundTasks = Tâches en arrière-plan
|
|||||||
tasks-clearCompleted = Effacer terminé
|
tasks-clearCompleted = Effacer terminé
|
||||||
tasks-noActive = Aucune tâche active
|
tasks-noActive = Aucune tâche active
|
||||||
tasks-cancelTask = Annuler la tâche
|
tasks-cancelTask = Annuler la tâche
|
||||||
|
tasks-statusPending = En attente
|
||||||
|
tasks-statusRunning = En cours
|
||||||
|
tasks-statusCompleted = Terminée
|
||||||
|
tasks-statusFailed = Échec : { $error }
|
||||||
|
tasks-statusCancelled = Annulée
|
||||||
tasks-triggerTitle = { $running } en cours, { $pending } en attente
|
tasks-triggerTitle = { $running } en cours, { $pending } en attente
|
||||||
app-taskCompleted = Tâche terminée : { $message }
|
app-taskCompleted = Tâche terminée : { $message }
|
||||||
app-taskFailed = Échec de la tâche : { $message }
|
app-taskFailed = Échec de la tâche : { $message }
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ tasks-backgroundTasks = Attività in background
|
|||||||
tasks-clearCompleted = Cancella completate
|
tasks-clearCompleted = Cancella completate
|
||||||
tasks-noActive = Nessuna attività attiva
|
tasks-noActive = Nessuna attività attiva
|
||||||
tasks-cancelTask = Annulla attività
|
tasks-cancelTask = Annulla attività
|
||||||
|
tasks-statusPending = In attesa
|
||||||
|
tasks-statusRunning = In esecuzione
|
||||||
|
tasks-statusCompleted = Completata
|
||||||
|
tasks-statusFailed = Non riuscita: { $error }
|
||||||
|
tasks-statusCancelled = Annullata
|
||||||
tasks-triggerTitle = { $running } in esecuzione, { $pending } in attesa
|
tasks-triggerTitle = { $running } in esecuzione, { $pending } in attesa
|
||||||
app-taskCompleted = Attività completata: { $message }
|
app-taskCompleted = Attività completata: { $message }
|
||||||
app-taskFailed = Attività non riuscita: { $message }
|
app-taskFailed = Attività non riuscita: { $message }
|
||||||
|
|||||||
Reference in New Issue
Block a user