1use serde::{Deserialize, Serialize};
9
10use crate::api::Client;
11use crate::api::error::ApiError;
12
13pub const DEFAULT_WIKI_URL: &str = "https://api.wiki.yandex.net";
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct WikiPage {
22 pub id: i64,
23 pub slug: String,
24 pub title: String,
25 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct WikiPageRef {
38 pub id: i64,
39 pub slug: String,
40}
41
42#[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
54pub const LAST_SEARCH_PAGE: u32 = 500;
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct WikiHit {
60 pub slug: String,
61 pub title: String,
62 #[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 #[serde(default, rename(deserialize = "content"))]
73 pub snippet: Option<String>,
74}
75
76#[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#[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 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 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 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 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 #[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#[derive(Debug, Clone, Serialize)]
255pub struct WikiComment {
256 pub id: u64,
257 pub author: Option<String>,
258 pub created_at: Option<String>,
259 pub body: String,
261 pub resolved: bool,
262 pub deleted: bool,
263 pub quote: Option<String>,
265 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#[derive(Debug, Clone, Serialize)]
315pub struct WikiAttachment {
316 pub id: u64,
317 pub name: String,
319 pub size: String,
321 pub mimetype: Option<String>,
322 pub created_at: Option<String>,
323 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct WikiGridRef {
367 #[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#[derive(Debug, Clone, Serialize)]
377pub struct WikiResource {
378 pub kind: String,
380 pub id: String,
381 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#[derive(Debug, Clone, Serialize)]
417pub struct WikiGrid {
418 pub id: String,
419 pub title: String,
420 pub page: Option<WikiPageRef>,
421 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 pub slug: String,
431 pub title: String,
432 #[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 #[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
494fn 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#[derive(Debug, Clone, Copy, Default)]
506pub struct GridQuery<'a> {
507 pub filter: Option<&'a str>,
509 pub sort: Option<&'a str>,
511 pub columns: Option<&'a str>,
513 pub rows: Option<&'a str>,
515 pub revision: Option<u64>,
516}
517
518#[derive(Debug, Clone, Serialize)]
524pub struct WikiAccess {
525 pub slug: String,
526 pub policy: Option<String>,
528 pub inherited_policy: Option<String>,
530 pub all_staff_role: Option<String>,
532 pub entries: Vec<AccessEntry>,
533}
534
535#[derive(Debug, Clone, Serialize)]
537pub struct AccessEntry {
538 pub id: String,
540 pub role: String,
542 pub kind: String,
544 pub who: String,
546 pub via: String,
548 pub inheritance: Option<String>,
549}
550
551fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
606pub struct WikiOperation {
607 pub id: String,
608 #[serde(rename = "type")]
610 pub kind: String,
611}
612
613#[derive(Debug, Clone, Serialize)]
615pub struct OperationStatus {
616 pub status: String,
618 pub percentage: Option<f64>,
619 pub details: Option<String>,
620 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 #[must_use]
632 pub fn page_slug(&self) -> Option<&str> {
633 self.result.as_ref()?.get("page")?.get("slug")?.as_str()
634 }
635
636 #[must_use]
638 pub fn grid_id(&self) -> Option<String> {
639 scalar(self.result.as_ref()?.get("grid_id"))
640 }
641}
642
643#[derive(Debug, Clone, Deserialize)]
645pub struct UploadSession {
646 pub session_id: String,
647}
648
649pub const UPLOAD_PART: usize = 8 * 1024 * 1024;
654
655#[must_use]
658pub fn upload_parts(len: usize, size: usize) -> Vec<std::ops::Range<usize>> {
659 if len == 0 {
660 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
670fn write_refused(error: ApiError) -> ApiError {
672 match error {
673 ApiError::Forbidden | ApiError::Unauthorized => ApiError::WikiWriteForbidden,
674 other => other,
675 }
676}
677
678#[derive(Debug, Clone, Copy)]
680pub enum CommentScope<'a> {
681 Page { status: Option<&'a str> },
683 Thread(u64),
685}
686
687impl Client {
688 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 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 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 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 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 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 pub async fn wiki_attachment_named(
860 &self,
861 slug: &str,
862 wanted: &str,
863 ) -> Result<(i64, WikiAttachment), ApiError> {
864 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 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 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 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 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 Ok(CursorPage {
961 next_cursor: page.next_cursor.filter(|cursor| !cursor.is_empty()),
962 results: page.results,
963 })
964 }
965}
966
967#[derive(Debug, Clone, Serialize, Deserialize)]
969pub struct WikiRestored {
970 pub id: i64,
971 pub slug: String,
972 #[serde(default)]
974 pub pages_count: Option<u64>,
975}
976
977impl Client {
978 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
1461fn 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
1471fn refused(error: ApiError) -> ApiError {
1478 match error {
1479 ApiError::Forbidden | ApiError::Unauthorized => ApiError::WikiForbidden,
1480 other => answered_by_wiki(other),
1481 }
1482}
1483
1484fn 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#[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
1512fn 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
1538fn 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 #[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 #[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 #[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 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 #[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}