diff --git a/README.md b/README.md index aba5679..31d9b53 100644 --- a/README.md +++ b/README.md @@ -21,24 +21,45 @@ tuipub logout # revoke the token and forget the account Running `tuipub` with no account starts the login flow first. -### Keys +### Writing (compose screen) | Key | Action | | --- | --- | -| Tab / Shift-Tab | Next / previous field | -| Ctrl-S | Publish (asks to confirm) | +| Tab / Shift-Tab | Next / previous field (Title, In reply to, Note, Tags, Crosspost) | +| Ctrl-S | Publish or save. The confirm dialog offers **d** (draft) / **p** (publish) | +| Ctrl-P | Your posts | +| Ctrl-N | New note (asks before discarding changes) | | Ctrl-R | Reload crosspost targets from the server | | Ctrl-O | Open the last published post | -| Esc / Ctrl-Q | Quit (asks if there's unpublished text) | +| Esc | Quit, or back to the post list while editing (asks if there are unsaved changes) | | ↑ ↓ / j k, Space, a | Crosspost list: move, toggle, all/none | -Content is sent as written (Markdown for StarPunk). Tags are comma-separated, and a leading `#` is dropped. +When editing, saving sends only the fields you changed. Crosspost targets can't be changed after posting, but StarPunk updates existing crossposts when you edit. + +### Your posts (Ctrl-P) + +| Key | Action | +| --- | --- | +| ↑ ↓ / j k | Move (scrolling past the end loads more) | +| Enter / e | Edit | +| p | Publish a draft | +| d | Delete (asks first) | +| u | Undo the last delete | +| o | Open in the browser | +| n | New note | +| r | Reload | +| Esc | Back to writing | + +Ctrl-Q quits from anywhere. + +Content is sent as written (Markdown for StarPunk). Tags are comma-separated, and a leading `#` is dropped. "In reply to" must be an http(s) URL. ## How it works - **Discovery**: reads `rel="micropub"` and `rel="indieauth-metadata"` from your site's `Link` headers and HTML. If there's no metadata link, it falls back to `authorization_endpoint` / `token_endpoint`. -- **Login**: authorization code flow with PKCE (S256). tuipub runs a one-shot callback server on `127.0.0.1:` and uses `http://127.0.0.1:/` as its `client_id`. The client ID and redirect URI share an origin, so the server needs no client registration. It checks `state` and `iss`. Scopes requested: `create media`. +- **Login**: authorization code flow with PKCE (S256). tuipub runs a one-shot callback server on `127.0.0.1:` and uses `http://127.0.0.1:/` as its `client_id`. The client ID and redirect URI share an origin, so the server needs no client registration. It checks `state` and `iss`. Scopes requested: `create update delete media`. Tokens from older tuipub versions only had `create media`; run `tuipub login` again to edit and delete. - **Token storage**: the system keyring (Secret Service / gnome-keyring), keyed by your profile URL. Account settings go in `~/.config/tuipub/config.toml`. +- **Posts list**: `q=source` without `url` (`limit`/`offset`, drafts included). Edits are Micropub `update` with `replace`/`delete`; deletes are `delete`/`undelete`; drafts are `post-status: draft`. - **Posting**: always sends a JSON body. `mp-syndicate-to` lists exactly the checked targets, and an empty list means "crosspost nowhere". If the target list can't be loaded, `mp-syndicate-to` is left out and the server's default applies (the confirm dialog says so). ### Environment @@ -49,7 +70,7 @@ Content is sent as written (Markdown for StarPunk). Tags are comma-separated, an ## StarPunk notes - StarPunk 2.3.3+ stores the Title as the note's `# Heading` first line. Older versions ignore it, so on those, start the note with `# Heading` instead. -- Editing, deleting and listing posts aren't supported yet, because StarPunk's Micropub endpoint doesn't implement `update`, `delete` or `q=source` listing. +- Listing, editing, deleting, drafts and replies need StarPunk 2.4.0+. tuipub hides the post list when a server says it can't list posts (`q` in `q=config`). ## Develop diff --git a/src/app.rs b/src/app.rs index 7028f6f..cb62e92 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,40 +2,56 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::style::{Modifier, Style}; -use ratatui_textarea::{Input, Key, TextArea, WrapMode}; +use ratatui_textarea::{CursorMove, Input, Key, TextArea, WrapMode}; -use crate::micropub::{self, Error, Note, ServerConfig, SyndicationTarget}; -use crate::worker::{Request, Response}; +use crate::micropub::{self, Error, Note, Post, ServerConfig, SyndicationTarget, Update}; +use crate::worker::{PAGE_SIZE, Request, Response}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Focus { Title, + Reply, Content, Tags, Syndication, } impl Focus { - const ORDER: [Focus; 4] = [ + const ORDER: [Focus; 5] = [ Focus::Title, + Focus::Reply, Focus::Content, Focus::Tags, Focus::Syndication, ]; +} - fn step(self, by: isize) -> Focus { - let i = Self::ORDER.iter().position(|f| *f == self).unwrap_or(0) as isize; - Self::ORDER[(i + by).rem_euclid(Self::ORDER.len() as isize) as usize] - } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Screen { + Compose, + Posts, +} + +/// What to do once the user agrees to throw away unsaved changes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Discard { + /// Start a new, empty note. + New, + /// Load the post at this index of the list into the editor. + Edit(usize), + /// Stop editing and go back to the post list. + BackToPosts, } #[derive(Debug, Clone, PartialEq)] pub enum Mode { - Editing, + Normal, ConfirmPublish, - Publishing, - Published(String), + Sending, + Published { url: String, draft: bool }, ConfirmQuit, + ConfirmDiscard(Discard), + ConfirmDelete(String), } /// Where the syndication target list came from. @@ -47,6 +63,14 @@ pub enum Targets { Failed(String), } +#[derive(Debug, Clone, PartialEq)] +pub enum Posts { + NotLoaded, + Loading, + Loaded, + Failed(String), +} + #[derive(Debug, Clone, PartialEq)] pub enum Status { Info(String), @@ -55,8 +79,9 @@ pub enum Status { pub struct App { pub me: String, - pub endpoint: String, + pub screen: Screen, pub title: TextArea<'static>, + pub reply: TextArea<'static>, pub content: TextArea<'static>, pub tags: TextArea<'static>, pub targets: Vec<(SyndicationTarget, bool)>, @@ -66,6 +91,18 @@ pub struct App { pub mode: Mode, pub status: Option, pub last_permalink: Option, + /// The post being edited (as loaded), or None for a new note. + pub editing: Option, + pub posts: Vec, + pub posts_state: Posts, + pub posts_cursor: usize, + /// The last page was full, so there may be more posts to load. + pub posts_more: bool, + pub can_list: bool, + /// Permalink of the last deleted post, for undo. + pub last_deleted: Option, + /// Granted scopes, to explain missing permissions. + pub scope: String, pub should_quit: bool, /// Set when a request failed for auth reasons, so main can suggest logging in again. pub needs_login: bool, @@ -78,6 +115,14 @@ fn text_area(placeholder: &str) -> TextArea<'static> { t } +fn title_area() -> TextArea<'static> { + text_area("Optional title") +} + +fn reply_area() -> TextArea<'static> { + text_area("Optional: URL of the post you're replying to") +} + /// The note body: soft-wraps at word boundaries (long words such as URLs break mid-word). fn content_area() -> TextArea<'static> { let mut t = text_area("What's on your mind? (Markdown)"); @@ -85,21 +130,45 @@ fn content_area() -> TextArea<'static> { t } +fn tags_area() -> TextArea<'static> { + text_area("Comma-separated, e.g. rust, indieweb") +} + +fn filled(mut area: TextArea<'static>, text: &str) -> TextArea<'static> { + area.insert_str(text); + area.move_cursor(CursorMove::Top); + area.move_cursor(CursorMove::Head); + area +} + +fn single_line(area: &TextArea) -> String { + area.lines().join(" ").trim().to_string() +} + impl App { - pub fn new(me: String, endpoint: String) -> Self { + pub fn new(me: String, scope: String) -> Self { let mut app = App { me, - endpoint, - title: text_area("Optional title"), + screen: Screen::Compose, + title: title_area(), + reply: reply_area(), content: content_area(), - tags: text_area("Comma-separated, e.g. rust, indieweb"), + tags: tags_area(), targets: Vec::new(), targets_state: Targets::Loading, target_cursor: 0, focus: Focus::Content, - mode: Mode::Editing, + mode: Mode::Normal, status: None, last_permalink: None, + editing: None, + posts: Vec::new(), + posts_state: Posts::NotLoaded, + posts_cursor: 0, + posts_more: false, + can_list: true, + last_deleted: None, + scope, should_quit: false, needs_login: false, }; @@ -107,10 +176,36 @@ impl App { app } + fn has_scope(&self, s: &str) -> bool { + self.scope.split_whitespace().any(|x| x == s) + } + + /// A hint if the token can't edit or delete (tokens from tuipub 0.1 only had create). + pub fn scope_hint(&self) -> Option<&'static str> { + (!self.has_scope("update") || !self.has_scope("delete")) + .then_some("Run `tuipub login` again to allow editing and deleting.") + } + + fn focus_order(&self) -> Vec { + Focus::ORDER + .into_iter() + // Crosspost targets are chosen once, when posting. + .filter(|f| !(self.editing.is_some() && *f == Focus::Syndication)) + .collect() + } + + fn step_focus(&mut self, by: isize) { + let order = self.focus_order(); + let i = order.iter().position(|f| *f == self.focus).unwrap_or(0) as isize; + self.focus = order[(i + by).rem_euclid(order.len() as isize) as usize]; + self.update_cursors(); + } + fn update_cursors(&mut self) { let focus = self.focus; for (field, area) in [ (Focus::Title, &mut self.title), + (Focus::Reply, &mut self.reply), (Focus::Content, &mut self.content), (Focus::Tags, &mut self.tags), ] { @@ -123,22 +218,41 @@ impl App { } } + /// Unsaved work: any text in a new note, or any change to the post being edited. pub fn is_dirty(&self) -> bool { - [&self.title, &self.content, &self.tags] - .iter() - .any(|t| t.lines().iter().any(|l| !l.trim().is_empty())) + match &self.editing { + Some(orig) => !Update::diff(orig, &self.note(orig.draft)).is_empty(), + None => [&self.title, &self.reply, &self.content, &self.tags] + .iter() + .any(|t| t.lines().iter().any(|l| !l.trim().is_empty())), + } } fn clear_form(&mut self) { - self.title = text_area("Optional title"); + self.title = title_area(); + self.reply = reply_area(); self.content = content_area(); - self.tags = text_area("Comma-separated, e.g. rust, indieweb"); + self.tags = tags_area(); + self.editing = None; self.focus = Focus::Content; self.update_cursors(); } - pub fn note(&self) -> Note { - let title = self.title.lines().join(" ").trim().to_string(); + fn load_post(&mut self, post: Post) { + self.title = filled(title_area(), post.title.as_deref().unwrap_or("")); + self.reply = filled(reply_area(), post.in_reply_to.as_deref().unwrap_or("")); + self.content = filled(content_area(), &post.content); + self.tags = filled(tags_area(), &post.tags.join(", ")); + self.editing = Some(post); + self.focus = Focus::Content; + self.update_cursors(); + self.screen = Screen::Compose; + self.status = None; + } + + pub fn note(&self, draft: bool) -> Note { + let title = single_line(&self.title); + let reply = single_line(&self.reply); Note { title: (!title.is_empty()).then_some(title), content: self.content.lines().join("\n").trim_end().to_string(), @@ -153,11 +267,27 @@ impl App { ), _ => None, }, + in_reply_to: (!reply.is_empty()).then_some(reply), + draft, } } + /// The status a save keeps unless the user picks another: drafts stay drafts. + pub fn default_draft(&self) -> bool { + self.editing.as_ref().is_some_and(|p| p.draft) + } + /// Human summary of where the post will be crossposted. pub fn syndication_summary(&self) -> String { + if let Some(p) = &self.editing { + return match p.syndication.len() { + 0 => "not crossposted".into(), + n => format!( + "{n} crossposted cop{} (edits follow)", + if n == 1 { "y" } else { "ies" } + ), + }; + } match &self.targets_state { Targets::Loaded => { let names: Vec<_> = self @@ -176,34 +306,120 @@ impl App { } } - pub fn handle_response(&mut self, resp: Response) { + pub fn selected_post(&self) -> Option<&Post> { + self.posts.get(self.posts_cursor) + } + + fn info(&mut self, s: impl Into) { + self.status = Some(Status::Info(s.into())); + } + + fn error(&mut self, s: impl Into) { + self.status = Some(Status::Error(s.into())); + } + + fn reload_posts(&mut self) -> Option { + if !self.can_list { + self.error("This server can't list posts."); + return None; + } + self.posts_state = Posts::Loading; + Some(Request::List { offset: 0 }) + } + + /// Handle a reply from the worker. May ask for a follow-up request (e.g. reload the list). + pub fn handle_response(&mut self, resp: Response) -> Option { match resp { Response::Config(Ok(cfg)) => self.set_config(cfg), Response::Config(Err(e)) => { self.targets_state = Targets::Failed(e.to_string()); self.report(e); } - Response::Published(Ok(url)) => { + Response::Published(Ok(url), draft) => { self.clear_form(); - self.status = Some(Status::Info(format!("Published {url}"))); + self.info(if draft { + format!("Saved draft {url}") + } else { + format!("Published {url}") + }); self.last_permalink = Some(url.clone()); - self.mode = Mode::Published(url); + self.mode = Mode::Published { url, draft }; + // The list is now out of date. + if self.posts_state != Posts::NotLoaded { + return self.reload_posts(); + } } - Response::Published(Err(e)) => { - self.mode = Mode::Editing; + Response::Published(Err(e), _) | Response::Updated { result: Err(e), .. } => { + self.mode = Mode::Normal; + self.report(e); + } + Response::Updated { + url, + result: Ok(()), + } => { + self.mode = Mode::Normal; + if self.editing.as_ref().is_some_and(|p| p.url == url) { + self.clear_form(); + } + self.last_permalink = Some(url); + self.screen = Screen::Posts; + self.info("Saved."); + return self.reload_posts(); + } + Response::Listed { + offset, + result: Ok(posts), + } => { + self.posts_more = posts.len() == PAGE_SIZE; + if offset == 0 { + self.posts = posts; + } else { + self.posts.extend(posts); + } + self.posts_cursor = self.posts_cursor.min(self.posts.len().saturating_sub(1)); + self.posts_state = Posts::Loaded; + } + Response::Listed { result: Err(e), .. } => { + self.posts_state = Posts::Failed(e.to_string()); + self.report(e); + } + Response::Deleted { + url, + result: Ok(()), + } => { + self.mode = Mode::Normal; + self.posts.retain(|p| p.url != url); + self.posts_cursor = self.posts_cursor.min(self.posts.len().saturating_sub(1)); + self.last_deleted = Some(url); + self.info("Deleted. Press u to undo."); + } + Response::Undeleted { + url, + result: Ok(()), + } => { + self.mode = Mode::Normal; + self.last_deleted = None; + self.info(format!("Restored {url}")); + return self.reload_posts(); + } + Response::Deleted { result: Err(e), .. } + | Response::Undeleted { result: Err(e), .. } => { + self.mode = Mode::Normal; self.report(e); } } + None } fn report(&mut self, e: Error) { if matches!(e, Error::Unauthorized(_)) { self.needs_login = true; } - self.status = Some(Status::Error(e.to_string())); + self.error(e.to_string()); } fn set_config(&mut self, cfg: ServerConfig) { + self.can_list = cfg.can_list(); // Keep earlier choices on refresh; new targets start checked (the server's default). let previous = std::mem::take(&mut self.targets); self.targets = cfg @@ -225,80 +441,188 @@ impl App { pub fn handle_key(&mut self, key: KeyEvent) -> Option { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); match self.mode.clone() { - Mode::Publishing => None, + Mode::Sending => None, Mode::ConfirmPublish => { - match key.code { - KeyCode::Char('y') | KeyCode::Enter => { - self.mode = Mode::Publishing; - self.status = Some(Status::Info("Publishing…".into())); - return Some(Request::Publish(self.note())); + let draft = match key.code { + KeyCode::Char('y') | KeyCode::Enter => self.default_draft(), + KeyCode::Char('p') => false, + KeyCode::Char('d') => true, + _ => { + self.mode = Mode::Normal; + return None; } - _ => self.mode = Mode::Editing, - } - None + }; + self.send(draft) } Mode::ConfirmQuit => { match key.code { KeyCode::Char('y') => self.should_quit = true, - _ => self.mode = Mode::Editing, + _ => self.mode = Mode::Normal, } None } - Mode::Published(url) => { + Mode::ConfirmDiscard(action) => { + self.mode = Mode::Normal; + if key.code == KeyCode::Char('y') { + return self.discard(action); + } + None + } + Mode::ConfirmDelete(url) => { + if key.code == KeyCode::Char('y') { + self.mode = Mode::Sending; + self.info("Deleting…"); + return Some(Request::Delete(url)); + } + self.mode = Mode::Normal; + None + } + Mode::Published { url, draft } => { + self.mode = Mode::Normal; if key.code == KeyCode::Char('o') { - self.open(&url); + self.open_post(&url, draft); } - self.mode = Mode::Editing; None } - Mode::Editing => self.handle_editing_key(key, ctrl), - } - } - - fn open(&mut self, url: &str) { - if let Err(e) = open::that_detached(url) { - self.status = Some(Status::Error(format!("Could not open browser: {e}"))); - } - } - - fn handle_editing_key(&mut self, key: KeyEvent, ctrl: bool) -> Option { - match (ctrl, key.code) { - (true, KeyCode::Char('q' | 'c')) | (false, KeyCode::Esc) => { - if self.is_dirty() { - self.mode = Mode::ConfirmQuit; - } else { - self.should_quit = true; + Mode::Normal => { + if ctrl && matches!(key.code, KeyCode::Char('q' | 'c')) { + self.quit(); + return None; } + match self.screen { + Screen::Compose => self.handle_compose_key(key, ctrl), + Screen::Posts => self.handle_posts_key(key, ctrl), + } + } + } + } + + fn quit(&mut self) { + if self.is_dirty() { + self.mode = Mode::ConfirmQuit; + } else { + self.should_quit = true; + } + } + + /// Validate the form, then publish a new note or save the edited post. + fn send(&mut self, draft: bool) -> Option { + let note = self.note(draft); + self.mode = Mode::Normal; + if let Some(r) = ¬e.in_reply_to + && !(r.starts_with("http://") || r.starts_with("https://")) + { + self.error("“In reply to” must be an http(s) URL."); + return None; + } + if let Some(orig) = &self.editing { + let update = Update::diff(orig, ¬e); + if update.is_empty() { + self.info("No changes to save."); + return None; + } + self.mode = Mode::Sending; + self.info("Saving…"); + return Some(Request::Update(update)); + } + self.mode = Mode::Sending; + self.info(if draft { + "Saving draft…" + } else { + "Publishing…" + }); + Some(Request::Publish(note)) + } + + fn discard(&mut self, action: Discard) -> Option { + match action { + Discard::New => { + self.clear_form(); + self.screen = Screen::Compose; + } + Discard::BackToPosts => { + self.clear_form(); + self.screen = Screen::Posts; + } + Discard::Edit(i) => { + if let Some(post) = self.posts.get(i).cloned() { + self.load_post(post); + } + } + } + None + } + + /// Run `action` now if nothing would be lost, or ask first. + fn guard(&mut self, action: Discard) -> Option { + if self.is_dirty() { + self.mode = Mode::ConfirmDiscard(action); + None + } else { + self.discard(action) + } + } + + fn open_post(&mut self, url: &str, draft: bool) { + if draft { + self.info("Drafts aren't public yet, so there's nothing to open."); + return; + } + if let Err(e) = open::that_detached(url) { + self.error(format!("Could not open browser: {e}")); + } + } + + fn show_posts(&mut self) -> Option { + self.screen = Screen::Posts; + if matches!(self.posts_state, Posts::NotLoaded | Posts::Failed(_)) { + return self.reload_posts(); + } + None + } + + fn handle_compose_key(&mut self, key: KeyEvent, ctrl: bool) -> Option { + match (ctrl, key.code) { + (false, KeyCode::Esc) => { + if self.editing.is_some() { + return self.guard(Discard::BackToPosts); + } + self.quit(); return None; } (true, KeyCode::Char('s')) => { - if self.note().content.trim().is_empty() { - self.status = Some(Status::Error("Write something first.".into())); + if self.note(false).content.trim().is_empty() { + self.error("Write something first."); } else { self.status = None; self.mode = Mode::ConfirmPublish; } return None; } + (true, KeyCode::Char('p')) => return self.show_posts(), + (true, KeyCode::Char('n')) => { + if self.editing.is_some() || self.is_dirty() { + return self.guard(Discard::New); + } + return None; + } (true, KeyCode::Char('r')) => { self.targets_state = Targets::Loading; - self.status = Some(Status::Info("Refreshing server config…".into())); + self.info("Refreshing server config…"); return Some(Request::LoadConfig); } (true, KeyCode::Char('o')) => { if let Some(url) = self.last_permalink.clone() { - self.open(&url); + self.open_post(&url, false); } return None; } (_, KeyCode::Tab) => { - self.focus = self.focus.step(1); - self.update_cursors(); + self.step_focus(1); return None; } (_, KeyCode::BackTab) => { - self.focus = self.focus.step(-1); - self.update_cursors(); + self.step_focus(-1); return None; } _ => {} @@ -308,17 +632,16 @@ impl App { Focus::Content => { self.content.input(Input::from(key)); } - Focus::Title | Focus::Tags => { + Focus::Title | Focus::Reply | Focus::Tags => { let input = Input::from(key); // Single-line fields: Enter moves on instead of adding a line. if input.key == Key::Enter || (input.ctrl && input.key == Key::Char('m')) { - self.focus = self.focus.step(1); - self.update_cursors(); + self.step_focus(1); } else { - let area = if self.focus == Focus::Title { - &mut self.title - } else { - &mut self.tags + let area = match self.focus { + Focus::Title => &mut self.title, + Focus::Reply => &mut self.reply, + _ => &mut self.tags, }; area.input(input); } @@ -328,6 +651,71 @@ impl App { None } + fn handle_posts_key(&mut self, key: KeyEvent, ctrl: bool) -> Option { + let n = self.posts.len(); + match (ctrl, key.code) { + (false, KeyCode::Esc) | (true, KeyCode::Char('p')) => self.screen = Screen::Compose, + (true, KeyCode::Char('n')) | (false, KeyCode::Char('n')) => { + return self.guard(Discard::New); + } + (false, KeyCode::Char('q')) => self.quit(), + (false, KeyCode::Up | KeyCode::Char('k')) => { + self.posts_cursor = self.posts_cursor.saturating_sub(1) + } + (false, KeyCode::Down | KeyCode::Char('j')) => { + if self.posts_cursor + 1 < n { + self.posts_cursor += 1; + } else if self.posts_more && self.posts_state == Posts::Loaded { + // At the bottom: fetch the next page. + self.posts_state = Posts::Loading; + return Some(Request::List { offset: n }); + } + } + (false, KeyCode::Home | KeyCode::Char('g')) => self.posts_cursor = 0, + (false, KeyCode::Char('r')) => { + self.posts_cursor = 0; + return self.reload_posts(); + } + (false, KeyCode::Char('u')) => match self.last_deleted.clone() { + Some(url) => { + self.mode = Mode::Sending; + self.info("Restoring…"); + return Some(Request::Undelete(url)); + } + None => self.info("Nothing to undo."), + }, + _ => {} + } + let post = self.selected_post().cloned()?; + match (ctrl, key.code) { + (false, KeyCode::Enter | KeyCode::Char('e')) => { + if self.editing.as_ref().is_some_and(|p| p.url == post.url) { + self.screen = Screen::Compose; + return None; + } + return self.guard(Discard::Edit(self.posts_cursor)); + } + (false, KeyCode::Char('d') | KeyCode::Delete) => { + self.mode = Mode::ConfirmDelete(post.url); + } + (false, KeyCode::Char('o')) => self.open_post(&post.url, post.draft), + (false, KeyCode::Char('p')) if post.draft => { + let mut update = Update { + url: post.url, + ..Default::default() + }; + update + .replace + .insert("post-status".into(), serde_json::json!(["published"])); + self.mode = Mode::Sending; + self.info("Publishing draft…"); + return Some(Request::Update(update)); + } + _ => {} + } + None + } + fn handle_target_key(&mut self, key: KeyEvent) { let n = self.targets.len(); if n == 0 { @@ -357,7 +745,7 @@ impl App { /// Bracketed paste: insert text into the focused field. pub fn handle_paste(&mut self, text: &str) { - if self.mode != Mode::Editing { + if self.mode != Mode::Normal || self.screen != Screen::Compose { return; } let text = text.replace("\r\n", "\n").replace('\r', "\n"); @@ -368,6 +756,9 @@ impl App { Focus::Title => { self.title.insert_str(text.replace('\n', " ")); } + Focus::Reply => { + self.reply.insert_str(text.replace('\n', "")); + } Focus::Tags => { self.tags.insert_str(text.replace('\n', ",")); } @@ -400,13 +791,40 @@ mod tests { fn loaded(targets: &[&str]) -> App { let mut app = App::new( "https://example.com/".into(), - "https://example.com/micropub".into(), + "create update delete media".into(), ); app.handle_response(Response::Config(Ok(ServerConfig { syndicate_to: targets.iter().map(|t| target(t)).collect(), + q: vec![], }))); app } + fn post(slug: &str, draft: bool) -> Post { + Post { + url: format!("https://example.com/note/{slug}"), + title: None, + content: format!("Post {slug}"), + tags: vec!["rust".into()], + published: "2026-10-31T12:00:00".into(), + draft, + in_reply_to: None, + syndication: vec![], + } + } + /// App on the post list with these posts loaded. + fn with_posts(posts: Vec) -> App { + let mut app = loaded(&[]); + assert!(matches!( + app.handle_key(ctrl('p')), + Some(Request::List { offset: 0 }) + )); + app.handle_response(Response::Listed { + offset: 0, + result: Ok(posts), + }); + assert_eq!(app.screen, Screen::Posts); + app + } #[test] fn compose_and_publish_flow() { @@ -425,22 +843,71 @@ mod tests { let Some(Request::Publish(note)) = app.handle_key(key(KeyCode::Char('y'))) else { panic!("expected publish request"); }; - assert_eq!(app.mode, Mode::Publishing); + assert_eq!(app.mode, Mode::Sending); assert_eq!(note.content, "Hello\nworld"); assert_eq!(note.title, None); assert_eq!(note.tags, vec!["rust", "tui"]); assert_eq!(note.syndicate_to, Some(vec!["gts".to_string()])); + assert!(!note.draft); app.handle_response(Response::Published( - Ok("https://example.com/notes/x".into()), + Ok("https://example.com/note/x".into()), + false, )); - assert_eq!( - app.mode, - Mode::Published("https://example.com/notes/x".into()) - ); + assert!(matches!(app.mode, Mode::Published { .. })); assert!(!app.is_dirty(), "form clears after publishing"); app.handle_key(key(KeyCode::Esc)); - assert_eq!(app.mode, Mode::Editing); + assert_eq!(app.mode, Mode::Normal); + } + + #[test] + fn save_new_note_as_draft_with_reply() { + let mut app = loaded(&[]); + app.handle_key(key(KeyCode::BackTab)); // -> reply + assert_eq!(app.focus, Focus::Reply); + typ(&mut app, "https://other.example/post"); + app.handle_key(key(KeyCode::Enter)); // -> content + typ(&mut app, "Nice post!"); + app.handle_key(ctrl('s')); + let Some(Request::Publish(note)) = app.handle_key(key(KeyCode::Char('d'))) else { + panic!("expected publish request"); + }; + assert!(note.draft); + assert_eq!( + note.in_reply_to.as_deref(), + Some("https://other.example/post") + ); + app.handle_response(Response::Published(Ok("u".into()), true)); + assert_eq!( + app.mode, + Mode::Published { + url: "u".into(), + draft: true + } + ); + } + + #[test] + fn reply_must_be_a_url() { + let mut app = loaded(&[]); + app.focus = Focus::Reply; + typ(&mut app, "not a url"); + app.focus = Focus::Content; + typ(&mut app, "x"); + app.handle_key(ctrl('s')); + assert!(app.handle_key(key(KeyCode::Char('y'))).is_none()); + assert!(matches!(app.status, Some(Status::Error(_)))); + assert_eq!(app.mode, Mode::Normal); + } + + #[test] + fn title_enter_moves_on() { + let mut app = loaded(&[]); + app.focus = Focus::Title; + typ(&mut app, "My title"); + app.handle_key(key(KeyCode::Enter)); + assert_eq!(app.focus, Focus::Reply); + assert_eq!(app.title.lines(), ["My title"]); } #[test] @@ -448,26 +915,15 @@ mod tests { let mut app = loaded(&[]); assert_eq!(app.content.wrap_mode(), WrapMode::WordOrGlyph); assert_eq!(app.title.wrap_mode(), WrapMode::None); - app.handle_response(Response::Published(Ok("u".into()))); + app.handle_response(Response::Published(Ok("u".into()), false)); assert_eq!(app.content.wrap_mode(), WrapMode::WordOrGlyph); } - #[test] - fn title_enter_moves_to_content() { - let mut app = loaded(&[]); - app.handle_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); - assert_eq!(app.focus, Focus::Title); - typ(&mut app, "My title"); - app.handle_key(key(KeyCode::Enter)); - assert_eq!(app.focus, Focus::Content); - assert_eq!(app.title.lines(), ["My title"]); - } - #[test] fn empty_note_is_not_published() { let mut app = loaded(&[]); app.handle_key(ctrl('s')); - assert_eq!(app.mode, Mode::Editing); + assert_eq!(app.mode, Mode::Normal); assert!(matches!(app.status, Some(Status::Error(_)))); } @@ -476,14 +932,14 @@ mod tests { let mut app = loaded(&["gts"]); app.focus = Focus::Syndication; app.handle_key(key(KeyCode::Char('a'))); - assert_eq!(app.note().syndicate_to, Some(vec![])); + assert_eq!(app.note(false).syndicate_to, Some(vec![])); assert_eq!(app.syndication_summary(), "nowhere"); - let mut app = App::new("m".into(), "e".into()); + let mut app = App::new("m".into(), String::new()); app.handle_response(Response::Config(Err(Error::NoLocation( reqwest::StatusCode::OK, )))); - assert_eq!(app.note().syndicate_to, None); + assert_eq!(app.note(false).syndicate_to, None); assert_eq!(app.syndication_summary(), "server default"); } @@ -493,6 +949,7 @@ mod tests { app.targets[0].1 = false; app.handle_response(Response::Config(Ok(ServerConfig { syndicate_to: vec![target("gts"), target("bsky"), target("new")], + q: vec![], }))); let on: Vec = app.targets.iter().map(|(_, on)| *on).collect(); assert_eq!(on, [false, true, true]); @@ -521,10 +978,11 @@ mod tests { typ(&mut app, "x"); app.handle_key(ctrl('s')); app.handle_key(key(KeyCode::Char('y'))); - app.handle_response(Response::Published(Err(Error::Unauthorized( - "expired".into(), - )))); - assert_eq!(app.mode, Mode::Editing); + app.handle_response(Response::Published( + Err(Error::Unauthorized("expired".into())), + false, + )); + assert_eq!(app.mode, Mode::Normal); assert!(app.needs_login); assert!(app.is_dirty(), "draft is kept after a failed publish"); } @@ -534,6 +992,198 @@ mod tests { let mut app = loaded(&[]); app.focus = Focus::Tags; app.handle_paste("a\nb"); - assert_eq!(app.note().tags, vec!["a", "b"]); + assert_eq!(app.note(false).tags, vec!["a", "b"]); + } + + #[test] + fn list_pages_when_scrolling_past_the_end() { + let page: Vec = (0..PAGE_SIZE) + .map(|i| post(&i.to_string(), false)) + .collect(); + let mut app = with_posts(page); + assert!(app.posts_more); + for _ in 0..PAGE_SIZE - 1 { + assert!(app.handle_key(key(KeyCode::Down)).is_none()); + } + let Some(Request::List { offset }) = app.handle_key(key(KeyCode::Down)) else { + panic!("expected next page"); + }; + assert_eq!(offset, PAGE_SIZE); + assert!( + app.handle_key(key(KeyCode::Down)).is_none(), + "one request at a time" + ); + app.handle_response(Response::Listed { + offset, + result: Ok(vec![post("last", false)]), + }); + assert_eq!(app.posts.len(), PAGE_SIZE + 1); + assert!(!app.posts_more); + app.handle_key(key(KeyCode::Down)); + assert_eq!(app.selected_post().unwrap().content, "Post last"); + } + + #[test] + fn edit_sends_only_changes_and_returns_to_list() { + let mut app = with_posts(vec![post("a", false), post("b", true)]); + app.handle_key(key(KeyCode::Down)); + app.handle_key(key(KeyCode::Enter)); + assert_eq!(app.screen, Screen::Compose); + assert_eq!(app.content.lines(), ["Post b"]); + assert_eq!(app.tags.lines(), ["rust"]); + assert!(!app.is_dirty()); + assert!(!app.focus_order().contains(&Focus::Syndication)); + + // Saving with no changes does nothing. + app.handle_key(ctrl('s')); + assert!(app.handle_key(key(KeyCode::Enter)).is_none()); + + app.handle_key(key(KeyCode::End)); + typ(&mut app, " edited"); + assert!(app.is_dirty()); + app.handle_key(ctrl('s')); + // Enter keeps the draft a draft, so only content changes. + let Some(Request::Update(u)) = app.handle_key(key(KeyCode::Enter)) else { + panic!("expected update"); + }; + assert_eq!(u.url, "https://example.com/note/b"); + assert_eq!(u.replace.keys().collect::>(), ["content"]); + assert!(u.delete.is_empty()); + + let next = app.handle_response(Response::Updated { + url: u.url, + result: Ok(()), + }); + assert!(matches!(next, Some(Request::List { offset: 0 }))); + assert_eq!(app.screen, Screen::Posts); + assert!(app.editing.is_none()); + } + + #[test] + fn publish_a_draft_from_the_editor_and_the_list() { + let mut app = with_posts(vec![post("d", true)]); + app.handle_key(key(KeyCode::Enter)); + app.handle_key(ctrl('s')); + let Some(Request::Update(u)) = app.handle_key(key(KeyCode::Char('p'))) else { + panic!("expected update"); + }; + assert_eq!(u.replace["post-status"], serde_json::json!(["published"])); + + let mut app = with_posts(vec![post("d", true), post("pub", false)]); + let Some(Request::Update(u)) = app.handle_key(key(KeyCode::Char('p'))) else { + panic!("expected update"); + }; + assert_eq!(u.url, "https://example.com/note/d"); + app.handle_response(Response::Updated { + url: u.url, + result: Ok(()), + }); + app.handle_key(key(KeyCode::Down)); + assert!( + app.handle_key(key(KeyCode::Char('p'))).is_none(), + "already published" + ); + } + + #[test] + fn delete_then_undo() { + let mut app = with_posts(vec![post("a", false), post("b", false)]); + assert!(app.handle_key(key(KeyCode::Char('d'))).is_none()); + assert!(matches!(app.mode, Mode::ConfirmDelete(_))); + let Some(Request::Delete(url)) = app.handle_key(key(KeyCode::Char('y'))) else { + panic!("expected delete"); + }; + app.handle_response(Response::Deleted { + url: url.clone(), + result: Ok(()), + }); + assert_eq!(app.posts.len(), 1); + assert_eq!(app.last_deleted.as_deref(), Some(url.as_str())); + + let Some(Request::Undelete(u)) = app.handle_key(key(KeyCode::Char('u'))) else { + panic!("expected undelete"); + }; + assert_eq!(u, url); + let next = app.handle_response(Response::Undeleted { + url: u, + result: Ok(()), + }); + assert!(matches!(next, Some(Request::List { offset: 0 }))); + assert!(app.last_deleted.is_none()); + } + + #[test] + fn delete_can_be_cancelled() { + let mut app = with_posts(vec![post("a", false)]); + app.handle_key(key(KeyCode::Char('d'))); + assert!(app.handle_key(key(KeyCode::Char('n'))).is_none()); + assert_eq!(app.mode, Mode::Normal); + assert_eq!(app.posts.len(), 1); + } + + #[test] + fn unsaved_text_is_protected_when_switching_posts() { + let mut app = with_posts(vec![post("a", false)]); + app.handle_key(key(KeyCode::Esc)); // back to compose + typ(&mut app, "half-written note"); + app.handle_key(ctrl('p')); + app.handle_key(key(KeyCode::Enter)); + assert_eq!(app.mode, Mode::ConfirmDiscard(Discard::Edit(0))); + app.handle_key(key(KeyCode::Char('n'))); + assert_eq!(app.screen, Screen::Posts); + assert!(app.editing.is_none()); + + app.handle_key(key(KeyCode::Enter)); + app.handle_key(key(KeyCode::Char('y'))); + assert_eq!(app.screen, Screen::Compose); + assert_eq!(app.content.lines(), ["Post a"]); + + // Esc while editing with changes asks before leaving. + typ(&mut app, "x"); + app.handle_key(key(KeyCode::Esc)); + assert_eq!(app.mode, Mode::ConfirmDiscard(Discard::BackToPosts)); + } + + #[test] + fn edit_toggles_name_and_reply() { + let mut orig = post("a", false); + orig.title = Some("Old".into()); + orig.in_reply_to = Some("https://r.example/".into()); + let mut app = with_posts(vec![orig]); + app.handle_key(key(KeyCode::Enter)); + assert_eq!(app.title.lines(), ["Old"]); + assert_eq!(app.reply.lines(), ["https://r.example/"]); + app.title = title_area(); + app.reply = reply_area(); + app.handle_key(ctrl('s')); + let Some(Request::Update(u)) = app.handle_key(key(KeyCode::Char('y'))) else { + panic!("expected update"); + }; + assert_eq!(u.delete, ["name", "in-reply-to"]); + } + + #[test] + fn scope_hint_for_old_tokens() { + let app = App::new("m".into(), "create media".into()); + assert!(app.scope_hint().is_some()); + assert!(loaded(&[]).scope_hint().is_none()); + } + + #[test] + fn server_without_listing() { + let mut app = loaded(&[]); + app.handle_response(Response::Config(Ok(ServerConfig { + syndicate_to: vec![], + q: vec!["config".into()], + }))); + assert!(app.handle_key(ctrl('p')).is_none()); + assert!(matches!(app.status, Some(Status::Error(_)))); + } + + #[test] + fn drafts_are_not_opened() { + let mut app = with_posts(vec![post("d", true)]); + app.handle_key(key(KeyCode::Char('o'))); + assert!(matches!(app.status, Some(Status::Info(ref s)) if s.contains("Drafts"))); } } diff --git a/src/auth.rs b/src/auth.rs index 31e6729..49a3128 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -15,7 +15,8 @@ use url::Url; use crate::{config::Config, discovery, token}; -pub const SCOPE: &str = "create media"; +/// create: post · update/delete: edit and delete posts · media: upload files. +pub const SCOPE: &str = "create update delete media"; const LOGIN_TIMEOUT: Duration = Duration::from_secs(300); pub struct Pkce { @@ -286,8 +287,23 @@ pub fn login(me_input: &str) -> Result { token::set(&cfg.me, &tok.access_token)?; cfg.save()?; eprintln!("Logged in as {} (scope: {})", cfg.me, cfg.scope); - if !cfg.scope.split_whitespace().any(|s| s == "create") { - eprintln!("Warning: the server did not grant the `create` scope, so posting will fail."); + let granted: Vec<&str> = cfg.scope.split_whitespace().collect(); + let missing: Vec<&str> = SCOPE.split(' ').filter(|s| !granted.contains(s)).collect(); + if !missing.is_empty() { + eprintln!( + "Note: the server didn't grant {}; tuipub can't {} without it.", + missing.join(", "), + missing + .iter() + .map(|s| match *s { + "create" => "post", + "update" => "edit posts", + "delete" => "delete posts", + _ => "upload media", + }) + .collect::>() + .join(" or ") + ); } Ok(cfg) } diff --git a/src/main.rs b/src/main.rs index f4ff5eb..4be9434 100644 --- a/src/main.rs +++ b/src/main.rs @@ -87,7 +87,7 @@ fn run_tui() -> Result<()> { let (tx, rx) = worker::spawn(client); tx.send(Request::LoadConfig).ok(); - let mut app = App::new(cfg.me.clone(), cfg.micropub.clone()); + let mut app = App::new(cfg.me.clone(), cfg.scope.clone()); let mut terminal = ratatui::init(); execute!(io::stdout(), EnableBracketedPaste).ok(); @@ -106,7 +106,9 @@ fn run_tui() -> Result<()> { } } while let Ok(resp) = rx.try_recv() { - app.handle_response(resp); + if let Some(req) = app.handle_response(resp) { + tx.send(req).context("background worker stopped")?; + } } } Ok(()) diff --git a/src/micropub.rs b/src/micropub.rs index 4d12a81..9335854 100644 --- a/src/micropub.rs +++ b/src/micropub.rs @@ -1,4 +1,4 @@ -//! Micropub client: `q=config` and create. +//! Micropub client: config, create, list (`q=source`), update, delete and undelete. use reqwest::{StatusCode, blocking::Client as Http}; use serde::Deserialize; @@ -15,6 +15,17 @@ pub struct SyndicationTarget { pub struct ServerConfig { #[serde(rename = "syndicate-to", default)] pub syndicate_to: Vec, + /// Queries the server says it supports (`q` in q=config). Empty if it doesn't say. + #[serde(default)] + pub q: Vec, +} + +impl ServerConfig { + /// Whether the server can list posts (`q=source` without `url`). + /// Servers that don't list their queries get the benefit of the doubt. + pub fn can_list(&self) -> bool { + self.q.is_empty() || self.q.iter().any(|q| q == "source") + } } #[derive(Debug, Clone, Default, PartialEq)] @@ -24,6 +35,9 @@ pub struct Note { pub tags: Vec, /// `None` leaves syndication to the server's default. `Some(vec![])` means none. pub syndicate_to: Option>, + pub in_reply_to: Option, + /// Save unpublished (`post-status: draft`). + pub draft: bool, } impl Note { @@ -38,6 +52,12 @@ impl Note { if !self.tags.is_empty() { props.insert("category".into(), json!(self.tags)); } + if let Some(u) = &self.in_reply_to { + props.insert("in-reply-to".into(), json!([u])); + } + if self.draft { + props.insert("post-status".into(), json!(["draft"])); + } let mut body = json!({ "type": ["h-entry"], "properties": props }); if let Some(targets) = &self.syndicate_to { body["mp-syndicate-to"] = json!(targets); @@ -46,6 +66,133 @@ impl Note { } } +/// A post as returned by `q=source`. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Post { + pub url: String, + pub title: Option, + pub content: String, + pub tags: Vec, + /// As sent by the server (ISO 8601). + pub published: String, + pub draft: bool, + pub in_reply_to: Option, + /// Links to crossposted copies. + pub syndication: Vec, +} + +fn first_str(props: &Value, key: &str) -> Option { + let v = props.get(key)?.get(0)?; + match v { + Value::String(s) => Some(s.clone()), + // e.g. content {"html": ..., "value": ...} or an h-cite with a url + Value::Object(o) => ["value", "text", "url", "html"] + .iter() + .find_map(|k| o.get(*k).and_then(Value::as_str)) + .map(str::to_string), + _ => None, + } +} + +fn all_strs(props: &Value, key: &str) -> Vec { + props + .get(key) + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +impl Post { + /// Read an mf2 JSON item. Needs at least a url. + pub fn from_mf2(item: &Value) -> Option { + let p = item.get("properties")?; + Some(Post { + url: first_str(p, "url")?, + title: first_str(p, "name").filter(|t| !t.trim().is_empty()), + content: first_str(p, "content").unwrap_or_default(), + tags: all_strs(p, "category"), + published: first_str(p, "published").unwrap_or_default(), + draft: first_str(p, "post-status").as_deref() == Some("draft"), + in_reply_to: first_str(p, "in-reply-to"), + syndication: all_strs(p, "syndication"), + }) + } +} + +/// A Micropub update: properties to replace and properties to remove. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Update { + pub url: String, + pub replace: serde_json::Map, + pub delete: Vec, +} + +impl Update { + /// Only what changed between `orig` and the edited `new`, so unchanged posts + /// (and their crossposts) aren't touched. Crosspost targets can't be changed. + pub fn diff(orig: &Post, new: &Note) -> Update { + let mut u = Update { + url: orig.url.clone(), + ..Default::default() + }; + if new.content != orig.content { + u.replace.insert("content".into(), json!([new.content])); + } + if new.title != orig.title { + match &new.title { + Some(t) => { + u.replace.insert("name".into(), json!([t])); + } + None => u.delete.push("name".into()), + } + } + let norm = |tags: &[String]| { + let mut t: Vec = tags.iter().map(|t| t.to_lowercase()).collect(); + t.sort(); + t + }; + if norm(&new.tags) != norm(&orig.tags) { + if new.tags.is_empty() { + u.delete.push("category".into()); + } else { + u.replace.insert("category".into(), json!(new.tags)); + } + } + if new.in_reply_to != orig.in_reply_to { + match &new.in_reply_to { + Some(r) => { + u.replace.insert("in-reply-to".into(), json!([r])); + } + None => u.delete.push("in-reply-to".into()), + } + } + if new.draft != orig.draft { + let status = if new.draft { "draft" } else { "published" }; + u.replace.insert("post-status".into(), json!([status])); + } + u + } + + pub fn is_empty(&self) -> bool { + self.replace.is_empty() && self.delete.is_empty() + } + + pub fn to_json(&self) -> Value { + let mut body = json!({"action": "update", "url": self.url}); + if !self.replace.is_empty() { + body["replace"] = Value::Object(self.replace.clone()); + } + if !self.delete.is_empty() { + body["delete"] = json!(self.delete); + } + body + } +} + /// Split "a, #b,, a" into ["a", "b"]. pub fn parse_tags(input: &str) -> Vec { let mut out: Vec = Vec::new(); @@ -151,6 +298,50 @@ impl Client { Ok(cfg) } + /// Recent posts, newest first, drafts included (`q=source` without `url`). + pub fn list(&self, limit: usize, offset: usize) -> Result, Error> { + let mut url = self.endpoint.clone(); + url.query_pairs_mut() + .append_pair("q", "source") + .append_pair("limit", &limit.to_string()) + .append_pair("offset", &offset.to_string()); + let resp = self + .http + .get(url) + .bearer_auth(&self.token) + .header("Accept", "application/json") + .send()?; + let body: Value = Self::check(resp)?.json()?; + Ok(body + .get("items") + .and_then(Value::as_array) + .map(|items| items.iter().filter_map(Post::from_mf2).collect()) + .unwrap_or_default()) + } + + fn action(&self, body: &Value) -> Result<(), Error> { + let resp = self + .http + .post(self.endpoint.clone()) + .bearer_auth(&self.token) + .header("Accept", "application/json") + .json(body) + .send()?; + Self::check(resp).map(|_| ()) + } + + pub fn update(&self, update: &Update) -> Result<(), Error> { + self.action(&update.to_json()) + } + + pub fn delete(&self, url: &str) -> Result<(), Error> { + self.action(&json!({"action": "delete", "url": url})) + } + + pub fn undelete(&self, url: &str) -> Result<(), Error> { + self.action(&json!({"action": "undelete", "url": url})) + } + /// Publish a note. Returns the post's permalink. pub fn create(&self, note: &Note) -> Result { let resp = self @@ -204,6 +395,7 @@ mod tests { content: "Hello **world**".into(), tags: vec!["a".into()], syndicate_to: Some(vec![]), + ..Default::default() }; assert_eq!( note.to_json(), @@ -329,4 +521,178 @@ mod tests { .unwrap_err(); assert!(matches!(err, Error::NoLocation(_))); } + + fn post() -> Post { + Post { + url: "https://example.com/note/1".into(), + title: Some("Old".into()), + content: "Body".into(), + tags: vec!["Rust".into(), "tui".into()], + published: "2026-10-31T12:00:00".into(), + draft: true, + in_reply_to: Some("https://other.example/x".into()), + syndication: vec![], + } + } + + fn same_note(p: &Post) -> Note { + Note { + title: p.title.clone(), + content: p.content.clone(), + tags: p.tags.clone(), + syndicate_to: None, + in_reply_to: p.in_reply_to.clone(), + draft: p.draft, + } + } + + #[test] + fn post_from_mf2_reads_starpunk_source() { + let item = json!({"type": ["h-entry"], "properties": { + "content": ["Hello"], "name": ["Title"], "category": ["a", "b"], + "published": ["2026-11-01T01:02:09"], "post-status": ["draft"], + "url": ["https://example.com/note/x"], "in-reply-to": ["https://r.example/"], + "syndication": ["https://social.example/@me/1"] + }}); + let p = Post::from_mf2(&item).unwrap(); + assert_eq!(p.title.as_deref(), Some("Title")); + assert_eq!(p.tags, ["a", "b"]); + assert!(p.draft); + assert_eq!(p.in_reply_to.as_deref(), Some("https://r.example/")); + assert_eq!(p.syndication.len(), 1); + + let html = + json!({"properties": {"url": ["u"], "content": [{"html": "

