From c8e6eb5b71ac8533408685c7c0c6db9e1dde0509 Mon Sep 17 00:00:00 2001 From: Harsh Bhatia Date: Sat, 29 Aug 2026 13:45:45 +0530 Subject: [PATCH 1/3] Add Wikipedia app: search or a random article, no browser needed Search Wikipedia's own action=query API from an on-panel keyboard, or skip straight to a random main-namespace article. Results paginate like the rss/hn examples, and the article body is fetched as plain text (explaintext=1) and paginated for the reading screen. Registers the app in the Store catalog and workspace. --- Cargo.lock | 8 + Cargo.toml | 1 + apps/catalog.json | 11 + apps/wiki/Cargo.toml | 14 ++ apps/wiki/src/api.rs | 262 ++++++++++++++++++++ apps/wiki/src/main.rs | 464 ++++++++++++++++++++++++++++++++++++ crates/kobo-cli/src/main.rs | 1 + 7 files changed, 761 insertions(+) create mode 100644 apps/wiki/Cargo.toml create mode 100644 apps/wiki/src/api.rs create mode 100644 apps/wiki/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index bf8cf7c0..e2bbf6a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -962,6 +962,14 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kobo-wiki" +version = "1.0.0" +dependencies = [ + "kobo-json", + "kobo-sdk", +] + [[package]] name = "kobo-xml" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 7b7158ea..ffbce98e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ members = [ "apps/arxiv", "apps/morse", "apps/sudoku", + "apps/wiki", "tools/icon-import", ] resolver = "2" diff --git a/apps/catalog.json b/apps/catalog.json index 2a460ce9..348f63e2 100644 --- a/apps/catalog.json +++ b/apps/catalog.json @@ -154,6 +154,17 @@ "minimum_cobalt_version": "0.3.1", "glyph": "check", "capabilities": [] + }, + { + "package": "kobo-wiki", + "id": "wiki", + "display_name": "Wikipedia", + "short_label": "Wikipedia", + "summary": "Search Wikipedia or read a random article, without a browser.", + "version": "1.0.0", + "minimum_cobalt_version": "0.2.0", + "glyph": "globe", + "capabilities": ["network"] } ] } diff --git a/apps/wiki/Cargo.toml b/apps/wiki/Cargo.toml new file mode 100644 index 00000000..8109e4e7 --- /dev/null +++ b/apps/wiki/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "kobo-wiki" +version = "1.0.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +kobo-json = { path = "../../crates/kobo-json" } +kobo-sdk = { path = "../../crates/kobo-sdk" } + +[lints] +workspace = true diff --git a/apps/wiki/src/api.rs b/apps/wiki/src/api.rs new file mode 100644 index 00000000..616025f7 --- /dev/null +++ b/apps/wiki/src/api.rs @@ -0,0 +1,262 @@ +//! Wikipedia's own `action=query` API: search, a random article, and one +//! article's plain-text extract. +//! +//! No API key, no rate-limit tier to reason about -- this is the same +//! anonymous endpoint `en.wikipedia.org` itself calls. `explaintext=1` asks +//! `MediaWiki` to strip wiki markup server-side, so the panel is handed prose +//! rather than a wikitext parser's homework. + +use kobo_json::Value; + +const API: &str = "https://en.wikipedia.org/w/api.php"; + +/// The most search results kept. A screen or two of titles; past that a +/// reader is scrolling, not choosing. +const MAX_RESULTS: usize = 20; + +/// The address that asks for titles matching what was typed. +#[must_use] +pub fn search_url(query: &str) -> String { + format!( + "{API}?action=query&list=search&format=json&srlimit={MAX_RESULTS}&srsearch={}", + encode(query.trim()) + ) +} + +/// The address that asks for one article title, chosen at random from the +/// encyclopedia's main namespace. +#[must_use] +pub fn random_url() -> String { + format!("{API}?action=query&list=random&format=json&rnnamespace=0&rnlimit=1") +} + +/// The address that asks for one article's plain-text body. +#[must_use] +pub fn extract_url(title: &str) -> String { + format!( + "{API}?action=query&format=json&prop=extracts&explaintext=1&exsectionformat=plain&titles={}", + encode(title) + ) +} + +/// Percent-encodes a query value. +/// +/// Everything outside the unreserved set goes, rather than a list of the +/// characters known to cause trouble. A title with an accent or an ampersand +/// (`AT&T`, `Motorhead`) should come back as a search that failed, not as a +/// malformed request. +fn encode(value: &str) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + let mut out = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + out.push(char::from(byte)); + } else { + out.push('%'); + out.push(char::from(HEX[usize::from(byte >> 4)])); + out.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + } + out +} + +/// One title the search found, with the line `MediaWiki` drew under it. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Hit { + pub title: String, + /// A snippet of the article with the match marked, HTML stripped. + pub snippet: String, +} + +/// Reads a search answer. +/// +/// Anything that is not `query.search`, an array of objects with a `title`, +/// is no results rather than a failure: the screen already has to say +/// "nothing found", and a reader cannot act on the difference between that +/// and the service having a bad afternoon. +#[must_use] +pub fn search_results(bytes: &[u8]) -> Vec { + let text = String::from_utf8_lossy(bytes); + let Ok(value) = kobo_json::parse(&text) else { + return Vec::new(); + }; + let Some(entries) = value.get("query").and_then(|query| query.get("search")) else { + return Vec::new(); + }; + let Some(entries) = entries.as_array() else { + return Vec::new(); + }; + entries + .iter() + .filter_map(|entry| { + let title = entry.get("title").and_then(Value::as_str)?.trim(); + if title.is_empty() { + return None; + } + let snippet = entry + .get("snippet") + .and_then(Value::as_str) + .map(strip_html) + .unwrap_or_default(); + Some(Hit { + title: title.to_owned(), + snippet, + }) + }) + .take(MAX_RESULTS) + .collect() +} + +/// The title a random-article answer named, if it named one. +#[must_use] +pub fn random_title(bytes: &[u8]) -> Option { + let text = String::from_utf8_lossy(bytes); + let value = kobo_json::parse(&text).ok()?; + let title = value + .get("query")? + .get("random")? + .index(0)? + .get("title")? + .as_str()? + .trim(); + (!title.is_empty()).then(|| title.to_owned()) +} + +/// One article's body, read back from an extract answer. +#[must_use] +pub fn extract(bytes: &[u8]) -> Option<(String, String)> { + let text = String::from_utf8_lossy(bytes); + let value = kobo_json::parse(&text).ok()?; + let pages = value.get("query")?.get("pages")?; + // Keyed by page id, and there is exactly one page in this answer because + // exactly one title was asked for. The key itself is meaningless (and is + // literally `"-1"` for a title that does not exist), so the first and + // only field is taken rather than searched for by name. + let Value::Object(fields) = pages else { + return None; + }; + let (_, page) = fields.first()?; + if page.get("missing").is_some() { + return None; + } + let title = page.get("title").and_then(Value::as_str)?.trim(); + let body = page.get("extract").and_then(Value::as_str)?.trim(); + if title.is_empty() || body.is_empty() { + return None; + } + Some((title.to_owned(), body.to_owned())) +} + +/// Drops HTML tags and decodes the handful of entities `MediaWiki`'s search +/// snippets actually carry. +/// +/// A snippet is `` around the matched word and +/// occasionally `"` or `&` from the source text. It is never a full +/// document, so this is not a general HTML reader: anything that looks like a +/// tag is dropped outright rather than interpreted. +fn strip_html(html: &str) -> String { + let mut out = String::with_capacity(html.len()); + let mut in_tag = false; + for ch in html.chars() { + match ch { + '<' => in_tag = true, + '>' => in_tag = false, + _ if in_tag => {} + _ => out.push(ch), + } + } + out.replace(""", "\"") + .replace("'", "'") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") +} + +#[cfg(test)] +mod tests { + use super::{ + encode, extract, extract_url, random_title, random_url, search_results, search_url, + }; + + #[test] + fn a_search_asks_for_titles_matching_what_was_typed() { + assert_eq!( + search_url(" Albert Einstein "), + "https://en.wikipedia.org/w/api.php?action=query&list=search&format=json&srlimit=20&srsearch=Albert%20Einstein" + ); + } + + #[test] + fn a_title_with_reserved_characters_is_encoded_rather_than_sent() { + assert_eq!(encode("AT&T"), "AT%26T"); + assert_eq!(encode("caf\u{e9}"), "caf%C3%A9"); + assert_eq!(encode("a-b_c.d~e"), "a-b_c.d~e"); + } + + #[test] + fn random_asks_the_main_namespace_for_one_title() { + assert_eq!( + random_url(), + "https://en.wikipedia.org/w/api.php?action=query&list=random&format=json&rnnamespace=0&rnlimit=1" + ); + } + + #[test] + fn an_extract_is_asked_for_by_exact_title() { + assert_eq!( + extract_url("E=mc²"), + "https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts&explaintext=1&exsectionformat=plain&titles=E%3Dmc%C2%B2" + ); + } + + const SEARCH: &str = r#"{"query":{"search":[ + {"title":"Albert Einstein","snippet":"German-born theoretical physicist"}, + {"title":"Einstein family","snippet":"Relatives of Einstein"} + ]}}"#; + + #[test] + fn results_come_back_in_the_order_the_service_ranked_them() { + let hits = search_results(SEARCH.as_bytes()); + assert_eq!(hits.len(), 2); + assert_eq!(hits[0].title, "Albert Einstein"); + assert_eq!(hits[0].snippet, "German-born theoretical physicist"); + assert_eq!(hits[1].title, "Einstein family"); + } + + #[test] + fn something_that_is_not_search_results_is_no_results_rather_than_a_failure() { + assert!(search_results(b"").is_empty()); + assert!(search_results(b"not json").is_empty()); + assert!(search_results(b"{}").is_empty()); + assert!(search_results(br#"{"query":{"search":[]}}"#).is_empty()); + assert!(search_results(br#"{"query":{"search":[{"snippet":"no title"}]}}"#).is_empty()); + } + + #[test] + fn a_random_answer_names_its_title() { + let body = + br#"{"batchcomplete":"","query":{"random":[{"id":736,"ns":0,"title":"Bicycle"}]}}"#; + assert_eq!(random_title(body), Some("Bicycle".to_owned())); + } + + #[test] + fn an_answer_with_no_random_title_is_read_as_none() { + assert_eq!(random_title(b"{}"), None); + assert_eq!(random_title(b"not json"), None); + } + + #[test] + fn an_extract_answer_yields_the_canonical_title_and_the_body() { + let body = br#"{"query":{"pages":{"736":{"pageid":736,"ns":0,"title":"Bicycle", + "extract":"A bicycle, also called a pedal cycle, is a human-powered vehicle."}}}}"#; + let (title, text) = extract(body).expect("an extract was read"); + assert_eq!(title, "Bicycle"); + assert!(text.starts_with("A bicycle")); + } + + #[test] + fn a_title_wikipedia_does_not_have_is_read_as_no_extract() { + let body = br#"{"query":{"pages":{"-1":{"ns":0,"title":"Nonexistentxyz","missing":""}}}}"#; + assert_eq!(extract(body), None); + } +} diff --git a/apps/wiki/src/main.rs b/apps/wiki/src/main.rs new file mode 100644 index 00000000..913c49c4 --- /dev/null +++ b/apps/wiki/src/main.rs @@ -0,0 +1,464 @@ +//! Wikipedia, on a panel with no scrollbar and no browser. +//! +//! Three screens: search, a list of what it found, and one article read as +//! plain prose. A fourth verb, Random, skips straight to an article nobody +//! chose, which on a device this quiet is the whole reason to pick it up +//! between the things somebody meant to look up. + +mod api; + +use kobo_sdk::keyboard::{Keyboard, Pressed}; +use kobo_sdk::{ + action_id, ActionId, BannerLevel, Context, Failure, Glyph, KoboApp, Screen, ScreenBuilder, + Task, TaskId, TaskOutcome, +}; +use std::process::ExitCode; + +/// How much of a search answer to accept. Twenty titles and snippets is a +/// few kilobytes; this is generous headroom over it. +const SEARCH_BYTES: u32 = 32 * 1024; + +/// How much of a random-article answer to accept. One title. +const RANDOM_BYTES: u32 = 4 * 1024; + +/// How much of an article's plain-text body to accept. +/// +/// A long article (a country, a war, a century) runs past a hundred +/// kilobytes of prose alone. Cut short by the runtime's own ceiling rather +/// than refused, which the reading screen says plainly when it happens. +const EXTRACT_BYTES: u32 = 512 * 1024; + +/// Which screen is in front of the reader. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum View { + #[default] + Home, + Search, + Results, + Reading, +} + +/// What the one outstanding request is for. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Awaiting { + Search, + Random, + Extract, +} + +#[derive(Default)] +struct Wiki { + view: View, + /// Where Back from the reading screen goes: a search a reader typed has + /// results worth returning to, a random article does not. + read_from: View, + keyboard: Keyboard, + /// What was typed, kept to caption the results screen while it loads. + query: String, + hits: Vec, + list_page: usize, + /// The open article's title, once one has arrived. + title: String, + /// The open article's body, as plain prose. + body: String, + /// The article, cut into pages that fit the panel. + pages: Vec>, + page: usize, + /// Whether the last extract arrived at its byte ceiling, so the reading + /// screen can say the article was cut rather than pretend it is whole. + cut_short: bool, + task: Option<(TaskId, Awaiting)>, + problem: Option, + /// The last task failure, kept as the SDK read it rather than as a + /// sentence: an empty screen wants the whole-screen version of the same + /// thing, and a screen with something on it wants the banner. + trouble: Option, +} + +impl Wiki { + fn awaiting(&self, what: Awaiting) -> bool { + matches!(self.task, Some((_, outstanding)) if outstanding == what) + } + + fn ask(&mut self, context: &mut Context, url: String, what: Awaiting) { + self.problem = None; + self.trouble = None; + match context.spawn_retrying(Task::Fetch { + url, + offset: 0, + max_bytes: match what { + Awaiting::Search => SEARCH_BYTES, + Awaiting::Random => RANDOM_BYTES, + Awaiting::Extract => EXTRACT_BYTES, + }, + credential: None, + headers: Vec::new(), + }) { + Some(task) => self.task = Some((task, what)), + None => self.problem = Some("The device is busy. Try that again.".to_owned()), + } + } + + fn ask_search(&mut self, context: &mut Context, query: &str) { + self.hits.clear(); + self.ask(context, api::search_url(query), Awaiting::Search); + } + + fn ask_random(&mut self, context: &mut Context) { + self.ask(context, api::random_url(), Awaiting::Random); + } + + fn ask_extract(&mut self, context: &mut Context, title: &str) { + self.pages.clear(); + self.ask(context, api::extract_url(title), Awaiting::Extract); + } + + /// Cuts the open article into pages that fit the panel. + fn lay_out(&mut self, context: &Context) { + // No bar: a reading page carries nothing at its foot but the place + // it is at. Reserving one leaves a hand's width of white above the + // position and takes lines off every page. + self.pages = context.paginate_reading(&self.title_and_body(), false); + self.page = 0; + } + + fn title_and_body(&self) -> String { + let mut text = self.title.clone(); + text.push_str("\n\n"); + text.push_str(&self.body); + text + } + + fn show(&mut self, context: &mut Context) { + let screen = match self.view { + View::Home => self.home(), + View::Search => self.search(), + View::Results => self.results(context), + View::Reading => self.reading(), + }; + // Every view but Home was reached from another one, so Back unwinds + // this application first and leaves it only from Home. + context.set_screen(screen.with_own_back(self.view != View::Home)); + } + + fn home(&self) -> Screen { + let mut screen = ScreenBuilder::new("wiki-home"); + if let Some(problem) = &self.problem { + screen = screen.banner(BannerLevel::Attention, problem.clone()); + } + if self.awaiting(Awaiting::Random) { + return screen + .activity("Finding something", None) + .skeleton(3) + .build(); + } + screen + .splash( + Some(Glyph::Globe), + "Wikipedia", + "Look something up, or let it choose.", + ) + .primary_button("search", "Search") + .buttons([("random", "Random article")]) + .build() + } + + fn search(&self) -> Screen { + let mut screen = ScreenBuilder::new("wiki-search").top_bar("Search Wikipedia"); + if let Some(problem) = &self.problem { + screen = screen.banner(BannerLevel::Attention, problem.clone()); + } + screen + .typed(&self.keyboard, "A person, a place, anything") + .keyboard(&self.keyboard, "Search") + .build() + } + + fn results(&self, context: &Context) -> Screen { + let mut screen = ScreenBuilder::new("wiki-results").top_bar("Results"); + if let Some(problem) = &self.problem { + screen = screen.banner(BannerLevel::Attention, problem.clone()); + } + if self.awaiting(Awaiting::Search) { + return screen + .divider() + .activity(format!("Searching for {}", self.query), None) + .skeleton(6) + .build(); + } + if self.hits.is_empty() { + if let Some(failure) = self.trouble { + return screen.failure_state(failure, "retry").build(); + } + return screen + .empty_state(format!("Nothing found for {}.", self.query)) + .primary_button("retry", "Try another search") + .build(); + } + let rows: Vec<(String, String)> = self + .hits + .iter() + .map(|hit| { + ( + context.one_line_row(&hit.title, true), + context.clamped_row(&hit.snippet, 2, true), + ) + }) + .collect(); + let borrowed: Vec<(&str, &str)> = rows + .iter() + .map(|(title, snippet)| (title.as_str(), snippet.as_str())) + .collect(); + let pages = context.paginate_rows(&borrowed, false); + let pages = if pages.is_empty() { + vec![Vec::new()] + } else { + pages + }; + let page = self.list_page.min(pages.len().saturating_sub(1)); + let shown = pages.get(page).cloned().unwrap_or_default(); + screen = screen.rows(shown.iter().map(|index| { + ( + format!("hit-{index}"), + rows[*index].0.clone(), + rows[*index].1.clone(), + Glyph::Book, + ) + })); + if pages.len() <= 1 { + return screen.build(); + } + screen + .page_turns("list-back", "list-next") + .page_position(page_number(page), page_total(pages.len())) + .build() + } + + fn reading(&self) -> Screen { + let mut screen = ScreenBuilder::new("wiki-reading") + .top_bar(self.title.clone()) + .reading(true); + if self.awaiting(Awaiting::Extract) { + return screen + .activity("Fetching the article", None) + .skeleton(6) + .build(); + } + if self.pages.is_empty() { + if let Some(failure) = self.trouble { + return screen.failure_state(failure, "reload").build(); + } + return screen.empty_state("This article arrived empty.").build(); + } + if self.cut_short { + screen = screen.banner( + BannerLevel::Attention, + "This article is longer than this can read in full; \ + showing the beginning of it." + .to_owned(), + ); + } + let page = self.page.min(self.pages.len() - 1); + for paragraph in &self.pages[page] { + screen = screen.text(paragraph.clone()); + } + screen + .page_turns("page-back", "page-next") + .page_position(page_number(page), page_total(self.pages.len())) + .build() + } +} + +/// A page number the position band can carry, one based and clamped. +fn page_number(page: usize) -> u16 { + u16::try_from(page.saturating_add(1)).unwrap_or(u16::MAX) +} + +/// How many pages there are, clamped. +fn page_total(pages: usize) -> u16 { + u16::try_from(pages).unwrap_or(u16::MAX) +} + +/// The index in a `prefix-N` action name, if that is what this is. +fn indexed(action: ActionId, prefix: &str, count: usize) -> Option { + (0..count).find(|index| action_id(&format!("{prefix}-{index}")) == action) +} + +impl KoboApp for Wiki { + fn on_start(&mut self, context: &mut Context) { + self.show(context); + } + + fn on_action(&mut self, context: &mut Context, action: ActionId) { + // The keyboard first: while the search screen is up, it owns the + // panel. + if self.view == View::Search { + match self.keyboard.press(action) { + Some(Pressed::Submitted) => { + let typed = self.keyboard.take().trim().to_owned(); + if typed.is_empty() { + return; + } + self.query.clone_from(&typed); + self.view = View::Results; + self.list_page = 0; + self.ask_search(context, &typed); + self.show(context); + return; + } + Some(Pressed::Edited | Pressed::Shifted) => { + self.show(context); + return; + } + None => {} + } + } + + if action == ActionId::BACK { + self.problem = None; + self.trouble = None; + match self.view { + View::Home => {} + View::Search => self.view = View::Home, + View::Results => self.view = View::Search, + View::Reading => self.view = self.read_from, + } + self.show(context); + return; + } + + if action == action_id("search") { + self.keyboard.clear(); + self.problem = None; + self.trouble = None; + self.view = View::Search; + self.show(context); + return; + } + + if action == action_id("random") { + self.problem = None; + self.trouble = None; + self.read_from = View::Home; + self.ask_random(context); + self.show(context); + return; + } + + if action == action_id("retry") { + self.view = View::Search; + self.show(context); + return; + } + + if action == action_id("reload") { + self.ask_extract(context, &self.title.clone()); + self.show(context); + return; + } + + if action == action_id("list-back") { + self.list_page = self.list_page.saturating_sub(1); + self.show(context); + return; + } + + if action == action_id("list-next") { + self.list_page += 1; + self.show(context); + return; + } + + if action == action_id("page-back") { + self.page = self.page.saturating_sub(1); + self.show(context); + return; + } + + if action == action_id("page-next") { + if self.page + 1 < self.pages.len() { + self.page += 1; + } + self.show(context); + return; + } + + if self.view == View::Results { + if let Some(index) = indexed(action, "hit", self.hits.len()) { + let Some(hit) = self.hits.get(index).cloned() else { + return; + }; + self.read_from = View::Results; + self.title.clone_from(&hit.title); + self.body.clear(); + self.view = View::Reading; + self.ask_extract(context, &hit.title); + self.show(context); + } + } + } + + fn on_task(&mut self, context: &mut Context, task: TaskId, outcome: TaskOutcome) { + let Some((outstanding, awaiting)) = self.task else { + return; + }; + if outstanding != task { + return; + } + self.task = None; + match outcome { + TaskOutcome::Completed(bytes) => match awaiting { + Awaiting::Search => { + self.hits = api::search_results(&bytes); + } + Awaiting::Random => match api::random_title(&bytes) { + Some(title) => { + self.title.clone_from(&title); + self.body.clear(); + self.view = View::Reading; + self.ask_extract(context, &title); + self.show(context); + return; + } + None => { + self.problem = Some("Wikipedia's answer could not be read.".to_owned()); + } + }, + Awaiting::Extract => { + self.cut_short = bytes.len() >= EXTRACT_BYTES as usize; + match api::extract(&bytes) { + Some((title, body)) => { + self.title = title; + self.body = body; + self.lay_out(context); + } + None => { + self.problem = Some(if self.cut_short { + "This article is larger than this can read.".to_owned() + } else { + "That article could not be read.".to_owned() + }); + } + } + } + }, + TaskOutcome::Failed(error) => { + let failure = Failure::of(error); + self.trouble = Some(failure); + self.problem = Some(failure.advice.to_owned()); + } + TaskOutcome::Cancelled => self.problem = Some("Cancelled.".to_owned()), + } + self.show(context); + } +} + +fn main() -> ExitCode { + match kobo_sdk::run("wiki", Wiki::default()) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("wiki: {error}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/kobo-cli/src/main.rs b/crates/kobo-cli/src/main.rs index 529574d4..10ade2d0 100644 --- a/crates/kobo-cli/src/main.rs +++ b/crates/kobo-cli/src/main.rs @@ -74,6 +74,7 @@ const STORE_PACKAGES: &[&str] = &[ "kobo-sudoku", "kobo-tictactoe", "kobo-todo", + "kobo-wiki", ]; /// Proof that the daemon in the package can actually take the panel. The /// phrase only exists inside `present_on_panel`, which is behind From f700db55bb90e60b3c0e01d98d148f21696071c3 Mon Sep 17 00:00:00 2001 From: Harsh Bhatia Date: Mon, 31 Aug 2026 22:28:02 +0530 Subject: [PATCH 2/3] Give wiki articles real section headings, and fix entity decoding Fetches the extract as HTML instead of explaintext=1, since plain text throws away the one thing that told a section title apart from the paragraph under it. A small scanner splits the extract into headings and paragraphs on

