Media uploads: photos, video and audio
Attach files from the new Media field (path prompt with Tab completion,
or paste/drop paths) or by URL. Files go to the server's media endpoint
(q=config media-endpoint, multipart 'file', 201 Location) in the
background; publishing waits for them. The extension picks the photo,
video or audio property; photos can carry alt text ({value, alt}).
Edits replace or delete a media property only when it changed. Notes
with media need no text. Warns when q=config lists post types without
video or audio.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+18
@@ -1817,6 +1817,16 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime_guess"
|
||||||
|
version = "2.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||||
|
dependencies = [
|
||||||
|
"mime",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "minimal-lexical"
|
name = "minimal-lexical"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -2678,6 +2688,7 @@ dependencies = [
|
|||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime_guess",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
@@ -3640,6 +3651,7 @@ dependencies = [
|
|||||||
"directories",
|
"directories",
|
||||||
"httpmock",
|
"httpmock",
|
||||||
"keyring",
|
"keyring",
|
||||||
|
"mime_guess",
|
||||||
"open",
|
"open",
|
||||||
"rand 0.10.3",
|
"rand 0.10.3",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
@@ -3678,6 +3690,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.27"
|
version = "1.0.27"
|
||||||
|
|||||||
+2
-1
@@ -9,11 +9,12 @@ base64 = "0.23.1"
|
|||||||
clap = { version = "4.6.7", features = ["derive"] }
|
clap = { version = "4.6.7", features = ["derive"] }
|
||||||
directories = "6.0.0"
|
directories = "6.0.0"
|
||||||
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
||||||
|
mime_guess = "2"
|
||||||
open = "5.4.5"
|
open = "5.4.5"
|
||||||
rand = "0.10.3"
|
rand = "0.10.3"
|
||||||
ratatui = "0.30.2"
|
ratatui = "0.30.2"
|
||||||
ratatui-textarea = "0.9.4"
|
ratatui-textarea = "0.9.4"
|
||||||
reqwest = { version = "0.13.5", default-features = false, features = ["blocking", "json", "rustls", "form", "query"] }
|
reqwest = { version = "0.13.5", default-features = false, features = ["blocking", "json", "rustls", "form", "query", "multipart"] }
|
||||||
scraper = "0.29.0"
|
scraper = "0.29.0"
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.154"
|
serde_json = "1.0.154"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Running `tuipub` with no account starts the login flow first.
|
|||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Tab / Shift-Tab | Next / previous field (Title, In reply to, Note, Tags, Crosspost) |
|
| Tab / Shift-Tab | Next / previous field (Title, In reply to, Note, Media, Tags, Crosspost) |
|
||||||
| Ctrl-T | Post type: Note → Like → Repost (only types the server lists in `q=config`) |
|
| Ctrl-T | Post type: Note → Like → Repost (only types the server lists in `q=config`) |
|
||||||
| Ctrl-S | Publish or save. The confirm dialog offers **d** (draft) / **p** (publish) |
|
| Ctrl-S | Publish or save. The confirm dialog offers **d** (draft) / **p** (publish) |
|
||||||
| Ctrl-P | Your posts |
|
| Ctrl-P | Your posts |
|
||||||
@@ -35,6 +35,15 @@ Running `tuipub` with no account starts the login flow first.
|
|||||||
| Esc | Quit, or back to the post list while editing (asks if there are unsaved changes) |
|
| Esc | Quit, or back to the post list while editing (asks if there are unsaved changes) |
|
||||||
| ← → / ↑ ↓, Space, a | Crosspost list: move, toggle, all/none (one row when they fit, stacked when the window is narrow) |
|
| ← → / ↑ ↓, Space, a | Crosspost list: move, toggle, all/none (one row when they fit, stacked when the window is narrow) |
|
||||||
|
|
||||||
|
**Media**: Tab to the Media field and press **a**, then type a path (Tab completes it) or an http(s) URL. You can also paste or drag files onto the field (quoted paths, `file://` URIs and `\ ` escapes all work), one per line. Files upload to the server's media endpoint in the background; a URL is attached as is. The file extension decides whether it goes in `photo`, `video` or `audio`. In the field: **Enter** alt text · **d** remove · **J/K** move down/up · **↑↓** select. Publishing waits for uploads to finish. A note with media needs no text, as Micropub doesn't require it. If the server lists post types in `q=config` and video or audio isn't among them, tuipub warns that it may drop the file.
|
||||||
|
|
||||||
|
| Key (Media field) | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| a | Attach a file or URL (Enter on an empty list does the same) |
|
||||||
|
| Enter / e | Edit alt text |
|
||||||
|
| d / Delete | Remove |
|
||||||
|
| J / K | Move down / up (the first one leads) |
|
||||||
|
|
||||||
**Likes and reposts** (StarPunk 2.5.0+): press Ctrl-T, paste the URL into "Like of" / "Repost of", and optionally add a comment. StarPunk turns likes and reposts of fediverse or Bluesky posts into native favourites/boosts when it crossposts; likes of ordinary web pages aren't crossposted. What a like or repost points at can't be changed afterwards, but its comment can.
|
**Likes and reposts** (StarPunk 2.5.0+): press Ctrl-T, paste the URL into "Like of" / "Repost of", and optionally add a comment. StarPunk turns likes and reposts of fediverse or Bluesky posts into native favourites/boosts when it crossposts; likes of ordinary web pages aren't crossposted. What a like or repost points at can't be changed afterwards, but its comment can.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
@@ -63,6 +72,7 @@ Content is sent as written (Markdown for StarPunk). Tags are comma-separated, an
|
|||||||
- **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.
|
- **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`.
|
- **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`.
|
||||||
|
- **Media**: uploads go to `media-endpoint` from `q=config` as `multipart/form-data` with one `file` part. The `Location` of the 201 response is the file's URL, sent as `photo` (`{"value", "alt"}` when there's alt text), `video` or `audio`. Edits `replace` a media property that changed and `delete` one that's now empty. Without a media endpoint you can still attach by URL.
|
||||||
- **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
|
||||||
@@ -73,6 +83,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.
|
||||||
|
- Media (2.6.4): only JPEG, PNG, GIF and WebP upload. It keeps the first 4 photos, and `video`/`audio` are ignored (its `q=config` doesn't list them, so tuipub warns). A note needs text even with photos.
|
||||||
- Listing, editing, deleting, drafts and replies need StarPunk 2.4.0+; likes and reposts need 2.5.0+. Ctrl-P shows a message instead of the list when a server says it can't list posts (`q` in `q=config`).
|
- Listing, editing, deleting, drafts and replies need StarPunk 2.4.0+; likes and reposts need 2.5.0+. Ctrl-P shows a message instead of the list when a server says it can't list posts (`q` in `q=config`).
|
||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|||||||
+623
-12
@@ -1,10 +1,14 @@
|
|||||||
//! App state and key handling. Pure logic: no terminal or network here.
|
//! App state and key handling. Pure logic: no terminal or network here.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use ratatui::style::{Modifier, Style};
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui_textarea::{CursorMove, Input, Key, TextArea, WrapMode};
|
use ratatui_textarea::{CursorMove, Input, Key, TextArea, WrapMode};
|
||||||
|
|
||||||
use crate::micropub::{self, Error, Kind, Note, Post, ServerConfig, SyndicationTarget, Update};
|
use crate::micropub::{
|
||||||
|
self, Error, Kind, Media, MediaKind, Note, Post, ServerConfig, SyndicationTarget, Update,
|
||||||
|
};
|
||||||
use crate::worker::{PAGE_SIZE, Request, Response};
|
use crate::worker::{PAGE_SIZE, Request, Response};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -12,15 +16,17 @@ pub enum Focus {
|
|||||||
Title,
|
Title,
|
||||||
Link,
|
Link,
|
||||||
Content,
|
Content,
|
||||||
|
Media,
|
||||||
Tags,
|
Tags,
|
||||||
Syndication,
|
Syndication,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Focus {
|
impl Focus {
|
||||||
const ORDER: [Focus; 5] = [
|
const ORDER: [Focus; 6] = [
|
||||||
Focus::Title,
|
Focus::Title,
|
||||||
Focus::Link,
|
Focus::Link,
|
||||||
Focus::Content,
|
Focus::Content,
|
||||||
|
Focus::Media,
|
||||||
Focus::Tags,
|
Focus::Tags,
|
||||||
Focus::Syndication,
|
Focus::Syndication,
|
||||||
];
|
];
|
||||||
@@ -43,6 +49,27 @@ pub enum Discard {
|
|||||||
BackToPosts,
|
BackToPosts,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A photo, video or audio file in the form. `url` is None while it uploads.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Attachment {
|
||||||
|
/// Matches an upload's reply to its attachment.
|
||||||
|
pub id: u64,
|
||||||
|
pub kind: MediaKind,
|
||||||
|
/// File name, for display.
|
||||||
|
pub name: String,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub alt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A one-line question shown in a popup.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Prompt {
|
||||||
|
/// A file path or URL to attach.
|
||||||
|
AddMedia,
|
||||||
|
/// Alt text for the attachment with this id.
|
||||||
|
Alt(u64),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum Mode {
|
pub enum Mode {
|
||||||
Normal,
|
Normal,
|
||||||
@@ -52,6 +79,7 @@ pub enum Mode {
|
|||||||
ConfirmQuit,
|
ConfirmQuit,
|
||||||
ConfirmDiscard(Discard),
|
ConfirmDiscard(Discard),
|
||||||
ConfirmDelete(String),
|
ConfirmDelete(String),
|
||||||
|
Prompt(Prompt),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the syndication target list came from.
|
/// Where the syndication target list came from.
|
||||||
@@ -85,6 +113,17 @@ pub struct App {
|
|||||||
pub link: TextArea<'static>,
|
pub link: TextArea<'static>,
|
||||||
pub content: TextArea<'static>,
|
pub content: TextArea<'static>,
|
||||||
pub tags: TextArea<'static>,
|
pub tags: TextArea<'static>,
|
||||||
|
pub media: Vec<Attachment>,
|
||||||
|
pub media_cursor: usize,
|
||||||
|
next_media_id: u64,
|
||||||
|
/// From q=config. None if the server has none (or the config hasn't loaded).
|
||||||
|
pub media_endpoint: Option<String>,
|
||||||
|
/// Media kinds the server lists as post types (all, if it lists none).
|
||||||
|
pub media_kinds: Vec<MediaKind>,
|
||||||
|
/// Folder of the last file attached, to start the next path from.
|
||||||
|
last_dir: String,
|
||||||
|
/// The text box of the open prompt.
|
||||||
|
pub prompt: TextArea<'static>,
|
||||||
pub targets: Vec<(SyndicationTarget, bool)>,
|
pub targets: Vec<(SyndicationTarget, bool)>,
|
||||||
pub targets_state: Targets,
|
pub targets_state: Targets,
|
||||||
pub target_cursor: usize,
|
pub target_cursor: usize,
|
||||||
@@ -157,6 +196,99 @@ fn single_line(area: &TextArea) -> String {
|
|||||||
area.lines().join(" ").trim().to_string()
|
area.lines().join(" ").trim().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ADD_MEDIA_HINT: &str = "Path to a photo, video or audio file, or its URL";
|
||||||
|
|
||||||
|
fn is_url(s: &str) -> bool {
|
||||||
|
s.starts_with("http://") || s.starts_with("https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last part of a URL's path, to name an attachment.
|
||||||
|
fn name_from_url(url: &str) -> String {
|
||||||
|
let path = url.split(['?', '#']).next().unwrap_or(url);
|
||||||
|
path.trim_end_matches('/')
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.filter(|n| !n.is_empty() && !n.contains(':'))
|
||||||
|
.unwrap_or(url)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_tilde(s: &str) -> PathBuf {
|
||||||
|
let home = std::env::var_os("HOME").map(PathBuf::from);
|
||||||
|
match (s.strip_prefix('~'), home) {
|
||||||
|
(Some(""), Some(h)) => h,
|
||||||
|
(Some(rest), Some(h)) if rest.starts_with('/') => h.join(&rest[1..]),
|
||||||
|
_ => PathBuf::from(s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A path as typed, pasted or dropped onto the terminal: quoted, `file://` URIs,
|
||||||
|
/// backslash-escaped spaces and `~` all work.
|
||||||
|
pub fn clean_path(input: &str) -> PathBuf {
|
||||||
|
let s = input.trim();
|
||||||
|
for q in ['\'', '"'] {
|
||||||
|
if s.len() >= 2 && s.starts_with(q) && s.ends_with(q) {
|
||||||
|
return expand_tilde(&s[1..s.len() - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.starts_with("file://")
|
||||||
|
&& let Some(path) = url::Url::parse(s).ok().and_then(|u| u.to_file_path().ok())
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut chars = s.chars();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
match c {
|
||||||
|
'\\' => out.extend(chars.next()),
|
||||||
|
c => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expand_tilde(&out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tab completion for a path: completes as far as it's unambiguous.
|
||||||
|
/// Returns the new text and how many entries matched.
|
||||||
|
pub fn complete_path(input: &str) -> (String, usize) {
|
||||||
|
let (dir_part, prefix) = match input.rfind('/') {
|
||||||
|
Some(i) => input.split_at(i + 1),
|
||||||
|
None => ("", input),
|
||||||
|
};
|
||||||
|
let dir = if dir_part.is_empty() {
|
||||||
|
PathBuf::from(".")
|
||||||
|
} else {
|
||||||
|
clean_path(dir_part)
|
||||||
|
};
|
||||||
|
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||||
|
return (input.to_string(), 0);
|
||||||
|
};
|
||||||
|
let matches: Vec<(String, bool)> = entries
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|e| {
|
||||||
|
let name = e.file_name().into_string().ok()?;
|
||||||
|
let hidden = name.starts_with('.') && !prefix.starts_with('.');
|
||||||
|
(name.starts_with(prefix) && !hidden).then(|| (name, e.path().is_dir()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let done = match matches.as_slice() {
|
||||||
|
[] => return (input.to_string(), 0),
|
||||||
|
[(name, true)] => format!("{name}/"),
|
||||||
|
[(name, false)] => name.clone(),
|
||||||
|
[(first, _), rest @ ..] => {
|
||||||
|
let mut common = first.as_str();
|
||||||
|
for (name, _) in rest {
|
||||||
|
while !name.starts_with(common) {
|
||||||
|
let mut c = common.chars();
|
||||||
|
c.next_back();
|
||||||
|
common = c.as_str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
common.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(format!("{dir_part}{done}"), matches.len())
|
||||||
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new(me: String, scope: String) -> Self {
|
pub fn new(me: String, scope: String) -> Self {
|
||||||
let mut app = App {
|
let mut app = App {
|
||||||
@@ -166,6 +298,13 @@ impl App {
|
|||||||
link: link_area(),
|
link: link_area(),
|
||||||
content: content_area(),
|
content: content_area(),
|
||||||
tags: tags_area(),
|
tags: tags_area(),
|
||||||
|
media: Vec::new(),
|
||||||
|
media_cursor: 0,
|
||||||
|
next_media_id: 0,
|
||||||
|
media_endpoint: None,
|
||||||
|
media_kinds: MediaKind::ALL.to_vec(),
|
||||||
|
last_dir: String::new(),
|
||||||
|
prompt: TextArea::default(),
|
||||||
targets: Vec::new(),
|
targets: Vec::new(),
|
||||||
targets_state: Targets::Loading,
|
targets_state: Targets::Loading,
|
||||||
target_cursor: 0,
|
target_cursor: 0,
|
||||||
@@ -239,19 +378,192 @@ impl App {
|
|||||||
|
|
||||||
/// Unsaved work: any text in a new note, or any change to the post being edited.
|
/// Unsaved work: any text in a new note, or any change to the post being edited.
|
||||||
pub fn is_dirty(&self) -> bool {
|
pub fn is_dirty(&self) -> bool {
|
||||||
match &self.editing {
|
self.uploading()
|
||||||
|
|| match &self.editing {
|
||||||
Some(orig) => !Update::diff(orig, &self.note(orig.draft)).is_empty(),
|
Some(orig) => !Update::diff(orig, &self.note(orig.draft)).is_empty(),
|
||||||
None => [&self.title, &self.link, &self.content, &self.tags]
|
None => {
|
||||||
|
!self.media.is_empty()
|
||||||
|
|| [&self.title, &self.link, &self.content, &self.tags]
|
||||||
.iter()
|
.iter()
|
||||||
.any(|t| t.lines().iter().any(|l| !l.trim().is_empty())),
|
.any(|t| t.lines().iter().any(|l| !l.trim().is_empty()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any attachment is still uploading.
|
||||||
|
pub fn uploading(&self) -> bool {
|
||||||
|
self.media.iter().any(|p| p.url.is_none())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_media(
|
||||||
|
&mut self,
|
||||||
|
kind: MediaKind,
|
||||||
|
name: String,
|
||||||
|
url: Option<String>,
|
||||||
|
alt: String,
|
||||||
|
) -> u64 {
|
||||||
|
if !self.media_kinds.contains(&kind) {
|
||||||
|
self.error(format!(
|
||||||
|
"This server doesn't list {} posts, so it may drop {name}.",
|
||||||
|
kind.property()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let id = self.next_media_id;
|
||||||
|
self.next_media_id += 1;
|
||||||
|
self.media.push(Attachment {
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
alt,
|
||||||
|
});
|
||||||
|
self.media_cursor = self.media.len() - 1;
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach a file by URL, or start uploading one. The extension decides
|
||||||
|
/// whether it's a photo, video or audio.
|
||||||
|
fn add_media(&mut self, input: &str) -> Option<Request> {
|
||||||
|
let input = input.trim();
|
||||||
|
if input.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if is_url(input) {
|
||||||
|
let kind = MediaKind::guess(input);
|
||||||
|
self.push_media(
|
||||||
|
kind,
|
||||||
|
name_from_url(input),
|
||||||
|
Some(input.to_string()),
|
||||||
|
String::new(),
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let Some(endpoint) = self.media_endpoint.clone() else {
|
||||||
|
self.error(match self.targets_state {
|
||||||
|
Targets::Loading => "Still loading the server config; try again in a moment.",
|
||||||
|
Targets::Failed(_) => "Couldn't load the server config (Ctrl-R to retry).",
|
||||||
|
Targets::Loaded => {
|
||||||
|
"This server has no media endpoint. You can still attach files by URL."
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let path = clean_path(input);
|
||||||
|
if !path.is_file() {
|
||||||
|
self.error(format!("No such file: {}", path.display()));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Some(dir) = input.rfind('/').map(|i| &input[..=i])
|
||||||
|
&& !dir.starts_with(['\'', '"'])
|
||||||
|
{
|
||||||
|
self.last_dir = dir.to_string();
|
||||||
|
}
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| input.to_string());
|
||||||
|
self.info(format!("Uploading {name}…"));
|
||||||
|
let id = self.push_media(MediaKind::guess(&name), name, None, String::new());
|
||||||
|
Some(Request::Upload { id, endpoint, path })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_prompt(&mut self, prompt: Prompt) {
|
||||||
|
let (placeholder, text) = match prompt {
|
||||||
|
Prompt::AddMedia => (ADD_MEDIA_HINT, self.last_dir.clone()),
|
||||||
|
Prompt::Alt(id) => (
|
||||||
|
"Describe it for people who can't see it",
|
||||||
|
self.media
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.id == id)
|
||||||
|
.map(|p| p.alt.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let mut area = filled(text_area(placeholder), &text);
|
||||||
|
area.move_cursor(CursorMove::End);
|
||||||
|
area.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||||
|
self.prompt = area;
|
||||||
|
self.mode = Mode::Prompt(prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_prompt_key(&mut self, prompt: Prompt, key: KeyEvent) -> Option<Request> {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => self.mode = Mode::Normal,
|
||||||
|
KeyCode::Enter => {
|
||||||
|
self.mode = Mode::Normal;
|
||||||
|
let text = single_line(&self.prompt);
|
||||||
|
match prompt {
|
||||||
|
Prompt::AddMedia => return self.add_media(&text),
|
||||||
|
Prompt::Alt(id) => {
|
||||||
|
if let Some(p) = self.media.iter_mut().find(|p| p.id == id) {
|
||||||
|
p.alt = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Tab if prompt == Prompt::AddMedia => {
|
||||||
|
let (text, n) = complete_path(&single_line(&self.prompt));
|
||||||
|
if n == 0 {
|
||||||
|
self.error("No matching files.");
|
||||||
|
} else {
|
||||||
|
self.status = None;
|
||||||
|
}
|
||||||
|
let mut area = filled(text_area(ADD_MEDIA_HINT), &text);
|
||||||
|
area.move_cursor(CursorMove::End);
|
||||||
|
area.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||||
|
self.prompt = area;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let input = Input::from(key);
|
||||||
|
if !(input.ctrl && input.key == Key::Char('m')) {
|
||||||
|
self.prompt.input(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_media_key(&mut self, key: KeyEvent) -> Option<Request> {
|
||||||
|
let n = self.media.len();
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
self.media_cursor = self.media_cursor.saturating_sub(1)
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
self.media_cursor = (self.media_cursor + 1).min(n.saturating_sub(1))
|
||||||
|
}
|
||||||
|
KeyCode::Char('a') => self.open_prompt(Prompt::AddMedia),
|
||||||
|
KeyCode::Enter if n == 0 => self.open_prompt(Prompt::AddMedia),
|
||||||
|
KeyCode::Enter | KeyCode::Char('e') => {
|
||||||
|
if let Some(p) = self.media.get(self.media_cursor) {
|
||||||
|
self.open_prompt(Prompt::Alt(p.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('d') | KeyCode::Delete | KeyCode::Backspace if n > 0 => {
|
||||||
|
self.media.remove(self.media_cursor);
|
||||||
|
self.media_cursor = self.media_cursor.min(self.media.len().saturating_sub(1));
|
||||||
|
}
|
||||||
|
// Move the selected attachment up or down (the first one leads).
|
||||||
|
KeyCode::Char('K') if self.media_cursor > 0 => {
|
||||||
|
self.media.swap(self.media_cursor, self.media_cursor - 1);
|
||||||
|
self.media_cursor -= 1;
|
||||||
|
}
|
||||||
|
KeyCode::Char('J') if self.media_cursor + 1 < n => {
|
||||||
|
self.media.swap(self.media_cursor, self.media_cursor + 1);
|
||||||
|
self.media_cursor += 1;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn clear_form(&mut self) {
|
fn clear_form(&mut self) {
|
||||||
self.title = title_area();
|
self.title = title_area();
|
||||||
self.link = link_area();
|
self.link = link_area();
|
||||||
self.content = content_area();
|
self.content = content_area();
|
||||||
self.tags = tags_area();
|
self.tags = tags_area();
|
||||||
|
self.media.clear();
|
||||||
|
self.media_cursor = 0;
|
||||||
self.editing = None;
|
self.editing = None;
|
||||||
self.kind = Kind::Note;
|
self.kind = Kind::Note;
|
||||||
self.focus = Focus::Content;
|
self.focus = Focus::Content;
|
||||||
@@ -289,6 +601,16 @@ impl App {
|
|||||||
self.link = filled(link_area_for(self.kind), link);
|
self.link = filled(link_area_for(self.kind), link);
|
||||||
self.content = filled(content_area(), &post.content);
|
self.content = filled(content_area(), &post.content);
|
||||||
self.tags = filled(tags_area(), &post.tags.join(", "));
|
self.tags = filled(tags_area(), &post.tags.join(", "));
|
||||||
|
self.media.clear();
|
||||||
|
for p in &post.media {
|
||||||
|
self.push_media(
|
||||||
|
p.kind,
|
||||||
|
name_from_url(&p.url),
|
||||||
|
Some(p.url.clone()),
|
||||||
|
p.alt.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.media_cursor = 0;
|
||||||
self.editing = Some(post);
|
self.editing = Some(post);
|
||||||
self.focus = Focus::Content;
|
self.focus = Focus::Content;
|
||||||
self.update_cursors();
|
self.update_cursors();
|
||||||
@@ -317,6 +639,17 @@ impl App {
|
|||||||
in_reply_to: link.clone().filter(|_| self.kind == Kind::Note),
|
in_reply_to: link.clone().filter(|_| self.kind == Kind::Note),
|
||||||
like_of: link.clone().filter(|_| self.kind == Kind::Like),
|
like_of: link.clone().filter(|_| self.kind == Kind::Like),
|
||||||
repost_of: link.filter(|_| self.kind == Kind::Repost),
|
repost_of: link.filter(|_| self.kind == Kind::Repost),
|
||||||
|
media: self
|
||||||
|
.media
|
||||||
|
.iter()
|
||||||
|
.filter_map(|a| {
|
||||||
|
a.url.clone().map(|url| Media {
|
||||||
|
kind: a.kind,
|
||||||
|
url,
|
||||||
|
alt: a.alt.clone(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
draft,
|
draft,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -456,6 +789,28 @@ impl App {
|
|||||||
self.mode = Mode::Normal;
|
self.mode = Mode::Normal;
|
||||||
self.report(e);
|
self.report(e);
|
||||||
}
|
}
|
||||||
|
// Replies for attachments that were removed (or a form that was cleared) are dropped.
|
||||||
|
Response::Uploaded { id, result } => {
|
||||||
|
let i = self.media.iter().position(|p| p.id == id)?;
|
||||||
|
match result {
|
||||||
|
Ok(url) => {
|
||||||
|
self.media[i].url = Some(url);
|
||||||
|
if !self.uploading() {
|
||||||
|
let name = self.media[i].name.clone();
|
||||||
|
self.info(format!("Uploaded {name}."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let gone = self.media.remove(i);
|
||||||
|
self.media_cursor =
|
||||||
|
self.media_cursor.min(self.media.len().saturating_sub(1));
|
||||||
|
if matches!(e, Error::Unauthorized(_)) {
|
||||||
|
self.needs_login = true;
|
||||||
|
}
|
||||||
|
self.error(format!("Couldn't upload {}: {e}", gone.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -469,6 +824,11 @@ impl App {
|
|||||||
|
|
||||||
fn set_config(&mut self, cfg: ServerConfig) {
|
fn set_config(&mut self, cfg: ServerConfig) {
|
||||||
self.can_list = cfg.can_list();
|
self.can_list = cfg.can_list();
|
||||||
|
self.media_endpoint = cfg.media_endpoint.clone();
|
||||||
|
self.media_kinds = MediaKind::ALL
|
||||||
|
.into_iter()
|
||||||
|
.filter(|k| cfg.supports_media(*k))
|
||||||
|
.collect();
|
||||||
self.kinds = Kind::ALL.into_iter().filter(|k| cfg.supports(*k)).collect();
|
self.kinds = Kind::ALL.into_iter().filter(|k| cfg.supports(*k)).collect();
|
||||||
// Keep earlier choices on refresh; new targets start checked (the server's default).
|
// Keep earlier choices on refresh; new targets start checked (the server's default).
|
||||||
let previous = std::mem::take(&mut self.targets);
|
let previous = std::mem::take(&mut self.targets);
|
||||||
@@ -492,6 +852,7 @@ impl App {
|
|||||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
match self.mode.clone() {
|
match self.mode.clone() {
|
||||||
Mode::Sending => None,
|
Mode::Sending => None,
|
||||||
|
Mode::Prompt(p) => self.handle_prompt_key(p, key),
|
||||||
Mode::ConfirmPublish => {
|
Mode::ConfirmPublish => {
|
||||||
let draft = match key.code {
|
let draft = match key.code {
|
||||||
KeyCode::Char('y') | KeyCode::Enter => self.default_draft(),
|
KeyCode::Char('y') | KeyCode::Enter => self.default_draft(),
|
||||||
@@ -559,6 +920,10 @@ impl App {
|
|||||||
fn send(&mut self, draft: bool) -> Option<Request> {
|
fn send(&mut self, draft: bool) -> Option<Request> {
|
||||||
let note = self.note(draft);
|
let note = self.note(draft);
|
||||||
self.mode = Mode::Normal;
|
self.mode = Mode::Normal;
|
||||||
|
if self.uploading() {
|
||||||
|
self.error("Wait for the uploads to finish.");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let link = note
|
let link = note
|
||||||
.in_reply_to
|
.in_reply_to
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -661,8 +1026,14 @@ impl App {
|
|||||||
}
|
}
|
||||||
(true, KeyCode::Char('s')) => {
|
(true, KeyCode::Char('s')) => {
|
||||||
// Likes and reposts don't need a comment.
|
// Likes and reposts don't need a comment.
|
||||||
if self.kind == Kind::Note && self.note(false).content.trim().is_empty() {
|
// Micropub doesn't require text, so a note with media may have none.
|
||||||
|
if self.kind == Kind::Note
|
||||||
|
&& self.media.is_empty()
|
||||||
|
&& self.note(false).content.trim().is_empty()
|
||||||
|
{
|
||||||
self.error("Write something first.");
|
self.error("Write something first.");
|
||||||
|
} else if self.uploading() {
|
||||||
|
self.error("Wait for the uploads to finish.");
|
||||||
} else {
|
} else {
|
||||||
self.status = None;
|
self.status = None;
|
||||||
self.mode = Mode::ConfirmPublish;
|
self.mode = Mode::ConfirmPublish;
|
||||||
@@ -716,6 +1087,7 @@ impl App {
|
|||||||
area.input(input);
|
area.input(input);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Focus::Media => return self.handle_media_key(key),
|
||||||
Focus::Syndication => self.handle_target_key(key),
|
Focus::Syndication => self.handle_target_key(key),
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
@@ -814,12 +1186,20 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bracketed paste: insert text into the focused field.
|
/// Bracketed paste: insert text into the focused field. Pasting (or dropping)
|
||||||
pub fn handle_paste(&mut self, text: &str) {
|
/// files onto the Media field attaches them, one per line.
|
||||||
if self.mode != Mode::Normal || self.screen != Screen::Compose {
|
pub fn handle_paste(&mut self, text: &str) -> Vec<Request> {
|
||||||
return;
|
|
||||||
}
|
|
||||||
let text = text.replace("\r\n", "\n").replace('\r', "\n");
|
let text = text.replace("\r\n", "\n").replace('\r', "\n");
|
||||||
|
if matches!(self.mode, Mode::Prompt(_)) {
|
||||||
|
self.prompt.insert_str(text.trim().replace('\n', " "));
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if self.mode != Mode::Normal || self.screen != Screen::Compose {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if self.focus == Focus::Media {
|
||||||
|
return text.lines().filter_map(|l| self.add_media(l)).collect();
|
||||||
|
}
|
||||||
match self.focus {
|
match self.focus {
|
||||||
Focus::Content => {
|
Focus::Content => {
|
||||||
self.content.insert_str(text);
|
self.content.insert_str(text);
|
||||||
@@ -833,8 +1213,9 @@ impl App {
|
|||||||
Focus::Tags => {
|
Focus::Tags => {
|
||||||
self.tags.insert_str(text.replace('\n', ","));
|
self.tags.insert_str(text.replace('\n', ","));
|
||||||
}
|
}
|
||||||
Focus::Syndication => {}
|
Focus::Media | Focus::Syndication => {}
|
||||||
}
|
}
|
||||||
|
Vec::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -904,6 +1285,7 @@ mod tests {
|
|||||||
typ(&mut app, "Hello");
|
typ(&mut app, "Hello");
|
||||||
app.handle_key(key(KeyCode::Enter));
|
app.handle_key(key(KeyCode::Enter));
|
||||||
typ(&mut app, "world");
|
typ(&mut app, "world");
|
||||||
|
app.handle_key(key(KeyCode::Tab)); // -> media
|
||||||
app.handle_key(key(KeyCode::Tab)); // -> tags
|
app.handle_key(key(KeyCode::Tab)); // -> tags
|
||||||
typ(&mut app, "rust, #tui");
|
typ(&mut app, "rust, #tui");
|
||||||
app.handle_key(key(KeyCode::Tab)); // -> syndication
|
app.handle_key(key(KeyCode::Tab)); // -> syndication
|
||||||
@@ -1333,4 +1715,233 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(u.replace.keys().collect::<Vec<_>>(), ["content"]);
|
assert_eq!(u.replace.keys().collect::<Vec<_>>(), ["content"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Loaded with a media endpoint, focused on the Media field.
|
||||||
|
fn media_app() -> App {
|
||||||
|
let mut app = App::new("https://example.com/".into(), "create media".into());
|
||||||
|
app.handle_response(Response::Config(Ok(ServerConfig {
|
||||||
|
media_endpoint: Some("https://example.com/micropub/media".into()),
|
||||||
|
..Default::default()
|
||||||
|
})));
|
||||||
|
app.focus = Focus::Media;
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scratch_dir(name: &str) -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!("tuipub-test-{name}-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attach(app: &mut App, path: &str) -> Option<Request> {
|
||||||
|
app.handle_key(key(KeyCode::Char('a')));
|
||||||
|
assert_eq!(app.mode, Mode::Prompt(Prompt::AddMedia));
|
||||||
|
app.handle_paste(path);
|
||||||
|
app.handle_key(key(KeyCode::Enter))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attach_upload_and_publish() {
|
||||||
|
let dir = scratch_dir("attach");
|
||||||
|
let file = dir.join("cat pic.jpg");
|
||||||
|
std::fs::write(&file, b"jpeg").unwrap();
|
||||||
|
let mut app = media_app();
|
||||||
|
|
||||||
|
let Some(Request::Upload { id, endpoint, path }) =
|
||||||
|
attach(&mut app, &file.display().to_string())
|
||||||
|
else {
|
||||||
|
panic!("expected an upload");
|
||||||
|
};
|
||||||
|
assert_eq!(endpoint, "https://example.com/micropub/media");
|
||||||
|
assert_eq!(path, file);
|
||||||
|
assert!(app.uploading() && app.is_dirty());
|
||||||
|
|
||||||
|
// Can't publish until the upload is done.
|
||||||
|
app.focus = Focus::Content;
|
||||||
|
typ(&mut app, "Look");
|
||||||
|
app.handle_key(ctrl('s'));
|
||||||
|
assert_eq!(app.mode, Mode::Normal);
|
||||||
|
assert!(matches!(app.status, Some(Status::Error(_))));
|
||||||
|
|
||||||
|
app.handle_response(Response::Uploaded {
|
||||||
|
id,
|
||||||
|
result: Ok("https://example.com/media/a.jpg".into()),
|
||||||
|
});
|
||||||
|
// Alt text.
|
||||||
|
app.focus = Focus::Media;
|
||||||
|
app.handle_key(key(KeyCode::Enter));
|
||||||
|
assert_eq!(app.mode, Mode::Prompt(Prompt::Alt(id)));
|
||||||
|
typ(&mut app, "A cat");
|
||||||
|
app.handle_key(key(KeyCode::Enter));
|
||||||
|
|
||||||
|
app.handle_key(ctrl('s'));
|
||||||
|
let Some(Request::Publish(note)) = app.handle_key(key(KeyCode::Enter)) else {
|
||||||
|
panic!("expected publish");
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
note.media,
|
||||||
|
vec![Media {
|
||||||
|
kind: MediaKind::Photo,
|
||||||
|
url: "https://example.com/media/a.jpg".into(),
|
||||||
|
alt: "A cat".into()
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert_eq!(app.last_dir, format!("{}/", dir.display()));
|
||||||
|
std::fs::remove_dir_all(dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_upload_is_removed_and_late_replies_ignored() {
|
||||||
|
let dir = scratch_dir("fail");
|
||||||
|
let file = dir.join("x.png");
|
||||||
|
std::fs::write(&file, b"png").unwrap();
|
||||||
|
let mut app = media_app();
|
||||||
|
let Some(Request::Upload { id, .. }) = attach(&mut app, &file.display().to_string()) else {
|
||||||
|
panic!("expected an upload");
|
||||||
|
};
|
||||||
|
app.handle_response(Response::Uploaded {
|
||||||
|
id,
|
||||||
|
result: Err(Error::Unauthorized("nope".into())),
|
||||||
|
});
|
||||||
|
assert!(app.media.is_empty() && app.needs_login);
|
||||||
|
assert!(matches!(&app.status, Some(Status::Error(e)) if e.contains("x.png")));
|
||||||
|
// A reply for something no longer in the form does nothing.
|
||||||
|
app.handle_response(Response::Uploaded {
|
||||||
|
id: 99,
|
||||||
|
result: Ok("u".into()),
|
||||||
|
});
|
||||||
|
assert!(app.media.is_empty());
|
||||||
|
std::fs::remove_dir_all(dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn urls_attach_without_upload_and_kinds_follow_extension() {
|
||||||
|
let mut app = loaded(&[]); // no media endpoint
|
||||||
|
app.focus = Focus::Media;
|
||||||
|
assert!(attach(&mut app, "https://cdn.example/clip.mp4").is_none());
|
||||||
|
assert!(attach(&mut app, "/nonexistent/file.jpg").is_none());
|
||||||
|
assert!(matches!(&app.status, Some(Status::Error(e)) if e.contains("no media endpoint")));
|
||||||
|
assert_eq!(app.media.len(), 1);
|
||||||
|
assert_eq!(app.media[0].kind, MediaKind::Video);
|
||||||
|
assert_eq!(app.media[0].name, "clip.mp4");
|
||||||
|
|
||||||
|
// Servers that list post types without video get a warning (but keep the file).
|
||||||
|
let mut app = App::new("m".into(), "media".into());
|
||||||
|
app.handle_response(Response::Config(Ok(ServerConfig {
|
||||||
|
post_types: vec![micropub::PostType {
|
||||||
|
kind: "photo".into(),
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
})));
|
||||||
|
app.focus = Focus::Media;
|
||||||
|
attach(&mut app, "https://cdn.example/clip.mp4");
|
||||||
|
assert!(matches!(&app.status, Some(Status::Error(e)) if e.contains("may drop")));
|
||||||
|
assert_eq!(app.media.len(), 1);
|
||||||
|
attach(&mut app, "https://cdn.example/a.jpg");
|
||||||
|
assert_eq!(app.media.len(), 2);
|
||||||
|
|
||||||
|
// Media alone is enough for a note: Micropub doesn't require text.
|
||||||
|
app.handle_key(ctrl('s'));
|
||||||
|
assert_eq!(app.mode, Mode::ConfirmPublish);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_file_is_reported() {
|
||||||
|
let mut app = media_app();
|
||||||
|
assert!(attach(&mut app, "/nonexistent/file.jpg").is_none());
|
||||||
|
assert!(matches!(&app.status, Some(Status::Error(e)) if e.contains("No such file")));
|
||||||
|
assert!(app.media.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pasting_paths_onto_media_field_uploads_each() {
|
||||||
|
let dir = scratch_dir("paste");
|
||||||
|
let (a, b) = (dir.join("a.jpg"), dir.join("b c.ogg"));
|
||||||
|
std::fs::write(&a, b"").unwrap();
|
||||||
|
std::fs::write(&b, b"").unwrap();
|
||||||
|
let mut app = media_app();
|
||||||
|
let reqs = app.handle_paste(&format!("{}\n'{}'\n", a.display(), b.display()));
|
||||||
|
assert_eq!(reqs.len(), 2);
|
||||||
|
assert_eq!(app.media[1].kind, MediaKind::Audio);
|
||||||
|
// Elsewhere, a path is just text.
|
||||||
|
app.focus = Focus::Content;
|
||||||
|
assert!(app.handle_paste(&a.display().to_string()).is_empty());
|
||||||
|
std::fs::remove_dir_all(dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_and_reorder_media_on_an_edited_post() {
|
||||||
|
let photo = |u: &str| Media {
|
||||||
|
kind: MediaKind::Photo,
|
||||||
|
url: u.into(),
|
||||||
|
alt: String::new(),
|
||||||
|
};
|
||||||
|
let mut p = post("a", false);
|
||||||
|
p.media = vec![photo("https://e/1.jpg"), photo("https://e/2.jpg")];
|
||||||
|
let mut app = with_posts(vec![p]);
|
||||||
|
app.handle_key(key(KeyCode::Enter));
|
||||||
|
assert_eq!(app.media.len(), 2);
|
||||||
|
assert!(!app.is_dirty());
|
||||||
|
|
||||||
|
app.focus = Focus::Media;
|
||||||
|
app.handle_key(key(KeyCode::Char('J')));
|
||||||
|
assert_eq!(app.media[1].name, "1.jpg");
|
||||||
|
assert_eq!(app.media_cursor, 1);
|
||||||
|
assert!(app.is_dirty(), "order counts as a change");
|
||||||
|
|
||||||
|
app.handle_key(key(KeyCode::Char('d')));
|
||||||
|
app.handle_key(key(KeyCode::Char('d')));
|
||||||
|
assert!(app.media.is_empty());
|
||||||
|
app.handle_key(ctrl('s'));
|
||||||
|
let Some(Request::Update(u)) = app.handle_key(key(KeyCode::Enter)) else {
|
||||||
|
panic!("expected update");
|
||||||
|
};
|
||||||
|
assert_eq!(u.delete, vec!["photo"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paths_are_cleaned() {
|
||||||
|
let home = std::env::var("HOME").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
clean_path(" ~/a b.jpg "),
|
||||||
|
PathBuf::from(format!("{home}/a b.jpg"))
|
||||||
|
);
|
||||||
|
assert_eq!(clean_path("/x/a\\ b.jpg"), PathBuf::from("/x/a b.jpg"));
|
||||||
|
assert_eq!(clean_path("'/x/it\\s.jpg'"), PathBuf::from("/x/it\\s.jpg"));
|
||||||
|
assert_eq!(clean_path("\"/x/a b.jpg\""), PathBuf::from("/x/a b.jpg"));
|
||||||
|
assert_eq!(
|
||||||
|
clean_path("file:///x/a%20b.jpg"),
|
||||||
|
PathBuf::from("/x/a b.jpg")
|
||||||
|
);
|
||||||
|
assert_eq!(name_from_url("https://e/media/2026/x.jpg?s=1"), "x.jpg");
|
||||||
|
assert_eq!(name_from_url("https://e/"), "e");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_completes_paths() {
|
||||||
|
let dir = scratch_dir("complete");
|
||||||
|
std::fs::write(dir.join("photo-1.jpg"), b"").unwrap();
|
||||||
|
std::fs::write(dir.join("photo-2.jpg"), b"").unwrap();
|
||||||
|
std::fs::create_dir_all(dir.join("videos")).unwrap();
|
||||||
|
std::fs::write(dir.join(".hidden"), b"").unwrap();
|
||||||
|
let d = format!("{}/", dir.display());
|
||||||
|
assert_eq!(complete_path(&format!("{d}ph")), (format!("{d}photo-"), 2));
|
||||||
|
assert_eq!(complete_path(&format!("{d}v")), (format!("{d}videos/"), 1));
|
||||||
|
assert_eq!(
|
||||||
|
complete_path(&format!("{d}photo-2")),
|
||||||
|
(format!("{d}photo-2.jpg"), 1)
|
||||||
|
);
|
||||||
|
assert_eq!(complete_path(&format!("{d}zz")).1, 0);
|
||||||
|
assert_eq!(complete_path(&d).1, 3, "hidden files only when asked for");
|
||||||
|
|
||||||
|
let mut app = media_app();
|
||||||
|
app.handle_key(key(KeyCode::Char('a')));
|
||||||
|
app.handle_paste(&format!("{d}v"));
|
||||||
|
app.handle_key(key(KeyCode::Tab));
|
||||||
|
assert_eq!(single_line(&app.prompt), format!("{d}videos/"));
|
||||||
|
app.handle_key(key(KeyCode::Esc));
|
||||||
|
assert_eq!(app.mode, Mode::Normal);
|
||||||
|
assert!(app.media.is_empty());
|
||||||
|
std::fs::remove_dir_all(dir).ok();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -101,7 +101,11 @@ fn run_tui() -> Result<()> {
|
|||||||
tx.send(req).context("background worker stopped")?;
|
tx.send(req).context("background worker stopped")?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::Paste(text) => app.handle_paste(&text),
|
Event::Paste(text) => {
|
||||||
|
for req in app.handle_paste(&text) {
|
||||||
|
tx.send(req).context("background worker stopped")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+292
-15
@@ -1,4 +1,7 @@
|
|||||||
//! Micropub client: config, create, list (`q=source`), update, delete and undelete.
|
//! Micropub client: config, create, list (`q=source`), update, delete, undelete
|
||||||
|
//! and media uploads.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use reqwest::{StatusCode, blocking::Client as Http};
|
use reqwest::{StatusCode, blocking::Client as Http};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -21,6 +24,94 @@ pub struct ServerConfig {
|
|||||||
/// Post types the server says it supports. Empty if it doesn't say.
|
/// Post types the server says it supports. Empty if it doesn't say.
|
||||||
#[serde(rename = "post-types", default)]
|
#[serde(rename = "post-types", default)]
|
||||||
pub post_types: Vec<PostType>,
|
pub post_types: Vec<PostType>,
|
||||||
|
/// Where to upload files. None if the server has no media endpoint.
|
||||||
|
#[serde(rename = "media-endpoint", default)]
|
||||||
|
pub media_endpoint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which Micropub property a media file goes in.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub enum MediaKind {
|
||||||
|
#[default]
|
||||||
|
Photo,
|
||||||
|
Video,
|
||||||
|
Audio,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MediaKind {
|
||||||
|
pub const ALL: [MediaKind; 3] = [MediaKind::Photo, MediaKind::Video, MediaKind::Audio];
|
||||||
|
|
||||||
|
pub fn property(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
MediaKind::Photo => "photo",
|
||||||
|
MediaKind::Video => "video",
|
||||||
|
MediaKind::Audio => "audio",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// From a file name or URL's extension. Unknown types count as photos.
|
||||||
|
pub fn guess(name: &str) -> MediaKind {
|
||||||
|
let path = name.split(['?', '#']).next().unwrap_or(name);
|
||||||
|
match mime_guess::from_path(path).first() {
|
||||||
|
Some(m) if m.type_() == mime_guess::mime::VIDEO => MediaKind::Video,
|
||||||
|
Some(m) if m.type_() == mime_guess::mime::AUDIO => MediaKind::Audio,
|
||||||
|
_ => MediaKind::Photo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file attached to a post: its URL (on the media endpoint or elsewhere) and,
|
||||||
|
/// for photos, alt text.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
pub struct Media {
|
||||||
|
pub kind: MediaKind,
|
||||||
|
pub url: String,
|
||||||
|
pub alt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Media {
|
||||||
|
/// A plain URL, or `{value, alt}` when there is alt text.
|
||||||
|
fn to_json(&self) -> Value {
|
||||||
|
if self.alt.is_empty() {
|
||||||
|
json!(self.url)
|
||||||
|
} else {
|
||||||
|
json!({"value": self.url, "alt": self.alt})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_mf2(kind: MediaKind, v: &Value) -> Option<Media> {
|
||||||
|
match v {
|
||||||
|
Value::String(url) => Some(Media {
|
||||||
|
kind,
|
||||||
|
url: url.clone(),
|
||||||
|
alt: String::new(),
|
||||||
|
}),
|
||||||
|
Value::Object(o) => Some(Media {
|
||||||
|
kind,
|
||||||
|
url: o
|
||||||
|
.get("value")
|
||||||
|
.or_else(|| o.get("url"))
|
||||||
|
.and_then(Value::as_str)?
|
||||||
|
.to_string(),
|
||||||
|
alt: o
|
||||||
|
.get("alt")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The media of one kind, as a property value. None if there is none.
|
||||||
|
fn media_json(media: &[Media], kind: MediaKind) -> Option<Value> {
|
||||||
|
let v: Vec<Value> = media
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.kind == kind)
|
||||||
|
.map(Media::to_json)
|
||||||
|
.collect();
|
||||||
|
(!v.is_empty()).then_some(Value::Array(v))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||||
@@ -66,6 +157,13 @@ impl ServerConfig {
|
|||||||
self.q.is_empty() || self.q.iter().any(|q| q == "source")
|
self.q.is_empty() || self.q.iter().any(|q| q == "source")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the server says it takes this kind of media (`photo`, `video` and
|
||||||
|
/// `audio` post types). Servers that don't list post types get the benefit of
|
||||||
|
/// the doubt. Per the spec, servers ignore properties they don't support.
|
||||||
|
pub fn supports_media(&self, kind: MediaKind) -> bool {
|
||||||
|
self.post_types.is_empty() || self.post_types.iter().any(|t| t.kind == kind.property())
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the server takes this kind of post. Notes always work; servers that
|
/// Whether the server takes this kind of post. Notes always work; servers that
|
||||||
/// don't list post types get the benefit of the doubt.
|
/// don't list post types get the benefit of the doubt.
|
||||||
pub fn supports(&self, kind: Kind) -> bool {
|
pub fn supports(&self, kind: Kind) -> bool {
|
||||||
@@ -85,6 +183,8 @@ pub struct Note {
|
|||||||
pub in_reply_to: Option<String>,
|
pub in_reply_to: Option<String>,
|
||||||
pub like_of: Option<String>,
|
pub like_of: Option<String>,
|
||||||
pub repost_of: Option<String>,
|
pub repost_of: Option<String>,
|
||||||
|
/// Photos, videos and audio, in order.
|
||||||
|
pub media: Vec<Media>,
|
||||||
/// Save unpublished (`post-status: draft`).
|
/// Save unpublished (`post-status: draft`).
|
||||||
pub draft: bool,
|
pub draft: bool,
|
||||||
}
|
}
|
||||||
@@ -94,8 +194,10 @@ impl Note {
|
|||||||
/// an empty `mp-syndicate-to`, which tells the server "crosspost nowhere".
|
/// an empty `mp-syndicate-to`, which tells the server "crosspost nowhere".
|
||||||
pub fn to_json(&self) -> Value {
|
pub fn to_json(&self) -> Value {
|
||||||
let mut props = serde_json::Map::new();
|
let mut props = serde_json::Map::new();
|
||||||
// Likes and reposts may have no comment.
|
// Likes, reposts and posts with media may have no text.
|
||||||
if !self.content.is_empty() || (self.like_of.is_none() && self.repost_of.is_none()) {
|
if !self.content.is_empty()
|
||||||
|
|| (self.like_of.is_none() && self.repost_of.is_none() && self.media.is_empty())
|
||||||
|
{
|
||||||
props.insert("content".into(), json!([self.content]));
|
props.insert("content".into(), json!([self.content]));
|
||||||
}
|
}
|
||||||
if let Some(t) = self.title.as_deref().filter(|t| !t.is_empty()) {
|
if let Some(t) = self.title.as_deref().filter(|t| !t.is_empty()) {
|
||||||
@@ -113,6 +215,11 @@ impl Note {
|
|||||||
if let Some(u) = &self.repost_of {
|
if let Some(u) = &self.repost_of {
|
||||||
props.insert("repost-of".into(), json!([u]));
|
props.insert("repost-of".into(), json!([u]));
|
||||||
}
|
}
|
||||||
|
for kind in MediaKind::ALL {
|
||||||
|
if let Some(v) = media_json(&self.media, kind) {
|
||||||
|
props.insert(kind.property().into(), v);
|
||||||
|
}
|
||||||
|
}
|
||||||
if self.draft {
|
if self.draft {
|
||||||
props.insert("post-status".into(), json!(["draft"]));
|
props.insert("post-status".into(), json!(["draft"]));
|
||||||
}
|
}
|
||||||
@@ -137,6 +244,7 @@ pub struct Post {
|
|||||||
pub in_reply_to: Option<String>,
|
pub in_reply_to: Option<String>,
|
||||||
pub like_of: Option<String>,
|
pub like_of: Option<String>,
|
||||||
pub repost_of: Option<String>,
|
pub repost_of: Option<String>,
|
||||||
|
pub media: Vec<Media>,
|
||||||
/// Links to crossposted copies.
|
/// Links to crossposted copies.
|
||||||
pub syndication: Vec<String>,
|
pub syndication: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -195,6 +303,16 @@ impl Post {
|
|||||||
in_reply_to: first_str(p, "in-reply-to"),
|
in_reply_to: first_str(p, "in-reply-to"),
|
||||||
like_of: first_str(p, "like-of"),
|
like_of: first_str(p, "like-of"),
|
||||||
repost_of: first_str(p, "repost-of"),
|
repost_of: first_str(p, "repost-of"),
|
||||||
|
media: MediaKind::ALL
|
||||||
|
.iter()
|
||||||
|
.flat_map(|&kind| {
|
||||||
|
p.get(kind.property())
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(move |v| Media::from_mf2(kind, v))
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
syndication: all_strs(p, "syndication"),
|
syndication: all_strs(p, "syndication"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -248,6 +366,16 @@ impl Update {
|
|||||||
None => u.delete.push("in-reply-to".into()),
|
None => u.delete.push("in-reply-to".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for kind in MediaKind::ALL {
|
||||||
|
let (old, new) = (media_json(&orig.media, kind), media_json(&new.media, kind));
|
||||||
|
match (old, new) {
|
||||||
|
(old, Some(v)) if old.as_ref() != Some(&v) => {
|
||||||
|
u.replace.insert(kind.property().into(), v);
|
||||||
|
}
|
||||||
|
(Some(_), None) => u.delete.push(kind.property().into()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
if new.draft != orig.draft {
|
if new.draft != orig.draft {
|
||||||
let status = if new.draft { "draft" } else { "published" };
|
let status = if new.draft { "draft" } else { "published" };
|
||||||
u.replace.insert("post-status".into(), json!([status]));
|
u.replace.insert("post-status".into(), json!([status]));
|
||||||
@@ -297,6 +425,8 @@ pub enum Error {
|
|||||||
NoLocation(StatusCode),
|
NoLocation(StatusCode),
|
||||||
#[error("network error: {0}")]
|
#[error("network error: {0}")]
|
||||||
Http(#[from] reqwest::Error),
|
Http(#[from] reqwest::Error),
|
||||||
|
#[error("{0}")]
|
||||||
|
File(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -306,6 +436,7 @@ struct ErrorBody {
|
|||||||
error_description: String,
|
error_description: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
http: Http,
|
http: Http,
|
||||||
endpoint: Url,
|
endpoint: Url,
|
||||||
@@ -420,6 +551,40 @@ impl Client {
|
|||||||
self.action(&json!({"action": "undelete", "url": url}))
|
self.action(&json!({"action": "undelete", "url": url}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upload a file to the media endpoint (resolved against the Micropub endpoint).
|
||||||
|
/// Returns the file's URL.
|
||||||
|
pub fn upload(&self, media_endpoint: &str, path: &Path) -> Result<String, Error> {
|
||||||
|
let endpoint = self
|
||||||
|
.endpoint
|
||||||
|
.join(media_endpoint)
|
||||||
|
.map_err(|e| Error::File(format!("bad media endpoint {media_endpoint}: {e}")))?;
|
||||||
|
let form = reqwest::blocking::multipart::Form::new()
|
||||||
|
.file("file", path)
|
||||||
|
.map_err(|e| Error::File(format!("can't read {}: {e}", path.display())))?;
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.post(endpoint.clone())
|
||||||
|
.bearer_auth(&self.token)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.multipart(form)
|
||||||
|
.send()?;
|
||||||
|
Self::located(&endpoint, Self::check(resp)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The URL in a 201's Location header, made absolute.
|
||||||
|
fn located(base: &Url, resp: reqwest::blocking::Response) -> Result<String, Error> {
|
||||||
|
let status = resp.status();
|
||||||
|
let location = resp
|
||||||
|
.headers()
|
||||||
|
.get("location")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.ok_or(Error::NoLocation(status))?;
|
||||||
|
Ok(base
|
||||||
|
.join(location)
|
||||||
|
.map(|u| u.to_string())
|
||||||
|
.unwrap_or_else(|_| location.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
@@ -429,18 +594,7 @@ impl Client {
|
|||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.json(¬e.to_json())
|
.json(¬e.to_json())
|
||||||
.send()?;
|
.send()?;
|
||||||
let resp = Self::check(resp)?;
|
Self::located(&self.endpoint, Self::check(resp)?)
|
||||||
let status = resp.status();
|
|
||||||
let location = resp
|
|
||||||
.headers()
|
|
||||||
.get("location")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.ok_or(Error::NoLocation(status))?;
|
|
||||||
Ok(self
|
|
||||||
.endpoint
|
|
||||||
.join(location)
|
|
||||||
.map(|u| u.to_string())
|
|
||||||
.unwrap_or_else(|_| location.to_string()))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -819,5 +973,128 @@ mod tests {
|
|||||||
assert!(cfg.supports(Kind::Note) && cfg.supports(Kind::Like));
|
assert!(cfg.supports(Kind::Note) && cfg.supports(Kind::Like));
|
||||||
assert!(!cfg.supports(Kind::Repost));
|
assert!(!cfg.supports(Kind::Repost));
|
||||||
assert!(ServerConfig::default().supports(Kind::Repost));
|
assert!(ServerConfig::default().supports(Kind::Repost));
|
||||||
|
assert!(!cfg.supports_media(MediaKind::Photo));
|
||||||
|
assert!(ServerConfig::default().supports_media(MediaKind::Video));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn media(kind: MediaKind, url: &str, alt: &str) -> Media {
|
||||||
|
Media {
|
||||||
|
kind,
|
||||||
|
url: url.into(),
|
||||||
|
alt: alt.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn media_json_and_parsing() {
|
||||||
|
let note = Note {
|
||||||
|
media: vec![
|
||||||
|
media(MediaKind::Photo, "https://e/a.jpg", "A cat"),
|
||||||
|
media(MediaKind::Video, "https://e/b.mp4", ""),
|
||||||
|
media(MediaKind::Photo, "https://e/c.jpg", ""),
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
note.to_json()["properties"],
|
||||||
|
json!({
|
||||||
|
"photo": [{"value": "https://e/a.jpg", "alt": "A cat"}, "https://e/c.jpg"],
|
||||||
|
"video": ["https://e/b.mp4"]
|
||||||
|
}),
|
||||||
|
"a note with media needs no content"
|
||||||
|
);
|
||||||
|
|
||||||
|
let p = Post::from_mf2(&json!({"properties": {
|
||||||
|
"url": ["u"],
|
||||||
|
"photo": [{"value": "https://e/a.jpg", "alt": "A cat"}, "https://e/c.jpg"],
|
||||||
|
"audio": ["https://e/d.ogg"]
|
||||||
|
}}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
p.media,
|
||||||
|
vec![
|
||||||
|
media(MediaKind::Photo, "https://e/a.jpg", "A cat"),
|
||||||
|
media(MediaKind::Photo, "https://e/c.jpg", ""),
|
||||||
|
media(MediaKind::Audio, "https://e/d.ogg", ""),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(MediaKind::guess("x.JPG"), MediaKind::Photo);
|
||||||
|
assert_eq!(MediaKind::guess("https://e/x.webm?t=1"), MediaKind::Video);
|
||||||
|
assert_eq!(MediaKind::guess("x.mp3"), MediaKind::Audio);
|
||||||
|
assert_eq!(MediaKind::guess("https://e/image"), MediaKind::Photo);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_diff_media() {
|
||||||
|
let mut orig = post();
|
||||||
|
orig.media = vec![
|
||||||
|
media(MediaKind::Photo, "https://e/a.jpg", ""),
|
||||||
|
media(MediaKind::Audio, "https://e/d.ogg", ""),
|
||||||
|
];
|
||||||
|
let mut n = same_note(&orig);
|
||||||
|
n.media = orig.media.clone();
|
||||||
|
assert!(Update::diff(&orig, &n).is_empty());
|
||||||
|
|
||||||
|
// New alt text replaces photos; dropping the audio deletes it; video is added.
|
||||||
|
n.media = vec![
|
||||||
|
media(MediaKind::Photo, "https://e/a.jpg", "Alt"),
|
||||||
|
media(MediaKind::Video, "https://e/v.mp4", ""),
|
||||||
|
];
|
||||||
|
let u = Update::diff(&orig, &n);
|
||||||
|
assert_eq!(
|
||||||
|
u.replace["photo"],
|
||||||
|
json!([{"value": "https://e/a.jpg", "alt": "Alt"}])
|
||||||
|
);
|
||||||
|
assert_eq!(u.replace["video"], json!(["https://e/v.mp4"]));
|
||||||
|
assert_eq!(u.delete, vec!["audio"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upload_sends_multipart_file_and_resolves_location() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("tuipub-upload-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let file = dir.join("cat.jpg");
|
||||||
|
std::fs::write(&file, b"JPEGDATA").unwrap();
|
||||||
|
|
||||||
|
let server = MockServer::start();
|
||||||
|
let m = server.mock(|when, then| {
|
||||||
|
when.method(POST)
|
||||||
|
.path("/media")
|
||||||
|
.header("authorization", "Bearer tok")
|
||||||
|
.header_includes("content-type", "multipart/form-data")
|
||||||
|
.body_includes("name=\"file\"; filename=\"cat.jpg\"")
|
||||||
|
.body_includes("Content-Type: image/jpeg")
|
||||||
|
.body_includes("JPEGDATA");
|
||||||
|
then.status(201).header("Location", "/media/2026/cat.jpg");
|
||||||
|
});
|
||||||
|
// A relative media endpoint resolves against the Micropub endpoint.
|
||||||
|
let url = client(&server).upload("/media", &file).unwrap();
|
||||||
|
m.assert();
|
||||||
|
assert_eq!(url, server.url("/media/2026/cat.jpg"));
|
||||||
|
|
||||||
|
let err = client(&server)
|
||||||
|
.upload("/media", &dir.join("missing.jpg"))
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, Error::File(_)), "{err}");
|
||||||
|
|
||||||
|
let server = MockServer::start();
|
||||||
|
server.mock(|when, then| {
|
||||||
|
when.method(POST).path("/media");
|
||||||
|
then.status(400).json_body(
|
||||||
|
json!({"error": "invalid_request", "error_description": "Unsupported type"}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
let err = client(&server).upload("/media", &file).unwrap_err();
|
||||||
|
assert_eq!(err.to_string(), "invalid_request: Unsupported type");
|
||||||
|
std::fs::remove_dir_all(dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_reads_media_endpoint() {
|
||||||
|
let cfg: ServerConfig =
|
||||||
|
serde_json::from_value(json!({"media-endpoint": "https://e/media"})).unwrap();
|
||||||
|
assert_eq!(cfg.media_endpoint.as_deref(), Some("https://e/media"));
|
||||||
|
assert!(ServerConfig::default().media_endpoint.is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ use ratatui::{
|
|||||||
widgets::{Block, BorderType, Clear, List, ListItem, ListState, Paragraph, Wrap},
|
widgets::{Block, BorderType, Clear, List, ListItem, ListState, Paragraph, Wrap},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::app::{App, Discard, Focus, Mode, Posts, Screen, Status, Targets};
|
use crate::app::{App, Discard, Focus, Mode, Posts, Prompt, Screen, Status, Targets};
|
||||||
use crate::micropub::{Kind, Post, SyndicationTarget};
|
use crate::micropub::{Kind, Media, MediaKind, Post, SyndicationTarget};
|
||||||
|
|
||||||
const ACCENT: Color = Color::Cyan;
|
const ACCENT: Color = Color::Cyan;
|
||||||
const DIM: Color = Color::DarkGray;
|
const DIM: Color = Color::DarkGray;
|
||||||
@@ -87,10 +87,12 @@ fn draw_compose(f: &mut Frame, app: &mut App, area: Rect) -> &'static str {
|
|||||||
}
|
}
|
||||||
_ => 3,
|
_ => 3,
|
||||||
};
|
};
|
||||||
let [title, reply, content, tags, targets] = Layout::vertical([
|
let media_height = app.media.len().clamp(1, 4) as u16 + 2;
|
||||||
|
let [title, reply, content, media, tags, targets] = Layout::vertical([
|
||||||
Constraint::Length(3),
|
Constraint::Length(3),
|
||||||
Constraint::Length(3),
|
Constraint::Length(3),
|
||||||
Constraint::Min(5),
|
Constraint::Min(5),
|
||||||
|
Constraint::Length(media_height),
|
||||||
Constraint::Length(3),
|
Constraint::Length(3),
|
||||||
Constraint::Length(targets_height),
|
Constraint::Length(targets_height),
|
||||||
])
|
])
|
||||||
@@ -122,6 +124,7 @@ fn draw_compose(f: &mut Frame, app: &mut App, area: Rect) -> &'static str {
|
|||||||
app.content
|
app.content
|
||||||
.set_block(field_block(&label, app.focus == Focus::Content));
|
.set_block(field_block(&label, app.focus == Focus::Content));
|
||||||
f.render_widget(&app.content, content);
|
f.render_widget(&app.content, content);
|
||||||
|
draw_media(f, app, media);
|
||||||
app.tags
|
app.tags
|
||||||
.set_block(field_block("Tags", app.focus == Focus::Tags));
|
.set_block(field_block("Tags", app.focus == Focus::Tags));
|
||||||
f.render_widget(&app.tags, tags);
|
f.render_widget(&app.tags, tags);
|
||||||
@@ -129,6 +132,12 @@ fn draw_compose(f: &mut Frame, app: &mut App, area: Rect) -> &'static str {
|
|||||||
draw_targets(f, app, targets);
|
draw_targets(f, app, targets);
|
||||||
|
|
||||||
match (app.focus, editing) {
|
match (app.focus, editing) {
|
||||||
|
(Focus::Media, _) if app.media.is_empty() => {
|
||||||
|
" a / Enter attach a file or URL (or paste/drop a path) · Tab next · Ctrl-S publish · Esc quit"
|
||||||
|
}
|
||||||
|
(Focus::Media, _) => {
|
||||||
|
" a attach · Enter alt text · d remove · J/K reorder · ↑↓ move · Tab next · Ctrl-S publish"
|
||||||
|
}
|
||||||
(Focus::Syndication, _) => {
|
(Focus::Syndication, _) => {
|
||||||
" ←→/↑↓ move · space toggle · a all/none · Tab next · Ctrl-S publish · Ctrl-P posts · Esc quit"
|
" ←→/↑↓ move · space toggle · a all/none · Tab next · Ctrl-S publish · Ctrl-P posts · Esc quit"
|
||||||
}
|
}
|
||||||
@@ -141,6 +150,88 @@ fn draw_compose(f: &mut Frame, app: &mut App, area: Rect) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn media_icon(kind: MediaKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
MediaKind::Photo => "📷",
|
||||||
|
MediaKind::Video => "🎬",
|
||||||
|
MediaKind::Audio => "🔊",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "2 photos, 1 video"
|
||||||
|
fn media_summary(media: &[Media]) -> String {
|
||||||
|
MediaKind::ALL
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&kind| {
|
||||||
|
let n = media.iter().filter(|m| m.kind == kind).count();
|
||||||
|
let name = match kind {
|
||||||
|
MediaKind::Photo => "photo",
|
||||||
|
MediaKind::Video => "video",
|
||||||
|
MediaKind::Audio => "audio file",
|
||||||
|
};
|
||||||
|
(n > 0).then(|| format!("{n} {name}{}", if n == 1 { "" } else { "s" }))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_media(f: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let focused = app.focus == Focus::Media;
|
||||||
|
let title = match app.media.len() {
|
||||||
|
0 => "Media".to_string(),
|
||||||
|
n => format!("Media · {n}"),
|
||||||
|
};
|
||||||
|
let block = field_block(&title, focused);
|
||||||
|
if app.media.is_empty() {
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(if focused {
|
||||||
|
"None · press a to attach a photo, video or audio file"
|
||||||
|
} else {
|
||||||
|
"None · Tab here, then a to attach a photo, video or audio file"
|
||||||
|
})
|
||||||
|
.fg(DIM)
|
||||||
|
.block(block),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let items: Vec<ListItem> = app
|
||||||
|
.media
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
let mut spans = vec![Span::raw(format!("{} ", media_icon(m.kind)))];
|
||||||
|
match &m.url {
|
||||||
|
None => spans.push(Span::styled(
|
||||||
|
format!("{} · uploading…", m.name),
|
||||||
|
Style::default().fg(Color::Yellow),
|
||||||
|
)),
|
||||||
|
Some(_) => spans.push(Span::raw(m.name.clone())),
|
||||||
|
}
|
||||||
|
if !m.alt.is_empty() {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!(" alt: {}", m.alt),
|
||||||
|
Style::default().fg(DIM),
|
||||||
|
));
|
||||||
|
} else if m.kind == MediaKind::Photo && m.url.is_some() {
|
||||||
|
spans.push(Span::styled(" no alt text", Style::default().fg(DIM)));
|
||||||
|
}
|
||||||
|
if !app.media_kinds.contains(&m.kind) {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!(" · server doesn't list {} posts", m.kind.property()),
|
||||||
|
Style::default().fg(Color::Yellow),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ListItem::new(Line::from(spans))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut list = List::new(items).block(block);
|
||||||
|
if focused {
|
||||||
|
list = list.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||||
|
}
|
||||||
|
let mut state = ListState::default().with_selected(Some(app.media_cursor));
|
||||||
|
f.render_stateful_widget(list, area, &mut state);
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -269,6 +360,15 @@ fn post_item(p: &Post) -> ListItem<'static> {
|
|||||||
} else {
|
} else {
|
||||||
Span::raw(head)
|
Span::raw(head)
|
||||||
});
|
});
|
||||||
|
if let Some(m) = p.media.first() {
|
||||||
|
spans.push(Span::raw(format!(" {}", media_icon(m.kind))));
|
||||||
|
if p.media.len() > 1 {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
p.media.len().to_string(),
|
||||||
|
Style::default().fg(DIM),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
if !p.tags.is_empty() {
|
if !p.tags.is_empty() {
|
||||||
let tags: Vec<String> = p.tags.iter().map(|t| format!("#{t}")).collect();
|
let tags: Vec<String> = p.tags.iter().map(|t| format!("#{t}")).collect();
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
@@ -349,6 +449,17 @@ fn draw_posts(f: &mut Frame, app: &App, area: Rect) -> &'static str {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
lines.extend(p.content.lines().map(|l| Line::raw(l.to_string())));
|
lines.extend(p.content.lines().map(|l| Line::raw(l.to_string())));
|
||||||
|
for m in &p.media {
|
||||||
|
let alt = if m.alt.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" · {}", m.alt)
|
||||||
|
};
|
||||||
|
lines.push(Line::styled(
|
||||||
|
format!("{} {}{alt}", media_icon(m.kind), m.url),
|
||||||
|
Style::default().fg(DIM),
|
||||||
|
));
|
||||||
|
}
|
||||||
lines.push(Line::raw(""));
|
lines.push(Line::raw(""));
|
||||||
lines.push(hint(&p.url));
|
lines.push(hint(&p.url));
|
||||||
for s in &p.syndication {
|
for s in &p.syndication {
|
||||||
@@ -376,6 +487,7 @@ fn draw_popup(f: &mut Frame, app: &App) {
|
|||||||
let editing = app.editing.is_some();
|
let editing = app.editing.is_some();
|
||||||
match &app.mode {
|
match &app.mode {
|
||||||
Mode::Normal => {}
|
Mode::Normal => {}
|
||||||
|
Mode::Prompt(p) => draw_prompt(f, app, *p),
|
||||||
Mode::ConfirmPublish => {
|
Mode::ConfirmPublish => {
|
||||||
let verb = match (editing, app.kind) {
|
let verb = match (editing, app.kind) {
|
||||||
(true, _) => "Save changes to ",
|
(true, _) => "Save changes to ",
|
||||||
@@ -391,6 +503,13 @@ fn draw_popup(f: &mut Frame, app: &App) {
|
|||||||
Span::raw("Crosspost: "),
|
Span::raw("Crosspost: "),
|
||||||
Span::styled(app.syndication_summary(), Style::default().fg(ACCENT)),
|
Span::styled(app.syndication_summary(), Style::default().fg(ACCENT)),
|
||||||
]));
|
]));
|
||||||
|
let media = app.note(false).media;
|
||||||
|
if !media.is_empty() {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw("Media: "),
|
||||||
|
Span::styled(media_summary(&media), Style::default().fg(ACCENT)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
if app.kind == Kind::Like && !editing {
|
if app.kind == Kind::Like && !editing {
|
||||||
lines.push(hint("Likes of ordinary web pages aren't crossposted."));
|
lines.push(hint("Likes of ordinary web pages aren't crossposted."));
|
||||||
}
|
}
|
||||||
@@ -471,6 +590,30 @@ fn draw_popup(f: &mut Frame, app: &App) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A one-line text box in a popup.
|
||||||
|
fn draw_prompt(f: &mut Frame, app: &App, prompt: Prompt) {
|
||||||
|
let (title, keys) = match prompt {
|
||||||
|
Prompt::AddMedia => (
|
||||||
|
"Attach a file",
|
||||||
|
"Tab complete · Enter attach · Esc cancel · a URL is attached as is",
|
||||||
|
),
|
||||||
|
Prompt::Alt(_) => ("Alt text", "Enter save · Esc cancel"),
|
||||||
|
};
|
||||||
|
let area = centered(f.area(), f.area().width.saturating_sub(8).clamp(40, 100), 4);
|
||||||
|
f.render_widget(Clear, area);
|
||||||
|
let block = Block::bordered()
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.border_style(Style::default().fg(ACCENT))
|
||||||
|
.title(format!(" {title} "))
|
||||||
|
.title_style(Style::default().bold());
|
||||||
|
let inner = block.inner(area);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
let [input, help] =
|
||||||
|
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
|
||||||
|
f.render_widget(&app.prompt, input);
|
||||||
|
f.render_widget(hint(keys), help);
|
||||||
|
}
|
||||||
|
|
||||||
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()
|
||||||
|
|||||||
+25
-1
@@ -1,5 +1,6 @@
|
|||||||
//! Runs Micropub requests on a background thread so the UI never blocks.
|
//! Runs Micropub requests on a background thread so the UI never blocks.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::mpsc::{self, Receiver, Sender};
|
use std::sync::mpsc::{self, Receiver, Sender};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
@@ -11,10 +12,18 @@ pub const PAGE_SIZE: usize = 20;
|
|||||||
pub enum Request {
|
pub enum Request {
|
||||||
LoadConfig,
|
LoadConfig,
|
||||||
Publish(Note),
|
Publish(Note),
|
||||||
List { offset: usize },
|
List {
|
||||||
|
offset: usize,
|
||||||
|
},
|
||||||
Update(Update),
|
Update(Update),
|
||||||
Delete(String),
|
Delete(String),
|
||||||
Undelete(String),
|
Undelete(String),
|
||||||
|
/// Upload a file. `id` matches the reply to the photo it's for.
|
||||||
|
Upload {
|
||||||
|
id: u64,
|
||||||
|
endpoint: String,
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Response {
|
pub enum Response {
|
||||||
@@ -37,6 +46,11 @@ pub enum Response {
|
|||||||
url: String,
|
url: String,
|
||||||
result: Result<(), Error>,
|
result: Result<(), Error>,
|
||||||
},
|
},
|
||||||
|
/// URL of the uploaded file.
|
||||||
|
Uploaded {
|
||||||
|
id: u64,
|
||||||
|
result: Result<String, Error>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn spawn(client: Client) -> (Sender<Request>, Receiver<Response>) {
|
pub fn spawn(client: Client) -> (Sender<Request>, Receiver<Response>) {
|
||||||
@@ -63,6 +77,16 @@ pub fn spawn(client: Client) -> (Sender<Request>, Receiver<Response>) {
|
|||||||
result: client.undelete(&url),
|
result: client.undelete(&url),
|
||||||
url,
|
url,
|
||||||
},
|
},
|
||||||
|
// Uploads can be slow, so each gets its own thread and the
|
||||||
|
// rest of the queue keeps moving.
|
||||||
|
Request::Upload { id, endpoint, path } => {
|
||||||
|
let (client, tx) = (client.clone(), resp_tx.clone());
|
||||||
|
thread::spawn(move || {
|
||||||
|
let result = client.upload(&endpoint, &path);
|
||||||
|
tx.send(Response::Uploaded { id, result }).ok();
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if resp_tx.send(resp).is_err() {
|
if resp_tx.send(resp).is_err() {
|
||||||
break;
|
break;
|
||||||
|
|||||||
Reference in New Issue
Block a user