x

", "value": "x"}]}}); + let p = Post::from_mf2(&html).unwrap(); + assert_eq!(p.content, "x"); + assert!(!p.draft && p.title.is_none()); + assert!( + Post::from_mf2(&json!({"properties": {}})).is_none(), + "url is required" + ); + } + + #[test] + fn update_diff_only_sends_changes() { + let orig = post(); + assert!(Update::diff(&orig, &same_note(&orig)).is_empty()); + + // Tag order and case don't count as a change. + let mut n = same_note(&orig); + n.tags = vec!["TUI".into(), "rust".into()]; + assert!(Update::diff(&orig, &n).is_empty()); + + let mut n = same_note(&orig); + n.content = "New body".into(); + n.title = None; + n.tags = vec![]; + n.in_reply_to = None; + n.draft = false; + let u = Update::diff(&orig, &n); + assert_eq!( + u.to_json(), + json!({ + "action": "update", "url": "https://example.com/note/1", + "replace": {"content": ["New body"], "post-status": ["published"]}, + "delete": ["name", "category", "in-reply-to"] + }) + ); + + let mut n = same_note(&orig); + n.title = Some("New".into()); + n.tags = vec!["x".into()]; + n.in_reply_to = Some("https://r2.example/".into()); + let u = Update::diff(&orig, &n); + assert_eq!(u.replace["name"], json!(["New"])); + assert_eq!(u.replace["category"], json!(["x"])); + assert_eq!(u.replace["in-reply-to"], json!(["https://r2.example/"])); + assert!(u.delete.is_empty()); + } + + #[test] + fn create_json_includes_reply_and_draft() { + let n = Note { + content: "x".into(), + in_reply_to: Some("https://r.example/".into()), + draft: true, + ..Default::default() + }; + assert_eq!( + n.to_json()["properties"], + json!({"content": ["x"], "in-reply-to": ["https://r.example/"], "post-status": ["draft"]}) + ); + } + + #[test] + fn list_update_delete_undelete() { + let server = MockServer::start(); + let list = server.mock(|when, then| { + when.method(GET) + .path("/micropub") + .query_param("q", "source") + .query_param("limit", "20") + .query_param("offset", "40") + .header("authorization", "Bearer tok"); + then.status(200).json_body(json!({"items": [ + {"type": ["h-entry"], "properties": {"url": ["https://e/note/a"], "content": ["A"]}}, + {"type": ["h-entry"], "properties": {"content": ["no url, skipped"]}} + ]})); + }); + let c = client(&server); + let posts = c.list(20, 40).unwrap(); + list.assert(); + assert_eq!(posts.len(), 1); + assert_eq!(posts[0].content, "A"); + + let upd = server.mock(|when, then| { + when.method(POST).path("/micropub").json_body(json!({ + "action": "update", "url": "https://e/note/a", "replace": {"content": ["B"]} + })); + then.status(204); + }); + let mut u = Update { + url: "https://e/note/a".into(), + ..Default::default() + }; + u.replace.insert("content".into(), json!(["B"])); + c.update(&u).unwrap(); + upd.assert(); + + let del = server.mock(|when, then| { + when.method(POST) + .path("/micropub") + .json_body(json!({"action": "delete", "url": "https://e/note/a"})); + then.status(204); + }); + let undel = server.mock(|when, then| { + when.method(POST) + .path("/micropub") + .json_body(json!({"action": "undelete", "url": "https://e/note/a"})); + then.status(204); + }); + c.delete("https://e/note/a").unwrap(); + c.undelete("https://e/note/a").unwrap(); + del.assert(); + undel.assert(); + } + + #[test] + fn insufficient_scope_on_update_is_unauthorized() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/micropub"); + then.status(403).json_body(json!({"error": "insufficient_scope", "error_description": "Token lacks update scope"})); + }); + let err = client(&server).delete("u").unwrap_err(); + assert!(matches!(err, Error::Unauthorized(_))); + } + + #[test] + fn config_reads_queries() { + let cfg: ServerConfig = serde_json::from_value(json!({"q": ["config", "source"]})).unwrap(); + assert!(cfg.can_list()); + let cfg: ServerConfig = serde_json::from_value(json!({"q": ["config"]})).unwrap(); + assert!(!cfg.can_list()); + assert!(ServerConfig::default().can_list()); + } } diff --git a/src/ui.rs b/src/ui.rs index 132215b..744e255 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -8,9 +8,11 @@ use ratatui::{ widgets::{Block, BorderType, Clear, List, ListItem, ListState, Paragraph, Wrap}, }; -use crate::app::{App, Focus, Mode, Status, Targets}; +use crate::app::{App, Discard, Focus, Mode, Posts, Screen, Status, Targets}; +use crate::micropub::Post; const ACCENT: Color = Color::Cyan; +const DIM: Color = Color::DarkGray; fn field_block(title: &str, focused: bool) -> Block<'static> { let block = Block::bordered().title(format!(" {title} ")); @@ -20,26 +22,28 @@ fn field_block(title: &str, focused: bool) -> Block<'static> { .border_style(Style::default().fg(ACCENT)) .title_style(Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)) } else { - block.border_style(Style::default().fg(Color::DarkGray)) + block.border_style(Style::default().fg(DIM)) } } +fn hint(s: &str) -> Line<'static> { + Line::styled(s.to_string(), Style::default().fg(DIM)) +} + pub fn draw(f: &mut Frame, app: &mut App) { - let targets_height = match app.targets_state { - Targets::Loaded if !app.targets.is_empty() => (app.targets.len() as u16 + 2).min(8), - _ => 3, - }; - let [header, title, content, tags, targets, status, help] = Layout::vertical([ + let [header, body, status, help] = Layout::vertical([ Constraint::Length(1), - Constraint::Length(3), - Constraint::Min(5), - Constraint::Length(3), - Constraint::Length(targets_height), + Constraint::Min(8), Constraint::Length(1), Constraint::Length(1), ]) .areas(f.area()); + let place = match (app.screen, &app.editing) { + (Screen::Posts, _) => "Posts".to_string(), + (Screen::Compose, Some(p)) => format!("Editing {}", p.url), + (Screen::Compose, None) => "New note".to_string(), + }; f.render_widget( Line::from(vec![ Span::styled( @@ -48,28 +52,15 @@ pub fn draw(f: &mut Frame, app: &mut App) { ), Span::raw(" "), Span::styled(app.me.clone(), Style::default().bold()), - Span::styled( - format!(" via {}", app.endpoint), - Style::default().fg(Color::DarkGray), - ), + Span::styled(format!(" · {place}"), Style::default().fg(DIM)), ]), header, ); - app.title - .set_block(field_block("Title", app.focus == Focus::Title)); - f.render_widget(&app.title, title); - let chars: usize = app.content.lines().iter().map(|l| l.chars().count()).sum(); - app.content.set_block(field_block( - &format!("Note · {chars} chars"), - app.focus == Focus::Content, - )); - f.render_widget(&app.content, content); - app.tags - .set_block(field_block("Tags", app.focus == Focus::Tags)); - f.render_widget(&app.tags, tags); - - draw_targets(f, app, targets); + let help_text = match app.screen { + Screen::Compose => draw_compose(f, app, body), + Screen::Posts => draw_posts(f, app, body), + }; let status_line = match &app.status { Some(Status::Info(s)) => Line::styled(format!(" {s}"), Style::default().fg(Color::Green)), @@ -77,75 +68,76 @@ pub fn draw(f: &mut Frame, app: &mut App) { None => Line::raw(""), }; f.render_widget(status_line, status); + f.render_widget(hint(help_text), help); - let help_text = match app.focus { - Focus::Syndication => { - " ↑↓ move · space toggle · a all/none · Tab next · Ctrl-S publish · Esc quit" + draw_popup(f, app); +} + +fn draw_compose(f: &mut Frame, app: &mut App, area: Rect) -> &'static str { + let editing = app.editing.is_some(); + let targets_height = match (&app.targets_state, editing) { + (_, true) => 3, + (Targets::Loaded, false) if !app.targets.is_empty() => { + (app.targets.len() as u16 + 2).min(8) } - _ => " Tab next field · Ctrl-S publish · Ctrl-R refresh · Ctrl-O open last post · Esc quit", + _ => 3, }; - f.render_widget( - Line::styled(help_text, Style::default().fg(Color::DarkGray)), - help, - ); + let [title, reply, content, tags, targets] = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(5), + Constraint::Length(3), + Constraint::Length(targets_height), + ]) + .areas(area); - match &app.mode { - Mode::Editing => {} - Mode::ConfirmPublish => popup( - f, - "Publish?", - vec![ - Line::from(vec![ - Span::raw("Post to "), - Span::styled(app.me.clone(), Style::default().bold()), - ]), - Line::from(vec![ - Span::raw("Crosspost: "), - Span::styled(app.syndication_summary(), Style::default().fg(ACCENT)), - ]), - Line::raw(""), - Line::styled( - "y / Enter publish · any other key cancel", - Style::default().fg(Color::DarkGray), - ), - ], - ), - Mode::Publishing => popup(f, "Publishing", vec![Line::raw("Sending to your site…")]), - Mode::Published(url) => popup( - f, - "Published", - vec![ - Line::styled(url.clone(), Style::default().fg(ACCENT).underlined()), - Line::raw(""), - Line::styled( - "o open in browser · any other key new note", - Style::default().fg(Color::DarkGray), - ), - ], - ), - Mode::ConfirmQuit => popup( - f, - "Quit?", - vec![ - Line::raw("This note hasn't been published and will be lost."), - Line::raw(""), - Line::styled( - "y quit · any other key keep editing", - Style::default().fg(Color::DarkGray), - ), - ], - ), + app.title + .set_block(field_block("Title", app.focus == Focus::Title)); + f.render_widget(&app.title, title); + app.reply + .set_block(field_block("In reply to", app.focus == Focus::Reply)); + f.render_widget(&app.reply, reply); + let chars: usize = app.content.lines().iter().map(|l| l.chars().count()).sum(); + let label = match &app.editing { + Some(p) if p.draft => format!("Note · draft · {chars} chars"), + _ => format!("Note · {chars} chars"), + }; + app.content + .set_block(field_block(&label, app.focus == Focus::Content)); + f.render_widget(&app.content, content); + app.tags + .set_block(field_block("Tags", app.focus == Focus::Tags)); + f.render_widget(&app.tags, tags); + + draw_targets(f, app, targets); + + match (app.focus, editing) { + (Focus::Syndication, _) => { + " ↑↓ move · space toggle · a all/none · Tab next · Ctrl-S publish · Ctrl-P posts · Esc quit" + } + (_, true) => { + " Tab next field · Ctrl-S save · Ctrl-N new note · Ctrl-P posts · Esc back to posts" + } + (_, false) => { + " Tab next field · Ctrl-S publish · Ctrl-P posts · Ctrl-R refresh · Ctrl-O open last · Esc quit" + } } } fn draw_targets(f: &mut Frame, app: &App, area: Rect) { let focused = app.focus == Focus::Syndication; let block = field_block("Crosspost to", focused); + if app.editing.is_some() { + let text = format!( + "{} · targets can't be changed after posting", + app.syndication_summary() + ); + f.render_widget(Paragraph::new(text).fg(DIM).block(block), area); + return; + } match &app.targets_state { Targets::Loading => f.render_widget( - Paragraph::new("Loading targets…") - .fg(Color::DarkGray) - .block(block), + Paragraph::new("Loading targets…").fg(DIM).block(block), area, ), Targets::Failed(_) => f.render_widget( @@ -156,7 +148,7 @@ fn draw_targets(f: &mut Frame, app: &App, area: Rect) { ), Targets::Loaded if app.targets.is_empty() => f.render_widget( Paragraph::new("This server has no crosspost targets") - .fg(Color::DarkGray) + .fg(DIM) .block(block), area, ), @@ -169,7 +161,7 @@ fn draw_targets(f: &mut Frame, app: &App, area: Rect) { ListItem::new(Line::from(vec![ Span::styled( mark, - Style::default().fg(if *on { Color::Green } else { Color::DarkGray }), + Style::default().fg(if *on { Color::Green } else { DIM }), ), Span::raw(t.name.clone()), ])) @@ -185,6 +177,232 @@ fn draw_targets(f: &mut Frame, app: &App, area: Rect) { } } +/// "2026-11-01T01:02:09.6" -> "2026-11-01 01:02" +fn short_date(iso: &str) -> String { + let date = iso.get(..10).unwrap_or(iso); + match iso.get(11..16) { + Some(time) => format!("{date} {time}"), + None => date.to_string(), + } +} + +/// The post's title, or the start of its first line. +fn headline(p: &Post) -> String { + p.title.clone().unwrap_or_else(|| { + p.content + .lines() + .find(|l| !l.trim().is_empty()) + .unwrap_or("(empty)") + .trim() + .to_string() + }) +} + +fn post_item(p: &Post) -> ListItem<'static> { + let mut spans = vec![Span::styled( + format!("{} ", short_date(&p.published)), + Style::default().fg(DIM), + )]; + if p.draft { + spans.push(Span::styled( + "draft ", + Style::default().fg(Color::Yellow).bold(), + )); + } + if p.in_reply_to.is_some() { + spans.push(Span::styled("↩ ", Style::default().fg(ACCENT))); + } + let head = headline(p); + spans.push(if p.title.is_some() { + Span::styled(head, Style::default().bold()) + } else { + Span::raw(head) + }); + if !p.tags.is_empty() { + let tags: Vec = p.tags.iter().map(|t| format!("#{t}")).collect(); + spans.push(Span::styled( + format!(" {}", tags.join(" ")), + Style::default().fg(DIM), + )); + } + if !p.syndication.is_empty() { + spans.push(Span::styled( + format!(" ↗{}", p.syndication.len()), + Style::default().fg(Color::Green), + )); + } + ListItem::new(Line::from(spans)) +} + +fn draw_posts(f: &mut Frame, app: &App, area: Rect) -> &'static str { + let [list_area, preview_area] = + Layout::vertical([Constraint::Min(5), Constraint::Percentage(40)]).areas(area); + + let count = match (&app.posts_state, app.posts_more) { + (Posts::Loading, _) => "loading…".to_string(), + (_, true) => format!("{}+", app.posts.len()), + _ => app.posts.len().to_string(), + }; + let block = field_block(&format!("Posts · {count}"), true); + match &app.posts_state { + Posts::Failed(e) if app.posts.is_empty() => f.render_widget( + Paragraph::new(format!("Couldn't load posts: {e} (r to retry)")) + .fg(Color::Red) + .wrap(Wrap { trim: true }) + .block(block), + list_area, + ), + _ if app.posts.is_empty() => { + let msg = if app.posts_state == Posts::Loading { + "Loading…" + } else { + "No posts yet." + }; + f.render_widget(Paragraph::new(msg).fg(DIM).block(block), list_area) + } + _ => { + let mut items: Vec = app.posts.iter().map(post_item).collect(); + if app.posts_more { + items.push(ListItem::new(hint(" ↓ more"))); + } + let list = List::new(items) + .block(block) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + let mut state = ListState::default().with_selected(Some(app.posts_cursor)); + f.render_stateful_widget(list, list_area, &mut state); + } + } + + let preview = match app.selected_post() { + Some(p) => { + let mut lines = Vec::new(); + if let Some(t) = &p.title { + lines.push(Line::styled(t.clone(), Style::default().bold())); + } + if let Some(r) = &p.in_reply_to { + lines.push(Line::styled( + format!("In reply to {r}"), + Style::default().fg(ACCENT), + )); + } + lines.extend(p.content.lines().map(|l| Line::raw(l.to_string()))); + lines.push(Line::raw("")); + lines.push(hint(&p.url)); + for s in &p.syndication { + lines.push(Line::styled( + format!("↗ {s}"), + Style::default().fg(Color::Green), + )); + } + Paragraph::new(lines).wrap(Wrap { trim: false }) + } + None => Paragraph::new(""), + }; + f.render_widget(preview.block(field_block("Preview", false)), preview_area); + + if app.scope_hint().is_some() { + " ↑↓ move · Enter edit · n new · r reload · Esc back · (log in again to edit/delete)" + } else if app.selected_post().is_some_and(|p| p.draft) { + " ↑↓ move · Enter edit · p publish · d delete · u undo · n new · r reload · Esc back" + } else { + " ↑↓ move · Enter edit · d delete · u undo delete · o open · n new · r reload · Esc back" + } +} + +fn draw_popup(f: &mut Frame, app: &App) { + let editing = app.editing.is_some(); + match &app.mode { + Mode::Normal => {} + Mode::ConfirmPublish => { + let mut lines = vec![Line::from(vec![ + Span::raw(if editing { + "Save changes to " + } else { + "Post to " + }), + Span::styled(app.me.clone(), Style::default().bold()), + ])]; + lines.push(Line::from(vec![ + Span::raw("Crosspost: "), + Span::styled(app.syndication_summary(), Style::default().fg(ACCENT)), + ])); + lines.push(Line::raw("")); + let keys = match (editing, app.default_draft()) { + (false, _) => "y / Enter publish · d save as draft · Esc cancel", + (true, true) => "y / Enter save as draft · p publish · Esc cancel", + (true, false) => "y / Enter save · d unpublish to draft · Esc cancel", + }; + lines.push(hint(keys)); + popup(f, if editing { "Save?" } else { "Publish?" }, lines); + } + Mode::Sending => popup(f, "Working", vec![Line::raw("Talking to your site…")]), + Mode::Published { url, draft } => { + let title = if *draft { "Draft saved" } else { "Published" }; + let mut lines = vec![Line::styled( + url.clone(), + Style::default().fg(ACCENT).underlined(), + )]; + lines.push(Line::raw("")); + lines.push(hint(if *draft { + "Not public yet; publish it from the post list (Ctrl-P) · any key to continue" + } else { + "o open in browser · any other key new note" + })); + popup(f, title, lines); + } + Mode::ConfirmQuit => popup( + f, + "Quit?", + vec![ + Line::raw(if editing { + "Your changes haven't been saved and will be lost." + } else { + "This note hasn't been published and will be lost." + }), + Line::raw(""), + hint("y quit · any other key keep editing"), + ], + ), + Mode::ConfirmDiscard(action) => { + let what = match action { + Discard::New | Discard::BackToPosts if editing => { + "Discard your changes to this post?" + } + Discard::Edit(_) if editing => "Discard your changes to the post you're editing?", + _ => "Discard the note you're writing?", + }; + popup( + f, + "Discard?", + vec![ + Line::raw(what), + Line::raw(""), + hint("y discard · any other key keep it"), + ], + ); + } + Mode::ConfirmDelete(url) => { + let head = app + .posts + .iter() + .find(|p| &p.url == url) + .map(headline) + .unwrap_or_else(|| url.clone()); + let head: String = head.chars().take(60).collect(); + popup( + f, + "Delete?", + vec![ + Line::styled(head, Style::default().bold()), + Line::raw("Its crossposts are removed too. You can undo with u."), + Line::raw(""), + hint("y delete · any other key cancel"), + ], + ); + } + } +} + fn popup(f: &mut Frame, title: &str, lines: Vec>) { let width = lines .iter() @@ -217,3 +435,22 @@ fn centered(area: Rect, width: u16, height: u16) -> Rect { .areas(row); cell } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dates_and_headlines() { + assert_eq!(short_date("2026-11-01T01:02:09.643392"), "2026-11-01 01:02"); + assert_eq!(short_date("2026-11-01"), "2026-11-01"); + assert_eq!(short_date(""), ""); + let mut p = Post { + content: "\nFirst line\nsecond".into(), + ..Default::default() + }; + assert_eq!(headline(&p), "First line"); + p.title = Some("Title".into()); + assert_eq!(headline(&p), "Title"); + } +} diff --git a/src/worker.rs b/src/worker.rs index 7e30954..a2e5f88 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -3,16 +3,40 @@ use std::sync::mpsc::{self, Receiver, Sender}; use std::thread; -use crate::micropub::{Client, Error, Note, ServerConfig}; +use crate::micropub::{Client, Error, Note, Post, ServerConfig, Update}; + +/// Posts fetched per page of the post list. +pub const PAGE_SIZE: usize = 20; pub enum Request { LoadConfig, Publish(Note), + List { offset: usize }, + Update(Update), + Delete(String), + Undelete(String), } pub enum Response { Config(Result), - Published(Result), + /// Permalink of the new post, and whether it was saved as a draft. + Published(Result, bool), + Listed { + offset: usize, + result: Result, Error>, + }, + Updated { + url: String, + result: Result<(), Error>, + }, + Deleted { + url: String, + result: Result<(), Error>, + }, + Undeleted { + url: String, + result: Result<(), Error>, + }, } pub fn spawn(client: Client) -> (Sender, Receiver) { @@ -22,7 +46,23 @@ pub fn spawn(client: Client) -> (Sender, Receiver) { for req in req_rx { let resp = match req { Request::LoadConfig => Response::Config(client.config()), - Request::Publish(note) => Response::Published(client.create(¬e)), + Request::Publish(note) => Response::Published(client.create(¬e), note.draft), + Request::List { offset } => Response::Listed { + offset, + result: client.list(PAGE_SIZE, offset), + }, + Request::Update(u) => Response::Updated { + result: client.update(&u), + url: u.url, + }, + Request::Delete(url) => Response::Deleted { + result: client.delete(&url), + url, + }, + Request::Undelete(url) => Response::Undeleted { + result: client.undelete(&url), + url, + }, }; if resp_tx.send(resp).is_err() { break;