| 66fd9d6b Andy McCall ↶ |
1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 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 |
|
| 21 |
pub const SUFFIX: &str = ".wiki"; |
| 22 |
|
| 23 |
const MAX_SEARCHED: usize = 500; |
| 24 |
|
| 25 |
|
| 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 |
|
| 36 |
pub fn slug_of(title: &str) -> String { |
| 37 |
title.split_whitespace().collect::<Vec<_>>().join("-") |
| 38 |
} |
| 39 |
|
| 40 |
|
| 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 |
|
| 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 |
|
| 55 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 156 |
|
| 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 |
|
| 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 |
|
| 186 |
|
| 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 |
} |