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:
+625
-14
@@ -1,10 +1,14 @@
|
||||
//! 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::style::{Modifier, Style};
|
||||
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};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -12,15 +16,17 @@ pub enum Focus {
|
||||
Title,
|
||||
Link,
|
||||
Content,
|
||||
Media,
|
||||
Tags,
|
||||
Syndication,
|
||||
}
|
||||
|
||||
impl Focus {
|
||||
const ORDER: [Focus; 5] = [
|
||||
const ORDER: [Focus; 6] = [
|
||||
Focus::Title,
|
||||
Focus::Link,
|
||||
Focus::Content,
|
||||
Focus::Media,
|
||||
Focus::Tags,
|
||||
Focus::Syndication,
|
||||
];
|
||||
@@ -43,6 +49,27 @@ pub enum Discard {
|
||||
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)]
|
||||
pub enum Mode {
|
||||
Normal,
|
||||
@@ -52,6 +79,7 @@ pub enum Mode {
|
||||
ConfirmQuit,
|
||||
ConfirmDiscard(Discard),
|
||||
ConfirmDelete(String),
|
||||
Prompt(Prompt),
|
||||
}
|
||||
|
||||
/// Where the syndication target list came from.
|
||||
@@ -85,6 +113,17 @@ pub struct App {
|
||||
pub link: TextArea<'static>,
|
||||
pub content: 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_state: Targets,
|
||||
pub target_cursor: usize,
|
||||
@@ -157,6 +196,99 @@ fn single_line(area: &TextArea) -> 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 {
|
||||
pub fn new(me: String, scope: String) -> Self {
|
||||
let mut app = App {
|
||||
@@ -166,6 +298,13 @@ impl App {
|
||||
link: link_area(),
|
||||
content: content_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_state: Targets::Loading,
|
||||
target_cursor: 0,
|
||||
@@ -239,12 +378,183 @@ impl App {
|
||||
|
||||
/// Unsaved work: any text in a new note, or any change to the post being edited.
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
match &self.editing {
|
||||
Some(orig) => !Update::diff(orig, &self.note(orig.draft)).is_empty(),
|
||||
None => [&self.title, &self.link, &self.content, &self.tags]
|
||||
.iter()
|
||||
.any(|t| t.lines().iter().any(|l| !l.trim().is_empty())),
|
||||
self.uploading()
|
||||
|| match &self.editing {
|
||||
Some(orig) => !Update::diff(orig, &self.note(orig.draft)).is_empty(),
|
||||
None => {
|
||||
!self.media.is_empty()
|
||||
|| [&self.title, &self.link, &self.content, &self.tags]
|
||||
.iter()
|
||||
.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) {
|
||||
@@ -252,6 +562,8 @@ impl App {
|
||||
self.link = link_area();
|
||||
self.content = content_area();
|
||||
self.tags = tags_area();
|
||||
self.media.clear();
|
||||
self.media_cursor = 0;
|
||||
self.editing = None;
|
||||
self.kind = Kind::Note;
|
||||
self.focus = Focus::Content;
|
||||
@@ -289,6 +601,16 @@ impl App {
|
||||
self.link = filled(link_area_for(self.kind), link);
|
||||
self.content = filled(content_area(), &post.content);
|
||||
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.focus = Focus::Content;
|
||||
self.update_cursors();
|
||||
@@ -317,6 +639,17 @@ impl App {
|
||||
in_reply_to: link.clone().filter(|_| self.kind == Kind::Note),
|
||||
like_of: link.clone().filter(|_| self.kind == Kind::Like),
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -456,6 +789,28 @@ impl App {
|
||||
self.mode = Mode::Normal;
|
||||
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
|
||||
}
|
||||
@@ -469,6 +824,11 @@ impl App {
|
||||
|
||||
fn set_config(&mut self, cfg: ServerConfig) {
|
||||
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();
|
||||
// Keep earlier choices on refresh; new targets start checked (the server's default).
|
||||
let previous = std::mem::take(&mut self.targets);
|
||||
@@ -492,6 +852,7 @@ impl App {
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
match self.mode.clone() {
|
||||
Mode::Sending => None,
|
||||
Mode::Prompt(p) => self.handle_prompt_key(p, key),
|
||||
Mode::ConfirmPublish => {
|
||||
let draft = match key.code {
|
||||
KeyCode::Char('y') | KeyCode::Enter => self.default_draft(),
|
||||
@@ -559,6 +920,10 @@ impl App {
|
||||
fn send(&mut self, draft: bool) -> Option<Request> {
|
||||
let note = self.note(draft);
|
||||
self.mode = Mode::Normal;
|
||||
if self.uploading() {
|
||||
self.error("Wait for the uploads to finish.");
|
||||
return None;
|
||||
}
|
||||
let link = note
|
||||
.in_reply_to
|
||||
.as_ref()
|
||||
@@ -661,8 +1026,14 @@ impl App {
|
||||
}
|
||||
(true, KeyCode::Char('s')) => {
|
||||
// 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.");
|
||||
} else if self.uploading() {
|
||||
self.error("Wait for the uploads to finish.");
|
||||
} else {
|
||||
self.status = None;
|
||||
self.mode = Mode::ConfirmPublish;
|
||||
@@ -716,6 +1087,7 @@ impl App {
|
||||
area.input(input);
|
||||
}
|
||||
}
|
||||
Focus::Media => return self.handle_media_key(key),
|
||||
Focus::Syndication => self.handle_target_key(key),
|
||||
}
|
||||
None
|
||||
@@ -814,12 +1186,20 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bracketed paste: insert text into the focused field.
|
||||
pub fn handle_paste(&mut self, text: &str) {
|
||||
if self.mode != Mode::Normal || self.screen != Screen::Compose {
|
||||
return;
|
||||
}
|
||||
/// Bracketed paste: insert text into the focused field. Pasting (or dropping)
|
||||
/// files onto the Media field attaches them, one per line.
|
||||
pub fn handle_paste(&mut self, text: &str) -> Vec<Request> {
|
||||
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 {
|
||||
Focus::Content => {
|
||||
self.content.insert_str(text);
|
||||
@@ -833,8 +1213,9 @@ impl App {
|
||||
Focus::Tags => {
|
||||
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");
|
||||
app.handle_key(key(KeyCode::Enter));
|
||||
typ(&mut app, "world");
|
||||
app.handle_key(key(KeyCode::Tab)); // -> media
|
||||
app.handle_key(key(KeyCode::Tab)); // -> tags
|
||||
typ(&mut app, "rust, #tui");
|
||||
app.handle_key(key(KeyCode::Tab)); // -> syndication
|
||||
@@ -1333,4 +1715,233 @@ mod tests {
|
||||
};
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user