/

/

, using kobo_html::to_text to clean up inline markup and entities within each block. Rendering real headings needed a place for them to draw larger than body text inside paginated prose, so this adds QuoteRole::Heading to the shared UI engine (kobo-ui), its wire encoding (kobo-protocol), and Context::paginate_tagged_reading (kobo-sdk) to measure them correctly against the reading face. Also fixes search-result snippets and paragraph text showing literal ' and other entities: both now go through kobo_html::to_text instead of a hand-rolled, incomplete entity list. Confirmed on real hardware (Kobo Libra Colour): headings now stand out from body text, and apostrophes render correctly. --- Cargo.lock | 1 + apps/wiki/Cargo.toml | 1 + apps/wiki/src/api.rs | 188 ++++++++++++++++++++++++++------ apps/wiki/src/main.rs | 51 ++++++--- crates/kobo-protocol/src/lib.rs | 16 ++- crates/kobo-sdk/src/lib.rs | 20 ++++ crates/kobo-ui/src/lib.rs | 23 +++- 7 files changed, 240 insertions(+), 60 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e2bbf6a6..8f70c2c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -966,6 +966,7 @@ dependencies = [ name = "kobo-wiki" version = "1.0.0" dependencies = [ + "kobo-html", "kobo-json", "kobo-sdk", ] diff --git a/apps/wiki/Cargo.toml b/apps/wiki/Cargo.toml index 8109e4e7..72e67647 100644 --- a/apps/wiki/Cargo.toml +++ b/apps/wiki/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true publish = false [dependencies] +kobo-html = { path = "../../crates/kobo-html" } kobo-json = { path = "../../crates/kobo-json" } kobo-sdk = { path = "../../crates/kobo-sdk" } diff --git a/apps/wiki/src/api.rs b/apps/wiki/src/api.rs index 616025f7..95109186 100644 --- a/apps/wiki/src/api.rs +++ b/apps/wiki/src/api.rs @@ -1,10 +1,11 @@ //! Wikipedia's own `action=query` API: search, a random article, and one -//! article's plain-text extract. +//! article's extract. //! //! No API key, no rate-limit tier to reason about -- this is the same -//! anonymous endpoint `en.wikipedia.org` itself calls. `explaintext=1` asks -//! `MediaWiki` to strip wiki markup server-side, so the panel is handed prose -//! rather than a wikitext parser's homework. +//! anonymous endpoint `en.wikipedia.org` itself calls. The extract is asked +//! for as simplified HTML rather than with `explaintext=1`: plain text throws +//! away the one thing that tells a section title apart from the paragraph +//! under it, which `

