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