| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | | use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell}; |
| 5 | | use crate::auth::{Access, Viewer}; |
| 6 | | use crate::db::accounts; |
| 7 | | use crate::repos::view::ChangeView; |
| 8 | | use crate::wiki; |
| 9 | | use crate::Shared; |
| 10 | | use askama::Template; |
| 11 | | use axum::extract::{Path, Query, State}; |
| 12 | | use axum::response::{IntoResponse, Redirect, Response}; |
| 13 | | use axum::Form; |
| 14 | | use serde::Deserialize; |
| 15 | |
|
| 16 | | #[derive(Template)] |
| 17 | | #[template(path = "wiki.html")] |
| 18 | | pub struct WikiPage { |
| 19 | | shell: Shell, |
| 20 | | repo: RepoShell, |
| 21 | | slug: String, |
| 22 | | title: String, |
| 23 | | |
| 24 | | html: Option<String>, |
| 25 | | sidebar: Option<String>, |
| 26 | | |
| 27 | | any: bool, |
| 28 | | can_edit: bool, |
| 29 | | last: Option<ChangeView>, |
| 30 | | notice: Option<String>, |
| 31 | | |
| 32 | | pages: Vec<String>, |
| 33 | | |
| 34 | | revisions: usize, |
| 35 | | |
| 36 | | toc: Vec<(u8, String, String)>, |
| 37 | | } |
| 38 | |
|
| 39 | | #[derive(Template)] |
| 40 | | #[template(path = "wiki_edit.html")] |
| 41 | | pub struct WikiEditPage { |
| 42 | | shell: Shell, |
| 43 | | repo: RepoShell, |
| 44 | | slug: String, |
| 45 | | title: String, |
| 46 | | content: String, |
| 47 | | |
| 48 | | exists: bool, |
| 49 | | error: Option<String>, |
| 50 | | } |
| 51 | |
|
| 52 | | #[derive(Template)] |
| 53 | | #[template(path = "wiki_pages.html")] |
| 54 | | pub struct WikiPagesPage { |
| 55 | | shell: Shell, |
| 56 | | repo: RepoShell, |
| 57 | | pages: Vec<String>, |
| 58 | | q: String, |
| 59 | | hits: Vec<(String, String)>, |
| 60 | | can_edit: bool, |
| 61 | | } |
| 62 | |
|
| 63 | | #[derive(Template)] |
| 64 | | #[template(path = "wiki_history.html")] |
| 65 | | pub struct WikiHistoryPage { |
| 66 | | shell: Shell, |
| 67 | | repo: RepoShell, |
| 68 | | slug: String, |
| 69 | | title: String, |
| 70 | | entries: Vec<ChangeView>, |
| 71 | | } |
| 72 | |
|
| 73 | 5× | fn base(owner: &str, name: &str) -> String { |
| 74 | 5× | format!("/{owner}/{name}/wiki/") |
| 75 | 5× | } |
| 76 | |
|
| 77 | 5× | fn render(owner: &str, name: &str, text: &str) -> String { |
| 78 | 5× | crate::render::markdown::render(text, &base(owner, name), &format!("/{owner}/{name}/wiki/_raw/")) |
| 79 | 5× | } |
| 80 | |
|
| 81 | |
|
| 82 | 11× | fn editable(row: &crate::db::repos::RepoRow, rs: &RepoShell) -> bool { |
| 83 | 11× | rs.can(Access::Write) && !row.archived |
| 84 | 11× | } |
| 85 | |
|
| 86 | | #[derive(Deserialize)] |
| 87 | | pub struct ShowQuery { |
| 88 | | notice: Option<String>, |
| 89 | | } |
| 90 | |
|
| 91 | |
|
| 92 | 5× | pub async fn home( |
| 93 | 5× | State(app): State<Shared>, |
| 94 | 5× | viewer: Viewer, |
| 95 | 5× | Path((owner, name)): Path<(String, String)>, |
| 96 | 5× | Query(q): Query<ShowQuery>, |
| 97 | 5× | ) -> Result<Page<WikiPage>, Error> { |
| 98 | 5× | show_page(app, viewer, owner, name, wiki::HOME.into(), q.notice).await |
| 99 | 5× | } |
| 100 | |
|
| 101 | |
|
| 102 | 5× | pub async fn show( |
| 103 | 5× | State(app): State<Shared>, |
| 104 | 5× | viewer: Viewer, |
| 105 | 5× | Path((owner, name, slug)): Path<(String, String, String)>, |
| 106 | 5× | Query(q): Query<ShowQuery>, |
| 107 | 5× | ) -> Result<Page<WikiPage>, Error> { |
| 108 | 5× | show_page(app, viewer, owner, name, slug, q.notice).await |
| 109 | 5× | } |
| 110 | |
|
| 111 | 10× | async fn show_page( |
| 112 | 10× | app: Shared, |
| 113 | 10× | viewer: Viewer, |
| 114 | 10× | owner: String, |
| 115 | 10× | name: String, |
| 116 | 10× | slug: String, |
| 117 | 10× | notice: Option<String>, |
| 118 | 10× | ) -> Result<Page<WikiPage>, Error> { |
| 119 | 10× | let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}"), &viewer); |
| 120 | 10× | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
| 121 | 10× | if !wiki::slug_ok(&slug) { |
| 122 | 0× | return Err(Error::not_found(shell)); |
| 123 | 10× | } |
| 124 | 10× | let e = |e| Error::from_repo(shell.clone(), e); |
| 125 | 10× | let text = wiki::page(&app, &repo, &slug).await.map_err(e)?; |
| 126 | 10× | let sidebar = wiki::page(&app, &repo, wiki::SIDEBAR).await.map_err(e)?; |
| 127 | 11× | let pages: Vec<String> = wiki::pages(&app, &repo).await.map_err(e)?.into_iter().filter(|p| p != wiki::SIDEBAR).collect(); |
| 128 | 10× | let any = !pages.is_empty(); |
| 129 | 10× | let history = if text.is_some() { wiki::history(&app, &repo, &slug, 1000).await.map_err(e)? } else { vec![] }; |
| 130 | 10× | let revisions = history.len(); |
| 131 | 10× | let last = history.into_iter().next(); |
| 132 | 10× | let rs = RepoShell::new(&app, &row, "wiki").with_role(role).with_sky(&app, &row).await; |
| 133 | 10× | let can_edit = editable(&row, &rs); |
| 134 | 10× | let (html, toc) = match text.as_deref().map(|t| contents(&render(&owner, &name, t))) { |
| 135 | 5× | Some((h, t)) => (Some(h), t), |
| 136 | 5× | None => (None, vec![]), |
| 137 | | }; |
| 138 | | Ok(Page(WikiPage { |
| 139 | 10× | html, |
| 140 | 10× | pages, |
| 141 | 10× | revisions, |
| 142 | 10× | toc, |
| 143 | 10× | sidebar: sidebar.as_deref().map(|t| render(&owner, &name, t)), |
| 144 | 10× | title: wiki::title_of(&slug), |
| 145 | 10× | shell, |
| 146 | 10× | repo: rs, |
| 147 | 10× | slug, |
| 148 | 10× | any, |
| 149 | 10× | can_edit, |
| 150 | 10× | last, |
| 151 | 10× | notice, |
| 152 | | })) |
| 153 | 10× | } |
| 154 | |
|
| 155 | |
|
| 156 | |
|
| 157 | |
|
| 158 | |
|
| 159 | 6× | fn contents(html: &str) -> (String, Vec<(u8, String, String)>) { |
| 160 | 6× | let mut out = String::with_capacity(html.len() + 64); |
| 161 | 6× | let mut toc = vec![]; |
| 162 | 6× | let mut used = std::collections::HashSet::new(); |
| 163 | 6× | let mut rest = html; |
| 164 | 16× | while let Some(at) = rest.find("<h") { |
| 165 | 10× | let level = rest.as_bytes().get(at + 2).copied().unwrap_or(0); |
| 166 | 10× | let tagged = matches!(level, b'1'..=b'3') && rest[at + 3..].starts_with('>'); |
| 167 | 10× | let close = format!("</h{}>", level as char); |
| 168 | 10× | let end = if tagged { rest[at..].find(&close).map(|e| at + e) } else { None }; |
| 169 | 10× | let Some(end) = end else { |
| 170 | 2× | out.push_str(&rest[..at + 2]); |
| 171 | 2× | rest = &rest[at + 2..]; |
| 172 | 2× | continue; |
| 173 | | }; |
| 174 | 8× | let inner = &rest[at + 4..end]; |
| 175 | 8× | let mut text = String::new(); |
| 176 | 8× | let mut in_tag = false; |
| 177 | 65× | for c in inner.chars() { |
| 178 | 65× | match c { |
| 179 | 0× | '<' => in_tag = true, |
| 180 | 0× | '>' => in_tag = false, |
| 181 | 65× | c if !in_tag => text.push(c), |
| 182 | 0× | _ => {} |
| 183 | | } |
| 184 | | } |
| 185 | 8× | let mut id: String = text |
| 186 | 8× | .replace("&", "and") |
| 187 | 8× | .chars() |
| 188 | 61× | .map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '-' }) |
| 189 | 8× | .collect::<String>() |
| 190 | 8× | .split('-') |
| 191 | 14× | .filter(|p| !p.is_empty()) |
| 192 | 8× | .collect::<Vec<_>>() |
| 193 | 8× | .join("-"); |
| 194 | 8× | if id.is_empty() { |
| 195 | 0× | id = "section".into(); |
| 196 | 8× | } |
| 197 | 8× | let mut unique = id.clone(); |
| 198 | 8× | let mut n = 1; |
| 199 | 9× | while !used.insert(unique.clone()) { |
| 200 | 1× | n += 1; |
| 201 | 1× | unique = format!("{id}-{n}"); |
| 202 | 1× | } |
| 203 | 8× | out.push_str(&rest[..at]); |
| 204 | 8× | out.push_str(&format!("<h{l} id=\"{unique}\">{inner}{close}", l = level as char)); |
| 205 | 8× | toc.push((level - b'0', text.trim().to_string(), unique)); |
| 206 | 8× | rest = &rest[end + close.len()..]; |
| 207 | | } |
| 208 | 6× | out.push_str(rest); |
| 209 | 6× | (out, toc) |
| 210 | 6× | } |
| 211 | |
|
| 212 | | #[derive(Deserialize)] |
| 213 | | pub struct PagesQuery { |
| 214 | | #[serde(default)] |
| 215 | | q: String, |
| 216 | | } |
| 217 | |
|
| 218 | |
|
| 219 | 1× | pub async fn pages( |
| 220 | 1× | State(app): State<Shared>, |
| 221 | 1× | viewer: Viewer, |
| 222 | 1× | Path((owner, name)): Path<(String, String)>, |
| 223 | 1× | Query(q): Query<PagesQuery>, |
| 224 | 1× | ) -> Result<Page<WikiPagesPage>, Error> { |
| 225 | 1× | let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/_pages"), &viewer); |
| 226 | 1× | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
| 227 | 1× | let e = |e| Error::from_repo(shell.clone(), e); |
| 228 | 1× | let pages = wiki::pages(&app, &repo).await.map_err(e)?; |
| 229 | 1× | let q: String = q.q.trim().chars().take(100).collect(); |
| 230 | 1× | let hits = if q.is_empty() { vec![] } else { wiki::search(&app, &repo, &q).await.map_err(e)? }; |
| 231 | 1× | let rs = RepoShell::new(&app, &row, "wiki").with_role(role); |
| 232 | 1× | let can_edit = editable(&row, &rs); |
| 233 | 1× | Ok(Page(WikiPagesPage { shell, repo: rs, pages, q, hits, can_edit })) |
| 234 | 1× | } |
| 235 | |
|
| 236 | |
|
| 237 | 1× | pub async fn history( |
| 238 | 1× | State(app): State<Shared>, |
| 239 | 1× | viewer: Viewer, |
| 240 | 1× | Path((owner, name, slug)): Path<(String, String, String)>, |
| 241 | 1× | ) -> Result<Page<WikiHistoryPage>, Error> { |
| 242 | 1× | let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}/history"), &viewer); |
| 243 | 1× | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
| 244 | 1× | if !wiki::slug_ok(&slug) { |
| 245 | 0× | return Err(Error::not_found(shell)); |
| 246 | 1× | } |
| 247 | 1× | let mut entries = wiki::history(&app, &repo, &slug, 100).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
| 248 | 1× | super::attribute(&app, &mut entries.iter_mut().collect::<Vec<_>>()).await; |
| 249 | 1× | Ok(Page(WikiHistoryPage { |
| 250 | 1× | title: wiki::title_of(&slug), |
| 251 | 1× | shell, |
| 252 | 1× | repo: RepoShell::new(&app, &row, "wiki").with_role(role), |
| 253 | 1× | slug, |
| 254 | 1× | entries, |
| 255 | 1× | })) |
| 256 | 1× | } |
| 257 | |
|
| 258 | | #[derive(Deserialize)] |
| 259 | | pub struct NewQuery { |
| 260 | | #[serde(default)] |
| 261 | | title: String, |
| 262 | | } |
| 263 | |
|
| 264 | |
|
| 265 | 0× | pub async fn new_form( |
| 266 | 0× | State(app): State<Shared>, |
| 267 | 0× | viewer: Viewer, |
| 268 | 0× | Path((owner, name)): Path<(String, String)>, |
| 269 | 0× | Query(q): Query<NewQuery>, |
| 270 | 0× | ) -> Result<Page<WikiEditPage>, Error> { |
| 271 | 0× | let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/_new"), &viewer); |
| 272 | 0× | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; |
| 273 | 0× | let slug = wiki::slug_of(&q.title); |
| 274 | | Ok(Page(WikiEditPage { |
| 275 | 0× | title: if slug.is_empty() { String::new() } else { wiki::title_of(&slug) }, |
| 276 | 0× | shell, |
| 277 | 0× | repo: RepoShell::new(&app, &row, "wiki").with_role(role), |
| 278 | 0× | slug, |
| 279 | 0× | content: String::new(), |
| 280 | | exists: false, |
| 281 | 0× | error: None, |
| 282 | | })) |
| 283 | 0× | } |
| 284 | |
|
| 285 | |
|
| 286 | 0× | pub async fn edit_form( |
| 287 | 0× | State(app): State<Shared>, |
| 288 | 0× | viewer: Viewer, |
| 289 | 0× | Path((owner, name, slug)): Path<(String, String, String)>, |
| 290 | 0× | ) -> Result<Page<WikiEditPage>, Error> { |
| 291 | 0× | let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}/edit"), &viewer); |
| 292 | 0× | let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; |
| 293 | 0× | if !wiki::slug_ok(&slug) { |
| 294 | 0× | return Err(Error::not_found(shell)); |
| 295 | 0× | } |
| 296 | 0× | let text = wiki::page(&app, &repo, &slug).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
| 297 | 0× | Ok(Page(WikiEditPage { |
| 298 | 0× | title: wiki::title_of(&slug), |
| 299 | 0× | shell, |
| 300 | 0× | repo: RepoShell::new(&app, &row, "wiki").with_role(role), |
| 301 | 0× | slug, |
| 302 | 0× | exists: text.is_some(), |
| 303 | 0× | content: text.unwrap_or_default(), |
| 304 | 0× | error: None, |
| 305 | 0× | })) |
| 306 | 0× | } |
| 307 | |
|
| 308 | | #[derive(Deserialize)] |
| 309 | | pub struct SaveForm { |
| 310 | | #[serde(rename = "_csrf")] |
| 311 | | csrf_token: String, |
| 312 | | |
| 313 | | |
| 314 | | #[serde(default)] |
| 315 | | title: String, |
| 316 | | #[serde(default)] |
| 317 | | content: String, |
| 318 | | #[serde(default)] |
| 319 | | message: String, |
| 320 | | } |
| 321 | |
|
| 322 | |
|
| 323 | |
|
| 324 | 3× | pub async fn save( |
| 325 | 3× | State(app): State<Shared>, |
| 326 | 3× | viewer: Viewer, |
| 327 | 3× | Path((owner, name, slug)): Path<(String, String, String)>, |
| 328 | 3× | Form(f): Form<SaveForm>, |
| 329 | 3× | ) -> Result<Response, Error> { |
| 330 | 3× | let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}/edit"), &viewer); |
| 331 | 3× | let me = super::settings::require(&viewer, &shell)?; |
| 332 | 3× | super::settings::csrf(&viewer, &f.csrf_token, &shell)?; |
| 333 | 3× | let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; |
| 334 | 3× | let is_new = slug == "_new"; |
| 335 | 3× | let old_slug = if is_new { String::new() } else { slug.clone() }; |
| 336 | 3× | let new_slug = wiki::slug_of(&f.title); |
| 337 | 3× | let content = f.content.replace("\r\n", "\n"); |
| 338 | 3× | let again = |msg: String| { |
| 339 | | Page(WikiEditPage { |
| 340 | 0× | title: f.title.clone(), |
| 341 | 0× | shell: shell.clone(), |
| 342 | 0× | repo: RepoShell::new(&app, &row, "wiki").with_role(role), |
| 343 | 0× | slug: if is_new { new_slug.clone() } else { old_slug.clone() }, |
| 344 | 0× | content: content.clone(), |
| 345 | 0× | exists: !is_new, |
| 346 | 0× | error: Some(msg), |
| 347 | | }) |
| 348 | 0× | .into_response() |
| 349 | 0× | }; |
| 350 | 3× | if !is_new && !wiki::slug_ok(&old_slug) { |
| 351 | 0× | return Err(Error::not_found(shell)); |
| 352 | 3× | } |
| 353 | 3× | if !wiki::slug_ok(&new_slug) { |
| 354 | 0× | return Ok(again("A page title is letters, digits, spaces, dots, dashes and underscores, up to 100 characters.".into())); |
| 355 | 3× | } |
| 356 | 3× | if content.trim().is_empty() { |
| 357 | 0× | return Ok(again("Write something on the page.".into())); |
| 358 | 3× | } |
| 359 | 3× | let signer = match super::edit::signer_for(&app, me.account.id).await { |
| 360 | 3× | Ok(s) => s, |
| 361 | 0× | Err(msg) => return Ok(again(msg)), |
| 362 | | }; |
| 363 | 3× | let renamed = !is_new && new_slug != old_slug; |
| 364 | 3× | let taken = wiki::page(&app, &repo, &new_slug).await.map_err(|e| Error::from_repo(shell.clone(), e))?.is_some(); |
| 365 | 3× | if (is_new || renamed) && taken { |
| 366 | 0× | return Ok(again(format!("There is already a page called {}.", wiki::title_of(&new_slug)))); |
| 367 | 3× | } |
| 368 | 3× | let message = if f.message.trim().is_empty() { |
| 369 | 2× | format!( |
| 370 | | "{} {}", |
| 371 | 2× | if is_new { |
| 372 | 1× | "Create" |
| 373 | 1× | } else if renamed { |
| 374 | 1× | "Rename and update" |
| 375 | | } else { |
| 376 | 0× | "Update" |
| 377 | | }, |
| 378 | 2× | wiki::title_of(&new_slug) |
| 379 | | ) |
| 380 | | } else { |
| 381 | 1× | f.message.trim().chars().take(200).collect() |
| 382 | | }; |
| 383 | 3× | let mut edits = vec![]; |
| 384 | 3× | if renamed { |
| 385 | 1× | edits.push(oxy_core::repo::FileEdit { path: wiki::path_of(&old_slug), content: None }); |
| 386 | 2× | } |
| 387 | 3× | edits.push(oxy_core::repo::FileEdit { path: wiki::path_of(&new_slug), content: Some(content.clone().into_bytes()) }); |
| 388 | 3× | if let Err(e) = wiki::commit(&app, &repo, edits, message.clone(), signer).await { |
| 389 | 0× | return Ok(again(e.to_string())); |
| 390 | 3× | } |
| 391 | 3× | accounts::audit(&app.db, Some(&me.account), "wiki.edited", &repo.to_string(), &new_slug).await.ok(); |
| 392 | 3× | crate::hooks::emit(&app, &row, "wiki", &me.account.username, serde_json::json!({ "page": new_slug, "message": message })).await; |
| 393 | 3× | Ok(Redirect::to(&format!("/{owner}/{name}/wiki/{new_slug}")).into_response()) |
| 394 | 3× | } |
| 395 | |
|
| 396 | | #[derive(Deserialize)] |
| 397 | | pub struct DeleteForm { |
| 398 | | #[serde(rename = "_csrf")] |
| 399 | | csrf_token: String, |
| 400 | | } |
| 401 | |
|
| 402 | |
|
| 403 | 1× | pub async fn delete( |
| 404 | 1× | State(app): State<Shared>, |
| 405 | 1× | viewer: Viewer, |
| 406 | 1× | Path((owner, name, slug)): Path<(String, String, String)>, |
| 407 | 1× | Form(f): Form<DeleteForm>, |
| 408 | 1× | ) -> Result<Response, Error> { |
| 409 | 1× | let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}"), &viewer); |
| 410 | 1× | let me = super::settings::require(&viewer, &shell)?; |
| 411 | 1× | super::settings::csrf(&viewer, &f.csrf_token, &shell)?; |
| 412 | 1× | let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; |
| 413 | 1× | if !wiki::slug_ok(&slug) || wiki::page(&app, &repo, &slug).await.map_err(|e| Error::from_repo(shell.clone(), e))?.is_none() { |
| 414 | 0× | return Err(Error::not_found(shell)); |
| 415 | 1× | } |
| 416 | 1× | let signer = super::edit::signer_for(&app, me.account.id).await.map_err(|m| Error::bad(shell.clone(), m))?; |
| 417 | 1× | let message = format!("Delete {}", wiki::title_of(&slug)); |
| 418 | 1× | wiki::commit(&app, &repo, vec![oxy_core::repo::FileEdit { path: wiki::path_of(&slug), content: None }], message.clone(), signer) |
| 419 | 1× | .await |
| 420 | 1× | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
| 421 | 1× | accounts::audit(&app.db, Some(&me.account), "wiki.deleted", &repo.to_string(), &slug).await.ok(); |
| 422 | 1× | crate::hooks::emit(&app, &row, "wiki", &me.account.username, serde_json::json!({ "page": slug, "message": message, "deleted": true })) |
| 423 | 1× | .await; |
| 424 | 1× | Ok(Redirect::to(&format!("/{owner}/{name}/wiki?notice={}", super::settings::urlencode(&format!("{} deleted.", wiki::title_of(&slug))))) |
| 425 | 1× | .into_response()) |
| 426 | 1× | } |
| 427 | |
|
| 428 | |
|
| 429 | |
|
| 430 | 0× | pub async fn raw( |
| 431 | 0× | State(app): State<Shared>, |
| 432 | 0× | viewer: Viewer, |
| 433 | 0× | Path((owner, name, path)): Path<(String, String, String)>, |
| 434 | 0× | ) -> Result<Response, Error> { |
| 435 | 0× | let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/_raw/{path}"), &viewer); |
| 436 | 0× | let (repo, _, _) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
| 437 | 0× | if !wiki::exists(&app, &repo) || path.contains("..") { |
| 438 | 0× | return Err(Error::not_found(shell)); |
| 439 | 0× | } |
| 440 | 0× | let p = path.trim_matches('/').to_string(); |
| 441 | 0× | let file_name = p.rsplit('/').next().unwrap_or("").to_string(); |
| 442 | 0× | let bytes = app |
| 443 | 0× | .wikis |
| 444 | 0× | .read(&repo, move |r, _| { |
| 445 | 0× | let heads = r.bookmarks()?.into_iter().find(|b| b.name == wiki::BOOKMARK).map(|b| b.heads).unwrap_or_default(); |
| 446 | 0× | Ok(r.file_at(&p, Some(&heads)).ok()) |
| 447 | 0× | }) |
| 448 | 0× | .await |
| 449 | 0× | .map_err(|e| Error::from_repo(shell.clone(), e))? |
| 450 | 0× | .ok_or_else(|| Error::not_found(shell.clone()))?; |
| 451 | 0× | Ok(super::raw(bytes, &file_name, true)) |
| 452 | 0× | } |
| 453 | |
|
| 454 | | #[cfg(test)] |
| 455 | | mod tests { |
| 456 | | #[test] |
| 457 | 1× | fn headings_get_ids_and_a_contents_list() { |
| 458 | 1× | let (html, toc) = super::contents("<h1>R* API</h1><p>x</p><h2>Build & run</h2><h2>Build & run</h2><h4>deep</h4><hr>"); |
| 459 | 1× | assert_eq!( |
| 460 | | html, |
| 461 | | "<h1 id=\"r-api\">R* API</h1><p>x</p><h2 id=\"build-and-run\">Build & run</h2><h2 id=\"build-and-run-2\">Build & run</h2><h4>deep</h4><hr>" |
| 462 | | ); |
| 463 | 1× | assert_eq!( |
| 464 | 3× | toc.iter().map(|t| (t.0, t.2.as_str())).collect::<Vec<_>>(), |
| 465 | | [(1, "r-api"), (2, "build-and-run"), (2, "build-and-run-2")] |
| 466 | | ); |
| 467 | 1× | } |
| 468 | | } |