` and `

` still carry. use kobo_json::Value; @@ -30,11 +31,11 @@ pub fn random_url() -> String { format!("{API}?action=query&list=random&format=json&rnnamespace=0&rnlimit=1") } -/// The address that asks for one article's plain-text body. +/// The address that asks for one article's body, as simplified HTML. #[must_use] pub fn extract_url(title: &str) -> String { format!( - "{API}?action=query&format=json&prop=extracts&explaintext=1&exsectionformat=plain&titles={}", + "{API}?action=query&format=json&prop=extracts&titles={}", encode(title) ) } @@ -96,7 +97,7 @@ pub fn search_results(bytes: &[u8]) -> Vec { let snippet = entry .get("snippet") .and_then(Value::as_str) - .map(strip_html) + .map(|snippet| kobo_html::to_text(snippet).trim().to_owned()) .unwrap_or_default(); Some(Hit { title: title.to_owned(), @@ -122,9 +123,16 @@ pub fn random_title(bytes: &[u8]) -> Option { (!title.is_empty()).then(|| title.to_owned()) } +/// One block of an article's body: a section title or a paragraph. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Block { + pub heading: bool, + pub text: String, +} + /// One article's body, read back from an extract answer. #[must_use] -pub fn extract(bytes: &[u8]) -> Option<(String, String)> { +pub fn extract(bytes: &[u8]) -> Option<(String, Vec)> { let text = String::from_utf8_lossy(bytes); let value = kobo_json::parse(&text).ok()?; let pages = value.get("query")?.get("pages")?; @@ -144,38 +152,69 @@ pub fn extract(bytes: &[u8]) -> Option<(String, String)> { if title.is_empty() || body.is_empty() { return None; } - Some((title.to_owned(), body.to_owned())) + let blocks = extract_blocks(body); + if blocks.is_empty() { + return None; + } + Some((title.to_owned(), blocks)) } -/// Drops HTML tags and decodes the handful of entities `MediaWiki`'s search -/// snippets actually carry. +/// Splits an article's HTML extract into section titles and paragraphs. +/// +/// `MediaWiki`'s plain-text extract (`explaintext=1`) throws the two apart: +/// a section title comes back as a line of prose indistinguishable from the +/// paragraph under it. Its HTML extract still carries `

