A terminal reader, ipx; the server is ipxd (#437)
ipx-tui (crates/tui) is iPX's reader in ratatui: your feeds, a feed's items, an item's text as plain text, read and flagged as on the page, through the API with an API token. It installs as `ipx`, so the daemon's binary is now `ipxd`: the Dockerfile, release script, tests and every doc and message that names its commands follow. Naming the binary ipxd alone renamed its crate, and with it every log target (ipx::scan became ipxd::scan): the default filters are ipx=info, so nearly every line would have been dropped and the dashboard's queries on the targets gone blank. The daemon's code is now a library still called ipx, under a five-line ipxd main.rs, and the targets are as they were. ratatui is taken without its default features: 32 new packages in place of 78. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
This commit is contained in:
14
crates/tui/CHANGELOG.md
Normal file
14
crates/tui/CHANGELOG.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Changelog: ipx-tui
|
||||
|
||||
The reader in a terminal (README.md). ipx's changelog is [crates/ipx/CHANGELOG.md](../ipx/CHANGELOG.md).
|
||||
|
||||
Its version is in `crates/tui/Cargo.toml`.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- `ipx`, iPX's reader in a terminal: your feeds, a feed's items and an item's text, read and flagged as on the page, signed in with an API token (#437).
|
||||
21
crates/tui/Cargo.toml
Normal file
21
crates/tui/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "ipx-tui"
|
||||
version = "0.1.0-dev"
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "ipx"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
clap = { workspace = true, features = ["env"] }
|
||||
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] }
|
||||
reqwest = { workspace = true, features = ["blocking"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
27
crates/tui/README.md
Normal file
27
crates/tui/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# ipx-tui
|
||||
|
||||
The `ipx` command. The server, which had that name, is `ipxd`.
|
||||
|
||||
iPX's reader in a terminal: your feeds on the left, a feed's items above, the item's text below.
|
||||
It talks to iPX's API as the page does, as you, with an API token made in the page's Settings.
|
||||
|
||||
```sh
|
||||
cargo build --release -p ipx-tui
|
||||
IPX_URL=http://192.168.1.130:8099 IPX_API_TOKEN=... target/release/ipx
|
||||
```
|
||||
|
||||
`--url` and `--token` do the same as the two variables.
|
||||
|
||||
| key | does |
|
||||
|---|---|
|
||||
| `j` `k`, arrows | move, or scroll the item's text |
|
||||
| `Enter`, `l`, `Tab` | open the feed or the item; opening an item marks it read |
|
||||
| `h`, `Esc` | back a pane; `Esc` on the feeds quits |
|
||||
| `Space`, `PgUp` `PgDn` | page through the item's text |
|
||||
| `m` | mark the item read or unread |
|
||||
| `f` | flag or unflag it |
|
||||
| `u` | unread items only, or every item (unread only to start) |
|
||||
| `r` | reload |
|
||||
| `q` | quit |
|
||||
|
||||
It shows a feed's newest 200 items.
|
||||
451
crates/tui/src/main.rs
Normal file
451
crates/tui/src/main.rs
Normal file
@@ -0,0 +1,451 @@
|
||||
//! A terminal reader for ipx (#437): your feeds, a feed's items and an item's text, through the
|
||||
//! same API the page uses, as the person whose API token it is given.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::Parser;
|
||||
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
use ratatui::style::{Modifier, Style, Stylize};
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph, Wrap};
|
||||
use ratatui::{DefaultTerminal, Frame};
|
||||
use reqwest::Url;
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "ipx", version, about = "Read iPX in a terminal")]
|
||||
struct Args {
|
||||
/// Where iPX is, as a browser reaches it.
|
||||
#[arg(long, env = "IPX_URL", default_value = "http://localhost:8099")]
|
||||
url: Url,
|
||||
/// An API token, made in the page's Settings.
|
||||
#[arg(long, env = "IPX_API_TOKEN", hide_env_values = true)]
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// The part of web.rs's FeedRow this needs.
|
||||
#[derive(Deserialize)]
|
||||
struct Feed {
|
||||
id: String,
|
||||
url: String,
|
||||
title: Option<String>,
|
||||
unread: i64,
|
||||
/// The OPML it came from.
|
||||
group: Option<String>,
|
||||
hide_from_all: bool,
|
||||
}
|
||||
|
||||
/// The part of db.rs's EntryRow this needs.
|
||||
#[derive(Deserialize)]
|
||||
struct Entry {
|
||||
guid: String,
|
||||
feed_id: String,
|
||||
title: Option<String>,
|
||||
link: Option<String>,
|
||||
published: Option<i64>,
|
||||
description: Option<String>,
|
||||
read: bool,
|
||||
flagged: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EntryPage {
|
||||
total: i64,
|
||||
entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
struct Api {
|
||||
http: reqwest::blocking::Client,
|
||||
base: Url,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl Api {
|
||||
/// The API's address for these path segments, each escaped: a guid is often a URL itself.
|
||||
fn url(&self, segments: &[&str]) -> Url {
|
||||
let mut url = self.base.clone();
|
||||
url.path_segments_mut().expect("an http URL has a path").pop_if_empty().extend(segments);
|
||||
url
|
||||
}
|
||||
|
||||
fn send(&self, req: reqwest::blocking::RequestBuilder) -> Result<reqwest::blocking::Response> {
|
||||
let res = req.bearer_auth(&self.token).send()?;
|
||||
if !res.status().is_success() {
|
||||
bail!("{} {}", res.status(), res.text().unwrap_or_default());
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
fn feeds(&self) -> Result<Vec<Feed>> {
|
||||
Ok(self.send(self.http.get(self.url(&["api", "feeds"])))?.json()?)
|
||||
}
|
||||
|
||||
/// The newest items of one feed, or of all of them when `feed` is None.
|
||||
// shortcut: the newest 200 only, no paging; page through `offset` if someone wants older.
|
||||
fn entries(&self, feed: Option<&str>, unread_only: bool) -> Result<EntryPage> {
|
||||
let mut url = match feed {
|
||||
Some(id) => self.url(&["api", "feeds", id, "entries"]),
|
||||
None => self.url(&["api", "entries"]),
|
||||
};
|
||||
let filter = if unread_only { "unread" } else { "all" };
|
||||
url.query_pairs_mut().append_pair("limit", "200").append_pair("filter", filter);
|
||||
Ok(self.send(self.http.get(url))?.json()?)
|
||||
}
|
||||
|
||||
/// Sets an item's read or flagged, and answers with its feed's row, for the new unread count.
|
||||
fn set_flag(&self, e: &Entry, flag: &str, on: bool) -> Result<Option<Feed>> {
|
||||
let url = self.url(&["api", "entries", &e.feed_id, &e.guid, "flags"]);
|
||||
Ok(self.send(self.http.post(url).json(&serde_json::json!({ flag: on })))?.json()?)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Clone, Copy)]
|
||||
enum Pane {
|
||||
Feeds,
|
||||
Items,
|
||||
Text,
|
||||
}
|
||||
|
||||
struct App {
|
||||
api: Api,
|
||||
/// Your feeds, after All Subscriptions, which is index 0 of `feed_list`.
|
||||
feeds: Vec<Feed>,
|
||||
feed_list: ListState,
|
||||
entries: Vec<Entry>,
|
||||
total: i64,
|
||||
entry_list: ListState,
|
||||
pane: Pane,
|
||||
scroll: u16,
|
||||
unread_only: bool,
|
||||
status: String,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn feed_id(&self) -> Option<&str> {
|
||||
let i = self.feed_list.selected()?;
|
||||
self.feeds.get(i.checked_sub(1)?).map(|f| f.id.as_str())
|
||||
}
|
||||
|
||||
fn entry(&self) -> Option<&Entry> {
|
||||
self.entries.get(self.entry_list.selected()?)
|
||||
}
|
||||
|
||||
/// Runs an API call, putting what went wrong in the status line instead of ending the program.
|
||||
fn attempt<T>(&mut self, what: Result<T>) -> Option<T> {
|
||||
what.map_err(|e| self.status = format!("{e:#}")).ok()
|
||||
}
|
||||
|
||||
fn load_feeds(&mut self) {
|
||||
if let Some(feeds) = self.attempt(self.api.feeds()) {
|
||||
self.feeds = feeds;
|
||||
self.status.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn load_entries(&mut self) {
|
||||
let page = self.api.entries(self.feed_id(), self.unread_only);
|
||||
if let Some(page) = self.attempt(page) {
|
||||
self.entries = page.entries;
|
||||
self.total = page.total;
|
||||
self.entry_list.select(if self.entries.is_empty() { None } else { Some(0) });
|
||||
self.scroll = 0;
|
||||
self.status.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the selected item's read or flagged, as the page does.
|
||||
fn flag(&mut self, flag: &str, on: impl Fn(&Entry) -> bool) {
|
||||
let Some(e) = self.entry() else { return };
|
||||
let on = on(e);
|
||||
let result = self.api.set_flag(e, flag, on);
|
||||
let Some(row) = self.attempt(result) else { return };
|
||||
if let Some(i) = self.entry_list.selected() {
|
||||
let e = &mut self.entries[i];
|
||||
if flag == "read" { e.read = on } else { e.flagged = on }
|
||||
}
|
||||
if let Some(row) = row
|
||||
&& let Some(f) = self.feeds.iter_mut().find(|f| f.id == row.id)
|
||||
{
|
||||
f.unread = row.unread;
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the selected item, which marks it read, as opening one on the page does.
|
||||
fn open(&mut self) {
|
||||
self.scroll = 0;
|
||||
self.pane = Pane::Text;
|
||||
if self.entry().is_some_and(|e| !e.read) {
|
||||
self.flag("read", |_| true);
|
||||
}
|
||||
}
|
||||
|
||||
fn step(&mut self, down: bool) {
|
||||
let list = match self.pane {
|
||||
Pane::Feeds => &mut self.feed_list,
|
||||
Pane::Items => &mut self.entry_list,
|
||||
Pane::Text => {
|
||||
self.scroll = if down { self.scroll.saturating_add(1) } else { self.scroll.saturating_sub(1) };
|
||||
return;
|
||||
}
|
||||
};
|
||||
if down {
|
||||
list.select_next()
|
||||
} else {
|
||||
list.select_previous()
|
||||
}
|
||||
if self.pane == Pane::Items {
|
||||
self.scroll = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a key, answering false when it is time to quit.
|
||||
fn key(&mut self, code: KeyCode) -> bool {
|
||||
match code {
|
||||
KeyCode::Char('q') => return false,
|
||||
KeyCode::Esc if self.pane == Pane::Feeds => return false,
|
||||
KeyCode::Char('j') | KeyCode::Down => self.step(true),
|
||||
KeyCode::Char('k') | KeyCode::Up => self.step(false),
|
||||
KeyCode::PageDown | KeyCode::Char(' ') if self.pane == Pane::Text => {
|
||||
self.scroll = self.scroll.saturating_add(10)
|
||||
}
|
||||
KeyCode::PageUp if self.pane == Pane::Text => self.scroll = self.scroll.saturating_sub(10),
|
||||
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => match self.pane {
|
||||
Pane::Feeds => {
|
||||
self.load_entries();
|
||||
self.pane = Pane::Items;
|
||||
}
|
||||
Pane::Items => self.open(),
|
||||
Pane::Text => {}
|
||||
},
|
||||
KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left | KeyCode::BackTab => {
|
||||
self.pane = match self.pane {
|
||||
Pane::Text => Pane::Items,
|
||||
_ => Pane::Feeds,
|
||||
}
|
||||
}
|
||||
KeyCode::Char('m') => self.flag("read", |e| !e.read),
|
||||
KeyCode::Char('f') => self.flag("flagged", |e| !e.flagged),
|
||||
KeyCode::Char('u') => {
|
||||
self.unread_only = !self.unread_only;
|
||||
self.load_entries();
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
self.load_feeds();
|
||||
self.load_entries();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn draw(&mut self, frame: &mut Frame) {
|
||||
let [main, foot] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area());
|
||||
let [left, right] = Layout::horizontal([Constraint::Percentage(28), Constraint::Fill(1)]).areas(main);
|
||||
let [top, bottom] = Layout::vertical([Constraint::Percentage(40), Constraint::Fill(1)]).areas(right);
|
||||
let block = |title: String, pane: Pane| {
|
||||
let b = Block::bordered().title(title);
|
||||
if self.pane == pane { b.border_style(Style::new().bold()) } else { b.dim() }
|
||||
};
|
||||
let picked = Style::new().add_modifier(Modifier::REVERSED);
|
||||
|
||||
// What the page counts (feeds.ts inAll): not a feed hidden from All Subscriptions, or
|
||||
// one from an OPML that is (#288).
|
||||
let hidden = |id: &str| self.feeds.iter().any(|g| g.id == id && g.hide_from_all);
|
||||
let all: i64 = self
|
||||
.feeds
|
||||
.iter()
|
||||
.filter(|f| !f.hide_from_all && !f.group.as_deref().is_some_and(hidden))
|
||||
.map(|f| f.unread)
|
||||
.sum();
|
||||
let feeds = std::iter::once(format!("All Subscriptions ({all})")).chain(self.feeds.iter().map(|f| {
|
||||
let name = f.title.as_deref().unwrap_or(&f.url);
|
||||
if f.unread > 0 { format!("{name} ({})", f.unread) } else { name.to_string() }
|
||||
}));
|
||||
let feeds = List::new(feeds).block(block("Feeds".into(), Pane::Feeds)).highlight_style(picked);
|
||||
frame.render_stateful_widget(feeds, left, &mut self.feed_list);
|
||||
|
||||
let items = self.entries.iter().map(|e| {
|
||||
let mark = format!("{}{}", if e.read { ' ' } else { '•' }, if e.flagged { '★' } else { ' ' });
|
||||
let line = format!("{mark} {:<10} {}", date(e.published), e.title.as_deref().unwrap_or("(untitled)"));
|
||||
if e.read { ListItem::new(line) } else { ListItem::new(line).bold() }
|
||||
});
|
||||
let shown = if self.unread_only { "unread" } else { "items" };
|
||||
let title = format!("{} of {} {shown}", self.entries.len(), self.total);
|
||||
let items = List::new(items).block(block(title, Pane::Items)).highlight_style(picked);
|
||||
frame.render_stateful_widget(items, top, &mut self.entry_list);
|
||||
|
||||
let text = match self.entry() {
|
||||
Some(e) => {
|
||||
let mut lines = vec![Line::from(e.title.clone().unwrap_or_default()).bold()];
|
||||
if let Some(link) = &e.link {
|
||||
lines.push(Line::from(link.clone()).dim());
|
||||
}
|
||||
lines.push(Line::default());
|
||||
lines
|
||||
.extend(text_of(e.description.as_deref().unwrap_or("")).lines().map(|l| Line::from(l.to_string())));
|
||||
lines
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
let text = Paragraph::new(text).wrap(Wrap { trim: false }).scroll((self.scroll, 0));
|
||||
frame.render_widget(text.block(block("Item".into(), Pane::Text)), bottom);
|
||||
|
||||
let help = "j/k move enter open h back m read f flag u unread only r reload q quit";
|
||||
let foot_text = if self.status.is_empty() { help } else { &self.status };
|
||||
frame.render_widget(Paragraph::new(foot_text).dim(), foot);
|
||||
}
|
||||
}
|
||||
|
||||
fn date(t: Option<i64>) -> String {
|
||||
t.and_then(|t| chrono::DateTime::from_timestamp(t, 0)).map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// An item's HTML as plain text for a terminal: tags dropped, blocks on lines of their own,
|
||||
/// entities decoded. ipx has already cleaned it, so there is no script or style to skip.
|
||||
fn text_of(html: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = html;
|
||||
loop {
|
||||
let (text, tag) = match rest.find('<') {
|
||||
Some(i) => (&rest[..i], Some(&rest[i + 1..])),
|
||||
None => (rest, None),
|
||||
};
|
||||
// HTML's own whitespace is not layout; the tags say where lines break.
|
||||
if text.starts_with(char::is_whitespace) && !out.is_empty() && !out.ends_with(['\n', ' ']) {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(&decode(&text.split_whitespace().collect::<Vec<_>>().join(" ")));
|
||||
if text.ends_with(char::is_whitespace) && !text.trim().is_empty() {
|
||||
out.push(' ');
|
||||
}
|
||||
let Some(tag) = tag else { break };
|
||||
let Some(end) = tag.find('>') else { break };
|
||||
let (inner, closing) = match tag[..end].strip_prefix('/') {
|
||||
Some(t) => (t, true),
|
||||
None => (&tag[..end], false),
|
||||
};
|
||||
let name = inner.split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or("").to_ascii_lowercase();
|
||||
match name.as_str() {
|
||||
"br" => out.push('\n'),
|
||||
"li" => {
|
||||
if !out.is_empty() && !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
if !closing {
|
||||
out.push_str("• ");
|
||||
}
|
||||
}
|
||||
"p" | "div" | "ul" | "ol" | "blockquote" | "pre" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
|
||||
if !out.is_empty() && !out.ends_with("\n\n") =>
|
||||
{
|
||||
out.push_str(if out.ends_with('\n') { "\n" } else { "\n\n" });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
rest = &tag[end + 1..];
|
||||
}
|
||||
out.lines().map(str::trim_end).collect::<Vec<_>>().join("\n").trim().to_string()
|
||||
}
|
||||
|
||||
/// HTML's entities, the named ones feeds use and every numbered one.
|
||||
fn decode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(i) = rest.find('&') {
|
||||
out.push_str(&rest[..i]);
|
||||
rest = &rest[i..];
|
||||
let end = rest[..rest.len().min(12)].find(';');
|
||||
let ch = end.and_then(|end| match &rest[1..end] {
|
||||
"amp" => Some('&'),
|
||||
"lt" => Some('<'),
|
||||
"gt" => Some('>'),
|
||||
"quot" => Some('"'),
|
||||
"apos" => Some('\''),
|
||||
"nbsp" => Some(' '),
|
||||
"hellip" => Some('…'),
|
||||
"mdash" => Some('—'),
|
||||
"ndash" => Some('–'),
|
||||
"rsquo" => Some('’'),
|
||||
"lsquo" => Some('‘'),
|
||||
"rdquo" => Some('”'),
|
||||
"ldquo" => Some('“'),
|
||||
n => {
|
||||
let n = n.strip_prefix('#')?;
|
||||
let code = match n.strip_prefix(['x', 'X']) {
|
||||
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
|
||||
None => n.parse().ok()?,
|
||||
};
|
||||
char::from_u32(code)
|
||||
}
|
||||
});
|
||||
match (ch, end) {
|
||||
(Some(c), Some(end)) => {
|
||||
out.push(c);
|
||||
rest = &rest[end + 1..];
|
||||
}
|
||||
_ => {
|
||||
out.push('&');
|
||||
rest = &rest[1..];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
fn run(terminal: &mut DefaultTerminal, app: &mut App) -> Result<()> {
|
||||
loop {
|
||||
terminal.draw(|f| app.draw(f))?;
|
||||
// A poll, not a blocking read, so a resize redraws without waiting for a key.
|
||||
if !event::poll(Duration::from_millis(500))? {
|
||||
continue;
|
||||
}
|
||||
if let Event::Key(k) = event::read()?
|
||||
&& k.kind == KeyEventKind::Press
|
||||
&& !app.key(k.code)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
let http = reqwest::blocking::Client::builder().timeout(Duration::from_secs(30)).build()?;
|
||||
let api = Api { http, base: args.url, token: args.token };
|
||||
// Before the terminal is taken over, so a wrong address or token is said plainly.
|
||||
let feeds = api.feeds().context("could not read your feeds")?;
|
||||
let mut app = App {
|
||||
api,
|
||||
feeds,
|
||||
feed_list: ListState::default().with_selected(Some(0)),
|
||||
entries: vec![],
|
||||
total: 0,
|
||||
entry_list: ListState::default(),
|
||||
pane: Pane::Feeds,
|
||||
scroll: 0,
|
||||
unread_only: true,
|
||||
status: String::new(),
|
||||
};
|
||||
app.load_entries();
|
||||
let mut terminal = ratatui::init();
|
||||
let result = run(&mut terminal, &mut app);
|
||||
ratatui::restore();
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn text_of_html() {
|
||||
let html = "<p>One & two,\n <b>bold</b>.</p><p>Next line<br>broken — ’s &bogus; &</p>\
|
||||
<ul><li>a</li><li>b</li></ul>";
|
||||
assert_eq!(text_of(html), "One & two, bold.\n\nNext line\nbroken — ’s &bogus; &\n\n• a\n• b");
|
||||
assert_eq!(text_of("plain"), "plain");
|
||||
assert_eq!(text_of("a <i>b</i> c"), "a b c");
|
||||
assert_eq!(text_of("<b>x</b> <i>y</i>"), "x y");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user