OXYGENHUB

Coverage / crates/hub/src/wiki.rs 94.6%

158 of 167 lines run, 42 of 71 functions · run #478 on main · run missed

1
//! Wikis (hub spec §6.11, oxygenhub#104): Markdown pages kept as an
2
//! Oxygen repository of their own beside the code, under
3
//! `<data>/wikis/<owner>/<name>`, read and written on the web and cloned
4
//! or pushed as `OWNER/NAME.wiki`. A page `Getting-Started` is the file
5
//! `Getting-Started.md` on bookmark `main`; `_Sidebar.md` is the sidebar.
6
//! Web edits are changes authored by the user and signed by the hub as
7
//! their delegate, exactly as code edits are (§6.9).
8
9
use crate::repos::edits::{Committed, Signer};
10
use crate::repos::view::{self, ChangeView};
11
use crate::repos::{RepoError, RepoName};
12
use crate::Shared;
13
use oxy_core::hash::ChangeHash;
14
use oxy_core::identity::Domain;
15
use oxy_core::repo::{FileEdit, Repo};
16
17
pub const BOOKMARK: &str = "main";
18
pub const HOME: &str = "Home";
19
pub const SIDEBAR: &str = "_Sidebar";
20
/// The suffix a wiki's clone address carries: `OWNER/NAME.wiki`.
21
pub const SUFFIX: &str = ".wiki";
22
/// Pages searched at most.
23
const MAX_SEARCHED: usize = 500;
24
25
/// A page's name in a URL and file name: `Getting-Started`.
2637×
pub fn slug_ok(slug: &str) -> bool {
2737×
    !slug.is_empty()
2836×
        && slug.len() <= 100
29213×
        && slug.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
3034×
        && !slug.contains("..")
3133×
        && !slug.starts_with('.')
3233×
        && !slug.ends_with('.')
3337×
}
34
35
/// `Getting Started` → `Getting-Started`.
364×
pub fn slug_of(title: &str) -> String {
374×
    title.split_whitespace().collect::<Vec<_>>().join("-")
384×
}
39
40
/// `Getting-Started` → `Getting Started`.
4129×
pub fn title_of(slug: &str) -> String {
4229×
    slug.replace('-', " ")
4329×
}
44
4531×
pub fn path_of(slug: &str) -> String {
4631×
    format!("{slug}.md")
4731×
}
48
49
/// The page a file at the root is, if it is one.
5016×
pub fn slug_from_path(path: &str) -> Option<&str> {
5116×
    path.strip_suffix(".md").filter(|s| !s.contains('/') && slug_ok(s))
5216×
}
53
54
/// `OWNER/NAME.wiki` → the repository it belongs to, and whether it was
55
/// a wiki address at all.
564538×
pub fn parent_of(name: &RepoName) -> (RepoName, bool) {
574538×
    match name.name.strip_suffix(SUFFIX).and_then(|n| RepoName::new(&name.owner, n)) {
5853×
        Some(parent) => (parent, true),
594485×
        None => (name.clone(), false),
60
    }
614538×
}
62
6349×
pub fn exists(app: &Shared, repo: &RepoName) -> bool {
6449×
    app.wikis.exists(repo)
6549×
}
66
67
/// `main`'s heads, empty before the first page.
6839×
fn heads(r: &Repo) -> Result<Vec<ChangeHash>, RepoError> {
6939×
    Ok(r.bookmarks()?.into_iter().find(|b| b.name == BOOKMARK).map(|b| b.heads).unwrap_or_default())
7039×
}
71
72
/// A page's Markdown, `None` when there is no such page.
7324×
pub async fn page(app: &Shared, repo: &RepoName, slug: &str) -> Result<Option<String>, RepoError> {
7424×
    if !exists(app, repo) {
755×
        return Ok(None);
7619×
    }
7719×
    let path = path_of(slug);
7819×
    app.wikis
7919×
        .read(repo, move |r, _| {
8019×
            let heads = heads(r)?;
8119×
            if heads.is_empty() {
820×
                return Ok(None);
8319×
            }
8419×
            Ok(r.file_at(&path, Some(&heads)).ok().map(|b| String::from_utf8_lossy(&b).into_owned()))
8519×
        })
8619×
        .await
8724×
}
88
89
/// Every page, by slug, sorted; the sidebar and other `_` pages left out.
9011×
pub async fn pages(app: &Shared, repo: &RepoName) -> Result<Vec<String>, RepoError> {
9111×
    if !exists(app, repo) {
922×
        return Ok(vec![]);
939×
    }
949×
    app.wikis
959×
        .read(repo, |r, cache| {
969×
            let heads = heads(r)?;
979×
            if heads.is_empty() {
980×
                return Ok(vec![]);
999×
            }
1009×
            let loaded = cache.load(r, &heads)?;
1019×
            let mut out: Vec<String> =
10212×
                loaded.snapshot.files.keys().filter_map(|p| slug_from_path(p)).filter(|s| !s.starts_with('_')).map(String::from).collect();
1039×
            out.sort_by_key(|s| s.to_lowercase());
1049×
            Ok(out)
1059×
        })
1069×
        .await
10711×
}
108
109
/// Pages whose text holds `q`, with a line that does: (slug, snippet).
1101×
pub async fn search(app: &Shared, repo: &RepoName, q: &str) -> Result<Vec<(String, String)>, RepoError> {
1111×
    let q = q.trim().to_lowercase();
1121×
    if q.is_empty() || !exists(app, repo) {
1130×
        return Ok(vec![]);
1141×
    }
1151×
    app.wikis
1161×
        .read(repo, move |r, cache| {
1171×
            let heads = heads(r)?;
1181×
            if heads.is_empty() {
1190×
                return Ok(vec![]);
1201×
            }
1211×
            let loaded = cache.load(r, &heads)?;
1221×
            let mut out = vec![];
1231×
            for path in loaded.snapshot.files.keys().take(MAX_SEARCHED) {
1241×
                let Some(slug) = slug_from_path(path) else { continue };
1251×
                let Ok(bytes) = r.file_at(path, Some(&heads)) else { continue };
1261×
                let text = String::from_utf8_lossy(&bytes);
1271×
                let hit = slug.to_lowercase().contains(&q) || text.to_lowercase().contains(&q);
1281×
                if hit {
1291×
                    let line = text.lines().find(|l| l.to_lowercase().contains(&q)).unwrap_or("").trim();
1301×
                    out.push((slug.to_string(), line.chars().take(160).collect()));
1310×
                }
132
            }
1331×
            Ok(out)
1341×
        })
1351×
        .await
1361×
}
137
138
/// The changes that touched a page, newest first.
1396×
pub async fn history(app: &Shared, repo: &RepoName, slug: &str, limit: usize) -> Result<Vec<ChangeView>, RepoError> {
1406×
    if !exists(app, repo) {
1410×
        return Ok(vec![]);
1426×
    }
1436×
    let path = path_of(slug);
1446×
    app.wikis
1456×
        .read(repo, move |r, cache| {
1466×
            if heads(r)?.is_empty() {
1470×
                return Ok(vec![]);
1486×
            }
1496×
            let target = view::resolve(r, cache, BOOKMARK)?;
1506×
            Ok(view::history(r, cache, target, Some(&path), 0, limit)?.entries)
1516×
        })
1526×
        .await
1536×
}
154
155
/// Record `edits` as a change on the wiki's `main`, authored by the user
156
/// and signed by the hub as their delegate; the wiki is made on the first.
1574×
pub async fn commit(app: &Shared, repo: &RepoName, edits: Vec<FileEdit>, message: String, signer: Signer) -> Result<Committed, RepoError> {
1584×
    if !exists(app, repo) {
159
        // Bound to an id from the start, so it clones before any push.
1601×
        app.wikis.create(repo, Some(rand::random::<[u8; 16]>()))?;
1613×
    }
1624×
    app.wikis
1634×
        .write(repo, move |r, _| {
1644×
            let base_heads = heads(r)?;
1654×
            let author = signer.author.hash();
1664×
            let change = r.build_change(&base_heads, &edits, &message, author)?;
1674×
            let hash = change.hash();
1684×
            let sig = signer.hub_secret.sign(Domain::Change, &signer.hub_key, &hash);
1694×
            let mut state = r.load_state(&base_heads)?;
1704×
            state.add(change.clone()).map_err(|e| RepoError::Oxy(oxy_core::repo::RepoError::Msg(e.to_string())))?;
1714×
            let mut candidates = base_heads.clone();
1724×
            candidates.push(hash);
1734×
            let heads = state.maximal(&candidates);
1744×
            let update = r.host_ref_update(BOOKMARK, &heads, Some((&signer.hub_key, &signer.hub_secret)))?;
1754×
            let (old_state, new_state) = (update.old_state.to_hex(), update.new_state.to_hex());
1764×
            let report = r.apply_hosted(Some((change, vec![sig])), &[signer.hub_identity.clone(), signer.author.clone()], update)?;
1774×
            if let Some(refused) = report.results.iter().find(|x| !x.ok) {
1780×
                return Err(RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!("the wiki refused the change: {}", refused.reason))));
1794×
            }
1804×
            Ok(Committed { change: hash, bookmark: BOOKMARK.into(), old_state, new_state, heads, report })
1814×
        })
1824×
        .await
1834×
}
184
185
/// Remove the wiki (to the trash), when the repository goes or an admin
186
/// asks under Settings › Advanced.
1872×
pub fn trash(app: &Shared, repo: &RepoName) -> Result<(), RepoError> {
1882×
    if exists(app, repo) {
1891×
        app.wikis.trash(repo)?;
1901×
    }
1912×
    Ok(())
1922×
}
193
194
#[cfg(test)]
195
mod tests {
196
    use super::*;
197
198
    #[test]
1991×
    fn slugs_titles_and_paths() {
2001×
        assert_eq!(slug_of("  Getting   Started "), "Getting-Started");
2011×
        assert_eq!(title_of("Getting-Started"), "Getting Started");
2021×
        assert_eq!(path_of("Home"), "Home.md");
2031×
        assert_eq!(slug_from_path("Home.md"), Some("Home"));
2041×
        assert_eq!(slug_from_path("docs/Home.md"), None);
2051×
        assert_eq!(slug_from_path("notes.txt"), None);
2061×
        assert!(slug_ok("_Sidebar") && slug_ok("v1.2-notes") && !slug_ok("") && !slug_ok("a/b") && !slug_ok("..") && !slug_ok("a b"));
2071×
    }
208
}