` and `

` +/// around a title, which is the only signal left to tell them apart, so this +/// reads that instead and asks [`kobo_html::to_text`] to clean up whatever +/// inline markup (``, ``, a stray ``) sits inside each block. /// -/// A snippet is `` around the matched word and -/// occasionally `"` or `&` from the source text. It is never a full -/// document, so this is not a general HTML reader: anything that looks like a -/// tag is dropped outright rather than interpreted. -fn strip_html(html: &str) -> String { - let mut out = String::with_capacity(html.len()); - let mut in_tag = false; - for ch in html.chars() { - match ch { - '<' => in_tag = true, - '>' => in_tag = false, - _ if in_tag => {} - _ => out.push(ch), +/// Written as its own small scan rather than a general HTML reader because +/// the shape is narrow and known: `prop=extracts` never nests a heading +/// inside a paragraph or one heading inside another, so a block's own close +/// tag is always the next one of the same name. +#[must_use] +pub fn extract_blocks(html: &str) -> Vec { + let mut blocks = Vec::new(); + let mut rest = html; + while let Some(start) = rest.find('<') { + let Some(tag_end) = rest[start..].find('>') else { + break; + }; + let tag_end = start + tag_end + 1; + let name = kobo_html::element_name(&rest[start + 1..tag_end - 1]); + match name.as_str() { + "h2" | "h3" | "p" => { + let body = &rest[tag_end..]; + let close = format!(""); + let (inner, after) = body.find(&close).map_or((body, ""), |offset| { + (&body[..offset], &body[offset + close.len()..]) + }); + let text = kobo_html::to_text(inner).trim().to_owned(); + if !text.is_empty() { + blocks.push(Block { + heading: name != "p", + text, + }); + } + rest = after; + } + // Everything else -- a table, an image gallery, a reference list + // -- is skipped rather than mangled: none of it reduces to a + // paragraph or a title, and running it through the paragraph path + // anyway is what the old plain-text extract already tried, which + // is the flat, structureless page this function exists to avoid + // repeating. + _ => rest = &rest[tag_end..], } } - out.replace(""", "\"") - .replace("'", "'") - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") + blocks } #[cfg(test)] mod tests { use super::{ - encode, extract, extract_url, random_title, random_url, search_results, search_url, + encode, extract, extract_blocks, extract_url, random_title, random_url, search_results, + search_url, Block, }; #[test] @@ -205,7 +244,7 @@ mod tests { fn an_extract_is_asked_for_by_exact_title() { assert_eq!( extract_url("E=mc²"), - "https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts&explaintext=1&exsectionformat=plain&titles=E%3Dmc%C2%B2" + "https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts&titles=E%3Dmc%C2%B2" ); } @@ -246,12 +285,28 @@ mod tests { } #[test] - fn an_extract_answer_yields_the_canonical_title_and_the_body() { + fn an_extract_answer_yields_the_canonical_title_and_its_blocks() { let body = br#"{"query":{"pages":{"736":{"pageid":736,"ns":0,"title":"Bicycle", - "extract":"A bicycle, also called a pedal cycle, is a human-powered vehicle."}}}}"#; - let (title, text) = extract(body).expect("an extract was read"); + "extract":"

