//! Wikis (hub spec §6.11, oxygenhub#104): Markdown pages kept as an //! Oxygen repository of their own beside the code, under //! `/wikis//`, read and written on the web and cloned //! or pushed as `OWNER/NAME.wiki`. A page `Getting-Started` is the file //! `Getting-Started.md` on bookmark `main`; `_Sidebar.md` is the sidebar. //! Web edits are changes authored by the user and signed by the hub as //! their delegate, exactly as code edits are (§6.9). use crate::repos::edits::{Committed, Signer}; use crate::repos::view::{self, ChangeView}; use crate::repos::{RepoError, RepoName}; use crate::Shared; use oxy_core::hash::ChangeHash; use oxy_core::identity::Domain; use oxy_core::repo::{FileEdit, Repo}; pub const BOOKMARK: &str = "main"; pub const HOME: &str = "Home"; pub const SIDEBAR: &str = "_Sidebar"; /// The suffix a wiki's clone address carries: `OWNER/NAME.wiki`. pub const SUFFIX: &str = ".wiki"; /// Pages searched at most. const MAX_SEARCHED: usize = 500; /// A page's name in a URL and file name: `Getting-Started`. pub fn slug_ok(slug: &str) -> bool { !slug.is_empty() && slug.len() <= 100 && slug.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) && !slug.contains("..") && !slug.starts_with('.') && !slug.ends_with('.') } /// `Getting Started` → `Getting-Started`. pub fn slug_of(title: &str) -> String { title.split_whitespace().collect::>().join("-") } /// `Getting-Started` → `Getting Started`. pub fn title_of(slug: &str) -> String { slug.replace('-', " ") } pub fn path_of(slug: &str) -> String { format!("{slug}.md") } /// The page a file at the root is, if it is one. pub fn slug_from_path(path: &str) -> Option<&str> { path.strip_suffix(".md").filter(|s| !s.contains('/') && slug_ok(s)) } /// `OWNER/NAME.wiki` → the repository it belongs to, and whether it was /// a wiki address at all. pub fn parent_of(name: &RepoName) -> (RepoName, bool) { match name.name.strip_suffix(SUFFIX).and_then(|n| RepoName::new(&name.owner, n)) { Some(parent) => (parent, true), None => (name.clone(), false), } } pub fn exists(app: &Shared, repo: &RepoName) -> bool { app.wikis.exists(repo) } /// `main`'s heads, empty before the first page. fn heads(r: &Repo) -> Result, RepoError> { Ok(r.bookmarks()?.into_iter().find(|b| b.name == BOOKMARK).map(|b| b.heads).unwrap_or_default()) } /// A page's Markdown, `None` when there is no such page. pub async fn page(app: &Shared, repo: &RepoName, slug: &str) -> Result, RepoError> { if !exists(app, repo) { return Ok(None); } let path = path_of(slug); app.wikis .read(repo, move |r, _| { let heads = heads(r)?; if heads.is_empty() { return Ok(None); } Ok(r.file_at(&path, Some(&heads)).ok().map(|b| String::from_utf8_lossy(&b).into_owned())) }) .await } /// Every page, by slug, sorted; the sidebar and other `_` pages left out. pub async fn pages(app: &Shared, repo: &RepoName) -> Result, RepoError> { if !exists(app, repo) { return Ok(vec![]); } app.wikis .read(repo, |r, cache| { let heads = heads(r)?; if heads.is_empty() { return Ok(vec![]); } let loaded = cache.load(r, &heads)?; let mut out: Vec = loaded.snapshot.files.keys().filter_map(|p| slug_from_path(p)).filter(|s| !s.starts_with('_')).map(String::from).collect(); out.sort_by_key(|s| s.to_lowercase()); Ok(out) }) .await } /// Pages whose text holds `q`, with a line that does: (slug, snippet). pub async fn search(app: &Shared, repo: &RepoName, q: &str) -> Result, RepoError> { let q = q.trim().to_lowercase(); if q.is_empty() || !exists(app, repo) { return Ok(vec![]); } app.wikis .read(repo, move |r, cache| { let heads = heads(r)?; if heads.is_empty() { return Ok(vec![]); } let loaded = cache.load(r, &heads)?; let mut out = vec![]; for path in loaded.snapshot.files.keys().take(MAX_SEARCHED) { let Some(slug) = slug_from_path(path) else { continue }; let Ok(bytes) = r.file_at(path, Some(&heads)) else { continue }; let text = String::from_utf8_lossy(&bytes); let hit = slug.to_lowercase().contains(&q) || text.to_lowercase().contains(&q); if hit { let line = text.lines().find(|l| l.to_lowercase().contains(&q)).unwrap_or("").trim(); out.push((slug.to_string(), line.chars().take(160).collect())); } } Ok(out) }) .await } /// The changes that touched a page, newest first. pub async fn history(app: &Shared, repo: &RepoName, slug: &str, limit: usize) -> Result, RepoError> { if !exists(app, repo) { return Ok(vec![]); } let path = path_of(slug); app.wikis .read(repo, move |r, cache| { if heads(r)?.is_empty() { return Ok(vec![]); } let target = view::resolve(r, cache, BOOKMARK)?; Ok(view::history(r, cache, target, Some(&path), 0, limit)?.entries) }) .await } /// Record `edits` as a change on the wiki's `main`, authored by the user /// and signed by the hub as their delegate; the wiki is made on the first. pub async fn commit(app: &Shared, repo: &RepoName, edits: Vec, message: String, signer: Signer) -> Result { if !exists(app, repo) { // Bound to an id from the start, so it clones before any push. app.wikis.create(repo, Some(rand::random::<[u8; 16]>()))?; } app.wikis .write(repo, move |r, _| { let base_heads = heads(r)?; let author = signer.author.hash(); let change = r.build_change(&base_heads, &edits, &message, author)?; let hash = change.hash(); let sig = signer.hub_secret.sign(Domain::Change, &signer.hub_key, &hash); let mut state = r.load_state(&base_heads)?; state.add(change.clone()).map_err(|e| RepoError::Oxy(oxy_core::repo::RepoError::Msg(e.to_string())))?; let mut candidates = base_heads.clone(); candidates.push(hash); let heads = state.maximal(&candidates); let update = r.host_ref_update(BOOKMARK, &heads, Some((&signer.hub_key, &signer.hub_secret)))?; let (old_state, new_state) = (update.old_state.to_hex(), update.new_state.to_hex()); let report = r.apply_hosted(Some((change, vec![sig])), &[signer.hub_identity.clone(), signer.author.clone()], update)?; if let Some(refused) = report.results.iter().find(|x| !x.ok) { return Err(RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!("the wiki refused the change: {}", refused.reason)))); } Ok(Committed { change: hash, bookmark: BOOKMARK.into(), old_state, new_state, heads, report }) }) .await } /// Remove the wiki (to the trash), when the repository goes or an admin /// asks under Settings › Advanced. pub fn trash(app: &Shared, repo: &RepoName) -> Result<(), RepoError> { if exists(app, repo) { app.wikis.trash(repo)?; } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn slugs_titles_and_paths() { assert_eq!(slug_of(" Getting Started "), "Getting-Started"); assert_eq!(title_of("Getting-Started"), "Getting Started"); assert_eq!(path_of("Home"), "Home.md"); assert_eq!(slug_from_path("Home.md"), Some("Home")); assert_eq!(slug_from_path("docs/Home.md"), None); assert_eq!(slug_from_path("notes.txt"), None); assert!(slug_ok("_Sidebar") && slug_ok("v1.2-notes") && !slug_ok("") && !slug_ok("a/b") && !slug_ok("..") && !slug_ok("a b")); } }