OXYGENHUB
# 3dd6582de060 oxygenhub / crates / hub / src / wiki.rs
8.0 KiB · Rust · last changed Wikis: Markdown pages as an Oxygen repository of their own 1 month ago source blame history raw
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`.
26
pub fn slug_ok(slug: &str) -> bool {
27
    !slug.is_empty()
28
        && slug.len() <= 100
29
        && slug.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
30
        && !slug.contains("..")
31
        && !slug.starts_with('.')
32
        && !slug.ends_with('.')
33
}
34
35
/// `Getting Started` → `Getting-Started`.
36
pub fn slug_of(title: &str) -> String {
37
    title.split_whitespace().collect::<Vec<_>>().join("-")
38
}
39
40
/// `Getting-Started` → `Getting Started`.
41
pub fn title_of(slug: &str) -> String {
42
    slug.replace('-', " ")
43
}
44
45
pub fn path_of(slug: &str) -> String {
46
    format!("{slug}.md")
47
}
48
49
/// The page a file at the root is, if it is one.
50
pub fn slug_from_path(path: &str) -> Option<&str> {
51
    path.strip_suffix(".md").filter(|s| !s.contains('/') && slug_ok(s))
52
}
53
54
/// `OWNER/NAME.wiki` → the repository it belongs to, and whether it was
55
/// a wiki address at all.
56
pub fn parent_of(name: &RepoName) -> (RepoName, bool) {
57
    match name.name.strip_suffix(SUFFIX).and_then(|n| RepoName::new(&name.owner, n)) {
58
        Some(parent) => (parent, true),
59
        None => (name.clone(), false),
60
    }
61
}
62
63
pub fn exists(app: &Shared, repo: &RepoName) -> bool {
64
    app.wikis.exists(repo)
65
}
66
67
/// `main`'s heads, empty before the first page.
68
fn heads(r: &Repo) -> Result<Vec<ChangeHash>, RepoError> {
69
    Ok(r.bookmarks()?.into_iter().find(|b| b.name == BOOKMARK).map(|b| b.heads).unwrap_or_default())
70
}
71
72
/// A page's Markdown, `None` when there is no such page.
73
pub async fn page(app: &Shared, repo: &RepoName, slug: &str) -> Result<Option<String>, RepoError> {
74
    if !exists(app, repo) {
75
        return Ok(None);
76
    }
77
    let path = path_of(slug);
78
    app.wikis
79
        .read(repo, move |r, _| {
80
            let heads = heads(r)?;
81
            if heads.is_empty() {
82
                return Ok(None);
83
            }
84
            Ok(r.file_at(&path, Some(&heads)).ok().map(|b| String::from_utf8_lossy(&b).into_owned()))
85
        })
86
        .await
87
}
88
89
/// Every page, by slug, sorted; the sidebar and other `_` pages left out.
90
pub async fn pages(app: &Shared, repo: &RepoName) -> Result<Vec<String>, RepoError> {
91
    if !exists(app, repo) {
92
        return Ok(vec![]);
93
    }
94
    app.wikis
95
        .read(repo, |r, cache| {
96
            let heads = heads(r)?;
97
            if heads.is_empty() {
98
                return Ok(vec![]);
99
            }
100
            let loaded = cache.load(r, &heads)?;
101
            let mut out: Vec<String> =
102
                loaded.snapshot.files.keys().filter_map(|p| slug_from_path(p)).filter(|s| !s.starts_with('_')).map(String::from).collect();
103
            out.sort_by_key(|s| s.to_lowercase());
104
            Ok(out)
105
        })
106
        .await
107
}
108
109
/// Pages whose text holds `q`, with a line that does: (slug, snippet).
110
pub async fn search(app: &Shared, repo: &RepoName, q: &str) -> Result<Vec<(String, String)>, RepoError> {
111
    let q = q.trim().to_lowercase();
112
    if q.is_empty() || !exists(app, repo) {
113
        return Ok(vec![]);
114
    }
115
    app.wikis
116
        .read(repo, move |r, cache| {
117
            let heads = heads(r)?;
118
            if heads.is_empty() {
119
                return Ok(vec![]);
120
            }
121
            let loaded = cache.load(r, &heads)?;
122
            let mut out = vec![];
123
            for path in loaded.snapshot.files.keys().take(MAX_SEARCHED) {
124
                let Some(slug) = slug_from_path(path) else { continue };
125
                let Ok(bytes) = r.file_at(path, Some(&heads)) else { continue };
126
                let text = String::from_utf8_lossy(&bytes);
127
                let hit = slug.to_lowercase().contains(&q) || text.to_lowercase().contains(&q);
128
                if hit {
129
                    let line = text.lines().find(|l| l.to_lowercase().contains(&q)).unwrap_or("").trim();
130
                    out.push((slug.to_string(), line.chars().take(160).collect()));
131
                }
132
            }
133
            Ok(out)
134
        })
135
        .await
136
}
137
138
/// The changes that touched a page, newest first.
139
pub async fn history(app: &Shared, repo: &RepoName, slug: &str, limit: usize) -> Result<Vec<ChangeView>, RepoError> {
140
    if !exists(app, repo) {
141
        return Ok(vec![]);
142
    }
143
    let path = path_of(slug);
144
    app.wikis
145
        .read(repo, move |r, cache| {
146
            if heads(r)?.is_empty() {
147
                return Ok(vec![]);
148
            }
149
            let target = view::resolve(r, cache, BOOKMARK)?;
150
            Ok(view::history(r, cache, target, Some(&path), 0, limit)?.entries)
151
        })
152
        .await
153
}
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.
157
pub async fn commit(app: &Shared, repo: &RepoName, edits: Vec<FileEdit>, message: String, signer: Signer) -> Result<Committed, RepoError> {
158
    if !exists(app, repo) {
159
        // Bound to an id from the start, so it clones before any push.
160
        app.wikis.create(repo, Some(rand::random::<[u8; 16]>()))?;
161
    }
162
    app.wikis
163
        .write(repo, move |r, _| {
164
            let base_heads = heads(r)?;
165
            let author = signer.author.hash();
166
            let change = r.build_change(&base_heads, &edits, &message, author)?;
167
            let hash = change.hash();
168
            let sig = signer.hub_secret.sign(Domain::Change, &signer.hub_key, &hash);
169
            let mut state = r.load_state(&base_heads)?;
170
            state.add(change.clone()).map_err(|e| RepoError::Oxy(oxy_core::repo::RepoError::Msg(e.to_string())))?;
171
            let mut candidates = base_heads.clone();
172
            candidates.push(hash);
173
            let heads = state.maximal(&candidates);
174
            let update = r.host_ref_update(BOOKMARK, &heads, Some((&signer.hub_key, &signer.hub_secret)))?;
175
            let (old_state, new_state) = (update.old_state.to_hex(), update.new_state.to_hex());
176
            let report = r.apply_hosted(Some((change, vec![sig])), &[signer.hub_identity.clone(), signer.author.clone()], update)?;
177
            if let Some(refused) = report.results.iter().find(|x| !x.ok) {
178
                return Err(RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!("the wiki refused the change: {}", refused.reason))));
179
            }
180
            Ok(Committed { change: hash, bookmark: BOOKMARK.into(), old_state, new_state, heads, report })
181
        })
182
        .await
183
}
184
185
/// Remove the wiki (to the trash), when the repository goes or an admin
186
/// asks under Settings › Advanced.
187
pub fn trash(app: &Shared, repo: &RepoName) -> Result<(), RepoError> {
188
    if exists(app, repo) {
189
        app.wikis.trash(repo)?;
190
    }
191
    Ok(())
192
}
193
194
#[cfg(test)]
195
mod tests {
196
    use super::*;
197
198
    #[test]
199
    fn slugs_titles_and_paths() {
200
        assert_eq!(slug_of("  Getting   Started "), "Getting-Started");
201
        assert_eq!(title_of("Getting-Started"), "Getting Started");
202
        assert_eq!(path_of("Home"), "Home.md");
203
        assert_eq!(slug_from_path("Home.md"), Some("Home"));
204
        assert_eq!(slug_from_path("docs/Home.md"), None);
205
        assert_eq!(slug_from_path("notes.txt"), None);
206
        assert!(slug_ok("_Sidebar") && slug_ok("v1.2-notes") && !slug_ok("") && !slug_ok("a/b") && !slug_ok("..") && !slug_ok("a b"));
207
    }
208
}