refactor: fix HIGH+MEDIUM architectural violations from code review

HIGH: fix watch_medium data-loss bug, standardize error handling on
ApiError, fix dep direction (rss/template-askama no longer dep on
application), extract ImageFetcher port (remove reqwest from app layer),
move event construction from save_review to use case, extract
infra-wiring crate (DbPool/EventBusBackend dedup), deduplicate
presentation helpers (encode_error, export streaming, multipart parsing)

MEDIUM: split LocalApContentQuery god-trait 10→3 methods, dedup movie
resolution orchestration, add RemoteActorDto/PersonDto mappers, move
AppConfig to infra-wiring, fix SocialQueryPort Uuid→UserId, replace
stringly-typed api-types with domain enums, move count_reviews_in_year
to StatsRepository, dedup event publisher cfg blocks, extract
should_enrich, move group_by_month to application, dedup
count_local_posts, add FederationFlags Default, TUI input helper +
ShowError rename + typed auth errors, api-types cleanup
(UserSettingsDto/UserProfileBase/PreviewRowData)

102 files changed, -681 lines net
This commit is contained in:
2026-07-10 02:08:39 +02:00
parent 26152660bb
commit 12da356a40
110 changed files with 1399 additions and 1867 deletions

View File

@@ -240,7 +240,8 @@ pub enum Action {
entries: Vec<DiaryEntryDto>,
total: u64,
},
DiaryLoadFailed(String),
ShowError(String),
AuthExpired,
HistoryLoaded(ReviewHistoryResponse),
HistoryLoadFailed(String),
ReviewCreated,
@@ -371,6 +372,35 @@ pub fn parse_csv(content: &str) -> Vec<ParsedRow> {
rows
}
/// Returns a mutable reference to whichever text field currently has focus,
/// or `None` if the active widget is non-textual (e.g. a rating spinner).
fn focused_input(app: &mut App) -> Option<&mut String> {
match &mut app.screen {
Screen::Setup(s) => Some(&mut s.api_url),
Screen::Login(s) => match s.focused {
LoginField::Email => Some(&mut s.email),
LoginField::Password => Some(&mut s.password),
},
Screen::Main(m) => match m.tab {
Tab::AddReview => match m.add_review.focused {
AddReviewField::ExternalId => Some(&mut m.add_review.external_id),
AddReviewField::Title => Some(&mut m.add_review.title),
AddReviewField::Year => Some(&mut m.add_review.year),
AddReviewField::WatchedAt => Some(&mut m.add_review.watched_at),
AddReviewField::Comment => Some(&mut m.add_review.comment),
_ => None,
},
Tab::BulkImport if matches!(m.bulk_import.stage, BulkImportStage::EnterPath) => {
Some(&mut m.bulk_import.file_path)
}
Tab::Settings if matches!(m.settings.focused, SettingsField::ApiUrl) => {
Some(&mut m.settings.api_url)
}
_ => None,
},
}
}
pub fn update(app: &mut App, action: Action) -> Vec<Command> {
match action {
// ── Global ───────────────────────────────────────────────────────────
@@ -435,77 +465,15 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
// ── Shared text input ────────────────────────────────────────────────
Action::InputChar(c) => {
match &mut app.screen {
Screen::Setup(s) => s.api_url.push(c),
Screen::Login(s) => match s.focused {
LoginField::Email => s.email.push(c),
LoginField::Password => s.password.push(c),
},
Screen::Main(m) => match m.tab {
Tab::AddReview => match m.add_review.focused {
AddReviewField::ExternalId => m.add_review.external_id.push(c),
AddReviewField::Title => m.add_review.title.push(c),
AddReviewField::Year => m.add_review.year.push(c),
AddReviewField::WatchedAt => m.add_review.watched_at.push(c),
AddReviewField::Comment => m.add_review.comment.push(c),
_ => {}
},
Tab::BulkImport
if matches!(m.bulk_import.stage, BulkImportStage::EnterPath) =>
{
m.bulk_import.file_path.push(c);
}
Tab::Settings if matches!(m.settings.focused, SettingsField::ApiUrl) => {
m.settings.api_url.push(c);
}
_ => {}
},
if let Some(field) = focused_input(app) {
field.push(c);
}
vec![]
}
Action::Backspace => {
match &mut app.screen {
Screen::Setup(s) => {
s.api_url.pop();
}
Screen::Login(s) => match s.focused {
LoginField::Email => {
s.email.pop();
}
LoginField::Password => {
s.password.pop();
}
},
Screen::Main(m) => match m.tab {
Tab::AddReview => match m.add_review.focused {
AddReviewField::ExternalId => {
m.add_review.external_id.pop();
}
AddReviewField::Title => {
m.add_review.title.pop();
}
AddReviewField::Year => {
m.add_review.year.pop();
}
AddReviewField::WatchedAt => {
m.add_review.watched_at.pop();
}
AddReviewField::Comment => {
m.add_review.comment.pop();
}
_ => {}
},
Tab::BulkImport
if matches!(m.bulk_import.stage, BulkImportStage::EnterPath) =>
{
m.bulk_import.file_path.pop();
}
Tab::Settings if matches!(m.settings.focused, SettingsField::ApiUrl) => {
m.settings.api_url.pop();
}
_ => {}
},
if let Some(field) = focused_input(app) {
field.pop();
}
vec![]
}
@@ -705,17 +673,8 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
vec![]
}
Action::DiaryLoadFailed(msg) => {
Action::ShowError(msg) => {
app.loading = false;
if msg.contains("unauthorized") || msg.contains("Unauthorized") {
app.token = None;
app.screen = Screen::Login(LoginState::default());
app.status = Some(StatusMsg {
text: "Session expired. Please log in again.".into(),
is_error: true,
});
return vec![Command::ClearToken];
}
app.status = Some(StatusMsg {
text: msg,
is_error: true,
@@ -723,6 +682,17 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
vec![]
}
Action::AuthExpired => {
app.loading = false;
app.token = None;
app.screen = Screen::Login(LoginState::default());
app.status = Some(StatusMsg {
text: "Session expired. Please log in again.".into(),
is_error: true,
});
vec![Command::ClearToken]
}
Action::HistoryLoaded(h) => {
app.loading = false;
if let Screen::Main(m) = &mut app.screen {

View File

@@ -5,9 +5,17 @@ use tokio::sync::mpsc;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyModifiers};
use tui::app::{self, Action, App, BulkImportStage, Command, Screen, SettingsField, Tab};
use tui::client::ApiClient;
use tui::client::{ApiClient, ApiError};
use tui::config::Config;
/// Convert an API error into either `AuthExpired` (for 401s) or a `ShowError`.
fn api_err_action(e: ApiError) -> Action {
match e {
ApiError::Unauthorized => Action::AuthExpired,
other => Action::ShowError(other.to_string()),
}
}
fn main() -> anyhow::Result<()> {
Config::init_keyring()?;
tokio::runtime::Builder::new_multi_thread()
@@ -53,7 +61,7 @@ async fn run() -> anyhow::Result<()> {
entries: r.items,
total: r.total_count,
},
Err(e) => Action::DiaryLoadFailed(e.to_string()),
Err(e) => api_err_action(e),
};
let _ = tx2.send(action).await;
});
@@ -109,7 +117,7 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
let tx2 = tx.clone();
let msg = format!("Failed to save config: {e}");
tokio::spawn(async move {
let _ = tx2.send(Action::DiaryLoadFailed(msg)).await;
let _ = tx2.send(Action::ShowError(msg)).await;
});
}
client.update_url(&url);
@@ -123,7 +131,7 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
.unwrap_or_else(|e| Err(anyhow::anyhow!(e)))
{
let msg = format!("Token not saved to keychain: {e}");
let _ = tx2.send(Action::DiaryLoadFailed(msg)).await;
let _ = tx2.send(Action::ShowError(msg)).await;
}
});
}
@@ -158,7 +166,7 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
entries: r.items,
total: r.total_count,
},
Err(e) => Action::DiaryLoadFailed(e.to_string()),
Err(e) => api_err_action(e),
};
let _ = tx.send(action).await;
});
@@ -173,6 +181,7 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
tokio::spawn(async move {
let action = match c.get_movie_history(&token, movie_id).await {
Ok(r) => Action::HistoryLoaded(r),
Err(ApiError::Unauthorized) => Action::AuthExpired,
Err(e) => Action::HistoryLoadFailed(e.to_string()),
};
let _ = tx.send(action).await;
@@ -188,6 +197,7 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
tokio::spawn(async move {
let action = match c.create_review(&token, &req).await {
Ok(()) => Action::ReviewCreated,
Err(ApiError::Unauthorized) => Action::AuthExpired,
Err(e) => Action::ReviewCreateFailed(e.to_string()),
};
let _ = tx.send(action).await;
@@ -203,6 +213,7 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
tokio::spawn(async move {
let action = match c.delete_review(&token, id).await {
Ok(()) => Action::ReviewDeleted(id),
Err(ApiError::Unauthorized) => Action::AuthExpired,
Err(e) => Action::ReviewDeleteFailed(e.to_string()),
};
let _ = tx.send(action).await;