Add the shared domain event bus.
This commit is contained in:
@@ -12,6 +12,7 @@ The project is under active development. Core blogging workflows are broadly ava
|
|||||||
- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
|
- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
|
||||||
- Template and Lua script management with explicit syntax-check feedback, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms.
|
- Template and Lua script management with explicit syntax-check feedback, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms.
|
||||||
- SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, and FTS5 search.
|
- SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, and FTS5 search.
|
||||||
|
- Project-scoped typed domain events synchronize desktop views with shared-engine and future CLI mutations; persisted CLI notifications are consumed once, and the selected UI language is shared through settings.
|
||||||
- Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation through cancellable section task groups.
|
- Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation through cancellable section task groups.
|
||||||
- Local preview in the app or system browser.
|
- Local preview in the app or system browser.
|
||||||
- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using online or local OpenAI-compatible endpoints with airplane-mode gating.
|
- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using online or local OpenAI-compatible endpoints with airplane-mode gating.
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ Available:
|
|||||||
- Explicit rebuild-from-filesystem paths for manual file changes. bDS2 does
|
- Explicit rebuild-from-filesystem paths for manual file changes. bDS2 does
|
||||||
not live-watch arbitrary project files; its external-change watcher is the
|
not live-watch arbitrary project files; its external-change watcher is the
|
||||||
extension CLI/database-notification contract.
|
extension CLI/database-notification contract.
|
||||||
|
- Typed, project-scoped post/media/tag/template/script/project/settings events at shared mutation boundaries, with deterministic subscribers and a one-shot persisted CLI-to-desktop notification bridge.
|
||||||
|
|
||||||
### Native desktop shell — Done
|
### Native desktop shell — Done
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ Available:
|
|||||||
|
|
||||||
- Iced workspace with activity bar, sidebar, tabs, status bar, task/output panel, toasts, and modal flows.
|
- Iced workspace with activity bar, sidebar, tabs, status bar, task/output panel, toasts, and modal flows.
|
||||||
- Native muda menus, localized labels, accelerators, and state-dependent command enablement.
|
- Native muda menus, localized labels, accelerators, and state-dependent command enablement.
|
||||||
|
- Event-driven sidebar/editor refresh, deleted-entity tab closure, and persisted server-selected UI language without mutation feedback loops.
|
||||||
- Native file/folder dialogs and recent-project handling.
|
- Native file/folder dialogs and recent-project handling.
|
||||||
- macOS open-file and URL lifecycle plumbing.
|
- macOS open-file and URL lifecycle plumbing.
|
||||||
- Localized UI separate from project content language.
|
- Localized UI separate from project content language.
|
||||||
|
|||||||
@@ -81,7 +81,11 @@ Open:
|
|||||||
- OPML/menu editor UI.
|
- OPML/menu editor UI.
|
||||||
- Replace the Menu Editor placeholder.
|
- Replace the Menu Editor placeholder.
|
||||||
|
|
||||||
### CLI, MCP, and domain events — Open
|
### CLI and MCP — Open; domain events — Done
|
||||||
|
|
||||||
|
Done:
|
||||||
|
|
||||||
|
- Domain event bus from `events.allium` for desktop, CLI, TUI, server, and future remote clients, including deterministic subscriptions, project scope, and persisted CLI notification consumption/pruning.
|
||||||
|
|
||||||
Open:
|
Open:
|
||||||
|
|
||||||
@@ -89,7 +93,6 @@ Open:
|
|||||||
- Commands from `cli.allium` and `cli_sync.allium` using the same project, database, engines, and settings as the desktop app.
|
- Commands from `cli.allium` and `cli_sync.allium` using the same project, database, engines, and settings as the desktop app.
|
||||||
- Reuse the core gallery batch-import engine already used by the desktop post editor for the CLI `gallery` command.
|
- Reuse the core gallery batch-import engine already used by the desktop post editor for the CLI `gallery` command.
|
||||||
- MCP tools/resources and proposal-based writes from `mcp.allium`.
|
- MCP tools/resources and proposal-based writes from `mcp.allium`.
|
||||||
- Domain event bus from `events.allium` for desktop, CLI, TUI, and future remote clients.
|
|
||||||
- Replace the MCP settings placeholder.
|
- Replace the MCP settings placeholder.
|
||||||
|
|
||||||
### Blogmark and transform pipeline — Done
|
### Blogmark and transform pipeline — Done
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP INDEX IF EXISTS db_notifications_prune_idx;
|
||||||
|
DROP INDEX IF EXISTS db_notifications_unseen_cli_idx;
|
||||||
|
ALTER TABLE db_notifications DROP COLUMN project_id;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE db_notifications ADD COLUMN project_id TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX db_notifications_unseen_cli_idx
|
||||||
|
ON db_notifications (from_cli, seen_at, created_at);
|
||||||
|
|
||||||
|
CREATE INDEX db_notifications_prune_idx
|
||||||
|
ON db_notifications (seen_at, created_at);
|
||||||
@@ -35,7 +35,7 @@ mod tests {
|
|||||||
let applied = db
|
let applied = db
|
||||||
.conn()
|
.conn()
|
||||||
.with_migrations(|conn| conn.applied_migrations().unwrap().len());
|
.with_migrations(|conn| conn.applied_migrations().unwrap().len());
|
||||||
assert_eq!(applied, 2);
|
assert_eq!(applied, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
98
crates/bds-core/src/db/queries/db_notification.rs
Normal file
98
crates/bds-core/src/db/queries/db_notification.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
use crate::db::DbConnection;
|
||||||
|
use crate::db::schema::db_notifications;
|
||||||
|
use crate::model::{DbNotification, NotificationAction, NotificationEntity};
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_arguments,
|
||||||
|
reason = "fields mirror the persisted notification record"
|
||||||
|
)]
|
||||||
|
pub fn insert_notification(
|
||||||
|
conn: &DbConnection,
|
||||||
|
entity_type: &NotificationEntity,
|
||||||
|
entity_id: &str,
|
||||||
|
action: &NotificationAction,
|
||||||
|
from_cli: bool,
|
||||||
|
seen_at: Option<i64>,
|
||||||
|
created_at: i64,
|
||||||
|
project_id: Option<&str>,
|
||||||
|
) -> QueryResult<()> {
|
||||||
|
conn.with(|connection| {
|
||||||
|
diesel::insert_into(db_notifications::table)
|
||||||
|
.values((
|
||||||
|
db_notifications::entity_type.eq(entity_type),
|
||||||
|
db_notifications::entity_id.eq(entity_id),
|
||||||
|
db_notifications::action.eq(action),
|
||||||
|
db_notifications::from_cli.eq(i32::from(from_cli)),
|
||||||
|
db_notifications::seen_at.eq(seen_at),
|
||||||
|
db_notifications::created_at.eq(created_at),
|
||||||
|
db_notifications::project_id.eq(project_id),
|
||||||
|
))
|
||||||
|
.execute(connection)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_notifications(conn: &DbConnection) -> QueryResult<Vec<DbNotification>> {
|
||||||
|
conn.with(|connection| {
|
||||||
|
db_notifications::table
|
||||||
|
.order((
|
||||||
|
db_notifications::created_at.asc(),
|
||||||
|
db_notifications::id.asc(),
|
||||||
|
))
|
||||||
|
.select(DbNotification::as_select())
|
||||||
|
.load(connection)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_unseen_cli_notifications(conn: &DbConnection) -> QueryResult<Vec<DbNotification>> {
|
||||||
|
conn.with(|connection| {
|
||||||
|
db_notifications::table
|
||||||
|
.filter(db_notifications::from_cli.eq(1))
|
||||||
|
.filter(db_notifications::seen_at.is_null())
|
||||||
|
.order((
|
||||||
|
db_notifications::created_at.asc(),
|
||||||
|
db_notifications::id.asc(),
|
||||||
|
))
|
||||||
|
.select(DbNotification::as_select())
|
||||||
|
.load(connection)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_notifications_seen(
|
||||||
|
conn: &DbConnection,
|
||||||
|
ids: &[i32],
|
||||||
|
seen_at: i64,
|
||||||
|
) -> QueryResult<usize> {
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
conn.with(|connection| {
|
||||||
|
diesel::update(db_notifications::table.filter(db_notifications::id.eq_any(ids)))
|
||||||
|
.set(db_notifications::seen_at.eq(seen_at))
|
||||||
|
.execute(connection)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prune_processed(conn: &DbConnection, cutoff: i64) -> QueryResult<usize> {
|
||||||
|
conn.with(|connection| {
|
||||||
|
diesel::delete(
|
||||||
|
db_notifications::table
|
||||||
|
.filter(db_notifications::seen_at.is_not_null())
|
||||||
|
.filter(db_notifications::created_at.le(cutoff)),
|
||||||
|
)
|
||||||
|
.execute(connection)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prune_unprocessed(conn: &DbConnection, cutoff: i64) -> QueryResult<usize> {
|
||||||
|
conn.with(|connection| {
|
||||||
|
diesel::delete(
|
||||||
|
db_notifications::table
|
||||||
|
.filter(db_notifications::seen_at.is_null())
|
||||||
|
.filter(db_notifications::created_at.le(cutoff)),
|
||||||
|
)
|
||||||
|
.execute(connection)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod db_notification;
|
||||||
pub mod generated_file_hash;
|
pub mod generated_file_hash;
|
||||||
pub mod import_definition;
|
pub mod import_definition;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ diesel::table! {
|
|||||||
from_cli -> Integer,
|
from_cli -> Integer,
|
||||||
seen_at -> Nullable<BigInt>,
|
seen_at -> Nullable<BigInt>,
|
||||||
created_at -> BigInt,
|
created_at -> BigInt,
|
||||||
|
project_id -> Nullable<Text>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -728,8 +728,7 @@ fn keyring_error(error: keyring::Error) -> EngineError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn set_setting(conn: &Connection, key: &str, value: &str, updated_at: i64) -> EngineResult<()> {
|
fn set_setting(conn: &Connection, key: &str, value: &str, updated_at: i64) -> EngineResult<()> {
|
||||||
setting::set_setting_value(conn, key, value, updated_at)?;
|
crate::engine::settings::set_at(conn, key, value, updated_at)
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_optional_setting(
|
fn set_optional_setting(
|
||||||
|
|||||||
160
crates/bds-core/src/engine/cli_sync.rs
Normal file
160
crates/bds-core/src/engine/cli_sync.rs
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
use crate::db::DbConnection as Connection;
|
||||||
|
use crate::db::queries::db_notification as qn;
|
||||||
|
use crate::engine::{EngineError, EngineResult, domain_events};
|
||||||
|
use crate::model::{DomainEntity, DomainEvent, NotificationAction};
|
||||||
|
use crate::util::now_unix_ms;
|
||||||
|
|
||||||
|
pub const PROCESSED_TTL_MS: i64 = 60 * 60 * 1_000;
|
||||||
|
pub const UNPROCESSED_TTL_MS: i64 = 24 * 60 * 60 * 1_000;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct PruneResult {
|
||||||
|
pub processed: usize,
|
||||||
|
pub unprocessed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a future CLI mutation through the same in-process event path, then
|
||||||
|
/// persist only the successfully published events for the desktop watcher.
|
||||||
|
pub fn run_cli_mutation<T>(
|
||||||
|
conn: &Connection,
|
||||||
|
operation: impl FnOnce() -> EngineResult<T>,
|
||||||
|
) -> EngineResult<T> {
|
||||||
|
let (result, events) = domain_events::capture_current_thread(operation)
|
||||||
|
.map_err(|message| EngineError::Validation(message.to_string()))?;
|
||||||
|
for event in &events {
|
||||||
|
record_cli_event(conn, event)?;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_cli_event(conn: &Connection, event: &DomainEvent) -> EngineResult<()> {
|
||||||
|
record_cli_event_at(conn, event, now_unix_ms())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn record_cli_event_at(
|
||||||
|
conn: &Connection,
|
||||||
|
event: &DomainEvent,
|
||||||
|
created_at: i64,
|
||||||
|
) -> EngineResult<()> {
|
||||||
|
let (entity, entity_id, action, project_id) = match event {
|
||||||
|
DomainEvent::EntityChanged {
|
||||||
|
project_id,
|
||||||
|
entity,
|
||||||
|
entity_id,
|
||||||
|
action,
|
||||||
|
} => (
|
||||||
|
entity.clone(),
|
||||||
|
entity_id.as_str(),
|
||||||
|
action.clone(),
|
||||||
|
Some(project_id.as_str()),
|
||||||
|
),
|
||||||
|
DomainEvent::SettingsChanged { project_id, key } => (
|
||||||
|
DomainEntity::Setting,
|
||||||
|
key.as_str(),
|
||||||
|
NotificationAction::Updated,
|
||||||
|
project_id.as_deref(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
qn::insert_notification(
|
||||||
|
conn, &entity, entity_id, &action, true, None, created_at, project_id,
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn consume_cli_notifications(conn: &Connection) -> EngineResult<Vec<DomainEvent>> {
|
||||||
|
consume_cli_notifications_at(conn, now_unix_ms())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn consume_cli_notifications_at(
|
||||||
|
conn: &Connection,
|
||||||
|
seen_at: i64,
|
||||||
|
) -> EngineResult<Vec<DomainEvent>> {
|
||||||
|
conn.begin_savepoint()?;
|
||||||
|
let result = (|| {
|
||||||
|
let notifications = qn::list_unseen_cli_notifications(conn)?;
|
||||||
|
let ids = notifications.iter().map(|item| item.id).collect::<Vec<_>>();
|
||||||
|
qn::mark_notifications_seen(conn, &ids, seen_at)?;
|
||||||
|
let events = notifications
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|notification| match notification.entity_type {
|
||||||
|
DomainEntity::Setting => Some(DomainEvent::SettingsChanged {
|
||||||
|
project_id: notification.project_id,
|
||||||
|
key: notification.entity_id,
|
||||||
|
}),
|
||||||
|
entity => notification
|
||||||
|
.project_id
|
||||||
|
.or_else(|| legacy_project_id(conn, &entity, ¬ification.entity_id))
|
||||||
|
.map(|project_id| DomainEvent::EntityChanged {
|
||||||
|
project_id,
|
||||||
|
entity,
|
||||||
|
entity_id: notification.entity_id,
|
||||||
|
action: notification.action,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(events)
|
||||||
|
})();
|
||||||
|
match result {
|
||||||
|
Ok(events) => {
|
||||||
|
conn.release_savepoint()?;
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let _ = conn.rollback_savepoint();
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_project_id(conn: &Connection, entity: &DomainEntity, entity_id: &str) -> Option<String> {
|
||||||
|
let resolved = match entity {
|
||||||
|
DomainEntity::Post => crate::db::queries::post::get_post_by_id(conn, entity_id)
|
||||||
|
.ok()
|
||||||
|
.map(|item| item.project_id),
|
||||||
|
DomainEntity::Media => crate::db::queries::media::get_media_by_id(conn, entity_id)
|
||||||
|
.ok()
|
||||||
|
.map(|item| item.project_id),
|
||||||
|
DomainEntity::Tag => crate::db::queries::tag::get_tag_by_id(conn, entity_id)
|
||||||
|
.ok()
|
||||||
|
.map(|item| item.project_id),
|
||||||
|
DomainEntity::Template => crate::db::queries::template::get_template_by_id(conn, entity_id)
|
||||||
|
.ok()
|
||||||
|
.map(|item| item.project_id),
|
||||||
|
DomainEntity::Script => crate::db::queries::script::get_script_by_id(conn, entity_id)
|
||||||
|
.ok()
|
||||||
|
.map(|item| item.project_id),
|
||||||
|
DomainEntity::Project => Some(entity_id.to_string()),
|
||||||
|
DomainEntity::Setting => None,
|
||||||
|
};
|
||||||
|
resolved.or_else(|| {
|
||||||
|
crate::db::queries::project::get_active_project(conn)
|
||||||
|
.ok()
|
||||||
|
.map(|project| project.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prune_notifications(conn: &Connection) -> EngineResult<PruneResult> {
|
||||||
|
prune_notifications_at(conn, now_unix_ms())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn prune_notifications_at(conn: &Connection, now: i64) -> EngineResult<PruneResult> {
|
||||||
|
Ok(PruneResult {
|
||||||
|
processed: qn::prune_processed(conn, now - PROCESSED_TTL_MS)?,
|
||||||
|
unprocessed: qn::prune_unprocessed(conn, now - UNPROCESSED_TTL_MS)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Desktop watcher poll: consume once, publish through the shared bus, then
|
||||||
|
/// apply both retention windows.
|
||||||
|
pub fn poll_notifications(conn: &Connection) -> EngineResult<usize> {
|
||||||
|
let events = consume_cli_notifications(conn)?;
|
||||||
|
let count = events.len();
|
||||||
|
for event in events {
|
||||||
|
domain_events::publish(event);
|
||||||
|
}
|
||||||
|
prune_notifications(conn)?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
175
crates/bds-core/src/engine/domain_events.rs
Normal file
175
crates/bds-core/src/engine/domain_events.rs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::mpsc::{self, Receiver, Sender};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
|
use crate::model::{DomainEntity, DomainEvent, NotificationAction};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct BusState {
|
||||||
|
next_id: u64,
|
||||||
|
subscribers: BTreeMap<u64, Sender<DomainEvent>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct BusInner {
|
||||||
|
state: Mutex<BusState>,
|
||||||
|
publish_lock: Mutex<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal deterministic in-process event bus. Delivery is synchronous and
|
||||||
|
/// ordered, while every subscriber owns an independent unbounded queue.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct EventBus {
|
||||||
|
inner: Arc<BusInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventBus {
|
||||||
|
pub fn subscribe(&self) -> EventSubscription {
|
||||||
|
let (sender, receiver) = mpsc::channel();
|
||||||
|
let _publish = lock(&self.inner.publish_lock);
|
||||||
|
let mut state = lock(&self.inner.state);
|
||||||
|
let id = state.next_id;
|
||||||
|
state.next_id = state.next_id.wrapping_add(1);
|
||||||
|
state.subscribers.insert(id, sender);
|
||||||
|
EventSubscription {
|
||||||
|
id: Some(id),
|
||||||
|
inner: Arc::clone(&self.inner),
|
||||||
|
receiver,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn publish(&self, event: DomainEvent) {
|
||||||
|
let _publish = lock(&self.inner.publish_lock);
|
||||||
|
let subscribers = lock(&self.inner.state)
|
||||||
|
.subscribers
|
||||||
|
.iter()
|
||||||
|
.map(|(id, sender)| (*id, sender.clone()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let disconnected = subscribers
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(id, sender)| sender.send(event.clone()).is_err().then_some(id))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if !disconnected.is_empty() {
|
||||||
|
let mut state = lock(&self.inner.state);
|
||||||
|
for id in disconnected {
|
||||||
|
state.subscribers.remove(&id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EventSubscription {
|
||||||
|
id: Option<u64>,
|
||||||
|
inner: Arc<BusInner>,
|
||||||
|
receiver: Receiver<DomainEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventSubscription {
|
||||||
|
pub fn drain(&self) -> Vec<DomainEvent> {
|
||||||
|
self.receiver.try_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unsubscribe(mut self) {
|
||||||
|
self.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detach(&mut self) {
|
||||||
|
if let Some(id) = self.id.take() {
|
||||||
|
let _publish = lock(&self.inner.publish_lock);
|
||||||
|
lock(&self.inner.state).subscribers.remove(&id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for EventSubscription {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||||
|
mutex
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn global_bus() -> &'static EventBus {
|
||||||
|
static BUS: OnceLock<EventBus> = OnceLock::new();
|
||||||
|
BUS.get_or_init(EventBus::default)
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static CAPTURED_EVENTS: RefCell<Option<Vec<DomainEvent>>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscribe() -> EventSubscription {
|
||||||
|
global_bus().subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn publish(event: DomainEvent) {
|
||||||
|
CAPTURED_EVENTS.with(|captured| {
|
||||||
|
if let Some(events) = captured.borrow_mut().as_mut() {
|
||||||
|
events.push(event.clone());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
global_bus().publish(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entity_changed(
|
||||||
|
project_id: &str,
|
||||||
|
entity: DomainEntity,
|
||||||
|
entity_id: &str,
|
||||||
|
action: NotificationAction,
|
||||||
|
) {
|
||||||
|
publish(DomainEvent::EntityChanged {
|
||||||
|
project_id: project_id.to_string(),
|
||||||
|
entity,
|
||||||
|
entity_id: entity_id.to_string(),
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn settings_changed(project_id: Option<&str>, key: &str) {
|
||||||
|
publish(DomainEvent::SettingsChanged {
|
||||||
|
project_id: project_id.map(str::to_string),
|
||||||
|
key: key.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn capture_current_thread<T>(
|
||||||
|
operation: impl FnOnce() -> T,
|
||||||
|
) -> Result<(T, Vec<DomainEvent>), &'static str> {
|
||||||
|
let started = CAPTURED_EVENTS.with(|captured| {
|
||||||
|
let mut captured = captured.borrow_mut();
|
||||||
|
if captured.is_some() {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
*captured = Some(Vec::new());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if !started {
|
||||||
|
return Err("nested CLI event capture is not supported");
|
||||||
|
}
|
||||||
|
let mut reset = CaptureReset::default();
|
||||||
|
let result = operation();
|
||||||
|
let events = CAPTURED_EVENTS.with(|captured| captured.borrow_mut().take().unwrap_or_default());
|
||||||
|
reset.finished = true;
|
||||||
|
Ok((result, events))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct CaptureReset {
|
||||||
|
finished: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for CaptureReset {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if !self.finished {
|
||||||
|
CAPTURED_EVENTS.with(|captured| {
|
||||||
|
captured.borrow_mut().take();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,8 @@ 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;
|
||||||
use crate::db::queries::post_media as qpm;
|
use crate::db::queries::post_media as qpm;
|
||||||
use crate::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult, domain_events};
|
||||||
use crate::model::{Media, MediaTranslation, PostMedia};
|
use crate::model::{DomainEntity, Media, MediaTranslation, NotificationAction, PostMedia};
|
||||||
use crate::util::sidecar::{
|
use crate::util::sidecar::{
|
||||||
MediaSidecar, MediaTranslationSidecar, read_sidecar, read_translation_sidecar,
|
MediaSidecar, MediaTranslationSidecar, read_sidecar, read_translation_sidecar,
|
||||||
};
|
};
|
||||||
@@ -178,6 +178,8 @@ pub(crate) fn import_media_at(
|
|||||||
// Index in FTS
|
// Index in FTS
|
||||||
fts_index_media(conn, &media)?;
|
fts_index_media(conn, &media)?;
|
||||||
|
|
||||||
|
emit_media(&media, NotificationAction::Created);
|
||||||
|
|
||||||
Ok(media)
|
Ok(media)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +233,8 @@ pub fn update_media(
|
|||||||
// Re-index FTS
|
// Re-index FTS
|
||||||
fts_index_media(conn, &media)?;
|
fts_index_media(conn, &media)?;
|
||||||
|
|
||||||
|
emit_media(&media, NotificationAction::Updated);
|
||||||
|
|
||||||
Ok(media)
|
Ok(media)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +342,7 @@ pub fn replace_media_file(
|
|||||||
match apply_result {
|
match apply_result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
fs::remove_file(backup)?;
|
fs::remove_file(backup)?;
|
||||||
|
emit_media(&media, NotificationAction::Updated);
|
||||||
Ok(Some(media))
|
Ok(Some(media))
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -416,9 +421,15 @@ pub fn delete_media(conn: &Connection, data_dir: &Path, media_id: &str) -> Engin
|
|||||||
// Delete from media table
|
// Delete from media table
|
||||||
qm::delete_media(conn, media_id)?;
|
qm::delete_media(conn, media_id)?;
|
||||||
|
|
||||||
|
emit_media(&media, NotificationAction::Deleted);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn emit_media(media: &Media, action: NotificationAction) {
|
||||||
|
domain_events::entity_changed(&media.project_id, DomainEntity::Media, &media.id, action);
|
||||||
|
}
|
||||||
|
|
||||||
/// Create or update a translation for a media item.
|
/// Create or update a translation for a media item.
|
||||||
pub fn upsert_media_translation(
|
pub fn upsert_media_translation(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ 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 cli_sync;
|
||||||
|
pub mod domain_events;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod gallery_import;
|
pub mod gallery_import;
|
||||||
pub mod generation;
|
pub mod generation;
|
||||||
@@ -19,6 +21,7 @@ pub mod rebuild;
|
|||||||
pub mod script;
|
pub mod script;
|
||||||
pub mod script_rebuild;
|
pub mod script_rebuild;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
|
pub mod settings;
|
||||||
pub mod site_assets;
|
pub mod site_assets;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
pub mod task;
|
pub mod task;
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use crate::db::fts;
|
|||||||
use crate::db::queries::post as qp;
|
use crate::db::queries::post as qp;
|
||||||
use crate::db::queries::post_link as ql;
|
use crate::db::queries::post_link as ql;
|
||||||
use crate::db::queries::post_translation as qt;
|
use crate::db::queries::post_translation as qt;
|
||||||
use crate::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult, domain_events};
|
||||||
use crate::model::{Post, PostLink, PostStatus, PostTranslation};
|
use crate::model::{DomainEntity, NotificationAction, Post, PostLink, PostStatus, PostTranslation};
|
||||||
use crate::util::frontmatter::{
|
use crate::util::frontmatter::{
|
||||||
read_post_file, read_translation_file, write_post_file, write_translation_file,
|
read_post_file, read_translation_file, write_post_file, write_translation_file,
|
||||||
};
|
};
|
||||||
@@ -90,6 +90,8 @@ pub fn create_post(
|
|||||||
// Index for FTS
|
// Index for FTS
|
||||||
fts_index_post(conn, &post)?;
|
fts_index_post(conn, &post)?;
|
||||||
|
|
||||||
|
emit_post(&post, NotificationAction::Created);
|
||||||
|
|
||||||
Ok(post)
|
Ok(post)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +186,8 @@ pub fn update_post(
|
|||||||
// Re-index FTS
|
// Re-index FTS
|
||||||
fts_index_post(conn, &post)?;
|
fts_index_post(conn, &post)?;
|
||||||
|
|
||||||
|
emit_post(&post, NotificationAction::Updated);
|
||||||
|
|
||||||
Ok(post)
|
Ok(post)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +209,7 @@ pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
|
|||||||
match publish_post_in_savepoint(conn, data_dir, post) {
|
match publish_post_in_savepoint(conn, data_dir, post) {
|
||||||
Ok(post) => {
|
Ok(post) => {
|
||||||
conn.release_savepoint()?;
|
conn.release_savepoint()?;
|
||||||
|
emit_post(&post, NotificationAction::Updated);
|
||||||
Ok(post)
|
Ok(post)
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -338,6 +343,7 @@ pub fn archive_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
|
|||||||
}
|
}
|
||||||
let now = now_unix_ms();
|
let now = now_unix_ms();
|
||||||
qp::update_post_status(conn, post_id, &PostStatus::Archived, now)?;
|
qp::update_post_status(conn, post_id, &PostStatus::Archived, now)?;
|
||||||
|
emit_post(&post, NotificationAction::Updated);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,6 +454,7 @@ pub fn discard_post_draft(conn: &Connection, data_dir: &Path, post_id: &str) ->
|
|||||||
})() {
|
})() {
|
||||||
Ok(post) => {
|
Ok(post) => {
|
||||||
conn.release_savepoint()?;
|
conn.release_savepoint()?;
|
||||||
|
emit_post(&post, NotificationAction::Updated);
|
||||||
Ok(post)
|
Ok(post)
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -496,9 +503,15 @@ pub fn delete_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineR
|
|||||||
// Delete post from DB
|
// Delete post from DB
|
||||||
qp::delete_post(conn, post_id)?;
|
qp::delete_post(conn, post_id)?;
|
||||||
|
|
||||||
|
emit_post(&post, NotificationAction::Deleted);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn emit_post(post: &Post, action: NotificationAction) {
|
||||||
|
domain_events::entity_changed(&post.project_id, DomainEntity::Post, &post.id, action);
|
||||||
|
}
|
||||||
|
|
||||||
/// Compute the canonical URL for a post: /{YYYY}/{MM}/{DD}/{slug}
|
/// Compute the canonical URL for a post: /{YYYY}/{MM}/{DD}/{slug}
|
||||||
pub fn canonical_url(created_at_ms: i64, slug: &str) -> String {
|
pub fn canonical_url(created_at_ms: i64, slug: &str) -> String {
|
||||||
let (y, m, d) = crate::util::timestamp::year_month_day_from_unix_ms(created_at_ms);
|
let (y, m, d) = crate::util::timestamp::year_month_day_from_unix_ms(created_at_ms);
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::db::queries::project as q;
|
use crate::db::queries::project as q;
|
||||||
use crate::engine::site_assets::copy_bundled_site_assets;
|
use crate::engine::site_assets::copy_bundled_site_assets;
|
||||||
use crate::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult, domain_events};
|
||||||
use crate::model::Project;
|
|
||||||
use crate::model::metadata::ProjectMetadata;
|
use crate::model::metadata::ProjectMetadata;
|
||||||
|
use crate::model::{DomainEntity, NotificationAction, Project};
|
||||||
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
|
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
|
||||||
|
|
||||||
/// The well-known ID of the default project (spec: DefaultProjectExists).
|
/// The well-known ID of the default project (spec: DefaultProjectExists).
|
||||||
@@ -58,6 +58,8 @@ pub fn create_project(
|
|||||||
// Write default meta files
|
// Write default meta files
|
||||||
write_default_meta_files(&data_dir, name)?;
|
write_default_meta_files(&data_dir, name)?;
|
||||||
|
|
||||||
|
emit_project(&project, NotificationAction::Created);
|
||||||
|
|
||||||
Ok(project)
|
Ok(project)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +122,7 @@ pub fn open_project(conn: &Connection, folder_path: &Path) -> EngineResult<Proje
|
|||||||
project.data_path = Some(resolved_path);
|
project.data_path = Some(resolved_path);
|
||||||
project.updated_at = now_unix_ms();
|
project.updated_at = now_unix_ms();
|
||||||
q::update_project(conn, &project)?;
|
q::update_project(conn, &project)?;
|
||||||
|
emit_project(&project, NotificationAction::Updated);
|
||||||
return Ok(project);
|
return Ok(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +141,7 @@ pub fn open_project(conn: &Connection, folder_path: &Path) -> EngineResult<Proje
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
q::insert_project(conn, &project)?;
|
q::insert_project(conn, &project)?;
|
||||||
|
emit_project(&project, NotificationAction::Created);
|
||||||
Ok(project)
|
Ok(project)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +174,7 @@ pub fn ensure_default_project(
|
|||||||
};
|
};
|
||||||
create_directory_structure(&data_dir)?;
|
create_directory_structure(&data_dir)?;
|
||||||
write_default_meta_files(&data_dir, "My Blog")?;
|
write_default_meta_files(&data_dir, "My Blog")?;
|
||||||
|
emit_project(&project, NotificationAction::Created);
|
||||||
Ok(project)
|
Ok(project)
|
||||||
}
|
}
|
||||||
Err(e) => Err(EngineError::Db(e)),
|
Err(e) => Err(EngineError::Db(e)),
|
||||||
@@ -188,6 +193,12 @@ pub fn get_active_project(conn: &Connection) -> EngineResult<Option<Project>> {
|
|||||||
/// Deactivate all projects, then activate the given one.
|
/// Deactivate all projects, then activate the given one.
|
||||||
pub fn set_active_project(conn: &Connection, project_id: &str) -> EngineResult<()> {
|
pub fn set_active_project(conn: &Connection, project_id: &str) -> EngineResult<()> {
|
||||||
q::set_active_project(conn, project_id)?;
|
q::set_active_project(conn, project_id)?;
|
||||||
|
domain_events::entity_changed(
|
||||||
|
project_id,
|
||||||
|
DomainEntity::Project,
|
||||||
|
project_id,
|
||||||
|
NotificationAction::Updated,
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,9 +247,15 @@ pub fn delete_project(
|
|||||||
let _ = fs::remove_dir_all(dir);
|
let _ = fs::remove_dir_all(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emit_project(&project, NotificationAction::Deleted);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn emit_project(project: &Project, action: NotificationAction) {
|
||||||
|
domain_events::entity_changed(&project.id, DomainEntity::Project, &project.id, action);
|
||||||
|
}
|
||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn create_directory_structure(data_dir: &Path) -> EngineResult<()> {
|
fn create_directory_structure(data_dir: &Path) -> EngineResult<()> {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use crate::db::DbConnection as Connection;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::db::queries::script as qs;
|
use crate::db::queries::script as qs;
|
||||||
use crate::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult, domain_events};
|
||||||
use crate::model::{Script, ScriptKind, ScriptStatus};
|
use crate::model::{DomainEntity, NotificationAction, Script, ScriptKind, ScriptStatus};
|
||||||
use crate::util::frontmatter::{ScriptFrontmatter, write_script_file};
|
use crate::util::frontmatter::{ScriptFrontmatter, write_script_file};
|
||||||
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
|
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
|
||||||
|
|
||||||
@@ -53,6 +53,7 @@ pub fn create_script(
|
|||||||
};
|
};
|
||||||
|
|
||||||
qs::insert_script(conn, &script)?;
|
qs::insert_script(conn, &script)?;
|
||||||
|
emit_script(&script, NotificationAction::Created);
|
||||||
Ok(script)
|
Ok(script)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +112,7 @@ pub fn update_script(
|
|||||||
script.version += 1;
|
script.version += 1;
|
||||||
script.updated_at = now_unix_ms();
|
script.updated_at = now_unix_ms();
|
||||||
qs::update_script(conn, &script)?;
|
qs::update_script(conn, &script)?;
|
||||||
|
emit_script(&script, NotificationAction::Updated);
|
||||||
Ok(script)
|
Ok(script)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +128,7 @@ pub fn save_script(conn: &Connection, script_id: &str, content: &str) -> EngineR
|
|||||||
}
|
}
|
||||||
|
|
||||||
qs::update_script(conn, &script)?;
|
qs::update_script(conn, &script)?;
|
||||||
|
emit_script(&script, NotificationAction::Updated);
|
||||||
Ok(script)
|
Ok(script)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +209,8 @@ pub fn publish_script(conn: &Connection, data_dir: &Path, script_id: &str) -> En
|
|||||||
script.updated_at = now;
|
script.updated_at = now;
|
||||||
qs::update_script(conn, &script)?;
|
qs::update_script(conn, &script)?;
|
||||||
|
|
||||||
|
emit_script(&script, NotificationAction::Updated);
|
||||||
|
|
||||||
Ok(script)
|
Ok(script)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +240,8 @@ pub fn unpublish_script(
|
|||||||
script.updated_at = now_unix_ms();
|
script.updated_at = now_unix_ms();
|
||||||
qs::update_script(conn, &script)?;
|
qs::update_script(conn, &script)?;
|
||||||
|
|
||||||
|
emit_script(&script, NotificationAction::Updated);
|
||||||
|
|
||||||
Ok(script)
|
Ok(script)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,9 +258,14 @@ pub fn delete_script(conn: &Connection, data_dir: &Path, script_id: &str) -> Eng
|
|||||||
}
|
}
|
||||||
|
|
||||||
qs::delete_script(conn, script_id)?;
|
qs::delete_script(conn, script_id)?;
|
||||||
|
emit_script(&script, NotificationAction::Deleted);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn emit_script(script: &Script, action: NotificationAction) {
|
||||||
|
domain_events::entity_changed(&script.project_id, DomainEntity::Script, &script.id, action);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helper functions ---
|
// --- Helper functions ---
|
||||||
|
|
||||||
fn script_kind_to_frontmatter(kind: &ScriptKind) -> String {
|
fn script_kind_to_frontmatter(kind: &ScriptKind) -> String {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use crate::db::queries::{
|
|||||||
media as media_q, media_translation, post as post_q, post_translation, project as project_q,
|
media as media_q, media_translation, post as post_q, post_translation, project as project_q,
|
||||||
setting,
|
setting,
|
||||||
};
|
};
|
||||||
use crate::engine::EngineResult;
|
use crate::engine::{EngineResult, domain_events};
|
||||||
use crate::util::now_unix_ms;
|
use crate::util::now_unix_ms;
|
||||||
|
|
||||||
const REBUILD_REQUIRED_SETTING: &str = "app.search-index-rebuild-required";
|
const REBUILD_REQUIRED_SETTING: &str = "app.search-index-rebuild-required";
|
||||||
@@ -57,6 +57,7 @@ pub fn prepare_search_index(conn: &Connection) -> EngineResult<bool> {
|
|||||||
return Err(error.into());
|
return Err(error.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
domain_events::settings_changed(None, REBUILD_REQUIRED_SETTING);
|
||||||
|
|
||||||
Ok(has_content)
|
Ok(has_content)
|
||||||
}
|
}
|
||||||
@@ -84,6 +85,7 @@ pub fn rebuild_search_index(
|
|||||||
match result {
|
match result {
|
||||||
Ok(report) => {
|
Ok(report) => {
|
||||||
conn.release_savepoint()?;
|
conn.release_savepoint()?;
|
||||||
|
domain_events::settings_changed(None, REBUILD_REQUIRED_SETTING);
|
||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
|||||||
28
crates/bds-core/src/engine/settings.rs
Normal file
28
crates/bds-core/src/engine/settings.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
use crate::db::DbConnection as Connection;
|
||||||
|
use crate::db::queries::setting;
|
||||||
|
use crate::engine::{EngineError, EngineResult, domain_events};
|
||||||
|
use crate::util::now_unix_ms;
|
||||||
|
|
||||||
|
pub const UI_LANGUAGE_KEY: &str = "ui.language";
|
||||||
|
|
||||||
|
pub fn get(conn: &Connection, key: &str) -> EngineResult<Option<String>> {
|
||||||
|
match setting::get_setting_by_key(conn, key) {
|
||||||
|
Ok(value) => Ok(Some(value.value)),
|
||||||
|
Err(diesel::result::Error::NotFound) => Ok(None),
|
||||||
|
Err(error) => Err(EngineError::Db(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(conn: &Connection, key: &str, value: &str) -> EngineResult<()> {
|
||||||
|
set_at(conn, key, value, now_unix_ms())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_at(conn: &Connection, key: &str, value: &str, updated_at: i64) -> EngineResult<()> {
|
||||||
|
setting::set_setting_value(conn, key, value, updated_at)?;
|
||||||
|
domain_events::settings_changed(None, key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ui_language(conn: &Connection) -> EngineResult<Option<String>> {
|
||||||
|
get(conn, UI_LANGUAGE_KEY)
|
||||||
|
}
|
||||||
@@ -6,9 +6,9 @@ use uuid::Uuid;
|
|||||||
use crate::db::queries::post as post_q;
|
use crate::db::queries::post as post_q;
|
||||||
use crate::db::queries::tag as tag_q;
|
use crate::db::queries::tag as tag_q;
|
||||||
use crate::engine::meta;
|
use crate::engine::meta;
|
||||||
use crate::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult, domain_events};
|
||||||
use crate::model::Tag;
|
|
||||||
use crate::model::metadata::TagEntry;
|
use crate::model::metadata::TagEntry;
|
||||||
|
use crate::model::{DomainEntity, NotificationAction, Tag};
|
||||||
use crate::util::now_unix_ms;
|
use crate::util::now_unix_ms;
|
||||||
|
|
||||||
/// Create a new tag. Case-insensitive duplicate check.
|
/// Create a new tag. Case-insensitive duplicate check.
|
||||||
@@ -38,6 +38,7 @@ pub fn create_tag(
|
|||||||
};
|
};
|
||||||
tag_q::insert_tag(conn, &tag)?;
|
tag_q::insert_tag(conn, &tag)?;
|
||||||
rewrite_tags_json(conn, data_dir, project_id)?;
|
rewrite_tags_json(conn, data_dir, project_id)?;
|
||||||
|
emit_tag(&tag, NotificationAction::Created);
|
||||||
Ok(tag)
|
Ok(tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ pub fn update_tag(
|
|||||||
tag.updated_at = now_unix_ms();
|
tag.updated_at = now_unix_ms();
|
||||||
tag_q::update_tag(conn, &tag)?;
|
tag_q::update_tag(conn, &tag)?;
|
||||||
rewrite_tags_json(conn, data_dir, &tag.project_id)?;
|
rewrite_tags_json(conn, data_dir, &tag.project_id)?;
|
||||||
|
emit_tag(&tag, NotificationAction::Updated);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +92,7 @@ pub fn delete_tag(
|
|||||||
tag_q::delete_tag(conn, tag_id)?;
|
tag_q::delete_tag(conn, tag_id)?;
|
||||||
rewrite_tags_json(conn, data_dir, project_id)?;
|
rewrite_tags_json(conn, data_dir, project_id)?;
|
||||||
flush_post_frontmatter(conn, data_dir, &modified)?;
|
flush_post_frontmatter(conn, data_dir, &modified)?;
|
||||||
|
emit_tag(&tag, NotificationAction::Deleted);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +136,7 @@ pub fn rename_tag(
|
|||||||
tag_q::update_tag(conn, &tag)?;
|
tag_q::update_tag(conn, &tag)?;
|
||||||
rewrite_tags_json(conn, data_dir, project_id)?;
|
rewrite_tags_json(conn, data_dir, project_id)?;
|
||||||
flush_post_frontmatter(conn, data_dir, &modified)?;
|
flush_post_frontmatter(conn, data_dir, &modified)?;
|
||||||
|
emit_tag(&tag, NotificationAction::Updated);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +153,7 @@ pub fn merge_tags(
|
|||||||
.map_err(|_| EngineError::NotFound(format!("target tag {target_id}")))?;
|
.map_err(|_| EngineError::NotFound(format!("target tag {target_id}")))?;
|
||||||
|
|
||||||
let mut all_modified = Vec::new();
|
let mut all_modified = Vec::new();
|
||||||
|
let mut deleted_tags = Vec::new();
|
||||||
for &source_id in source_ids {
|
for &source_id in source_ids {
|
||||||
let source_tag = tag_q::get_tag_by_id(conn, source_id)
|
let source_tag = tag_q::get_tag_by_id(conn, source_id)
|
||||||
.map_err(|_| EngineError::NotFound(format!("source tag {source_id}")))?;
|
.map_err(|_| EngineError::NotFound(format!("source tag {source_id}")))?;
|
||||||
@@ -181,13 +186,22 @@ pub fn merge_tags(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tag_q::delete_tag(conn, source_id)?;
|
tag_q::delete_tag(conn, source_id)?;
|
||||||
|
deleted_tags.push(source_tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
rewrite_tags_json(conn, data_dir, project_id)?;
|
rewrite_tags_json(conn, data_dir, project_id)?;
|
||||||
flush_post_frontmatter(conn, data_dir, &all_modified)?;
|
flush_post_frontmatter(conn, data_dir, &all_modified)?;
|
||||||
|
for tag in &deleted_tags {
|
||||||
|
emit_tag(tag, NotificationAction::Deleted);
|
||||||
|
}
|
||||||
|
emit_tag(&target_tag, NotificationAction::Updated);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn emit_tag(tag: &Tag, action: NotificationAction) {
|
||||||
|
domain_events::entity_changed(&tag.project_id, DomainEntity::Tag, &tag.id, action);
|
||||||
|
}
|
||||||
|
|
||||||
/// Import tags from meta/tags.json into DB, preserving colors and properties.
|
/// Import tags from meta/tags.json into DB, preserving colors and properties.
|
||||||
/// Creates new tags or updates existing ones with file-based properties.
|
/// Creates new tags or updates existing ones with file-based properties.
|
||||||
pub fn import_tags_from_file(
|
pub fn import_tags_from_file(
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ use diesel::prelude::*;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::db::queries::template as qt;
|
use crate::db::queries::template as qt;
|
||||||
use crate::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult, domain_events};
|
||||||
use crate::model::{Template, TemplateKind, TemplateStatus};
|
use crate::model::{DomainEntity, NotificationAction, Template, TemplateKind, TemplateStatus};
|
||||||
use crate::util::frontmatter::{TemplateFrontmatter, write_template_file};
|
use crate::util::frontmatter::{TemplateFrontmatter, write_template_file};
|
||||||
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
|
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
|
||||||
|
|
||||||
@@ -49,6 +49,7 @@ pub fn create_template(
|
|||||||
};
|
};
|
||||||
|
|
||||||
qt::insert_template(conn, &tpl)?;
|
qt::insert_template(conn, &tpl)?;
|
||||||
|
emit_template(&tpl, NotificationAction::Created);
|
||||||
Ok(tpl)
|
Ok(tpl)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +109,7 @@ pub fn update_template(
|
|||||||
tpl.version += 1;
|
tpl.version += 1;
|
||||||
tpl.updated_at = now_unix_ms();
|
tpl.updated_at = now_unix_ms();
|
||||||
qt::update_template(conn, &tpl)?;
|
qt::update_template(conn, &tpl)?;
|
||||||
|
emit_template(&tpl, NotificationAction::Updated);
|
||||||
Ok(tpl)
|
Ok(tpl)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +130,7 @@ pub fn save_template(
|
|||||||
}
|
}
|
||||||
|
|
||||||
qt::update_template(conn, &tpl)?;
|
qt::update_template(conn, &tpl)?;
|
||||||
|
emit_template(&tpl, NotificationAction::Updated);
|
||||||
Ok(tpl)
|
Ok(tpl)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +193,8 @@ pub fn publish_template(
|
|||||||
tpl.updated_at = now;
|
tpl.updated_at = now;
|
||||||
qt::update_template(conn, &tpl)?;
|
qt::update_template(conn, &tpl)?;
|
||||||
|
|
||||||
|
emit_template(&tpl, NotificationAction::Updated);
|
||||||
|
|
||||||
Ok(tpl)
|
Ok(tpl)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +227,8 @@ pub fn unpublish_template(
|
|||||||
tpl.updated_at = now_unix_ms();
|
tpl.updated_at = now_unix_ms();
|
||||||
qt::update_template(conn, &tpl)?;
|
qt::update_template(conn, &tpl)?;
|
||||||
|
|
||||||
|
emit_template(&tpl, NotificationAction::Updated);
|
||||||
|
|
||||||
Ok(tpl)
|
Ok(tpl)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,9 +271,19 @@ pub fn delete_template(
|
|||||||
}
|
}
|
||||||
|
|
||||||
qt::delete_template(conn, template_id)?;
|
qt::delete_template(conn, template_id)?;
|
||||||
|
emit_template(&tpl, NotificationAction::Deleted);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn emit_template(template: &Template, action: NotificationAction) {
|
||||||
|
domain_events::entity_changed(
|
||||||
|
&template.project_id,
|
||||||
|
DomainEntity::Template,
|
||||||
|
&template.id,
|
||||||
|
action,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helper functions ---
|
// --- Helper functions ---
|
||||||
|
|
||||||
fn template_kind_to_frontmatter(kind: &TemplateKind) -> String {
|
fn template_kind_to_frontmatter(kind: &TemplateKind) -> String {
|
||||||
|
|||||||
29
crates/bds-core/src/model/event.rs
Normal file
29
crates/bds-core/src/model/event.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::{DomainEntity, NotificationAction};
|
||||||
|
|
||||||
|
/// The single event shape shared by desktop, CLI synchronization, and future
|
||||||
|
/// remote clients.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum DomainEvent {
|
||||||
|
EntityChanged {
|
||||||
|
project_id: String,
|
||||||
|
entity: DomainEntity,
|
||||||
|
entity_id: String,
|
||||||
|
action: NotificationAction,
|
||||||
|
},
|
||||||
|
SettingsChanged {
|
||||||
|
project_id: Option<String>,
|
||||||
|
key: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DomainEvent {
|
||||||
|
pub fn project_id(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
Self::EntityChanged { project_id, .. } => Some(project_id),
|
||||||
|
Self::SettingsChanged { project_id, .. } => project_id.as_deref(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,13 +28,18 @@ pub struct GeneratedFileHash {
|
|||||||
)]
|
)]
|
||||||
#[diesel(sql_type = diesel::sql_types::Text)]
|
#[diesel(sql_type = diesel::sql_types::Text)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum NotificationEntity {
|
pub enum DomainEntity {
|
||||||
Post,
|
Post,
|
||||||
Media,
|
Media,
|
||||||
|
Tag,
|
||||||
Script,
|
Script,
|
||||||
Template,
|
Template,
|
||||||
|
Project,
|
||||||
|
Setting,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub type NotificationEntity = DomainEntity;
|
||||||
|
|
||||||
#[derive(
|
#[derive(
|
||||||
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, diesel::AsExpression, diesel::FromSqlRow,
|
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, diesel::AsExpression, diesel::FromSqlRow,
|
||||||
)]
|
)]
|
||||||
@@ -46,27 +51,33 @@ pub enum NotificationAction {
|
|||||||
Deleted,
|
Deleted,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::str::FromStr for NotificationEntity {
|
impl std::str::FromStr for DomainEntity {
|
||||||
type Err = String;
|
type Err = String;
|
||||||
|
|
||||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
match value {
|
match value {
|
||||||
"post" => Ok(Self::Post),
|
"post" => Ok(Self::Post),
|
||||||
"media" => Ok(Self::Media),
|
"media" => Ok(Self::Media),
|
||||||
|
"tag" => Ok(Self::Tag),
|
||||||
"script" => Ok(Self::Script),
|
"script" => Ok(Self::Script),
|
||||||
"template" => Ok(Self::Template),
|
"template" => Ok(Self::Template),
|
||||||
|
"project" => Ok(Self::Project),
|
||||||
|
"setting" => Ok(Self::Setting),
|
||||||
_ => Err(format!("invalid NotificationEntity: {value}")),
|
_ => Err(format!("invalid NotificationEntity: {value}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NotificationEntity {
|
impl DomainEntity {
|
||||||
pub const fn as_str(&self) -> &'static str {
|
pub const fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Post => "post",
|
Self::Post => "post",
|
||||||
Self::Media => "media",
|
Self::Media => "media",
|
||||||
|
Self::Tag => "tag",
|
||||||
Self::Script => "script",
|
Self::Script => "script",
|
||||||
Self::Template => "template",
|
Self::Template => "template",
|
||||||
|
Self::Project => "project",
|
||||||
|
Self::Setting => "setting",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,8 +113,7 @@ impl NotificationAction {
|
|||||||
check_for_backend(diesel::sqlite::Sqlite)
|
check_for_backend(diesel::sqlite::Sqlite)
|
||||||
)]
|
)]
|
||||||
pub struct DbNotification {
|
pub struct DbNotification {
|
||||||
#[diesel(deserialize_as = i32)]
|
pub id: i32,
|
||||||
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,
|
||||||
@@ -112,6 +122,7 @@ pub struct DbNotification {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub seen_at: Option<i64>,
|
pub seen_at: Option<i64>,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
|
pub project_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod event;
|
||||||
mod generation;
|
mod generation;
|
||||||
mod import;
|
mod import;
|
||||||
mod media;
|
mod media;
|
||||||
@@ -8,8 +9,9 @@ mod script;
|
|||||||
mod tag;
|
mod tag;
|
||||||
mod template;
|
mod template;
|
||||||
|
|
||||||
|
pub use event::DomainEvent;
|
||||||
pub use generation::{
|
pub use generation::{
|
||||||
DbNotification, GeneratedFileHash, NotificationAction, NotificationEntity,
|
DbNotification, DomainEntity, GeneratedFileHash, NotificationAction, NotificationEntity,
|
||||||
PublishingPreferences, SshMode,
|
PublishingPreferences, SshMode,
|
||||||
};
|
};
|
||||||
pub use import::{
|
pub use import::{
|
||||||
|
|||||||
472
crates/bds-core/tests/domain_events.rs
Normal file
472
crates/bds-core/tests/domain_events.rs
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
use bds_core::db::Database;
|
||||||
|
use bds_core::engine::{cli_sync, domain_events};
|
||||||
|
use bds_core::model::{
|
||||||
|
DomainEntity, DomainEvent, NotificationAction, NotificationEntity, Project, ScriptKind,
|
||||||
|
TemplateKind,
|
||||||
|
};
|
||||||
|
use image::DynamicImage;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn post_event(project_id: &str, id: &str, action: NotificationAction) -> DomainEvent {
|
||||||
|
DomainEvent::EntityChanged {
|
||||||
|
project_id: project_id.to_string(),
|
||||||
|
entity: DomainEntity::Post,
|
||||||
|
entity_id: id.to_string(),
|
||||||
|
action,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_project(id: &str, slug: &str) -> Project {
|
||||||
|
Project {
|
||||||
|
id: id.to_string(),
|
||||||
|
name: format!("Project {id}"),
|
||||||
|
slug: slug.to_string(),
|
||||||
|
description: None,
|
||||||
|
data_path: None,
|
||||||
|
is_active: false,
|
||||||
|
created_at: 1,
|
||||||
|
updated_at: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_one_entity_event(
|
||||||
|
subscription: &domain_events::EventSubscription,
|
||||||
|
entity: DomainEntity,
|
||||||
|
id: &str,
|
||||||
|
action: NotificationAction,
|
||||||
|
) {
|
||||||
|
let matching = subscription
|
||||||
|
.drain()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|event| {
|
||||||
|
matches!(
|
||||||
|
event,
|
||||||
|
DomainEvent::EntityChanged {
|
||||||
|
entity: actual_entity,
|
||||||
|
entity_id,
|
||||||
|
action: actual_action,
|
||||||
|
..
|
||||||
|
} if actual_entity == &entity && entity_id == id && actual_action == &action
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
assert_eq!(matching, 1, "{entity:?} {action:?} must emit once");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subscribers_receive_events_in_order_and_unsubscribe_stops_delivery() {
|
||||||
|
let bus = domain_events::EventBus::default();
|
||||||
|
let first = bus.subscribe();
|
||||||
|
let second = bus.subscribe();
|
||||||
|
let created = post_event("project", "one", NotificationAction::Created);
|
||||||
|
let updated = post_event("project", "one", NotificationAction::Updated);
|
||||||
|
|
||||||
|
bus.publish(created.clone());
|
||||||
|
bus.publish(updated.clone());
|
||||||
|
assert_eq!(first.drain(), vec![created.clone(), updated.clone()]);
|
||||||
|
assert_eq!(second.drain(), vec![created, updated]);
|
||||||
|
|
||||||
|
first.unsubscribe();
|
||||||
|
let deleted = post_event("project", "one", NotificationAction::Deleted);
|
||||||
|
bus.publish(deleted.clone());
|
||||||
|
assert_eq!(second.drain(), vec![deleted]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cli_notifications_are_consumed_once_marked_seen_and_pruned_by_age() {
|
||||||
|
let db = Database::open_in_memory().unwrap();
|
||||||
|
db.migrate().unwrap();
|
||||||
|
let now = 2 * cli_sync::UNPROCESSED_TTL_MS;
|
||||||
|
let recent = post_event("project", "recent", NotificationAction::Updated);
|
||||||
|
cli_sync::record_cli_event_at(db.conn(), &recent, now).unwrap();
|
||||||
|
cli_sync::record_cli_event_at(
|
||||||
|
db.conn(),
|
||||||
|
&post_event("project", "processed-old", NotificationAction::Updated),
|
||||||
|
now - cli_sync::PROCESSED_TTL_MS - 1,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let events = cli_sync::consume_cli_notifications_at(db.conn(), now).unwrap();
|
||||||
|
assert_eq!(events.len(), 2);
|
||||||
|
assert!(
|
||||||
|
cli_sync::consume_cli_notifications_at(db.conn(), now)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
|
||||||
|
let notifications =
|
||||||
|
bds_core::db::queries::db_notification::list_notifications(db.conn()).unwrap();
|
||||||
|
assert!(notifications.iter().all(|item| item.seen_at == Some(now)));
|
||||||
|
assert!(notifications.iter().all(|item| item.from_cli));
|
||||||
|
assert!(
|
||||||
|
notifications
|
||||||
|
.iter()
|
||||||
|
.all(|item| item.project_id.as_deref() == Some("project"))
|
||||||
|
);
|
||||||
|
|
||||||
|
cli_sync::record_cli_event_at(
|
||||||
|
db.conn(),
|
||||||
|
&post_event("project", "unprocessed-old", NotificationAction::Updated),
|
||||||
|
now - cli_sync::UNPROCESSED_TTL_MS - 1,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let pruned = cli_sync::prune_notifications_at(db.conn(), now).unwrap();
|
||||||
|
assert_eq!(pruned.processed, 1);
|
||||||
|
assert_eq!(pruned.unprocessed, 1);
|
||||||
|
let remaining = bds_core::db::queries::db_notification::list_notifications(db.conn()).unwrap();
|
||||||
|
assert_eq!(remaining.len(), 1);
|
||||||
|
assert_eq!(remaining[0].entity_id, "recent");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn desktop_events_do_not_create_cli_notification_rows() {
|
||||||
|
let db = Database::open_in_memory().unwrap();
|
||||||
|
db.migrate().unwrap();
|
||||||
|
domain_events::publish(post_event(
|
||||||
|
"project",
|
||||||
|
"desktop",
|
||||||
|
NotificationAction::Created,
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
bds_core::db::queries::db_notification::list_notifications(db.conn())
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_cli_rows_without_scope_resolve_the_entity_project() {
|
||||||
|
let db = Database::open_in_memory().unwrap();
|
||||||
|
db.migrate().unwrap();
|
||||||
|
bds_core::db::fts::ensure_fts_tables(db.conn()).unwrap();
|
||||||
|
let project_id = Uuid::new_v4().to_string();
|
||||||
|
bds_core::db::queries::project::insert_project(db.conn(), &test_project(&project_id, "legacy"))
|
||||||
|
.unwrap();
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let post = bds_core::engine::post::create_post(
|
||||||
|
db.conn(),
|
||||||
|
dir.path(),
|
||||||
|
&project_id,
|
||||||
|
"Legacy",
|
||||||
|
None,
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
bds_core::db::queries::db_notification::insert_notification(
|
||||||
|
db.conn(),
|
||||||
|
&DomainEntity::Post,
|
||||||
|
&post.id,
|
||||||
|
&NotificationAction::Updated,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
1,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
cli_sync::consume_cli_notifications_at(db.conn(), 2).unwrap(),
|
||||||
|
vec![post_event(
|
||||||
|
&project_id,
|
||||||
|
&post.id,
|
||||||
|
NotificationAction::Updated
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notification_entity_types_cover_every_shared_mutation_family() {
|
||||||
|
assert_eq!(NotificationEntity::Post.as_str(), "post");
|
||||||
|
assert_eq!(NotificationEntity::Media.as_str(), "media");
|
||||||
|
assert_eq!(NotificationEntity::Tag.as_str(), "tag");
|
||||||
|
assert_eq!(NotificationEntity::Template.as_str(), "template");
|
||||||
|
assert_eq!(NotificationEntity::Script.as_str(), "script");
|
||||||
|
assert_eq!(NotificationEntity::Project.as_str(), "project");
|
||||||
|
assert_eq!(NotificationEntity::Setting.as_str(), "setting");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn representative_shared_mutations_emit_exactly_one_typed_event_each() {
|
||||||
|
let db = Database::open_in_memory().unwrap();
|
||||||
|
db.migrate().unwrap();
|
||||||
|
bds_core::db::fts::ensure_fts_tables(db.conn()).unwrap();
|
||||||
|
let project_id = Uuid::new_v4().to_string();
|
||||||
|
bds_core::db::queries::project::insert_project(db.conn(), &test_project(&project_id, "events"))
|
||||||
|
.unwrap();
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let source = dir.path().join("event.png");
|
||||||
|
DynamicImage::new_rgb8(2, 2).save(&source).unwrap();
|
||||||
|
let setting_key = format!("test.event.{}", Uuid::new_v4());
|
||||||
|
let subscription = domain_events::subscribe();
|
||||||
|
|
||||||
|
let post = bds_core::engine::post::create_post(
|
||||||
|
db.conn(),
|
||||||
|
dir.path(),
|
||||||
|
&project_id,
|
||||||
|
"Post",
|
||||||
|
Some("Body"),
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let media = bds_core::engine::media::import_media(
|
||||||
|
db.conn(),
|
||||||
|
dir.path(),
|
||||||
|
&project_id,
|
||||||
|
&source,
|
||||||
|
"event.png",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let tag = bds_core::engine::tag::create_tag(db.conn(), dir.path(), &project_id, "Event", None)
|
||||||
|
.unwrap();
|
||||||
|
let template = bds_core::engine::template::create_template(
|
||||||
|
db.conn(),
|
||||||
|
&project_id,
|
||||||
|
"Event",
|
||||||
|
TemplateKind::Post,
|
||||||
|
"{{ content }}",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let script = bds_core::engine::script::create_script(
|
||||||
|
db.conn(),
|
||||||
|
&project_id,
|
||||||
|
"Event",
|
||||||
|
ScriptKind::Utility,
|
||||||
|
"function main() end",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let created_project = bds_core::engine::project::create_project(
|
||||||
|
db.conn(),
|
||||||
|
"Event Project",
|
||||||
|
Some(dir.path().join("project").to_string_lossy().as_ref()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
bds_core::engine::settings::set(db.conn(), &setting_key, "value").unwrap();
|
||||||
|
|
||||||
|
let relevant = subscription
|
||||||
|
.drain()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|event| match event {
|
||||||
|
DomainEvent::EntityChanged {
|
||||||
|
project_id: scope, ..
|
||||||
|
} => scope == &project_id || scope == &created_project.id,
|
||||||
|
DomainEvent::SettingsChanged { key, .. } => key == &setting_key,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(relevant.len(), 7);
|
||||||
|
for (entity, id) in [
|
||||||
|
(DomainEntity::Post, post.id.clone()),
|
||||||
|
(DomainEntity::Media, media.id.clone()),
|
||||||
|
(DomainEntity::Tag, tag.id.clone()),
|
||||||
|
(DomainEntity::Template, template.id.clone()),
|
||||||
|
(DomainEntity::Script, script.id.clone()),
|
||||||
|
(DomainEntity::Project, created_project.id.clone()),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
relevant
|
||||||
|
.iter()
|
||||||
|
.filter(|event| matches!(
|
||||||
|
event,
|
||||||
|
DomainEvent::EntityChanged {
|
||||||
|
entity: actual_entity,
|
||||||
|
entity_id,
|
||||||
|
action: NotificationAction::Created,
|
||||||
|
..
|
||||||
|
} if actual_entity == &entity && entity_id == &id
|
||||||
|
))
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"{entity:?} mutation must emit once"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
relevant
|
||||||
|
.iter()
|
||||||
|
.filter(|event| matches!(
|
||||||
|
event,
|
||||||
|
DomainEvent::SettingsChanged { key, project_id: None } if key == &setting_key
|
||||||
|
))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
bds_core::engine::post::update_post(
|
||||||
|
db.conn(),
|
||||||
|
dir.path(),
|
||||||
|
&post.id,
|
||||||
|
Some("Updated Post"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_one_entity_event(
|
||||||
|
&subscription,
|
||||||
|
DomainEntity::Post,
|
||||||
|
&post.id,
|
||||||
|
NotificationAction::Updated,
|
||||||
|
);
|
||||||
|
bds_core::engine::post::publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||||
|
assert_one_entity_event(
|
||||||
|
&subscription,
|
||||||
|
DomainEntity::Post,
|
||||||
|
&post.id,
|
||||||
|
NotificationAction::Updated,
|
||||||
|
);
|
||||||
|
|
||||||
|
bds_core::engine::media::update_media(
|
||||||
|
db.conn(),
|
||||||
|
dir.path(),
|
||||||
|
&media.id,
|
||||||
|
Some(Some("Updated Media")),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_one_entity_event(
|
||||||
|
&subscription,
|
||||||
|
DomainEntity::Media,
|
||||||
|
&media.id,
|
||||||
|
NotificationAction::Updated,
|
||||||
|
);
|
||||||
|
bds_core::engine::tag::update_tag(
|
||||||
|
db.conn(),
|
||||||
|
dir.path(),
|
||||||
|
&tag.id,
|
||||||
|
Some("Updated Tag"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_one_entity_event(
|
||||||
|
&subscription,
|
||||||
|
DomainEntity::Tag,
|
||||||
|
&tag.id,
|
||||||
|
NotificationAction::Updated,
|
||||||
|
);
|
||||||
|
bds_core::engine::template::publish_template(db.conn(), dir.path(), &template.id).unwrap();
|
||||||
|
assert_one_entity_event(
|
||||||
|
&subscription,
|
||||||
|
DomainEntity::Template,
|
||||||
|
&template.id,
|
||||||
|
NotificationAction::Updated,
|
||||||
|
);
|
||||||
|
bds_core::engine::script::publish_script(db.conn(), dir.path(), &script.id).unwrap();
|
||||||
|
assert_one_entity_event(
|
||||||
|
&subscription,
|
||||||
|
DomainEntity::Script,
|
||||||
|
&script.id,
|
||||||
|
NotificationAction::Updated,
|
||||||
|
);
|
||||||
|
|
||||||
|
bds_core::engine::post::delete_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||||
|
assert_one_entity_event(
|
||||||
|
&subscription,
|
||||||
|
DomainEntity::Post,
|
||||||
|
&post.id,
|
||||||
|
NotificationAction::Deleted,
|
||||||
|
);
|
||||||
|
bds_core::engine::media::delete_media(db.conn(), dir.path(), &media.id).unwrap();
|
||||||
|
assert_one_entity_event(
|
||||||
|
&subscription,
|
||||||
|
DomainEntity::Media,
|
||||||
|
&media.id,
|
||||||
|
NotificationAction::Deleted,
|
||||||
|
);
|
||||||
|
bds_core::engine::tag::delete_tag(db.conn(), dir.path(), &project_id, &tag.id).unwrap();
|
||||||
|
assert_one_entity_event(
|
||||||
|
&subscription,
|
||||||
|
DomainEntity::Tag,
|
||||||
|
&tag.id,
|
||||||
|
NotificationAction::Deleted,
|
||||||
|
);
|
||||||
|
bds_core::engine::template::delete_template(db.conn(), dir.path(), &template.id, false)
|
||||||
|
.unwrap();
|
||||||
|
assert_one_entity_event(
|
||||||
|
&subscription,
|
||||||
|
DomainEntity::Template,
|
||||||
|
&template.id,
|
||||||
|
NotificationAction::Deleted,
|
||||||
|
);
|
||||||
|
bds_core::engine::script::delete_script(db.conn(), dir.path(), &script.id).unwrap();
|
||||||
|
assert_one_entity_event(
|
||||||
|
&subscription,
|
||||||
|
DomainEntity::Script,
|
||||||
|
&script.id,
|
||||||
|
NotificationAction::Deleted,
|
||||||
|
);
|
||||||
|
bds_core::engine::project::delete_project(
|
||||||
|
db.conn(),
|
||||||
|
&created_project.id,
|
||||||
|
Some(&dir.path().join("project")),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_one_entity_event(
|
||||||
|
&subscription,
|
||||||
|
DomainEntity::Project,
|
||||||
|
&created_project.id,
|
||||||
|
NotificationAction::Deleted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cli_mutation_persists_the_shared_event_for_the_desktop_process() {
|
||||||
|
let db = Database::open_in_memory().unwrap();
|
||||||
|
db.migrate().unwrap();
|
||||||
|
bds_core::db::fts::ensure_fts_tables(db.conn()).unwrap();
|
||||||
|
let project_id = Uuid::new_v4().to_string();
|
||||||
|
bds_core::db::queries::project::insert_project(db.conn(), &test_project(&project_id, "cli"))
|
||||||
|
.unwrap();
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
let post = cli_sync::run_cli_mutation(db.conn(), || {
|
||||||
|
bds_core::engine::post::create_post(
|
||||||
|
db.conn(),
|
||||||
|
dir.path(),
|
||||||
|
&project_id,
|
||||||
|
"CLI",
|
||||||
|
None,
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let notifications =
|
||||||
|
bds_core::db::queries::db_notification::list_notifications(db.conn()).unwrap();
|
||||||
|
assert_eq!(notifications.len(), 1);
|
||||||
|
assert_eq!(notifications[0].entity_id, post.id);
|
||||||
|
assert_eq!(
|
||||||
|
notifications[0].project_id.as_deref(),
|
||||||
|
Some(project_id.as_str())
|
||||||
|
);
|
||||||
|
assert!(notifications[0].from_cli);
|
||||||
|
}
|
||||||
@@ -11,10 +11,10 @@ use bds_core::db::Database;
|
|||||||
use bds_core::engine;
|
use bds_core::engine;
|
||||||
use bds_core::engine::ai::{self, AiEndpointConfig, AiEndpointKind};
|
use bds_core::engine::ai::{self, AiEndpointConfig, AiEndpointKind};
|
||||||
use bds_core::engine::task::{TaskId, TaskManager, TaskStatus};
|
use bds_core::engine::task::{TaskId, TaskManager, TaskStatus};
|
||||||
use bds_core::i18n::{UiLocale, detect_os_locale};
|
use bds_core::i18n::{UiLocale, detect_os_locale, normalize_language};
|
||||||
use bds_core::model::{
|
use bds_core::model::{
|
||||||
ImportDefinition, ImportReport, Media, Post, PostStatus, PostTranslation, Project,
|
DomainEntity, DomainEvent, ImportDefinition, ImportReport, Media, NotificationAction, Post,
|
||||||
PublishingPreferences, Script, SshMode, Template,
|
PostStatus, PostTranslation, Project, PublishingPreferences, Script, SshMode, Template,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::components::webview::{self, WebViewConfig, WebViewController};
|
use crate::components::webview::{self, WebViewConfig, WebViewController};
|
||||||
@@ -144,6 +144,7 @@ pub enum Message {
|
|||||||
|
|
||||||
// Tasks
|
// Tasks
|
||||||
TaskTick,
|
TaskTick,
|
||||||
|
DomainEventsTick,
|
||||||
CancelTask(TaskId),
|
CancelTask(TaskId),
|
||||||
ToggleTaskGroup(String),
|
ToggleTaskGroup(String),
|
||||||
|
|
||||||
@@ -718,21 +719,10 @@ fn save_script_editor_state_impl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn save_editor_settings_state_impl(db: &Database, state: &SettingsViewState) -> Result<(), String> {
|
fn save_editor_settings_state_impl(db: &Database, state: &SettingsViewState) -> Result<(), String> {
|
||||||
let now = bds_core::util::now_unix_ms();
|
|
||||||
[
|
[
|
||||||
bds_core::db::queries::setting::set_setting_value(
|
engine::settings::set(db.conn(), "editor.default_mode", &state.default_mode),
|
||||||
db.conn(),
|
engine::settings::set(db.conn(), "editor.diff_view_style", &state.diff_view_style),
|
||||||
"editor.default_mode",
|
engine::settings::set(
|
||||||
&state.default_mode,
|
|
||||||
now,
|
|
||||||
),
|
|
||||||
bds_core::db::queries::setting::set_setting_value(
|
|
||||||
db.conn(),
|
|
||||||
"editor.diff_view_style",
|
|
||||||
&state.diff_view_style,
|
|
||||||
now,
|
|
||||||
),
|
|
||||||
bds_core::db::queries::setting::set_setting_value(
|
|
||||||
db.conn(),
|
db.conn(),
|
||||||
"editor.wrap_long_lines",
|
"editor.wrap_long_lines",
|
||||||
if state.wrap_long_lines {
|
if state.wrap_long_lines {
|
||||||
@@ -740,9 +730,8 @@ fn save_editor_settings_state_impl(db: &Database, state: &SettingsViewState) ->
|
|||||||
} else {
|
} else {
|
||||||
"false"
|
"false"
|
||||||
},
|
},
|
||||||
now,
|
|
||||||
),
|
),
|
||||||
bds_core::db::queries::setting::set_setting_value(
|
engine::settings::set(
|
||||||
db.conn(),
|
db.conn(),
|
||||||
"editor.hide_unchanged_regions",
|
"editor.hide_unchanged_regions",
|
||||||
if state.hide_unchanged_regions {
|
if state.hide_unchanged_regions {
|
||||||
@@ -750,7 +739,6 @@ fn save_editor_settings_state_impl(db: &Database, state: &SettingsViewState) ->
|
|||||||
} else {
|
} else {
|
||||||
"false"
|
"false"
|
||||||
},
|
},
|
||||||
now,
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -849,6 +837,7 @@ pub struct BdsApp {
|
|||||||
|
|
||||||
// Tasks
|
// Tasks
|
||||||
task_manager: Arc<TaskManager>,
|
task_manager: Arc<TaskManager>,
|
||||||
|
domain_events: engine::domain_events::EventSubscription,
|
||||||
script_menu_actions: Arc<Mutex<Vec<MenuAction>>>,
|
script_menu_actions: Arc<Mutex<Vec<MenuAction>>>,
|
||||||
task_snapshots: Vec<TaskSnapshot>,
|
task_snapshots: Vec<TaskSnapshot>,
|
||||||
collapsed_task_groups: HashSet<String>,
|
collapsed_task_groups: HashSet<String>,
|
||||||
@@ -911,8 +900,7 @@ pub struct BdsApp {
|
|||||||
|
|
||||||
impl BdsApp {
|
impl BdsApp {
|
||||||
pub fn new() -> (Self, Task<Message>) {
|
pub fn new() -> (Self, Task<Message>) {
|
||||||
let locale = detect_os_locale();
|
let os_locale = detect_os_locale();
|
||||||
let (menu_bar, registry) = menu::build_menu_bar(locale);
|
|
||||||
|
|
||||||
// Open or create the database
|
// Open or create the database
|
||||||
let db_path = dirs::data_dir()
|
let db_path = dirs::data_dir()
|
||||||
@@ -929,6 +917,12 @@ impl BdsApp {
|
|||||||
if let Some(ref db) = db {
|
if let Some(ref db) = db {
|
||||||
let _ = db.migrate();
|
let _ = db.migrate();
|
||||||
}
|
}
|
||||||
|
let locale = db
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|db| engine::settings::ui_language(db.conn()).ok().flatten())
|
||||||
|
.map(|language| normalize_language(&language))
|
||||||
|
.unwrap_or(os_locale);
|
||||||
|
let (menu_bar, registry) = menu::build_menu_bar(locale);
|
||||||
let search_index_rebuild_required = db
|
let search_index_rebuild_required = db
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|db| engine::search::prepare_search_index(db.conn()).unwrap_or(true));
|
.is_some_and(|db| engine::search::prepare_search_index(db.conn()).unwrap_or(true));
|
||||||
@@ -1010,6 +1004,7 @@ impl BdsApp {
|
|||||||
panel_visible: false,
|
panel_visible: false,
|
||||||
panel_tab: PanelTab::Tasks,
|
panel_tab: PanelTab::Tasks,
|
||||||
task_manager: Arc::new(TaskManager::default()),
|
task_manager: Arc::new(TaskManager::default()),
|
||||||
|
domain_events: engine::domain_events::subscribe(),
|
||||||
script_menu_actions: Arc::new(Mutex::new(Vec::new())),
|
script_menu_actions: Arc::new(Mutex::new(Vec::new())),
|
||||||
task_snapshots: Vec::new(),
|
task_snapshots: Vec::new(),
|
||||||
collapsed_task_groups: HashSet::new(),
|
collapsed_task_groups: HashSet::new(),
|
||||||
@@ -1083,6 +1078,7 @@ impl BdsApp {
|
|||||||
panel_visible: false,
|
panel_visible: false,
|
||||||
panel_tab: PanelTab::Tasks,
|
panel_tab: PanelTab::Tasks,
|
||||||
task_manager: Arc::new(TaskManager::default()),
|
task_manager: Arc::new(TaskManager::default()),
|
||||||
|
domain_events: engine::domain_events::subscribe(),
|
||||||
script_menu_actions: Arc::new(Mutex::new(Vec::new())),
|
script_menu_actions: Arc::new(Mutex::new(Vec::new())),
|
||||||
task_snapshots: Vec::new(),
|
task_snapshots: Vec::new(),
|
||||||
collapsed_task_groups: HashSet::new(),
|
collapsed_task_groups: HashSet::new(),
|
||||||
@@ -1844,6 +1840,7 @@ impl BdsApp {
|
|||||||
.map(|action| self.dispatch_menu_action(action)),
|
.map(|action| self.dispatch_menu_action(action)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Message::DomainEventsTick => self.process_domain_events(),
|
||||||
Message::CancelTask(task_id) => {
|
Message::CancelTask(task_id) => {
|
||||||
if self.cancel_site_generation_task(task_id) {
|
if self.cancel_site_generation_task(task_id) {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
@@ -2036,15 +2033,20 @@ impl BdsApp {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::SetUiLocale(locale) => {
|
Message::SetUiLocale(locale) => {
|
||||||
self.ui_locale = locale;
|
if let Some(db) = &self.db {
|
||||||
self.locale_dropdown_open = false;
|
match engine::settings::set(
|
||||||
menu::update_menu_labels(&self.menu_registry, locale);
|
db.conn(),
|
||||||
// Re-translate singleton tab titles per tabs.allium
|
engine::settings::UI_LANGUAGE_KEY,
|
||||||
for tab in &mut self.tabs {
|
locale.code(),
|
||||||
if let Some(key) = tab.tab_type.i18n_key() {
|
) {
|
||||||
tab.title = t(locale, key);
|
Ok(()) => self.apply_ui_locale(locale),
|
||||||
|
Err(error) => {
|
||||||
|
self.add_output(&error.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
self.apply_ui_locale(locale);
|
||||||
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::ToggleLocaleDropdown => {
|
Message::ToggleLocaleDropdown => {
|
||||||
@@ -2416,6 +2418,8 @@ impl BdsApp {
|
|||||||
|
|
||||||
let task_tick =
|
let task_tick =
|
||||||
iced::time::every(std::time::Duration::from_millis(500)).map(|_| Message::TaskTick);
|
iced::time::every(std::time::Duration::from_millis(500)).map(|_| Message::TaskTick);
|
||||||
|
let domain_event_tick = iced::time::every(std::time::Duration::from_millis(100))
|
||||||
|
.map(|_| Message::DomainEventsTick);
|
||||||
|
|
||||||
let toast_tick = if !self.toasts.is_empty() {
|
let toast_tick = if !self.toasts.is_empty() {
|
||||||
iced::time::every(std::time::Duration::from_millis(250)).map(|_| Message::ExpireToasts)
|
iced::time::every(std::time::Duration::from_millis(250)).map(|_| Message::ExpireToasts)
|
||||||
@@ -2441,11 +2445,232 @@ impl BdsApp {
|
|||||||
Subscription::none()
|
Subscription::none()
|
||||||
};
|
};
|
||||||
|
|
||||||
Subscription::batch([menu_sub, task_tick, toast_tick, drag_sub])
|
Subscription::batch([menu_sub, task_tick, domain_event_tick, toast_tick, drag_sub])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Private helpers ──
|
// ── Private helpers ──
|
||||||
|
|
||||||
|
fn apply_ui_locale(&mut self, locale: UiLocale) {
|
||||||
|
self.ui_locale = locale;
|
||||||
|
self.locale_dropdown_open = false;
|
||||||
|
menu::update_menu_labels(&self.menu_registry, locale);
|
||||||
|
for tab in &mut self.tabs {
|
||||||
|
if let Some(key) = tab.tab_type.i18n_key() {
|
||||||
|
tab.title = t(locale, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_domain_events(&mut self) -> Task<Message> {
|
||||||
|
if let Some(db) = &self.db {
|
||||||
|
let _ = engine::cli_sync::poll_notifications(db.conn());
|
||||||
|
}
|
||||||
|
let events = self.domain_events.drain();
|
||||||
|
if events.is_empty() {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut refresh_content = false;
|
||||||
|
for event in events {
|
||||||
|
refresh_content |= self.handle_domain_event(event);
|
||||||
|
}
|
||||||
|
if refresh_content {
|
||||||
|
self.refresh_counts()
|
||||||
|
} else {
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_domain_event(&mut self, event: DomainEvent) -> bool {
|
||||||
|
match event {
|
||||||
|
DomainEvent::SettingsChanged { project_id, key } => {
|
||||||
|
let relevant = project_id.is_none()
|
||||||
|
|| project_id.as_deref()
|
||||||
|
== self
|
||||||
|
.active_project
|
||||||
|
.as_ref()
|
||||||
|
.map(|project| project.id.as_str());
|
||||||
|
if !relevant {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if key == engine::settings::UI_LANGUAGE_KEY
|
||||||
|
&& let Some(db) = &self.db
|
||||||
|
&& let Ok(Some(language)) = engine::settings::ui_language(db.conn())
|
||||||
|
{
|
||||||
|
self.apply_ui_locale(normalize_language(&language));
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.tabs
|
||||||
|
.iter()
|
||||||
|
.any(|tab| tab.tab_type == TabType::Settings)
|
||||||
|
{
|
||||||
|
self.settings_state = None;
|
||||||
|
if let Some(tab) = self
|
||||||
|
.tabs
|
||||||
|
.iter()
|
||||||
|
.find(|tab| tab.tab_type == TabType::Settings)
|
||||||
|
.cloned()
|
||||||
|
{
|
||||||
|
self.load_editor_for_tab(&tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
DomainEvent::EntityChanged {
|
||||||
|
project_id,
|
||||||
|
entity: DomainEntity::Project,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let previous_active = self
|
||||||
|
.active_project
|
||||||
|
.as_ref()
|
||||||
|
.map(|project| project.id.clone());
|
||||||
|
if let Some(db) = &self.db {
|
||||||
|
self.projects = engine::project::list_projects(db.conn()).unwrap_or_default();
|
||||||
|
self.active_project = engine::project::get_active_project(db.conn())
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
self.data_dir = self
|
||||||
|
.active_project
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|project| project.data_path.as_deref())
|
||||||
|
.map(PathBuf::from);
|
||||||
|
}
|
||||||
|
previous_active.as_deref() == Some(project_id.as_str())
|
||||||
|
|| self
|
||||||
|
.active_project
|
||||||
|
.as_ref()
|
||||||
|
.map(|project| project.id.as_str())
|
||||||
|
== Some(project_id.as_str())
|
||||||
|
}
|
||||||
|
DomainEvent::EntityChanged {
|
||||||
|
project_id,
|
||||||
|
entity,
|
||||||
|
entity_id,
|
||||||
|
action,
|
||||||
|
} => {
|
||||||
|
if self
|
||||||
|
.active_project
|
||||||
|
.as_ref()
|
||||||
|
.map(|project| project.id.as_str())
|
||||||
|
!= Some(project_id.as_str())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.refresh_entity_editor(entity, &entity_id, action);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_entity_editor(
|
||||||
|
&mut self,
|
||||||
|
entity: DomainEntity,
|
||||||
|
entity_id: &str,
|
||||||
|
action: NotificationAction,
|
||||||
|
) {
|
||||||
|
if action == NotificationAction::Deleted {
|
||||||
|
match entity {
|
||||||
|
DomainEntity::Post => {
|
||||||
|
self.post_editors.remove(entity_id);
|
||||||
|
}
|
||||||
|
DomainEntity::Media => {
|
||||||
|
self.media_editors.remove(entity_id);
|
||||||
|
}
|
||||||
|
DomainEntity::Template => {
|
||||||
|
self.template_editors.remove(entity_id);
|
||||||
|
}
|
||||||
|
DomainEntity::Script => {
|
||||||
|
self.script_editors.remove(entity_id);
|
||||||
|
}
|
||||||
|
DomainEntity::Tag => {
|
||||||
|
self.tags_view_state = None;
|
||||||
|
}
|
||||||
|
DomainEntity::Project | DomainEntity::Setting => {}
|
||||||
|
}
|
||||||
|
if !matches!(entity, DomainEntity::Tag) {
|
||||||
|
self.close_entity_tab(entity_id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if entity == DomainEntity::Tag {
|
||||||
|
self.tags_view_state = None;
|
||||||
|
if let Some(tab) = self
|
||||||
|
.tabs
|
||||||
|
.iter()
|
||||||
|
.find(|tab| tab.tab_type == TabType::Tags)
|
||||||
|
.cloned()
|
||||||
|
{
|
||||||
|
self.load_editor_for_tab(&tab);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tab = self.tabs.iter().find(|tab| tab.id == entity_id).cloned();
|
||||||
|
let Some(tab) = tab else { return };
|
||||||
|
let dirty = match entity {
|
||||||
|
DomainEntity::Post => self
|
||||||
|
.post_editors
|
||||||
|
.get(entity_id)
|
||||||
|
.is_some_and(|state| state.is_dirty),
|
||||||
|
DomainEntity::Media => self
|
||||||
|
.media_editors
|
||||||
|
.get(entity_id)
|
||||||
|
.is_some_and(|state| state.is_dirty),
|
||||||
|
DomainEntity::Template => self
|
||||||
|
.template_editors
|
||||||
|
.get(entity_id)
|
||||||
|
.is_some_and(|state| state.is_dirty),
|
||||||
|
DomainEntity::Script => self
|
||||||
|
.script_editors
|
||||||
|
.get(entity_id)
|
||||||
|
.is_some_and(|state| state.is_dirty),
|
||||||
|
DomainEntity::Tag | DomainEntity::Project | DomainEntity::Setting => false,
|
||||||
|
};
|
||||||
|
if dirty {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match entity {
|
||||||
|
DomainEntity::Post => {
|
||||||
|
self.post_editors.remove(entity_id);
|
||||||
|
}
|
||||||
|
DomainEntity::Media => {
|
||||||
|
self.media_editors.remove(entity_id);
|
||||||
|
}
|
||||||
|
DomainEntity::Template => {
|
||||||
|
self.template_editors.remove(entity_id);
|
||||||
|
}
|
||||||
|
DomainEntity::Script => {
|
||||||
|
self.script_editors.remove(entity_id);
|
||||||
|
}
|
||||||
|
DomainEntity::Tag | DomainEntity::Project | DomainEntity::Setting => {}
|
||||||
|
}
|
||||||
|
self.load_editor_for_tab(&tab);
|
||||||
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == entity_id) {
|
||||||
|
tab.title = match entity {
|
||||||
|
DomainEntity::Post => self
|
||||||
|
.post_editors
|
||||||
|
.get(entity_id)
|
||||||
|
.map(|state| state.title.clone()),
|
||||||
|
DomainEntity::Media => self
|
||||||
|
.media_editors
|
||||||
|
.get(entity_id)
|
||||||
|
.map(|state| state.title.clone()),
|
||||||
|
DomainEntity::Template => self
|
||||||
|
.template_editors
|
||||||
|
.get(entity_id)
|
||||||
|
.map(|state| state.title.clone()),
|
||||||
|
DomainEntity::Script => self
|
||||||
|
.script_editors
|
||||||
|
.get(entity_id)
|
||||||
|
.map(|state| state.title.clone()),
|
||||||
|
DomainEntity::Tag | DomainEntity::Project | DomainEntity::Setting => None,
|
||||||
|
}
|
||||||
|
.unwrap_or_else(|| tab.title.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn dispatch_menu_action(&mut self, action: MenuAction) -> Task<Message> {
|
fn dispatch_menu_action(&mut self, action: MenuAction) -> Task<Message> {
|
||||||
match action {
|
match action {
|
||||||
// File
|
// File
|
||||||
@@ -8587,4 +8812,156 @@ mod tests {
|
|||||||
assert_eq!(regenerated, 1);
|
assert_eq!(regenerated, 1);
|
||||||
assert!(small_thumb.exists());
|
assert!(small_thumb.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn desktop_watcher_consumes_cli_event_refreshes_sidebar_and_marks_seen() {
|
||||||
|
let (db, project, tmp) = setup();
|
||||||
|
let created = script::create_script(
|
||||||
|
db.conn(),
|
||||||
|
&project.id,
|
||||||
|
"From CLI",
|
||||||
|
ScriptKind::Utility,
|
||||||
|
"function main() end",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut app = make_app(db, project.clone(), &tmp);
|
||||||
|
bds_core::engine::cli_sync::record_cli_event(
|
||||||
|
app.db.as_ref().unwrap().conn(),
|
||||||
|
&bds_core::model::DomainEvent::EntityChanged {
|
||||||
|
project_id: project.id,
|
||||||
|
entity: bds_core::model::DomainEntity::Script,
|
||||||
|
entity_id: created.id.clone(),
|
||||||
|
action: bds_core::model::NotificationAction::Created,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let _ = app.update(Message::DomainEventsTick);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.sidebar_scripts
|
||||||
|
.iter()
|
||||||
|
.any(|script| script.id == created.id)
|
||||||
|
);
|
||||||
|
let rows = bds_core::db::queries::db_notification::list_notifications(
|
||||||
|
app.db.as_ref().unwrap().conn(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert!(rows[0].seen_at.is_some());
|
||||||
|
assert!(app.toasts.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn locale_setting_is_persisted_and_external_event_reloads_without_feedback() {
|
||||||
|
let (db, project, tmp) = setup();
|
||||||
|
let mut app = make_app(db, project, &tmp);
|
||||||
|
|
||||||
|
let _ = app.update(Message::SetUiLocale(UiLocale::De));
|
||||||
|
assert_eq!(app.ui_locale, UiLocale::De);
|
||||||
|
assert_eq!(
|
||||||
|
bds_core::engine::settings::ui_language(app.db.as_ref().unwrap().conn())
|
||||||
|
.unwrap()
|
||||||
|
.as_deref(),
|
||||||
|
Some("de")
|
||||||
|
);
|
||||||
|
|
||||||
|
bds_core::engine::settings::set(
|
||||||
|
app.db.as_ref().unwrap().conn(),
|
||||||
|
bds_core::engine::settings::UI_LANGUAGE_KEY,
|
||||||
|
"fr-FR",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let _ = app.update(Message::DomainEventsTick);
|
||||||
|
|
||||||
|
assert_eq!(app.ui_locale, UiLocale::Fr);
|
||||||
|
assert!(app.toasts.is_empty());
|
||||||
|
assert!(
|
||||||
|
bds_core::db::queries::db_notification::list_notifications(
|
||||||
|
app.db.as_ref().unwrap().conn()
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleted_entity_event_closes_its_open_editor_without_notification() {
|
||||||
|
let (db, project, tmp) = setup();
|
||||||
|
let created = script::create_script(
|
||||||
|
db.conn(),
|
||||||
|
&project.id,
|
||||||
|
"Delete externally",
|
||||||
|
ScriptKind::Utility,
|
||||||
|
"function main() end",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut app = make_app(db, project, &tmp);
|
||||||
|
let tab = Tab {
|
||||||
|
id: created.id.clone(),
|
||||||
|
tab_type: TabType::Scripts,
|
||||||
|
title: created.title,
|
||||||
|
is_transient: false,
|
||||||
|
is_dirty: false,
|
||||||
|
};
|
||||||
|
app.tabs.push(tab.clone());
|
||||||
|
app.load_editor_for_tab(&tab);
|
||||||
|
|
||||||
|
script::delete_script(app.db.as_ref().unwrap().conn(), tmp.path(), &created.id).unwrap();
|
||||||
|
let _ = app.update(Message::DomainEventsTick);
|
||||||
|
|
||||||
|
assert!(!app.tabs.iter().any(|tab| tab.id == created.id));
|
||||||
|
assert!(!app.script_editors.contains_key(&created.id));
|
||||||
|
assert!(app.toasts.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_update_event_preserves_unsaved_editor_buffer() {
|
||||||
|
let (db, project, tmp) = setup();
|
||||||
|
let created = script::create_script(
|
||||||
|
db.conn(),
|
||||||
|
&project.id,
|
||||||
|
"Edit externally",
|
||||||
|
ScriptKind::Utility,
|
||||||
|
"function main() end",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut app = make_app(db, project.clone(), &tmp);
|
||||||
|
let tab = Tab {
|
||||||
|
id: created.id.clone(),
|
||||||
|
tab_type: TabType::Scripts,
|
||||||
|
title: created.title,
|
||||||
|
is_transient: false,
|
||||||
|
is_dirty: true,
|
||||||
|
};
|
||||||
|
app.tabs.push(tab.clone());
|
||||||
|
app.load_editor_for_tab(&tab);
|
||||||
|
let editor = app.script_editors.get_mut(&created.id).unwrap();
|
||||||
|
editor.content = "local unsaved buffer".to_string();
|
||||||
|
editor.is_dirty = true;
|
||||||
|
|
||||||
|
script::update_script(
|
||||||
|
app.db.as_ref().unwrap().conn(),
|
||||||
|
&created.id,
|
||||||
|
&project.id,
|
||||||
|
Some("Remote title"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let _ = app.update(Message::DomainEventsTick);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.script_editors[&created.id].content,
|
||||||
|
"local unsaved buffer"
|
||||||
|
);
|
||||||
|
assert!(app.script_editors[&created.id].is_dirty);
|
||||||
|
assert!(app.toasts.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
-- Distilled from: src/main/engine/CliNotifier.ts, NotificationWatcher.ts
|
-- Distilled from: src/main/engine/CliNotifier.ts, NotificationWatcher.ts
|
||||||
|
|
||||||
entity DbNotification {
|
entity DbNotification {
|
||||||
entity_type: String -- post, media, script, template
|
project_id: String?
|
||||||
|
entity_type: String -- post, media, tag, script, template, project, setting
|
||||||
entity_id: String
|
entity_id: String
|
||||||
action: created | updated | deleted
|
action: created | updated | deleted
|
||||||
from_cli: Boolean
|
from_cli: Boolean
|
||||||
@@ -19,14 +20,15 @@ surface CliSyncRuntimeSurface {
|
|||||||
facing _: CliSyncRuntime
|
facing _: CliSyncRuntime
|
||||||
|
|
||||||
provides:
|
provides:
|
||||||
CliMutationPerformed(entity_type, entity_id, action)
|
CliMutationPerformed(project_id, entity_type, entity_id, action)
|
||||||
DbFileChangeDetected()
|
DbFileChangeDetected()
|
||||||
}
|
}
|
||||||
|
|
||||||
rule CliWriteNotification {
|
rule CliWriteNotification {
|
||||||
when: CliMutationPerformed(entity_type, entity_id, action)
|
when: CliMutationPerformed(project_id, entity_type, entity_id, action)
|
||||||
-- CLI inserts notification row; app watches for it
|
-- CLI inserts notification row; app watches for it
|
||||||
ensures: DbNotification.created(
|
ensures: DbNotification.created(
|
||||||
|
project_id: project_id,
|
||||||
entity_type: entity_type,
|
entity_type: entity_type,
|
||||||
entity_id: entity_id,
|
entity_id: entity_id,
|
||||||
action: action,
|
action: action,
|
||||||
@@ -43,7 +45,7 @@ rule AppWatchNotifications {
|
|||||||
for n in unseen:
|
for n in unseen:
|
||||||
ensures: n.seen_at = now
|
ensures: n.seen_at = now
|
||||||
ensures: EngineCacheInvalidated(n.entity_type)
|
ensures: EngineCacheInvalidated(n.entity_type)
|
||||||
ensures: EntityChangedEvent(n.entity_type, n.entity_id, n.action)
|
ensures: EntityChangedEvent(n.project_id, n.entity_type, n.entity_id, n.action)
|
||||||
-- IPC event to renderer for UI refresh
|
-- IPC event to renderer for UI refresh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
-- lib/bds/desktop/shell_live.ex, lib/bds/cli_sync/watcher.ex
|
-- lib/bds/desktop/shell_live.ex, lib/bds/cli_sync/watcher.ex
|
||||||
|
|
||||||
entity EntityChangedEvent {
|
entity EntityChangedEvent {
|
||||||
entity: String -- post, media, tag, template, script
|
project_id: String
|
||||||
|
entity: String -- post, media, tag, template, script, project
|
||||||
entity_id: String
|
entity_id: String
|
||||||
action: created | updated | deleted
|
action: created | updated | deleted
|
||||||
-- Same topic and payload shape as the CLI sync watcher, so one
|
-- Same topic and payload shape as the CLI sync watcher, so one
|
||||||
@@ -13,6 +14,7 @@ entity EntityChangedEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entity SettingsChangedEvent {
|
entity SettingsChangedEvent {
|
||||||
|
project_id: String? -- absent for a global setting
|
||||||
key: String -- e.g. ui.language
|
key: String -- e.g. ui.language
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,16 +22,18 @@ surface EventBusSurface {
|
|||||||
facing _: EventBus
|
facing _: EventBus
|
||||||
|
|
||||||
provides:
|
provides:
|
||||||
ContextMutationSucceeded(entity, entity_id, action)
|
ContextMutationSucceeded(project_id, entity, entity_id, action)
|
||||||
GlobalSettingWritten(key)
|
GlobalSettingWritten(key)
|
||||||
ClientSubscribed()
|
ClientSubscribed()
|
||||||
|
ClientUnsubscribed()
|
||||||
}
|
}
|
||||||
|
|
||||||
rule BroadcastEntityMutation {
|
rule BroadcastEntityMutation {
|
||||||
when: ContextMutationSucceeded(entity, entity_id, action)
|
when: ContextMutationSucceeded(project_id, entity, entity_id, action)
|
||||||
-- Posts, Media, Tags, Templates, Scripts broadcast after every
|
-- Posts, Media, Tags, Templates, Scripts broadcast after every
|
||||||
-- successful create/update/publish/delete (publish maps to updated).
|
-- successful create/update/publish/delete (publish maps to updated).
|
||||||
ensures: EntityChangedEvent.created(
|
ensures: EntityChangedEvent.created(
|
||||||
|
project_id: project_id,
|
||||||
entity: entity,
|
entity: entity,
|
||||||
entity_id: entity_id,
|
entity_id: entity_id,
|
||||||
action: action
|
action: action
|
||||||
@@ -43,13 +47,18 @@ rule BroadcastSettingsChange {
|
|||||||
|
|
||||||
rule ClientSynchronization {
|
rule ClientSynchronization {
|
||||||
when: ClientSubscribed()
|
when: ClientSubscribed()
|
||||||
-- Every shell (LiveView GUI or TUI session) subscribes on mount and
|
-- Every shell subscribes on mount. Each subscriber receives events in
|
||||||
-- refreshes affected views on each event; deleted entities close
|
-- publish order and refreshes only the affected project; deleted entities
|
||||||
-- their open tabs. This keeps all connected clients synchronized
|
-- close their open tabs. A refresh never writes the mutation again.
|
||||||
-- regardless of which client or pipeline originated the change.
|
|
||||||
ensures: ClientRefreshOnEvent()
|
ensures: ClientRefreshOnEvent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rule ClientUnsubscription {
|
||||||
|
when: ClientUnsubscribed()
|
||||||
|
-- A detached client receives no later events.
|
||||||
|
ensures: ClientDetached()
|
||||||
|
}
|
||||||
|
|
||||||
rule ServerSideUiLanguage {
|
rule ServerSideUiLanguage {
|
||||||
when: GlobalSettingWritten(key: "ui.language")
|
when: GlobalSettingWritten(key: "ui.language")
|
||||||
-- The UI language is a single server-side setting: changing it in any
|
-- The UI language is a single server-side setting: changing it in any
|
||||||
|
|||||||
Reference in New Issue
Block a user