This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
use api_types::{DiaryEntryDto, LogReviewRequest, ReviewHistoryResponse};
|
||||
use crate::config::Config;
|
||||
use api_types::{DiaryEntryDto, LogReviewRequest, ReviewHistoryResponse};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ── Screens ───────────────────────────────────────────────────────────────────
|
||||
@@ -352,9 +352,17 @@ pub fn parse_csv(content: &str) -> Vec<ParsedRow> {
|
||||
},
|
||||
manual_title: if title.is_empty() { None } else { Some(title) },
|
||||
manual_release_year,
|
||||
manual_director: if director.is_empty() { None } else { Some(director) },
|
||||
manual_director: if director.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(director)
|
||||
},
|
||||
rating,
|
||||
comment: if comment.is_empty() { None } else { Some(comment) },
|
||||
comment: if comment.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(comment)
|
||||
},
|
||||
watched_at,
|
||||
}),
|
||||
});
|
||||
@@ -645,20 +653,22 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
|
||||
|
||||
Action::ScrollUp => {
|
||||
if let Screen::Main(m) = &mut app.screen
|
||||
&& m.diary.selected > 0 {
|
||||
m.diary.selected -= 1;
|
||||
m.diary.history = None;
|
||||
}
|
||||
&& m.diary.selected > 0
|
||||
{
|
||||
m.diary.selected -= 1;
|
||||
m.diary.history = None;
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
Action::OpenHistory => {
|
||||
if let Screen::Main(m) = &mut app.screen
|
||||
&& let Some(entry) = m.diary.entries.get(m.diary.selected) {
|
||||
let movie_id = entry.movie.id;
|
||||
app.loading = true;
|
||||
return vec![Command::LoadHistory { movie_id }];
|
||||
}
|
||||
&& let Some(entry) = m.diary.entries.get(m.diary.selected)
|
||||
{
|
||||
let movie_id = entry.movie.id;
|
||||
app.loading = true;
|
||||
return vec![Command::LoadHistory { movie_id }];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
@@ -675,11 +685,12 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
|
||||
|
||||
Action::LoadPrev => {
|
||||
if let Screen::Main(m) = &mut app.screen
|
||||
&& m.diary.offset > 0 {
|
||||
let prev = m.diary.offset.saturating_sub(20);
|
||||
m.diary.offset = prev;
|
||||
return vec![Command::LoadDiary { offset: prev }];
|
||||
}
|
||||
&& m.diary.offset > 0
|
||||
{
|
||||
let prev = m.diary.offset.saturating_sub(20);
|
||||
m.diary.offset = prev;
|
||||
return vec![Command::LoadDiary { offset: prev }];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
@@ -730,17 +741,19 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
|
||||
|
||||
Action::DeleteInit => {
|
||||
if let Screen::Main(m) = &mut app.screen
|
||||
&& let Some(entry) = m.diary.entries.get(m.diary.selected) {
|
||||
m.diary.delete_pending = Some(entry.review.id);
|
||||
}
|
||||
&& let Some(entry) = m.diary.entries.get(m.diary.selected)
|
||||
{
|
||||
m.diary.delete_pending = Some(entry.review.id);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
Action::DeleteConfirm => {
|
||||
if let Screen::Main(m) = &mut app.screen
|
||||
&& let Some(review_id) = m.diary.delete_pending.take() {
|
||||
return vec![Command::DeleteReview(review_id)];
|
||||
}
|
||||
&& let Some(review_id) = m.diary.delete_pending.take()
|
||||
{
|
||||
return vec![Command::DeleteReview(review_id)];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
@@ -778,72 +791,75 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
|
||||
// ── Add Review ────────────────────────────────────────────────────────
|
||||
Action::RatingUp => {
|
||||
if let Screen::Main(m) = &mut app.screen
|
||||
&& m.add_review.rating < 5 {
|
||||
m.add_review.rating += 1;
|
||||
}
|
||||
&& m.add_review.rating < 5
|
||||
{
|
||||
m.add_review.rating += 1;
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
Action::RatingDown => {
|
||||
if let Screen::Main(m) = &mut app.screen
|
||||
&& m.add_review.rating > 0 {
|
||||
m.add_review.rating -= 1;
|
||||
}
|
||||
&& m.add_review.rating > 0
|
||||
{
|
||||
m.add_review.rating -= 1;
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
Action::ReviewSubmit => {
|
||||
if let Screen::Main(m) = &app.screen
|
||||
&& m.tab == Tab::AddReview {
|
||||
let f = &m.add_review;
|
||||
let has_ext = !f.external_id.is_empty();
|
||||
let has_title = !f.title.is_empty();
|
||||
let has_watched = !f.watched_at.is_empty();
|
||||
let ext_id = if has_ext {
|
||||
Some(f.external_id.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let title = if has_title {
|
||||
Some(f.title.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let year: Option<u16> = f.year.parse().ok();
|
||||
let rating = f.rating;
|
||||
let comment = if f.comment.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(f.comment.clone())
|
||||
};
|
||||
let watched_at = f.watched_at.clone();
|
||||
&& m.tab == Tab::AddReview
|
||||
{
|
||||
let f = &m.add_review;
|
||||
let has_ext = !f.external_id.is_empty();
|
||||
let has_title = !f.title.is_empty();
|
||||
let has_watched = !f.watched_at.is_empty();
|
||||
let ext_id = if has_ext {
|
||||
Some(f.external_id.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let title = if has_title {
|
||||
Some(f.title.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let year: Option<u16> = f.year.parse().ok();
|
||||
let rating = f.rating;
|
||||
let comment = if f.comment.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(f.comment.clone())
|
||||
};
|
||||
let watched_at = f.watched_at.clone();
|
||||
|
||||
if !has_ext && !has_title {
|
||||
app.status = Some(StatusMsg {
|
||||
text: "Title or external ID required".into(),
|
||||
is_error: true,
|
||||
});
|
||||
return vec![];
|
||||
}
|
||||
if !has_watched {
|
||||
app.status = Some(StatusMsg {
|
||||
text: "Watched-at date required".into(),
|
||||
is_error: true,
|
||||
});
|
||||
return vec![];
|
||||
}
|
||||
let req = LogReviewRequest {
|
||||
external_metadata_id: ext_id,
|
||||
manual_title: title,
|
||||
manual_release_year: year,
|
||||
manual_director: None,
|
||||
rating,
|
||||
comment,
|
||||
watched_at,
|
||||
};
|
||||
app.loading = true;
|
||||
return vec![Command::CreateReview(req)];
|
||||
if !has_ext && !has_title {
|
||||
app.status = Some(StatusMsg {
|
||||
text: "Title or external ID required".into(),
|
||||
is_error: true,
|
||||
});
|
||||
return vec![];
|
||||
}
|
||||
if !has_watched {
|
||||
app.status = Some(StatusMsg {
|
||||
text: "Watched-at date required".into(),
|
||||
is_error: true,
|
||||
});
|
||||
return vec![];
|
||||
}
|
||||
let req = LogReviewRequest {
|
||||
external_metadata_id: ext_id,
|
||||
manual_title: title,
|
||||
manual_release_year: year,
|
||||
manual_director: None,
|
||||
rating,
|
||||
comment,
|
||||
watched_at,
|
||||
};
|
||||
app.loading = true;
|
||||
return vec![Command::CreateReview(req)];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
@@ -871,45 +887,49 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
|
||||
// ── Bulk Import ───────────────────────────────────────────────────────
|
||||
Action::BulkParseFile => {
|
||||
if let Screen::Main(m) = &mut app.screen
|
||||
&& m.tab == Tab::BulkImport && m.bulk_import.stage == BulkImportStage::EnterPath {
|
||||
let path = m.bulk_import.file_path.trim().to_string();
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(content) => {
|
||||
m.bulk_import.parsed = parse_csv(&content);
|
||||
m.bulk_import.stage = BulkImportStage::Preview;
|
||||
}
|
||||
Err(e) => {
|
||||
app.status = Some(StatusMsg {
|
||||
text: format!("Cannot read file: {e}"),
|
||||
is_error: true,
|
||||
});
|
||||
}
|
||||
&& m.tab == Tab::BulkImport
|
||||
&& m.bulk_import.stage == BulkImportStage::EnterPath
|
||||
{
|
||||
let path = m.bulk_import.file_path.trim().to_string();
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(content) => {
|
||||
m.bulk_import.parsed = parse_csv(&content);
|
||||
m.bulk_import.stage = BulkImportStage::Preview;
|
||||
}
|
||||
Err(e) => {
|
||||
app.status = Some(StatusMsg {
|
||||
text: format!("Cannot read file: {e}"),
|
||||
is_error: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
Action::BulkImportAll => {
|
||||
if let Screen::Main(m) = &mut app.screen
|
||||
&& m.tab == Tab::BulkImport && m.bulk_import.stage == BulkImportStage::Preview {
|
||||
let valid: Vec<LogReviewRequest> = m
|
||||
.bulk_import
|
||||
.parsed
|
||||
.iter()
|
||||
.filter_map(|r| r.result.as_ref().ok().cloned())
|
||||
.collect();
|
||||
if valid.is_empty() {
|
||||
app.status = Some(StatusMsg {
|
||||
text: "No valid rows to import".into(),
|
||||
is_error: true,
|
||||
});
|
||||
return vec![];
|
||||
}
|
||||
m.bulk_import.results = vec![None; valid.len()];
|
||||
m.bulk_import.valid_requests = valid;
|
||||
m.bulk_import.stage = BulkImportStage::Importing { done: 0 };
|
||||
return vec![Command::ImportNext(0)];
|
||||
&& m.tab == Tab::BulkImport
|
||||
&& m.bulk_import.stage == BulkImportStage::Preview
|
||||
{
|
||||
let valid: Vec<LogReviewRequest> = m
|
||||
.bulk_import
|
||||
.parsed
|
||||
.iter()
|
||||
.filter_map(|r| r.result.as_ref().ok().cloned())
|
||||
.collect();
|
||||
if valid.is_empty() {
|
||||
app.status = Some(StatusMsg {
|
||||
text: "No valid rows to import".into(),
|
||||
is_error: true,
|
||||
});
|
||||
return vec![];
|
||||
}
|
||||
m.bulk_import.results = vec![None; valid.len()];
|
||||
m.bulk_import.valid_requests = valid;
|
||||
m.bulk_import.stage = BulkImportStage::Importing { done: 0 };
|
||||
return vec![Command::ImportNext(0)];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
|
||||
@@ -97,11 +97,7 @@ impl ApiClient {
|
||||
Ok(check_status(resp).await?.json().await?)
|
||||
}
|
||||
|
||||
pub async fn export_diary(
|
||||
&self,
|
||||
token: &str,
|
||||
format: &str,
|
||||
) -> Result<Vec<u8>, ApiError> {
|
||||
pub async fn export_diary(&self, token: &str, format: &str) -> Result<Vec<u8>, ApiError> {
|
||||
let resp = self
|
||||
.http
|
||||
.get(self.api("/diary/export"))
|
||||
@@ -178,7 +174,9 @@ impl ApiClient {
|
||||
.http
|
||||
.post(self.api("/social/follow"))
|
||||
.bearer_auth(token)
|
||||
.json(&FollowRequest { handle: handle.into() })
|
||||
.json(&FollowRequest {
|
||||
handle: handle.into(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
check_status(resp).await?;
|
||||
@@ -190,7 +188,9 @@ impl ApiClient {
|
||||
.http
|
||||
.post(self.api("/social/unfollow"))
|
||||
.bearer_auth(token)
|
||||
.json(&ActorUrlRequest { actor_url: actor_url.into() })
|
||||
.json(&ActorUrlRequest {
|
||||
actor_url: actor_url.into(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
check_status(resp).await?;
|
||||
@@ -202,7 +202,9 @@ impl ApiClient {
|
||||
.http
|
||||
.post(self.api("/social/followers/accept"))
|
||||
.bearer_auth(token)
|
||||
.json(&ActorUrlRequest { actor_url: actor_url.into() })
|
||||
.json(&ActorUrlRequest {
|
||||
actor_url: actor_url.into(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
check_status(resp).await?;
|
||||
@@ -214,7 +216,9 @@ impl ApiClient {
|
||||
.http
|
||||
.post(self.api("/social/followers/reject"))
|
||||
.bearer_auth(token)
|
||||
.json(&ActorUrlRequest { actor_url: actor_url.into() })
|
||||
.json(&ActorUrlRequest {
|
||||
actor_url: actor_url.into(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
check_status(resp).await?;
|
||||
@@ -226,7 +230,9 @@ impl ApiClient {
|
||||
.http
|
||||
.post(self.api("/social/followers/remove"))
|
||||
.bearer_auth(token)
|
||||
.json(&ActorUrlRequest { actor_url: actor_url.into() })
|
||||
.json(&ActorUrlRequest {
|
||||
actor_url: actor_url.into(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
check_status(resp).await?;
|
||||
|
||||
@@ -42,21 +42,22 @@ async fn run() -> anyhow::Result<()> {
|
||||
|
||||
// If we start directly in Main (saved token), trigger an initial diary load
|
||||
if matches!(app.screen, Screen::Main(_))
|
||||
&& let Some(token) = &saved_token {
|
||||
let c = client.clone();
|
||||
let t = token.clone();
|
||||
let tx2 = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let action = match c.get_diary(&t, 0, 20).await {
|
||||
Ok(r) => Action::DiaryLoaded {
|
||||
entries: r.items,
|
||||
total: r.total_count,
|
||||
},
|
||||
Err(e) => Action::DiaryLoadFailed(e.to_string()),
|
||||
};
|
||||
let _ = tx2.send(action).await;
|
||||
});
|
||||
}
|
||||
&& let Some(token) = &saved_token
|
||||
{
|
||||
let c = client.clone();
|
||||
let t = token.clone();
|
||||
let tx2 = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let action = match c.get_diary(&t, 0, 20).await {
|
||||
Ok(r) => Action::DiaryLoaded {
|
||||
entries: r.items,
|
||||
total: r.total_count,
|
||||
},
|
||||
Err(e) => Action::DiaryLoadFailed(e.to_string()),
|
||||
};
|
||||
let _ = tx2.send(action).await;
|
||||
});
|
||||
}
|
||||
|
||||
let result = async {
|
||||
loop {
|
||||
@@ -64,20 +65,21 @@ async fn run() -> anyhow::Result<()> {
|
||||
|
||||
// Poll keyboard — non-blocking with short timeout
|
||||
if event::poll(Duration::from_millis(50))?
|
||||
&& let Event::Key(key) = event::read()? {
|
||||
if key.kind != ratatui::crossterm::event::KeyEventKind::Press {
|
||||
continue;
|
||||
&& let Event::Key(key) = event::read()?
|
||||
{
|
||||
if key.kind != ratatui::crossterm::event::KeyEventKind::Press {
|
||||
continue;
|
||||
}
|
||||
if let Some(action) = key_to_action(&app, key) {
|
||||
if matches!(action, Action::Quit) {
|
||||
break;
|
||||
}
|
||||
if let Some(action) = key_to_action(&app, key) {
|
||||
if matches!(action, Action::Quit) {
|
||||
break;
|
||||
}
|
||||
let cmds = app::update(&mut app, action);
|
||||
for cmd in cmds {
|
||||
handle_command(cmd, &app, &client, &tx);
|
||||
}
|
||||
let cmds = app::update(&mut app, action);
|
||||
for cmd in cmds {
|
||||
handle_command(cmd, &app, &client, &tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drain async results
|
||||
while let Ok(action) = rx.try_recv() {
|
||||
|
||||
@@ -51,8 +51,14 @@ fn log_review_request_includes_director_when_set() {
|
||||
fn api_client_builds_versioned_urls() {
|
||||
let client = ApiClient::new("http://localhost:3000");
|
||||
assert_eq!(client.api("/diary"), "http://localhost:3000/api/v1/diary");
|
||||
assert_eq!(client.api("/auth/login"), "http://localhost:3000/api/v1/auth/login");
|
||||
assert_eq!(client.api("/social/follow"), "http://localhost:3000/api/v1/social/follow");
|
||||
assert_eq!(
|
||||
client.api("/auth/login"),
|
||||
"http://localhost:3000/api/v1/auth/login"
|
||||
);
|
||||
assert_eq!(
|
||||
client.api("/social/follow"),
|
||||
"http://localhost:3000/api/v1/social/follow"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user