Post list, edit, delete/undo, drafts and replies
Uses StarPunk 2.4.0's Micropub update, delete/undelete, post-status and q=source listing. Login asks for update and delete scopes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,24 +21,45 @@ tuipub logout # revoke the token and forget the account
|
|||||||
|
|
||||||
Running `tuipub` with no account starts the login flow first.
|
Running `tuipub` with no account starts the login flow first.
|
||||||
|
|
||||||
### Keys
|
### Writing (compose screen)
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Tab / Shift-Tab | Next / previous field |
|
| Tab / Shift-Tab | Next / previous field (Title, In reply to, Note, Tags, Crosspost) |
|
||||||
| Ctrl-S | Publish (asks to confirm) |
|
| 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-R | Reload crosspost targets from the server |
|
||||||
| Ctrl-O | Open the last published post |
|
| 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 |
|
| ↑ ↓ / 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
|
## 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`.
|
- **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:<random port>` and uses `http://127.0.0.1:<port>/` 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:<random port>` and uses `http://127.0.0.1:<port>/` 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`.
|
- **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).
|
- **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
|
### Environment
|
||||||
@@ -49,7 +70,7 @@ Content is sent as written (Markdown for StarPunk). Tags are comma-separated, an
|
|||||||
## StarPunk notes
|
## 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.
|
- 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
|
## Develop
|
||||||
|
|
||||||
|
|||||||
+748
-98
File diff suppressed because it is too large
Load Diff
+19
-3
@@ -15,7 +15,8 @@ use url::Url;
|
|||||||
|
|
||||||
use crate::{config::Config, discovery, token};
|
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);
|
const LOGIN_TIMEOUT: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
pub struct Pkce {
|
pub struct Pkce {
|
||||||
@@ -286,8 +287,23 @@ pub fn login(me_input: &str) -> Result<Config> {
|
|||||||
token::set(&cfg.me, &tok.access_token)?;
|
token::set(&cfg.me, &tok.access_token)?;
|
||||||
cfg.save()?;
|
cfg.save()?;
|
||||||
eprintln!("Logged in as {} (scope: {})", cfg.me, cfg.scope);
|
eprintln!("Logged in as {} (scope: {})", cfg.me, cfg.scope);
|
||||||
if !cfg.scope.split_whitespace().any(|s| s == "create") {
|
let granted: Vec<&str> = cfg.scope.split_whitespace().collect();
|
||||||
eprintln!("Warning: the server did not grant the `create` scope, so posting will fail.");
|
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::<Vec<_>>()
|
||||||
|
.join(" or ")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -87,7 +87,7 @@ fn run_tui() -> Result<()> {
|
|||||||
let (tx, rx) = worker::spawn(client);
|
let (tx, rx) = worker::spawn(client);
|
||||||
tx.send(Request::LoadConfig).ok();
|
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();
|
let mut terminal = ratatui::init();
|
||||||
execute!(io::stdout(), EnableBracketedPaste).ok();
|
execute!(io::stdout(), EnableBracketedPaste).ok();
|
||||||
|
|
||||||
@@ -106,7 +106,9 @@ fn run_tui() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
while let Ok(resp) = rx.try_recv() {
|
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(())
|
Ok(())
|
||||||
|
|||||||
+367
-1
@@ -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 reqwest::{StatusCode, blocking::Client as Http};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -15,6 +15,17 @@ pub struct SyndicationTarget {
|
|||||||
pub struct ServerConfig {
|
pub struct ServerConfig {
|
||||||
#[serde(rename = "syndicate-to", default)]
|
#[serde(rename = "syndicate-to", default)]
|
||||||
pub syndicate_to: Vec<SyndicationTarget>,
|
pub syndicate_to: Vec<SyndicationTarget>,
|
||||||
|
/// Queries the server says it supports (`q` in q=config). Empty if it doesn't say.
|
||||||
|
#[serde(default)]
|
||||||
|
pub q: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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)]
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
@@ -24,6 +35,9 @@ pub struct Note {
|
|||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
/// `None` leaves syndication to the server's default. `Some(vec![])` means none.
|
/// `None` leaves syndication to the server's default. `Some(vec![])` means none.
|
||||||
pub syndicate_to: Option<Vec<String>>,
|
pub syndicate_to: Option<Vec<String>>,
|
||||||
|
pub in_reply_to: Option<String>,
|
||||||
|
/// Save unpublished (`post-status: draft`).
|
||||||
|
pub draft: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Note {
|
impl Note {
|
||||||
@@ -38,6 +52,12 @@ impl Note {
|
|||||||
if !self.tags.is_empty() {
|
if !self.tags.is_empty() {
|
||||||
props.insert("category".into(), json!(self.tags));
|
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 });
|
let mut body = json!({ "type": ["h-entry"], "properties": props });
|
||||||
if let Some(targets) = &self.syndicate_to {
|
if let Some(targets) = &self.syndicate_to {
|
||||||
body["mp-syndicate-to"] = json!(targets);
|
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<String>,
|
||||||
|
pub content: String,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
/// As sent by the server (ISO 8601).
|
||||||
|
pub published: String,
|
||||||
|
pub draft: bool,
|
||||||
|
pub in_reply_to: Option<String>,
|
||||||
|
/// Links to crossposted copies.
|
||||||
|
pub syndication: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_str(props: &Value, key: &str) -> Option<String> {
|
||||||
|
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<String> {
|
||||||
|
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<Post> {
|
||||||
|
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<String, Value>,
|
||||||
|
pub delete: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String> = 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"].
|
/// Split "a, #b,, a" into ["a", "b"].
|
||||||
pub fn parse_tags(input: &str) -> Vec<String> {
|
pub fn parse_tags(input: &str) -> Vec<String> {
|
||||||
let mut out: Vec<String> = Vec::new();
|
let mut out: Vec<String> = Vec::new();
|
||||||
@@ -151,6 +298,50 @@ impl Client {
|
|||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recent posts, newest first, drafts included (`q=source` without `url`).
|
||||||
|
pub fn list(&self, limit: usize, offset: usize) -> Result<Vec<Post>, 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.
|
/// Publish a note. Returns the post's permalink.
|
||||||
pub fn create(&self, note: &Note) -> Result<String, Error> {
|
pub fn create(&self, note: &Note) -> Result<String, Error> {
|
||||||
let resp = self
|
let resp = self
|
||||||
@@ -204,6 +395,7 @@ mod tests {
|
|||||||
content: "Hello **world**".into(),
|
content: "Hello **world**".into(),
|
||||||
tags: vec!["a".into()],
|
tags: vec!["a".into()],
|
||||||
syndicate_to: Some(vec![]),
|
syndicate_to: Some(vec![]),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
note.to_json(),
|
note.to_json(),
|
||||||
@@ -329,4 +521,178 @@ mod tests {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, Error::NoLocation(_)));
|
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": "<p>x</p>", "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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ use ratatui::{
|
|||||||
widgets::{Block, BorderType, Clear, List, ListItem, ListState, Paragraph, Wrap},
|
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 ACCENT: Color = Color::Cyan;
|
||||||
|
const DIM: Color = Color::DarkGray;
|
||||||
|
|
||||||
fn field_block(title: &str, focused: bool) -> Block<'static> {
|
fn field_block(title: &str, focused: bool) -> Block<'static> {
|
||||||
let block = Block::bordered().title(format!(" {title} "));
|
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))
|
.border_style(Style::default().fg(ACCENT))
|
||||||
.title_style(Style::default().fg(ACCENT).add_modifier(Modifier::BOLD))
|
.title_style(Style::default().fg(ACCENT).add_modifier(Modifier::BOLD))
|
||||||
} else {
|
} 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) {
|
pub fn draw(f: &mut Frame, app: &mut App) {
|
||||||
let targets_height = match app.targets_state {
|
let [header, body, status, help] = Layout::vertical([
|
||||||
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([
|
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
Constraint::Length(3),
|
Constraint::Min(8),
|
||||||
Constraint::Min(5),
|
|
||||||
Constraint::Length(3),
|
|
||||||
Constraint::Length(targets_height),
|
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
])
|
])
|
||||||
.areas(f.area());
|
.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(
|
f.render_widget(
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
@@ -48,28 +52,15 @@ pub fn draw(f: &mut Frame, app: &mut App) {
|
|||||||
),
|
),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled(app.me.clone(), Style::default().bold()),
|
Span::styled(app.me.clone(), Style::default().bold()),
|
||||||
Span::styled(
|
Span::styled(format!(" · {place}"), Style::default().fg(DIM)),
|
||||||
format!(" via {}", app.endpoint),
|
|
||||||
Style::default().fg(Color::DarkGray),
|
|
||||||
),
|
|
||||||
]),
|
]),
|
||||||
header,
|
header,
|
||||||
);
|
);
|
||||||
|
|
||||||
app.title
|
let help_text = match app.screen {
|
||||||
.set_block(field_block("Title", app.focus == Focus::Title));
|
Screen::Compose => draw_compose(f, app, body),
|
||||||
f.render_widget(&app.title, title);
|
Screen::Posts => draw_posts(f, app, body),
|
||||||
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 status_line = match &app.status {
|
let status_line = match &app.status {
|
||||||
Some(Status::Info(s)) => Line::styled(format!(" {s}"), Style::default().fg(Color::Green)),
|
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(""),
|
None => Line::raw(""),
|
||||||
};
|
};
|
||||||
f.render_widget(status_line, status);
|
f.render_widget(status_line, status);
|
||||||
|
f.render_widget(hint(help_text), help);
|
||||||
|
|
||||||
let help_text = match app.focus {
|
draw_popup(f, app);
|
||||||
Focus::Syndication => {
|
}
|
||||||
" ↑↓ move · space toggle · a all/none · Tab next · Ctrl-S publish · Esc quit"
|
|
||||||
|
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(
|
let [title, reply, content, tags, targets] = Layout::vertical([
|
||||||
Line::styled(help_text, Style::default().fg(Color::DarkGray)),
|
Constraint::Length(3),
|
||||||
help,
|
Constraint::Length(3),
|
||||||
);
|
Constraint::Min(5),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Length(targets_height),
|
||||||
|
])
|
||||||
|
.areas(area);
|
||||||
|
|
||||||
match &app.mode {
|
app.title
|
||||||
Mode::Editing => {}
|
.set_block(field_block("Title", app.focus == Focus::Title));
|
||||||
Mode::ConfirmPublish => popup(
|
f.render_widget(&app.title, title);
|
||||||
f,
|
app.reply
|
||||||
"Publish?",
|
.set_block(field_block("In reply to", app.focus == Focus::Reply));
|
||||||
vec![
|
f.render_widget(&app.reply, reply);
|
||||||
Line::from(vec![
|
let chars: usize = app.content.lines().iter().map(|l| l.chars().count()).sum();
|
||||||
Span::raw("Post to "),
|
let label = match &app.editing {
|
||||||
Span::styled(app.me.clone(), Style::default().bold()),
|
Some(p) if p.draft => format!("Note · draft · {chars} chars"),
|
||||||
]),
|
_ => format!("Note · {chars} chars"),
|
||||||
Line::from(vec![
|
};
|
||||||
Span::raw("Crosspost: "),
|
app.content
|
||||||
Span::styled(app.syndication_summary(), Style::default().fg(ACCENT)),
|
.set_block(field_block(&label, app.focus == Focus::Content));
|
||||||
]),
|
f.render_widget(&app.content, content);
|
||||||
Line::raw(""),
|
app.tags
|
||||||
Line::styled(
|
.set_block(field_block("Tags", app.focus == Focus::Tags));
|
||||||
"y / Enter publish · any other key cancel",
|
f.render_widget(&app.tags, tags);
|
||||||
Style::default().fg(Color::DarkGray),
|
|
||||||
),
|
draw_targets(f, app, targets);
|
||||||
],
|
|
||||||
),
|
match (app.focus, editing) {
|
||||||
Mode::Publishing => popup(f, "Publishing", vec![Line::raw("Sending to your site…")]),
|
(Focus::Syndication, _) => {
|
||||||
Mode::Published(url) => popup(
|
" ↑↓ move · space toggle · a all/none · Tab next · Ctrl-S publish · Ctrl-P posts · Esc quit"
|
||||||
f,
|
}
|
||||||
"Published",
|
(_, true) => {
|
||||||
vec![
|
" Tab next field · Ctrl-S save · Ctrl-N new note · Ctrl-P posts · Esc back to posts"
|
||||||
Line::styled(url.clone(), Style::default().fg(ACCENT).underlined()),
|
}
|
||||||
Line::raw(""),
|
(_, false) => {
|
||||||
Line::styled(
|
" Tab next field · Ctrl-S publish · Ctrl-P posts · Ctrl-R refresh · Ctrl-O open last · Esc quit"
|
||||||
"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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_targets(f: &mut Frame, app: &App, area: Rect) {
|
fn draw_targets(f: &mut Frame, app: &App, area: Rect) {
|
||||||
let focused = app.focus == Focus::Syndication;
|
let focused = app.focus == Focus::Syndication;
|
||||||
let block = field_block("Crosspost to", focused);
|
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 {
|
match &app.targets_state {
|
||||||
Targets::Loading => f.render_widget(
|
Targets::Loading => f.render_widget(
|
||||||
Paragraph::new("Loading targets…")
|
Paragraph::new("Loading targets…").fg(DIM).block(block),
|
||||||
.fg(Color::DarkGray)
|
|
||||||
.block(block),
|
|
||||||
area,
|
area,
|
||||||
),
|
),
|
||||||
Targets::Failed(_) => f.render_widget(
|
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(
|
Targets::Loaded if app.targets.is_empty() => f.render_widget(
|
||||||
Paragraph::new("This server has no crosspost targets")
|
Paragraph::new("This server has no crosspost targets")
|
||||||
.fg(Color::DarkGray)
|
.fg(DIM)
|
||||||
.block(block),
|
.block(block),
|
||||||
area,
|
area,
|
||||||
),
|
),
|
||||||
@@ -169,7 +161,7 @@ fn draw_targets(f: &mut Frame, app: &App, area: Rect) {
|
|||||||
ListItem::new(Line::from(vec![
|
ListItem::new(Line::from(vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
mark,
|
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()),
|
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<String> = 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<ListItem> = 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<Line<'static>>) {
|
fn popup(f: &mut Frame, title: &str, lines: Vec<Line<'static>>) {
|
||||||
let width = lines
|
let width = lines
|
||||||
.iter()
|
.iter()
|
||||||
@@ -217,3 +435,22 @@ fn centered(area: Rect, width: u16, height: u16) -> Rect {
|
|||||||
.areas(row);
|
.areas(row);
|
||||||
cell
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+43
-3
@@ -3,16 +3,40 @@
|
|||||||
use std::sync::mpsc::{self, Receiver, Sender};
|
use std::sync::mpsc::{self, Receiver, Sender};
|
||||||
use std::thread;
|
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 {
|
pub enum Request {
|
||||||
LoadConfig,
|
LoadConfig,
|
||||||
Publish(Note),
|
Publish(Note),
|
||||||
|
List { offset: usize },
|
||||||
|
Update(Update),
|
||||||
|
Delete(String),
|
||||||
|
Undelete(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Response {
|
pub enum Response {
|
||||||
Config(Result<ServerConfig, Error>),
|
Config(Result<ServerConfig, Error>),
|
||||||
Published(Result<String, Error>),
|
/// Permalink of the new post, and whether it was saved as a draft.
|
||||||
|
Published(Result<String, Error>, bool),
|
||||||
|
Listed {
|
||||||
|
offset: usize,
|
||||||
|
result: Result<Vec<Post>, 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<Request>, Receiver<Response>) {
|
pub fn spawn(client: Client) -> (Sender<Request>, Receiver<Response>) {
|
||||||
@@ -22,7 +46,23 @@ pub fn spawn(client: Client) -> (Sender<Request>, Receiver<Response>) {
|
|||||||
for req in req_rx {
|
for req in req_rx {
|
||||||
let resp = match req {
|
let resp = match req {
|
||||||
Request::LoadConfig => Response::Config(client.config()),
|
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() {
|
if resp_tx.send(resp).is_err() {
|
||||||
break;
|
break;
|
||||||
|
|||||||
Reference in New Issue
Block a user