A bicycle is a human-powered vehicle.

History

Invented in the 19th century.

"}}}}"#; + let (title, blocks) = extract(body).expect("an extract was read"); assert_eq!(title, "Bicycle"); - assert!(text.starts_with("A bicycle")); + assert_eq!( + blocks, + vec![ + Block { + heading: false, + text: "A bicycle is a human-powered vehicle.".to_owned(), + }, + Block { + heading: true, + text: "History".to_owned(), + }, + Block { + heading: false, + text: "Invented in the 19th century.".to_owned(), + }, + ] + ); } #[test] @@ -259,4 +314,67 @@ mod tests { let body = br#"{"query":{"pages":{"-1":{"ns":0,"title":"Nonexistentxyz","missing":""}}}}"#; assert_eq!(extract(body), None); } + + #[test] + fn a_heading_is_told_apart_from_the_paragraph_under_it() { + let html = "

Uses

\n

Bicycles are used for transport.

\n

Commuting

\n

Many people commute by bike.

"; + let blocks = extract_blocks(html); + assert_eq!( + blocks, + vec![ + Block { + heading: true, + text: "Uses".to_owned(), + }, + Block { + heading: false, + text: "Bicycles are used for transport.".to_owned(), + }, + Block { + heading: true, + text: "Commuting".to_owned(), + }, + Block { + heading: false, + text: "Many people commute by bike.".to_owned(), + }, + ] + ); + } + + #[test] + fn an_empty_paragraph_contributes_no_block() { + // MediaWiki's own extract carries `

