Skip to main content

ytcli/api/
wiki.rs

1//! Yandex Wiki: a second host reached with the same account and organisation.
2//!
3//! The Wiki takes the token and the organisation header Tracker does, so one
4//! client carries both and only the address differs
5//! (`docs/adr/0007-yandex-wiki.md`). Pages are addressed by slug — the path in
6//! their URL — because that is what anyone has to hand.
7
8use serde::{Deserialize, Serialize};
9
10use crate::api::Client;
11use crate::api::error::ApiError;
12
13/// Default Wiki API root. Overridable so tests can point at a `wiremock` server.
14pub const DEFAULT_WIKI_URL: &str = "https://api.wiki.yandex.net";
15
16/// One page, in our schema.
17///
18/// The date is lifted out of the Wiki's `attributes` block, where nobody reading
19/// the JSON would think to look for it.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct WikiPage {
22    pub id: i64,
23    pub slug: String,
24    pub title: String,
25    /// `wysiwyg` pages are Markdown, `page` ones the legacy wiki markup; `grid`
26    /// and `template` are the other two.
27    #[serde(default)]
28    pub page_type: Option<String>,
29    #[serde(default)]
30    pub modified_at: Option<String>,
31    #[serde(default)]
32    pub content: Option<String>,
33}
34
35/// A page named by a listing: the Wiki sends its id and slug, and no title.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct WikiPageRef {
38    pub id: i64,
39    pub slug: String,
40}
41
42/// One page of a Wiki listing.
43///
44/// The Wiki pages by cursor and never says how many there are, so the only
45/// honest tally is "this many, and there are more" (ADR 7). The cursor is kept
46/// in the JSON form too: without it a script cannot ask for the next page.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct CursorPage<T> {
49    pub results: Vec<T>,
50    #[serde(default)]
51    pub next_cursor: Option<String>,
52}
53
54/// The last page of hits the Wiki's search will serve.
55pub const LAST_SEARCH_PAGE: u32 = 500;
56
57/// One search hit: a page, or a file attached to one.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct WikiHit {
60    pub slug: String,
61    pub title: String,
62    /// `page` or `file`.
63    #[serde(rename = "type")]
64    pub kind: String,
65    #[serde(default)]
66    pub modified_at: Option<String>,
67    #[serde(default)]
68    pub url: Option<String>,
69    /// The Wiki's excerpt of the matching text — somebody else's words. The
70    /// Wiki calls it `content`; it is not the page's content, so our schema
71    /// does not either.
72    #[serde(default, rename(deserialize = "content"))]
73    pub snippet: Option<String>,
74}
75
76/// A page of search hits.
77///
78/// Search is the one Wiki listing that pages by number, and it gives no total
79/// either, so the next page number is all a caller can be told.
80#[derive(Debug, Clone, Serialize)]
81pub struct WikiHits {
82    pub results: Vec<WikiHit>,
83    pub next_page: Option<u32>,
84}
85
86#[derive(Deserialize)]
87struct SearchAnswer {
88    results: Vec<WikiHit>,
89    #[serde(default)]
90    next_cursor: Option<String>,
91}
92
93/// The page as the Wiki sends it.
94#[derive(Deserialize)]
95struct Answer {
96    id: i64,
97    slug: String,
98    title: String,
99    #[serde(default)]
100    page_type: Option<String>,
101    #[serde(default)]
102    content: Option<String>,
103    #[serde(default)]
104    attributes: Option<Attributes>,
105}
106
107#[derive(Deserialize)]
108struct Attributes {
109    #[serde(default)]
110    modified_at: Option<String>,
111}
112
113impl From<Answer> for WikiPage {
114    fn from(answer: Answer) -> Self {
115        Self {
116            id: answer.id,
117            slug: answer.slug,
118            title: answer.title,
119            page_type: answer.page_type,
120            modified_at: answer
121                .attributes
122                .and_then(|attributes| attributes.modified_at),
123            content: answer.content,
124        }
125    }
126}
127
128impl Client {
129    /// `GET /v1/pages?slug=…`, with the text and the dates.
130    pub async fn wiki_page(&self, slug: &str) -> Result<WikiPage, ApiError> {
131        let url = format!(
132            "{}/v1/pages?slug={}&fields=content,attributes",
133            self.wiki_url,
134            encode(slug)
135        );
136        let (value, _) = self
137            .send_url(
138                reqwest::Method::GET,
139                &url,
140                None,
141                &format!("wiki page `{slug}`"),
142            )
143            .await
144            .map_err(refused)?;
145        serde_json::from_value::<Answer>(value)
146            .map(WikiPage::from)
147            .map_err(ApiError::Decode)
148    }
149}
150
151impl Client {
152    /// `GET /v1/pages/descendants?slug=…` — every page under one, at any depth.
153    ///
154    /// A page of results can hold fewer than `page_size` even when more follow,
155    /// so only `next_cursor` says whether the listing is complete.
156    pub async fn wiki_descendants(
157        &self,
158        slug: &str,
159        cursor: Option<&str>,
160        page_size: u32,
161    ) -> Result<CursorPage<WikiPageRef>, ApiError> {
162        use std::fmt::Write as _;
163
164        let mut url = format!(
165            "{}/v1/pages/descendants?slug={}&page_size={page_size}",
166            self.wiki_url,
167            encode(slug)
168        );
169        if let Some(cursor) = cursor {
170            let _ = write!(url, "&cursor={}", encode(cursor));
171        }
172        let (value, _) = self
173            .send_url(
174                reqwest::Method::GET,
175                &url,
176                None,
177                &format!("wiki page `{slug}`"),
178            )
179            .await
180            .map_err(refused)?;
181        serde_json::from_value(value).map_err(ApiError::Decode)
182    }
183
184    /// `POST /v1/search` — a read, whatever the method says.
185    ///
186    /// `page` is the Wiki's numbered `cursor` (1..=500); the string cursors it
187    /// answers with only say whether another page exists.
188    pub async fn wiki_search(
189        &self,
190        query: &str,
191        kind: Option<&str>,
192        page: u32,
193        limit: u32,
194    ) -> Result<WikiHits, ApiError> {
195        let mut body = serde_json::json!({ "query": query, "cursor": page, "limit": limit });
196        if let Some(kind) = kind {
197            body["filters"] = serde_json::json!({ "type": kind });
198        }
199        let url = format!("{}/v1/search", self.wiki_url);
200        let (value, _) = self
201            .send_url(reqwest::Method::POST, &url, Some(&body), "wiki search")
202            .await
203            .map_err(refused)?;
204        let answer: SearchAnswer = serde_json::from_value(value).map_err(ApiError::Decode)?;
205
206        let more =
207            answer.next_cursor.is_some_and(|cursor| !cursor.is_empty()) && page < LAST_SEARCH_PAGE;
208        Ok(WikiHits {
209            results: answer.results,
210            next_page: more.then_some(page + 1),
211        })
212    }
213
214    /// Whether the Wiki accepts this token in this organisation.
215    ///
216    /// `GET /v1/users/me`: the smallest request the Wiki answers, and one that
217    /// needs both the token and the organisation header to be right. No page is
218    /// read, so the answer costs one request and exposes nothing.
219    pub async fn wiki_reachable(&self) -> Result<(), ApiError> {
220        let url = format!("{}/v1/users/me", self.wiki_url);
221        self.send_url(reqwest::Method::GET, &url, None, "the Wiki's current user")
222            .await
223            .map(|_| ())
224            .map_err(refused)
225    }
226
227    /// Ask the Wiki something directly, at a path under its host.
228    ///
229    /// The Wiki's counterpart of [`Client::probe_get`] and friends: compiled
230    /// only for the `live` feature, so the questions the live suite asks — does
231    /// a payload still have the shape the fixtures claim — cost the binary
232    /// nothing.
233    #[cfg(feature = "live")]
234    pub async fn probe_wiki(
235        &self,
236        method: reqwest::Method,
237        path: &str,
238        body: Option<&serde_json::Value>,
239    ) -> Result<serde_json::Value, ApiError> {
240        let url = format!("{}{path}", self.wiki_url);
241        let (value, _) = self.send_url(method, &url, body, path).await?;
242        Ok(value)
243    }
244}
245
246/// One comment, in our schema.
247///
248/// The author is reduced to the login, which is what the rest of the CLI
249/// takes; the thread's size is lifted out of `thread_info`, and only the list
250/// of a page's comments sends it.
251#[derive(Debug, Clone, Serialize)]
252pub struct WikiComment {
253    pub id: u64,
254    pub author: Option<String>,
255    pub created_at: Option<String>,
256    /// Somebody else's words, in markup the Wiki does not name.
257    pub body: String,
258    pub resolved: bool,
259    pub deleted: bool,
260    /// The passage of the page an inline comment is anchored to.
261    pub quote: Option<String>,
262    /// Posts in this comment's thread, itself included.
263    pub thread_posts: Option<u64>,
264}
265
266#[derive(Deserialize)]
267struct CommentAnswer {
268    id: u64,
269    #[serde(default)]
270    body: String,
271    #[serde(default)]
272    author: Option<Person>,
273    #[serde(default)]
274    created_at: Option<String>,
275    #[serde(default)]
276    is_deleted: bool,
277    #[serde(default)]
278    resolve_status: Option<String>,
279    #[serde(default)]
280    inline_text: Option<String>,
281    #[serde(default)]
282    thread_info: Option<ThreadInfo>,
283}
284
285#[derive(Deserialize)]
286struct Person {
287    username: String,
288}
289
290#[derive(Deserialize)]
291struct ThreadInfo {
292    total_posts: u64,
293}
294
295impl From<CommentAnswer> for WikiComment {
296    fn from(answer: CommentAnswer) -> Self {
297        Self {
298            id: answer.id,
299            author: answer.author.map(|person| person.username),
300            created_at: answer.created_at,
301            body: answer.body,
302            resolved: answer.resolve_status.as_deref() == Some("resolved"),
303            deleted: answer.is_deleted,
304            quote: answer.inline_text.filter(|text| !text.is_empty()),
305            thread_posts: answer.thread_info.map(|info| info.total_posts),
306        }
307    }
308}
309
310/// A file attached to a page, in our schema.
311#[derive(Debug, Clone, Serialize)]
312pub struct WikiAttachment {
313    pub id: u64,
314    /// Chosen by whoever uploaded it.
315    pub name: String,
316    /// As the Wiki sends it: a string, in units it does not name.
317    pub size: String,
318    pub mimetype: Option<String>,
319    pub created_at: Option<String>,
320    /// The uploader's login.
321    pub author: Option<String>,
322    pub download_url: Option<String>,
323}
324
325#[derive(Deserialize)]
326struct AttachmentAnswer {
327    id: u64,
328    name: String,
329    #[serde(default)]
330    size: serde_json::Value,
331    #[serde(default)]
332    mimetype: Option<String>,
333    #[serde(default)]
334    created_at: Option<String>,
335    #[serde(default)]
336    user: Option<Person>,
337    #[serde(default)]
338    download_url: Option<String>,
339}
340
341impl From<AttachmentAnswer> for WikiAttachment {
342    fn from(answer: AttachmentAnswer) -> Self {
343        Self {
344            id: answer.id,
345            name: answer.name,
346            // Documented as a string; a number is taken too rather than
347            // failing the whole listing over one field's type.
348            size: match answer.size {
349                serde_json::Value::String(size) => size,
350                serde_json::Value::Null => "-".to_owned(),
351                other => other.to_string(),
352            },
353            mimetype: answer.mimetype,
354            created_at: answer.created_at,
355            author: answer.user.map(|person| person.username),
356            download_url: answer.download_url,
357        }
358    }
359}
360
361/// A grid named by a listing.
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct WikiGridRef {
364    /// A uuid, where pages have numbers.
365    #[serde(deserialize_with = "text_id")]
366    pub id: String,
367    pub title: String,
368    #[serde(default)]
369    pub created_at: Option<String>,
370}
371
372/// One thing a page holds: a file or a grid, told apart by `kind`.
373#[derive(Debug, Clone, Serialize)]
374pub struct WikiResource {
375    /// `attachment` or `grid`.
376    pub kind: String,
377    pub id: String,
378    /// A file's name, or a grid's title.
379    pub name: String,
380    pub created_at: Option<String>,
381}
382
383#[derive(Deserialize)]
384struct ResourceAnswer {
385    #[serde(rename = "type")]
386    kind: String,
387    item: serde_json::Value,
388}
389
390impl From<ResourceAnswer> for WikiResource {
391    fn from(answer: ResourceAnswer) -> Self {
392        let text = |field: &str| {
393            answer.item.get(field).and_then(|value| match value {
394                serde_json::Value::String(text) => Some(text.clone()),
395                serde_json::Value::Null => None,
396                other => Some(other.to_string()),
397            })
398        };
399        Self {
400            id: text("id").unwrap_or_default(),
401            name: text("name").or_else(|| text("title")).unwrap_or_default(),
402            created_at: text("created_at"),
403            kind: answer.kind,
404        }
405    }
406}
407
408/// One dynamic table, in our schema.
409///
410/// Rows keep their cells in column order, as the Wiki sends them, so the
411/// columns and the cells pair by position; the values stay as the Wiki typed
412/// them — a user, a ticket, a list — for a script to use.
413#[derive(Debug, Clone, Serialize)]
414pub struct WikiGrid {
415    pub id: String,
416    pub title: String,
417    pub page: Option<WikiPageRef>,
418    /// Changes with every edit; a write can name the one it was made against.
419    pub revision: String,
420    pub columns: Vec<GridColumn>,
421    pub rows: Vec<GridRow>,
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize)]
425pub struct GridColumn {
426    /// What `--columns` and `--filter` name the column by.
427    pub slug: String,
428    pub title: String,
429    /// `string`, `number`, `date`, `select`, `staff`, `checkbox`, `ticket`,
430    /// `ticket_field`.
431    #[serde(rename = "type")]
432    pub kind: String,
433}
434
435#[derive(Debug, Clone, Serialize)]
436pub struct GridRow {
437    pub id: String,
438    pub cells: Vec<serde_json::Value>,
439}
440
441#[derive(Deserialize)]
442struct GridAnswer {
443    #[serde(deserialize_with = "text_id")]
444    id: String,
445    title: String,
446    #[serde(default)]
447    page: Option<WikiPageRef>,
448    #[serde(default, deserialize_with = "text_id")]
449    revision: String,
450    // A grid just created may come back before it has any structure to show.
451    #[serde(default)]
452    structure: Structure,
453    #[serde(default)]
454    rows: Vec<RowAnswer>,
455}
456
457#[derive(Default, Deserialize)]
458struct Structure {
459    #[serde(default)]
460    columns: Vec<GridColumn>,
461}
462
463#[derive(Deserialize)]
464struct RowAnswer {
465    #[serde(deserialize_with = "text_id")]
466    id: String,
467    #[serde(default)]
468    row: Vec<serde_json::Value>,
469}
470
471impl From<GridAnswer> for WikiGrid {
472    fn from(answer: GridAnswer) -> Self {
473        Self {
474            id: answer.id,
475            title: answer.title,
476            page: answer.page,
477            revision: answer.revision,
478            columns: answer.structure.columns,
479            rows: answer
480                .rows
481                .into_iter()
482                .map(|row| GridRow {
483                    id: row.id,
484                    cells: row.row,
485                })
486                .collect(),
487        }
488    }
489}
490
491/// An id the Wiki documents as a string and sometimes sends as a number.
492fn text_id<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
493    Ok(match serde_json::Value::deserialize(deserializer)? {
494        serde_json::Value::String(text) => text,
495        serde_json::Value::Null => String::new(),
496        other => other.to_string(),
497    })
498}
499
500/// What to read of a grid. The Wiki does the filtering, so a narrow question
501/// costs a narrow answer.
502#[derive(Debug, Clone, Copy, Default)]
503pub struct GridQuery<'a> {
504    /// `[slug] ~ text AND [n] < 3`, in the Wiki's own syntax.
505    pub filter: Option<&'a str>,
506    /// `slug, -other`.
507    pub sort: Option<&'a str>,
508    /// Column slugs, comma-separated.
509    pub columns: Option<&'a str>,
510    /// Row ids, comma-separated.
511    pub rows: Option<&'a str>,
512    pub revision: Option<u64>,
513}
514
515/// Who can read and edit a page, in our schema.
516///
517/// The Wiki has no access endpoint to read: the page carries it when asked,
518/// in three lists — direct grants, grants by link, and those inherited from a
519/// parent. They are flattened into one, each entry saying which list it was.
520#[derive(Debug, Clone, Serialize)]
521pub struct WikiAccess {
522    pub slug: String,
523    /// `inherited`, `all_staff` or `custom`.
524    pub policy: Option<String>,
525    /// What an inherited policy comes to: `all_staff` or `custom`.
526    pub inherited_policy: Option<String>,
527    /// The role everyone in the organisation has, when the policy is `all_staff`.
528    pub all_staff_role: Option<String>,
529    pub entries: Vec<AccessEntry>,
530}
531
532/// One grant: a role, held by a user or a group.
533#[derive(Debug, Clone, Serialize)]
534pub struct AccessEntry {
535    /// What `wiki regrant` and `wiki revoke` take.
536    pub id: String,
537    /// `reader`, `editor`, `extra_editor` or `author`.
538    pub role: String,
539    /// `user` or `group`.
540    pub kind: String,
541    /// A user's login, or a group's name.
542    pub who: String,
543    /// `direct`, `by_link` or `inherited`.
544    pub via: String,
545    pub inheritance: Option<String>,
546}
547
548/// A string field, or a number written as one: the Wiki is not consistent.
549fn scalar(value: Option<&serde_json::Value>) -> Option<String> {
550    match value? {
551        serde_json::Value::String(text) => Some(text.clone()),
552        serde_json::Value::Null => None,
553        other => Some(other.to_string()),
554    }
555}
556
557impl WikiAccess {
558    fn from_page(slug: &str, page: &serde_json::Value) -> Self {
559        let policy = page.get("access_policy");
560        let field = |name: &str| scalar(policy.and_then(|policy| policy.get(name)));
561        let mut entries = Vec::new();
562        if let Some(lists) = page.get("access_lists") {
563            for via in ["direct", "by_link", "inherited"] {
564                let items = lists.get(via).and_then(serde_json::Value::as_array);
565                for item in items.into_iter().flatten() {
566                    entries.push(AccessEntry::from_item(item, via));
567                }
568            }
569        }
570        Self {
571            slug: slug.to_owned(),
572            policy: field("access_type"),
573            inherited_policy: field("inherited_access_type"),
574            all_staff_role: field("all_staff_role"),
575            entries,
576        }
577    }
578}
579
580impl AccessEntry {
581    fn from_item(item: &serde_json::Value, via: &str) -> Self {
582        let present = |name: &str| item.get(name).filter(|value| !value.is_null());
583        let (kind, who) = if let Some(user) = present("user") {
584            ("user", scalar(user.get("username")))
585        } else if let Some(group) = present("group") {
586            ("group", scalar(group.get("name")))
587        } else {
588            ("-", None)
589        };
590        Self {
591            id: scalar(item.get("id")).unwrap_or_default(),
592            role: scalar(item.get("role")).unwrap_or_default(),
593            kind: kind.to_owned(),
594            who: who.unwrap_or_default(),
595            via: via.to_owned(),
596            inheritance: scalar(item.get("inheritance")),
597        }
598    }
599}
600
601/// Work the Wiki does after it has answered: a clone.
602#[derive(Debug, Clone, Serialize, Deserialize)]
603pub struct WikiOperation {
604    pub id: String,
605    /// `clone` for a page, `clone_inline_grid` for a grid.
606    #[serde(rename = "type")]
607    pub kind: String,
608}
609
610/// Where an operation has got to.
611#[derive(Debug, Clone, Serialize)]
612pub struct OperationStatus {
613    /// `scheduled`, `in_progress`, `success` or `failed`.
614    pub status: String,
615    pub percentage: Option<f64>,
616    pub details: Option<String>,
617    /// What a finished clone made: the new page, and for a grid the new grid.
618    pub result: Option<serde_json::Value>,
619}
620
621impl OperationStatus {
622    #[must_use]
623    pub fn is_done(&self) -> bool {
624        matches!(self.status.as_str(), "success" | "failed")
625    }
626
627    /// The slug of the page a finished clone made, or landed its grid on.
628    #[must_use]
629    pub fn page_slug(&self) -> Option<&str> {
630        self.result.as_ref()?.get("page")?.get("slug")?.as_str()
631    }
632
633    /// The id of the grid a finished grid clone made.
634    #[must_use]
635    pub fn grid_id(&self) -> Option<String> {
636        scalar(self.result.as_ref()?.get("grid_id"))
637    }
638}
639
640/// An upload the Wiki has opened for one file.
641#[derive(Debug, Clone, Deserialize)]
642pub struct UploadSession {
643    pub session_id: String,
644}
645
646/// The size of each part of an upload but the last.
647///
648/// The Wiki takes parts of 5 to 16 MB, the last one smaller; this sits in
649/// the middle, so a file needs few requests and no part is refused for size.
650pub const UPLOAD_PART: usize = 8 * 1024 * 1024;
651
652/// The byte ranges an upload of `len` bytes is sent in: one part for a file
653/// smaller than a part, otherwise parts of `size` and a shorter last one.
654#[must_use]
655pub fn upload_parts(len: usize, size: usize) -> Vec<std::ops::Range<usize>> {
656    if len == 0 {
657        // An empty file is still one part: the Wiki needs part 1 to finish.
658        return std::iter::once(0..0).collect();
659    }
660    let size = size.max(1);
661    (0..len)
662        .step_by(size)
663        .map(|start| start..(start + size).min(len))
664        .collect()
665}
666
667/// A write refused for want of rights, told apart from a read refused.
668fn write_refused(error: ApiError) -> ApiError {
669    match error {
670        ApiError::Forbidden | ApiError::Unauthorized => ApiError::WikiWriteForbidden,
671        other => other,
672    }
673}
674
675/// Which comments to list.
676#[derive(Debug, Clone, Copy)]
677pub enum CommentScope<'a> {
678    /// The page's comments, optionally only `resolved` or `unresolved` ones.
679    Page { status: Option<&'a str> },
680    /// Every post in one comment's thread.
681    Thread(u64),
682}
683
684impl Client {
685    /// The numeric id behind a slug.
686    ///
687    /// Only the page read and the descendants listing take a slug; everything
688    /// else under a page wants its id, so this costs one request first.
689    pub async fn wiki_page_id(&self, slug: &str) -> Result<i64, ApiError> {
690        #[derive(Deserialize)]
691        struct Identity {
692            id: i64,
693        }
694
695        let url = format!("{}/v1/pages?slug={}", self.wiki_url, encode(slug));
696        let (value, _) = self
697            .send_url(
698                reqwest::Method::GET,
699                &url,
700                None,
701                &format!("wiki page `{slug}`"),
702            )
703            .await
704            .map_err(refused)?;
705        serde_json::from_value::<Identity>(value)
706            .map(|identity| identity.id)
707            .map_err(ApiError::Decode)
708    }
709
710    /// A page's comments, or one thread of them.
711    pub async fn wiki_comments(
712        &self,
713        slug: &str,
714        scope: CommentScope<'_>,
715        cursor: Option<&str>,
716        page_size: u32,
717    ) -> Result<CursorPage<WikiComment>, ApiError> {
718        let id = self.wiki_page_id(slug).await?;
719        let (tail, what) = match scope {
720            CommentScope::Page { status } => (
721                format!(
722                    "comments?{}",
723                    status.map_or_else(String::new, |status| format!("status_filter={status}&"))
724                ),
725                format!("comments on wiki page `{slug}`"),
726            ),
727            CommentScope::Thread(comment) => (
728                format!("comments/{comment}/thread?"),
729                format!("comment {comment} on wiki page `{slug}`"),
730            ),
731        };
732        let page: CursorPage<CommentAnswer> = self
733            .wiki_listing(id, &tail, cursor, page_size, &what)
734            .await?;
735        Ok(CursorPage {
736            results: page.results.into_iter().map(WikiComment::from).collect(),
737            next_cursor: page.next_cursor,
738        })
739    }
740
741    /// The files attached to a page.
742    pub async fn wiki_attachments(
743        &self,
744        slug: &str,
745        cursor: Option<&str>,
746        page_size: u32,
747    ) -> Result<CursorPage<WikiAttachment>, ApiError> {
748        let id = self.wiki_page_id(slug).await?;
749        let page: CursorPage<AttachmentAnswer> = self
750            .wiki_listing(
751                id,
752                "attachments?",
753                cursor,
754                page_size,
755                &format!("attachments of wiki page `{slug}`"),
756            )
757            .await?;
758        Ok(CursorPage {
759            results: page.results.into_iter().map(WikiAttachment::from).collect(),
760            next_cursor: page.next_cursor,
761        })
762    }
763
764    /// The grids on a page.
765    pub async fn wiki_grids(
766        &self,
767        slug: &str,
768        cursor: Option<&str>,
769        page_size: u32,
770    ) -> Result<CursorPage<WikiGridRef>, ApiError> {
771        let id = self.wiki_page_id(slug).await?;
772        self.wiki_listing(
773            id,
774            "grids?",
775            cursor,
776            page_size,
777            &format!("grids of wiki page `{slug}`"),
778        )
779        .await
780    }
781
782    /// What a page holds, files and grids together, optionally one kind or
783    /// only those whose title matches `query`.
784    pub async fn wiki_resources(
785        &self,
786        slug: &str,
787        kind: Option<&str>,
788        query: Option<&str>,
789        cursor: Option<&str>,
790        page_size: u32,
791    ) -> Result<CursorPage<WikiResource>, ApiError> {
792        use std::fmt::Write as _;
793
794        let id = self.wiki_page_id(slug).await?;
795        let mut tail = "resources?".to_owned();
796        if let Some(kind) = kind {
797            let _ = write!(tail, "types={}&", encode(kind));
798        }
799        if let Some(query) = query {
800            let _ = write!(tail, "q={}&", encode(query));
801        }
802        let page: CursorPage<ResourceAnswer> = self
803            .wiki_listing(
804                id,
805                &tail,
806                cursor,
807                page_size,
808                &format!("resources of wiki page `{slug}`"),
809            )
810            .await?;
811        Ok(CursorPage {
812            results: page.results.into_iter().map(WikiResource::from).collect(),
813            next_cursor: page.next_cursor,
814        })
815    }
816
817    /// `GET /v1/grids/{id}`: every row that matches — the Wiki does not page
818    /// a grid.
819    pub async fn wiki_grid(&self, id: &str, query: GridQuery<'_>) -> Result<WikiGrid, ApiError> {
820        use std::fmt::Write as _;
821
822        let mut url = format!("{}/v1/grids/{}", self.wiki_url, encode(id));
823        let mut separator = '?';
824        for (name, value) in [
825            ("filter", query.filter),
826            ("sort", query.sort),
827            ("only_cols", query.columns),
828            ("only_rows", query.rows),
829        ] {
830            if let Some(value) = value {
831                let _ = write!(url, "{separator}{name}={}", encode(value));
832                separator = '&';
833            }
834        }
835        if let Some(revision) = query.revision {
836            let _ = write!(url, "{separator}revision={revision}");
837        }
838        let (value, _) = self
839            .send_url(
840                reqwest::Method::GET,
841                &url,
842                None,
843                &format!("wiki grid `{id}`"),
844            )
845            .await
846            .map_err(refused)?;
847        serde_json::from_value::<GridAnswer>(value)
848            .map(WikiGrid::from)
849            .map_err(ApiError::Decode)
850    }
851
852    /// One file on a page, by its id or its name, and the page's id with it.
853    ///
854    /// The Wiki has no lookup by name, so the listing is read until the file
855    /// turns up.
856    pub async fn wiki_attachment_named(
857        &self,
858        slug: &str,
859        wanted: &str,
860    ) -> Result<(i64, WikiAttachment), ApiError> {
861        // Enough for any page a person picks a file from by name; past it the
862        // search stops instead of reading an unbounded listing.
863        const PAGES: usize = 20;
864
865        let id = self.wiki_page_id(slug).await?;
866        let what = format!("attachments of wiki page `{slug}`");
867        let mut cursor: Option<String> = None;
868        for _ in 0..PAGES {
869            let page: CursorPage<AttachmentAnswer> = self
870                .wiki_listing(id, "attachments?", cursor.as_deref(), 100, &what)
871                .await?;
872            if let Some(found) = page
873                .results
874                .into_iter()
875                .map(WikiAttachment::from)
876                .find(|file| file.id.to_string() == wanted || file.name == wanted)
877            {
878                return Ok((id, found));
879            }
880            match page.next_cursor {
881                Some(next) => cursor = Some(next),
882                None => break,
883            }
884        }
885        Err(ApiError::NotFound(format!(
886            "attachment `{wanted}` on wiki page `{slug}`"
887        )))
888    }
889
890    /// A file's bytes, by page id and file id.
891    pub async fn wiki_attachment_bytes(&self, page: i64, file: u64) -> Result<Vec<u8>, ApiError> {
892        let url = format!(
893            "{}/v1/pages/{page}/attachments/{file}/download",
894            self.wiki_url
895        );
896        self.wiki_bytes(&url, &format!("attachment {file}")).await
897    }
898
899    /// A file's bytes, by its address: `<slug>/.files/<name>`. The Wiki follows
900    /// a page that has moved.
901    pub async fn wiki_file_bytes(&self, path: &str) -> Result<Vec<u8>, ApiError> {
902        let url = format!(
903            "{}/v1/pages/attachments/download_by_url?url={}",
904            self.wiki_url,
905            encode(path)
906        );
907        self.wiki_bytes(&url, &format!("wiki file `{path}`")).await
908    }
909
910    /// The body of a download, as bytes.
911    ///
912    /// The address is always built from the configured Wiki host, never taken
913    /// from a payload, so the token goes nowhere else; a redirect to storage
914    /// on another host loses the `Authorization` header on the way.
915    async fn wiki_bytes(&self, url: &str, what: &str) -> Result<Vec<u8>, ApiError> {
916        let response = self.http.get(url).send().await?;
917        let status = response.status();
918        if !status.is_success() {
919            return Err(match status.as_u16() {
920                401 | 403 => ApiError::WikiForbidden,
921                404 => ApiError::NotFound(what.to_owned()),
922                _ => ApiError::Rejected {
923                    status,
924                    message: String::new(),
925                },
926            });
927        }
928        Ok(response.bytes().await?.to_vec())
929    }
930
931    /// `GET /v1/pages/{id}/{tail}page_size=…&cursor=…`: one page of a listing
932    /// under a page. `tail` ends in `?` or `&`, ready for the paging.
933    async fn wiki_listing<T: serde::de::DeserializeOwned>(
934        &self,
935        id: i64,
936        tail: &str,
937        cursor: Option<&str>,
938        page_size: u32,
939        what: &str,
940    ) -> Result<CursorPage<T>, ApiError> {
941        use std::fmt::Write as _;
942
943        let mut url = format!(
944            "{}/v1/pages/{id}/{tail}page_size={page_size}",
945            self.wiki_url
946        );
947        if let Some(cursor) = cursor {
948            let _ = write!(url, "&cursor={}", encode(cursor));
949        }
950        let (value, _) = self
951            .send_url(reqwest::Method::GET, &url, None, what)
952            .await
953            .map_err(refused)?;
954        let page: CursorPage<T> = serde_json::from_value(value).map_err(ApiError::Decode)?;
955        // An empty string is how some listings say "no more"; to the tally it
956        // must mean the same as null.
957        Ok(CursorPage {
958            next_cursor: page.next_cursor.filter(|cursor| !cursor.is_empty()),
959            results: page.results,
960        })
961    }
962}
963
964/// A page brought back from deletion.
965#[derive(Debug, Clone, Serialize, Deserialize)]
966pub struct WikiRestored {
967    pub id: i64,
968    pub slug: String,
969    /// The page and the subpages restored with it.
970    #[serde(default)]
971    pub pages_count: Option<u64>,
972}
973
974impl Client {
975    /// `POST /v1/pages`. The parent is whatever the slug's path says.
976    pub async fn wiki_create(
977        &self,
978        body: &serde_json::Value,
979        silent: bool,
980    ) -> Result<WikiPageRef, ApiError> {
981        let url = format!(
982            "{}/v1/pages{}",
983            self.wiki_url,
984            query(&[silent.then_some("is_silent=true")])
985        );
986        self.wiki_write(reqwest::Method::POST, &url, Some(body), "the new wiki page")
987            .await
988    }
989
990    /// `POST /v1/pages/{id}` — an update, whatever the method says. Content
991    /// replaces the page's text in full; `merge` lets the Wiki fold in edits
992    /// made since, where it would otherwise refuse.
993    pub async fn wiki_update(
994        &self,
995        id: i64,
996        body: &serde_json::Value,
997        merge: bool,
998        silent: bool,
999    ) -> Result<WikiPageRef, ApiError> {
1000        let url = format!(
1001            "{}/v1/pages/{id}{}",
1002            self.wiki_url,
1003            query(&[
1004                merge.then_some("allow_merge=true"),
1005                silent.then_some("is_silent=true"),
1006            ])
1007        );
1008        self.wiki_write(
1009            reqwest::Method::POST,
1010            &url,
1011            Some(body),
1012            &format!("wiki page {id}"),
1013        )
1014        .await
1015    }
1016
1017    /// `POST /v1/pages/{id}/append-content`.
1018    pub async fn wiki_append(
1019        &self,
1020        id: i64,
1021        body: &serde_json::Value,
1022        silent: bool,
1023    ) -> Result<WikiPageRef, ApiError> {
1024        let url = format!(
1025            "{}/v1/pages/{id}/append-content{}",
1026            self.wiki_url,
1027            query(&[silent.then_some("is_silent=true")])
1028        );
1029        self.wiki_write(
1030            reqwest::Method::POST,
1031            &url,
1032            Some(body),
1033            &format!("wiki page {id}"),
1034        )
1035        .await
1036    }
1037
1038    /// `DELETE /v1/pages/{id}`, answering with the one token that restores it.
1039    pub async fn wiki_delete(&self, id: i64, recursive: bool) -> Result<String, ApiError> {
1040        #[derive(Deserialize)]
1041        struct Deleted {
1042            recovery_token: String,
1043        }
1044
1045        // The reference names both flags and not the difference between them;
1046        // a recursive delete sends both, a plain one neither.
1047        let url = format!(
1048            "{}/v1/pages/{id}{}",
1049            self.wiki_url,
1050            query(&[
1051                recursive.then_some("recursive=true"),
1052                recursive.then_some("allow_recursive=true"),
1053            ])
1054        );
1055        let deleted: Deleted = self
1056            .wiki_write(
1057                reqwest::Method::DELETE,
1058                &url,
1059                None,
1060                &format!("wiki page {id}"),
1061            )
1062            .await?;
1063        Ok(deleted.recovery_token)
1064    }
1065
1066    /// `POST /v1/pages/{id}/comments`: a comment, or a reply when the body
1067    /// names a `parent_id`.
1068    pub async fn wiki_comment(
1069        &self,
1070        page: i64,
1071        body: &serde_json::Value,
1072    ) -> Result<WikiComment, ApiError> {
1073        let url = format!("{}/v1/pages/{page}/comments", self.wiki_url);
1074        let answer: CommentAnswer = self
1075            .wiki_write(
1076                reqwest::Method::POST,
1077                &url,
1078                Some(body),
1079                &format!("wiki page {page}"),
1080            )
1081            .await?;
1082        Ok(WikiComment::from(answer))
1083    }
1084
1085    /// `DELETE /v1/pages/{id}/comments/{comment}`, answering how many
1086    /// comments the page has left.
1087    pub async fn wiki_delete_comment(
1088        &self,
1089        page: i64,
1090        comment: u64,
1091    ) -> Result<Option<u64>, ApiError> {
1092        #[derive(Deserialize)]
1093        struct Left {
1094            #[serde(default)]
1095            comments_count: Option<u64>,
1096        }
1097
1098        let url = format!("{}/v1/pages/{page}/comments/{comment}", self.wiki_url);
1099        let left: Left = self
1100            .wiki_write(
1101                reqwest::Method::DELETE,
1102                &url,
1103                None,
1104                &format!("comment {comment} on wiki page {page}"),
1105            )
1106            .await?;
1107        Ok(left.comments_count)
1108    }
1109
1110    /// Who can read and edit a page: the page read, asked for its access.
1111    pub async fn wiki_access(&self, slug: &str) -> Result<WikiAccess, ApiError> {
1112        let url = format!(
1113            "{}/v1/pages?slug={}&fields=access_policy,access_lists",
1114            self.wiki_url,
1115            encode(slug)
1116        );
1117        let (value, _) = self
1118            .send_url(
1119                reqwest::Method::GET,
1120                &url,
1121                None,
1122                &format!("wiki page `{slug}`"),
1123            )
1124            .await
1125            .map_err(refused)?;
1126        Ok(WikiAccess::from_page(slug, &value))
1127    }
1128
1129    /// `POST /v1/pages/{id}/access`. Unless `allow_selflock`, the Wiki is
1130    /// asked to refuse a change that would lock the caller out.
1131    pub async fn wiki_grant(
1132        &self,
1133        page: i64,
1134        body: &serde_json::Value,
1135        allow_selflock: bool,
1136    ) -> Result<AccessEntry, ApiError> {
1137        let url = format!(
1138            "{}/v1/pages/{page}/access{}",
1139            self.wiki_url,
1140            query(&[(!allow_selflock).then_some("prevent_selflock=true")])
1141        );
1142        let item: serde_json::Value = self
1143            .wiki_write(
1144                reqwest::Method::POST,
1145                &url,
1146                Some(body),
1147                &format!("access to wiki page {page}"),
1148            )
1149            .await?;
1150        Ok(AccessEntry::from_item(&item, "direct"))
1151    }
1152
1153    /// `POST /v1/pages/{id}/access/{access}`: another role, or inheritance.
1154    pub async fn wiki_regrant(
1155        &self,
1156        page: i64,
1157        access: &str,
1158        body: &serde_json::Value,
1159        allow_selflock: bool,
1160    ) -> Result<AccessEntry, ApiError> {
1161        let url = format!(
1162            "{}/v1/pages/{page}/access/{}{}",
1163            self.wiki_url,
1164            encode(access),
1165            query(&[(!allow_selflock).then_some("prevent_selflock=true")])
1166        );
1167        let item: serde_json::Value = self
1168            .wiki_write(
1169                reqwest::Method::POST,
1170                &url,
1171                Some(body),
1172                &format!("access {access} on wiki page {page}"),
1173            )
1174            .await?;
1175        Ok(AccessEntry::from_item(&item, "direct"))
1176    }
1177
1178    /// `DELETE /v1/pages/{id}/access/{access}`, or every personal grant on
1179    /// the page when no access is named.
1180    pub async fn wiki_revoke(
1181        &self,
1182        page: i64,
1183        access: Option<&str>,
1184        allow_selflock: bool,
1185    ) -> Result<(), ApiError> {
1186        let one = access.map_or_else(String::new, |access| format!("/{}", encode(access)));
1187        let url = format!(
1188            "{}/v1/pages/{page}/access{one}{}",
1189            self.wiki_url,
1190            query(&[(!allow_selflock).then_some("prevent_selflock=true")])
1191        );
1192        let _: serde_json::Value = self
1193            .wiki_write(
1194                reqwest::Method::DELETE,
1195                &url,
1196                None,
1197                &format!("access to wiki page {page}"),
1198            )
1199            .await?;
1200        Ok(())
1201    }
1202
1203    /// `POST /v1/pages/{id}/clone`: accepted now, done later.
1204    pub async fn wiki_clone_page(
1205        &self,
1206        page: i64,
1207        body: &serde_json::Value,
1208    ) -> Result<WikiOperation, ApiError> {
1209        let url = format!("{}/v1/pages/{page}/clone", self.wiki_url);
1210        self.wiki_started(&url, body, &format!("wiki page {page}"))
1211            .await
1212    }
1213
1214    /// `POST /v1/grids/{id}/clone`: accepted now, done later.
1215    pub async fn wiki_clone_grid(
1216        &self,
1217        grid: &str,
1218        body: &serde_json::Value,
1219    ) -> Result<WikiOperation, ApiError> {
1220        let url = format!("{}/v1/grids/{}/clone", self.wiki_url, encode(grid));
1221        self.wiki_started(&url, body, &format!("wiki grid `{grid}`"))
1222            .await
1223    }
1224
1225    async fn wiki_started(
1226        &self,
1227        url: &str,
1228        body: &serde_json::Value,
1229        what: &str,
1230    ) -> Result<WikiOperation, ApiError> {
1231        #[derive(Deserialize)]
1232        struct Started {
1233            operation: WikiOperation,
1234        }
1235
1236        let started: Started = self
1237            .wiki_write(reqwest::Method::POST, url, Some(body), what)
1238            .await?;
1239        Ok(started.operation)
1240    }
1241
1242    /// `GET /v1/operations/{kind}/{id}`: a read, however often it is asked.
1243    pub async fn wiki_operation(
1244        &self,
1245        operation: &WikiOperation,
1246    ) -> Result<OperationStatus, ApiError> {
1247        let url = format!(
1248            "{}/v1/operations/{}/{}",
1249            self.wiki_url,
1250            encode(&operation.kind),
1251            encode(&operation.id)
1252        );
1253        let (value, _) = self
1254            .send_url(
1255                reqwest::Method::GET,
1256                &url,
1257                None,
1258                &format!("operation {}/{}", operation.kind, operation.id),
1259            )
1260            .await
1261            .map_err(refused)?;
1262        let progress = value.get("progress");
1263        Ok(OperationStatus {
1264            status: scalar(value.get("status")).unwrap_or_default(),
1265            percentage: progress
1266                .and_then(|progress| progress.get("percentage"))
1267                .and_then(serde_json::Value::as_f64),
1268            details: scalar(progress.and_then(|progress| progress.get("details")))
1269                .filter(|details| !details.is_empty()),
1270            result: value
1271                .get("result")
1272                .filter(|result| !result.is_null())
1273                .cloned(),
1274        })
1275    }
1276
1277    /// `POST /v1/grids`: a new grid on a page, with no columns yet.
1278    pub async fn wiki_grid_create(&self, body: &serde_json::Value) -> Result<WikiGrid, ApiError> {
1279        let url = format!("{}/v1/grids", self.wiki_url);
1280        let answer: GridAnswer = self
1281            .wiki_write(reqwest::Method::POST, &url, Some(body), "the new wiki grid")
1282            .await?;
1283        Ok(WikiGrid::from(answer))
1284    }
1285
1286    /// One change under `/v1/grids/{id}`, answered with what the Wiki says —
1287    /// the new revision among it.
1288    pub async fn wiki_grid_write(
1289        &self,
1290        method: reqwest::Method,
1291        grid: &str,
1292        tail: &str,
1293        body: Option<&serde_json::Value>,
1294    ) -> Result<serde_json::Value, ApiError> {
1295        let url = format!("{}/v1/grids/{}{tail}", self.wiki_url, encode(grid));
1296        self.wiki_write(method, &url, body, &format!("wiki grid `{grid}`"))
1297            .await
1298    }
1299
1300    /// `POST /v1/upload_sessions`: the first of the four requests a file takes.
1301    pub async fn wiki_upload_start(
1302        &self,
1303        name: &str,
1304        size: usize,
1305    ) -> Result<UploadSession, ApiError> {
1306        let url = format!("{}/v1/upload_sessions", self.wiki_url);
1307        let body = serde_json::json!({ "file_name": name, "file_size": size });
1308        self.wiki_write(
1309            reqwest::Method::POST,
1310            &url,
1311            Some(&body),
1312            &format!("an upload of {name}"),
1313        )
1314        .await
1315    }
1316
1317    /// `PUT /v1/upload_sessions/{id}/upload_part`: raw bytes, not JSON, so it
1318    /// goes around the JSON sender.
1319    pub async fn wiki_upload_part(
1320        &self,
1321        session: &str,
1322        part: u32,
1323        bytes: Vec<u8>,
1324    ) -> Result<(), ApiError> {
1325        let url = format!(
1326            "{}/v1/upload_sessions/{}/upload_part?part_number={part}",
1327            self.wiki_url,
1328            encode(session)
1329        );
1330        let response = self
1331            .http
1332            .put(&url)
1333            .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
1334            .body(bytes)
1335            .send()
1336            .await?;
1337        super::classify(response, &format!("upload session {session}"))
1338            .await
1339            .map(|_| ())
1340            .map_err(write_refused)
1341    }
1342
1343    /// `POST /v1/upload_sessions/{id}/finish`.
1344    pub async fn wiki_upload_finish(&self, session: &str) -> Result<(), ApiError> {
1345        let url = format!(
1346            "{}/v1/upload_sessions/{}/finish",
1347            self.wiki_url,
1348            encode(session)
1349        );
1350        let _: serde_json::Value = self
1351            .wiki_write(
1352                reqwest::Method::POST,
1353                &url,
1354                None,
1355                &format!("upload session {session}"),
1356            )
1357            .await?;
1358        Ok(())
1359    }
1360
1361    /// `POST /v1/upload_sessions/{id}/abort`: so a failed upload does not go
1362    /// on holding the account's upload quota.
1363    pub async fn wiki_upload_abort(&self, session: &str) -> Result<(), ApiError> {
1364        let url = format!(
1365            "{}/v1/upload_sessions/{}/abort",
1366            self.wiki_url,
1367            encode(session)
1368        );
1369        let _: serde_json::Value = self
1370            .wiki_write(
1371                reqwest::Method::POST,
1372                &url,
1373                None,
1374                &format!("upload session {session}"),
1375            )
1376            .await?;
1377        Ok(())
1378    }
1379
1380    /// `POST /v1/pages/{id}/attachments`: finished uploads become the page's
1381    /// files.
1382    pub async fn wiki_attach(
1383        &self,
1384        page: i64,
1385        sessions: &[String],
1386    ) -> Result<Vec<WikiAttachment>, ApiError> {
1387        #[derive(Deserialize)]
1388        struct Attached {
1389            #[serde(default)]
1390            results: Vec<AttachmentAnswer>,
1391        }
1392
1393        let url = format!("{}/v1/pages/{page}/attachments", self.wiki_url);
1394        let body = serde_json::json!({ "upload_sessions": sessions });
1395        let attached: Attached = self
1396            .wiki_write(
1397                reqwest::Method::POST,
1398                &url,
1399                Some(&body),
1400                &format!("wiki page {page}"),
1401            )
1402            .await?;
1403        Ok(attached
1404            .results
1405            .into_iter()
1406            .map(WikiAttachment::from)
1407            .collect())
1408    }
1409
1410    /// `DELETE /v1/pages/{id}/attachments/{file}`.
1411    pub async fn wiki_delete_attachment(&self, page: i64, file: u64) -> Result<(), ApiError> {
1412        let url = format!("{}/v1/pages/{page}/attachments/{file}", self.wiki_url);
1413        let _: serde_json::Value = self
1414            .wiki_write(
1415                reqwest::Method::DELETE,
1416                &url,
1417                None,
1418                &format!("attachment {file}"),
1419            )
1420            .await?;
1421        Ok(())
1422    }
1423
1424    /// `POST /v1/recovery_tokens/{token}/recover`.
1425    pub async fn wiki_restore(&self, token: &str) -> Result<WikiRestored, ApiError> {
1426        let url = format!(
1427            "{}/v1/recovery_tokens/{}/recover",
1428            self.wiki_url,
1429            encode(token)
1430        );
1431        self.wiki_write(
1432            reqwest::Method::POST,
1433            &url,
1434            Some(&serde_json::json!({})),
1435            &format!("recovery token `{token}`"),
1436        )
1437        .await
1438    }
1439
1440    async fn wiki_write<T: serde::de::DeserializeOwned>(
1441        &self,
1442        method: reqwest::Method,
1443        url: &str,
1444        body: Option<&serde_json::Value>,
1445        what: &str,
1446    ) -> Result<T, ApiError> {
1447        let (value, _) =
1448            self.send_url(method, url, body, what)
1449                .await
1450                .map_err(|error| match error {
1451                    ApiError::Forbidden | ApiError::Unauthorized => ApiError::WikiWriteForbidden,
1452                    other => other,
1453                })?;
1454        serde_json::from_value(value).map_err(ApiError::Decode)
1455    }
1456}
1457
1458/// `?a&b` from whichever parts are present, or nothing at all.
1459fn query(parts: &[Option<&str>]) -> String {
1460    let present: Vec<&str> = parts.iter().flatten().copied().collect();
1461    if present.is_empty() {
1462        String::new()
1463    } else {
1464        format!("?{}", present.join("&"))
1465    }
1466}
1467
1468/// A refusal from the Wiki, told apart from Tracker's.
1469///
1470/// The Wiki answers 401 to a token it will not serve — the documented case — and
1471/// 403 to one that lacks rights, and both most often mean a token issued before
1472/// `wiki:read` was granted. Tracker's 401 means the token itself is dead; the
1473/// Wiki's is fixed by one command, which the error can name.
1474fn refused(error: ApiError) -> ApiError {
1475    match error {
1476        ApiError::Forbidden | ApiError::Unauthorized => ApiError::WikiForbidden,
1477        other => other,
1478    }
1479}
1480
1481/// The slug in whatever was pasted: a slug already, or a page's full address.
1482///
1483/// Query and fragment go, and so do the slashes around the path, which the
1484/// browser adds and the API does not want. A copied address arrives
1485/// percent-encoded — every Cyrillic slug does — and is decoded here, or it
1486/// would be encoded a second time on its way to the API and name no page.
1487#[must_use]
1488pub fn slug_of(target: &str) -> String {
1489    let path = match target.split_once("://") {
1490        Some((_, rest)) => rest.split_once('/').map_or("", |(_, path)| path),
1491        None => target,
1492    };
1493    decode(
1494        path.split(['?', '#'])
1495            .next()
1496            .unwrap_or_default()
1497            .trim_matches('/'),
1498    )
1499}
1500
1501/// `%D0%B7` back to `з`. Text that does not decode to UTF-8 is kept as typed:
1502/// a slug with a literal `%` in it is rarer than a mangled one, but not ours
1503/// to guess at.
1504fn decode(text: &str) -> String {
1505    let bytes = text.as_bytes();
1506    let mut decoded = Vec::with_capacity(bytes.len());
1507    let mut at = 0;
1508    while at < bytes.len() {
1509        let hex = bytes
1510            .get(at + 1..at + 3)
1511            .and_then(|pair| std::str::from_utf8(pair).ok())
1512            .and_then(|pair| u8::from_str_radix(pair, 16).ok());
1513        match (bytes[at], hex) {
1514            (b'%', Some(byte)) => {
1515                decoded.push(byte);
1516                at += 3;
1517            }
1518            (byte, _) => {
1519                decoded.push(byte);
1520                at += 1;
1521            }
1522        }
1523    }
1524    String::from_utf8(decoded).unwrap_or_else(|_| text.to_owned())
1525}
1526
1527/// Percent-encoding for one query value. A slug is a path, and its slashes
1528/// have to survive being put inside another URL's query.
1529fn encode(text: &str) -> String {
1530    use std::fmt::Write as _;
1531
1532    let mut encoded = String::with_capacity(text.len());
1533    for byte in text.bytes() {
1534        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
1535            encoded.push(char::from(byte));
1536        } else {
1537            let _ = write!(encoded, "%{byte:02X}");
1538        }
1539    }
1540    encoded
1541}
1542
1543#[cfg(test)]
1544mod tests {
1545    use super::*;
1546
1547    /// A file under one part goes in one; a larger one in full parts and a
1548    /// shorter last one, with every byte in exactly one part.
1549    #[test]
1550    fn an_upload_is_cut_into_parts_the_wiki_accepts() {
1551        let one = |range: std::ops::Range<usize>| std::iter::once(range).collect::<Vec<_>>();
1552        assert_eq!(upload_parts(5, UPLOAD_PART), one(0..5));
1553        assert_eq!(upload_parts(0, UPLOAD_PART), one(0..0));
1554        assert_eq!(upload_parts(10, 4), vec![0..4, 4..8, 8..10]);
1555        assert_eq!(upload_parts(8, 4), vec![0..4, 4..8]);
1556    }
1557
1558    #[test]
1559    fn a_slug_is_taken_as_it_is() {
1560        assert_eq!(
1561            slug_of("users/ilubenets/runbook"),
1562            "users/ilubenets/runbook"
1563        );
1564    }
1565
1566    /// What people actually have is the address bar.
1567    #[test]
1568    fn an_address_is_reduced_to_its_slug() {
1569        assert_eq!(
1570            slug_of("https://wiki.yandex.ru/users/ilubenets/runbook/?from=search#deploy"),
1571            "users/ilubenets/runbook"
1572        );
1573    }
1574
1575    /// The browser hands over a Cyrillic slug percent-encoded; sent on as it
1576    /// is, it would be encoded twice and name no page.
1577    #[test]
1578    fn a_copied_address_is_decoded() {
1579        assert_eq!(
1580            slug_of(
1581                "https://wiki.yandex.ru/users/%D1%8F%D0%BD/%D0%B7%D0%B0%D0%BC%D0%B5%D1%82%D0%BA%D0%B8/"
1582            ),
1583            "users/ян/заметки"
1584        );
1585        // Not a valid escape, or not UTF-8 once decoded: kept as typed.
1586        assert_eq!(slug_of("users/100%/x"), "users/100%/x");
1587        assert_eq!(slug_of("users/%FF"), "users/%FF");
1588    }
1589
1590    #[test]
1591    fn a_bare_host_names_no_page() {
1592        assert_eq!(slug_of("https://wiki.yandex.ru/"), "");
1593        assert_eq!(slug_of("https://wiki.yandex.ru"), "");
1594    }
1595
1596    /// Cyrillic slugs exist; they have to reach the API byte for byte.
1597    #[test]
1598    fn a_slug_survives_being_put_in_a_query() {
1599        assert_eq!(
1600            encode("users/ян/заметки"),
1601            "users%2F%D1%8F%D0%BD%2F%D0%B7%D0%B0%D0%BC%D0%B5%D1%82%D0%BA%D0%B8"
1602        );
1603    }
1604}