\n\n

` + // as spacing between the lead and the first heading. + let html = "

\n\n

Real text.

"; + let blocks = extract_blocks(html); + assert_eq!( + blocks, + vec![Block { + heading: false, + text: "Real text.".to_owned(), + }] + ); + } + + #[test] + fn a_structural_element_is_skipped_rather_than_mangled_into_a_paragraph() { + // A gallery or a table does not reduce to one paragraph, so it is + // left out entirely rather than read as one anyway. + let html = "

Before.

  • One
  • Two

After.

"; + let blocks = extract_blocks(html); + assert_eq!( + blocks, + vec![ + Block { + heading: false, + text: "Before.".to_owned(), + }, + Block { + heading: false, + text: "After.".to_owned(), + }, + ] + ); + } } diff --git a/apps/wiki/src/main.rs b/apps/wiki/src/main.rs index 913c49c4..801bd8f1 100644 --- a/apps/wiki/src/main.rs +++ b/apps/wiki/src/main.rs @@ -9,8 +9,8 @@ mod api; use kobo_sdk::keyboard::{Keyboard, Pressed}; use kobo_sdk::{ - action_id, ActionId, BannerLevel, Context, Failure, Glyph, KoboApp, Screen, ScreenBuilder, - Task, TaskId, TaskOutcome, + action_id, ActionId, BannerLevel, Context, Failure, Glyph, KoboApp, QuoteRole, Screen, + ScreenBuilder, Task, TaskId, TaskOutcome, }; use std::process::ExitCode; @@ -59,10 +59,12 @@ struct Wiki { list_page: usize, /// The open article's title, once one has arrived. title: String, - /// The open article's body, as plain prose. - body: String, - /// The article, cut into pages that fit the panel. - pages: Vec>, + /// The open article's body, as section titles and paragraphs, in order. + blocks: Vec, + /// The article, cut into pages that fit the panel. Each paragraph carries + /// its role, so a section title still reads larger than the paragraphs + /// under it once the article has been split at the panel's edges. + pages: Vec>, page: usize, /// Whether the last extract arrived at its byte ceiling, so the reading /// screen can say the article was cut rather than pretend it is whole. @@ -115,18 +117,31 @@ impl Wiki { /// Cuts the open article into pages that fit the panel. fn lay_out(&mut self, context: &Context) { + let paragraphs = self.article_paragraphs(); + let borrowed: Vec<_> = paragraphs + .iter() + .map(|(tag, depth, role, text)| (*tag, *depth, *role, text.as_str())) + .collect(); // No bar: a reading page carries nothing at its foot but the place // it is at. Reserving one leaves a hand's width of white above the // position and takes lines off every page. - self.pages = context.paginate_reading(&self.title_and_body(), false); + self.pages = context.paginate_tagged_reading(&borrowed, false); self.page = 0; } - fn title_and_body(&self) -> String { - let mut text = self.title.clone(); - text.push_str("\n\n"); - text.push_str(&self.body); - text + /// The article as prose, the title and every section title marked apart + /// from the paragraphs under them. + fn article_paragraphs(&self) -> Vec<(u32, u8, QuoteRole, String)> { + let mut paragraphs = vec![(0, 0, QuoteRole::Heading, self.title.clone())]; + for block in &self.blocks { + let role = if block.heading { + QuoteRole::Heading + } else { + QuoteRole::Body + }; + paragraphs.push((0, 0, role, block.text.clone())); + } + paragraphs } fn show(&mut self, context: &mut Context) { @@ -259,8 +274,8 @@ impl Wiki { ); } let page = self.page.min(self.pages.len() - 1); - for paragraph in &self.pages[page] { - screen = screen.text(paragraph.clone()); + for (_, depth, role, text) in &self.pages[page] { + screen = screen.quote_as(*depth, *role, text.clone()); } screen .page_turns("page-back", "page-next") @@ -390,7 +405,7 @@ impl KoboApp for Wiki { }; self.read_from = View::Results; self.title.clone_from(&hit.title); - self.body.clear(); + self.blocks.clear(); self.view = View::Reading; self.ask_extract(context, &hit.title); self.show(context); @@ -414,7 +429,7 @@ impl KoboApp for Wiki { Awaiting::Random => match api::random_title(&bytes) { Some(title) => { self.title.clone_from(&title); - self.body.clear(); + self.blocks.clear(); self.view = View::Reading; self.ask_extract(context, &title); self.show(context); @@ -427,9 +442,9 @@ impl KoboApp for Wiki { Awaiting::Extract => { self.cut_short = bytes.len() >= EXTRACT_BYTES as usize; match api::extract(&bytes) { - Some((title, body)) => { + Some((title, blocks)) => { self.title = title; - self.body = body; + self.blocks = blocks; self.lay_out(context); } None => { diff --git a/crates/kobo-protocol/src/lib.rs b/crates/kobo-protocol/src/lib.rs index 8c303661..d5b5ecc2 100644 --- a/crates/kobo-protocol/src/lib.rs +++ b/crates/kobo-protocol/src/lib.rs @@ -4646,6 +4646,7 @@ fn encode_node( output.push(match role { kobo_ui::QuoteRole::Body => 0, kobo_ui::QuoteRole::Byline => 1, + kobo_ui::QuoteRole::Heading => 2, }); // A flag rather than a reserved action id, because zero is a // perfectly ordinary action and there is no value to spare. @@ -5638,11 +5639,13 @@ fn decode_node( // deeper reply, not a malformed frame, and the renderer was // always going to draw it at the cap anyway. depth: depth.min(kobo_ui::MAX_QUOTE_DEPTH), - // An unknown role is prose. A frame from a newer application - // that has invented a third kind of line should still be - // readable, and the thing it certainly is not is a byline. + // An unknown role is prose. A frame from a runtime older than + // this decoder, carrying a role newer than the ones it knew + // about, should still be readable, and the thing it certainly + // is not is a byline. role: match role { 1 => kobo_ui::QuoteRole::Byline, + 2 => kobo_ui::QuoteRole::Heading, _ => kobo_ui::QuoteRole::Body, }, fold, @@ -7300,6 +7303,13 @@ mod node_coverage_tests { fold: None, text: "A reply".into(), }, + Node::Quote { + id: NodeId(31), + depth: 0, + role: kobo_ui::QuoteRole::Heading, + fold: None, + text: "A section title".into(), + }, Node::Button { id: NodeId(3), action: ActionId(1), diff --git a/crates/kobo-sdk/src/lib.rs b/crates/kobo-sdk/src/lib.rs index 52bd17b2..0f3c1a48 100644 --- a/crates/kobo-sdk/src/lib.rs +++ b/crates/kobo-sdk/src/lib.rs @@ -2842,6 +2842,26 @@ impl Context { kobo_ui::paginate_tagged(paragraphs, &self.metrics, self.paged_area(nav_bar)) } + /// The same, in the reading face. + /// + /// The companion to [`ScreenBuilder::reading`], for the same reason + /// [`Self::paginate_reading`] is: a serif sets the same words wider and on + /// more generous lines, and headings measured in the interface face on a + /// screen drawn in the reading face lose their last lines with nothing on + /// the panel to say so. + #[must_use] + pub fn paginate_tagged_reading( + &self, + paragraphs: &[(u32, u8, QuoteRole, &str)], + nav_bar: bool, + ) -> Vec> { + kobo_ui::paginate_tagged( + paragraphs, + &self.metrics, + self.paged_area_in(nav_bar, kobo_ui::Face::Reading), + ) + } + /// `text` cut to the single line a list row can show, ellipsised if it /// did not fit. /// diff --git a/crates/kobo-ui/src/lib.rs b/crates/kobo-ui/src/lib.rs index b423c372..98bc3de1 100644 --- a/crates/kobo-ui/src/lib.rs +++ b/crates/kobo-ui/src/lib.rs @@ -197,6 +197,12 @@ pub enum QuoteRole { Body, /// Who said it, and when. Smaller, and in the muted tone. Byline, + /// A section title inside the flow: bold enough to lead the paragraphs + /// under it without competing with the screen's own title above it. The + /// same size [`Node::Heading`] uses below its first level, and for the + /// same reason: a document's own headings are not the screen's heading, + /// and drawing them as large would leave a page several titles deep. + Heading, } impl QuoteRole { @@ -209,6 +215,7 @@ impl QuoteRole { match self { Self::Body => FontSize::Body, Self::Byline => FontSize::Caption, + Self::Heading => FontSize::Title, } } @@ -216,7 +223,7 @@ impl QuoteRole { #[must_use] pub const fn tone(self) -> u8 { match self { - Self::Body => tone::INK, + Self::Body | Self::Heading => tone::INK, Self::Byline => tone::MUTED, } } @@ -5365,7 +5372,7 @@ fn layout_node( // control it does not have would ragged the whole comment. let fold = match role { QuoteRole::Byline => *fold, - QuoteRole::Body => None, + QuoteRole::Body | QuoteRole::Heading => None, }; let mark = fold.map_or(0, |_| fold_mark_width(metrics)); let text_width = max(1, full_width - mark); @@ -5376,7 +5383,7 @@ fn layout_node( // from the comment. let measured = lines.len() as i32 * size.line_height_in(prose); let height = match role { - QuoteRole::Body => max(MIN_TEXT_HEIGHT, measured), + QuoteRole::Body | QuoteRole::Heading => max(MIN_TEXT_HEIGHT, measured), QuoteRole::Byline => byline_height(measured, metrics), }; layout.nodes.push(LayoutNode { @@ -7847,7 +7854,7 @@ pub fn paginate_tagged( let measured = lines.len() as i32 * line_height; used += spacing + match role { - QuoteRole::Body => max_i32(MIN_TEXT_HEIGHT, measured), + QuoteRole::Body | QuoteRole::Heading => max_i32(MIN_TEXT_HEIGHT, measured), QuoteRole::Byline => byline_height(measured, metrics), }; page.push((tag, depth, role, lines.join(" "))); @@ -15384,6 +15391,14 @@ mod prose_tests { QuoteRole::Byline.size().tenth_mm() < QuoteRole::Body.size().tenth_mm(), "the byline was not smaller than the comment it introduces" ); + // A section heading inside the flow leads its own paragraphs without + // competing with the screen's own title: bigger than the body under + // it, ink rather than muted, and never mistaken for a byline. + assert_eq!(QuoteRole::Heading.tone(), tone::INK); + assert!( + QuoteRole::Heading.size().tenth_mm() > QuoteRole::Body.size().tenth_mm(), + "a heading in the flow was not larger than the body under it" + ); let measure = |screen: &Screen| { screen .layout() From 67f899e9ca643c21a3aafc0cb82f323057a2af92 Mon Sep 17 00:00:00 2001 From: Harsh Bhatia Date: Tue, 1 Sep 2026 15:53:11 +0530 Subject: [PATCH 3/3] Add store page, homepage card, and screenshot for Wikipedia Bumps minimum_cobalt_version to 0.3.1, the current release, and registers the app with the site generator (screenshot mapping, docs/apps/wiki/index.html, a homepage card, and a sitemap entry), matching how every other Store app is published. The screenshot is a clean capture from the Clara BW simulator profile (kobo drive --ideal) showing an article with two section headings, the feature this app's other commit added. --- apps/catalog.json | 2 +- docs/apps/wiki/index.html | 161 ++++++++++++++++++++++++++++++++++ docs/index.html | 5 ++ docs/media/site/apps/wiki.png | Bin 0 -> 151335 bytes docs/sitemap.xml | 1 + tools/generate-app-pages.mjs | 3 +- 6 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 docs/apps/wiki/index.html create mode 100644 docs/media/site/apps/wiki.png diff --git a/apps/catalog.json b/apps/catalog.json index 348f63e2..14c06f15 100644 --- a/apps/catalog.json +++ b/apps/catalog.json @@ -162,7 +162,7 @@ "short_label": "Wikipedia", "summary": "Search Wikipedia or read a random article, without a browser.", "version": "1.0.0", - "minimum_cobalt_version": "0.2.0", + "minimum_cobalt_version": "0.3.1", "glyph": "globe", "capabilities": ["network"] } diff --git a/docs/apps/wiki/index.html b/docs/apps/wiki/index.html new file mode 100644 index 00000000..a19dc091 --- /dev/null +++ b/docs/apps/wiki/index.html @@ -0,0 +1,161 @@ + + + + + +Install Wikipedia on Kobo | Cobalt + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+

Kobo app

+

Wikipedia

+

Search Wikipedia or read a random article, without a browser.

+
Version 1.0.0network
+
+
+ A Wikipedia article with its section headings on a Kobo +
+
+
+

Install with Cobalt

+

Link your Kobo to install

+

On your Kobo, open App Store, then Install links. Scan the QR code, or enter the pairing code and verification key shown there.

+
+
+ + +
+
+ + +
+ +
+

+
+
+

Cobalt not installed?

+

Set up Cobalt first

+

Install Cobalt once over USB, then return to this page. Future apps install and update over Wi-Fi without reconnecting the cable.

+
    +
  1. Check that your Kobo model and firmware are supported.
  2. +
  3. Connect the charged Kobo to a Mac or Linux computer and follow the setup guide.
  4. +
  5. Restart your Kobo, open Cobalt App Store, and return here to link it.
  6. +
+ Set up Cobalt +
+ + +
+ + + + diff --git a/docs/index.html b/docs/index.html index d5511afe..ce0dedab 100644 --- a/docs/index.html +++ b/docs/index.html @@ -572,6 +572,11 @@

Tic-tac-toe

Magnet

Locates the hall sensor behind the bezel and reports its changes.

+
+ A Wikipedia article with its section headings on a Kobo +

Wikipedia

+

Search Wikipedia or read a random article, without a browser.

+