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.send_url(method, &url, body, path).await?;
242 Ok(value)
243 }
244}
245
246#[derive(Debug, Clone, Serialize)]
252pub struct WikiComment {
253 pub id: u64,
254 pub author: Option<String>,
255 pub created_at: Option<String>,
256 pub body: String,
258 pub resolved: bool,
259 pub deleted: bool,
260 pub quote: Option<String>,
262 pub thread_posts: Option<u64>,
264}
265
266#[derive(Deserialize)]
267struct CommentAnswer {
268 id: u64,
269 #[serde(default)]
270 body: String,
271 #[serde(default)]
272 author: Option<Person>,
273 #[serde(default)]
274 created_at: Option<String>,
275 #[serde(default)]
276 is_deleted: bool,
277 #[serde(default)]
278 resolve_status: Option<String>,
279 #[serde(default)]
280 inline_text: Option<String>,
281 #[serde(default)]
282 thread_info: Option<ThreadInfo>,
283}
284
285#[derive(Deserialize)]
286struct Person {
287 username: String,
288}
289
290#[derive(Deserialize)]
291struct ThreadInfo {
292 total_posts: u64,
293}
294
295impl From<CommentAnswer> for WikiComment {
296 fn from(answer: CommentAnswer) -> Self {
297 Self {
298 id: answer.id,
299 author: answer.author.map(|person| person.username),
300 created_at: answer.created_at,
301 body: answer.body,
302 resolved: answer.resolve_status.as_deref() == Some("resolved"),
303 deleted: answer.is_deleted,
304 quote: answer.inline_text.filter(|text| !text.is_empty()),
305 thread_posts: answer.thread_info.map(|info| info.total_posts),
306 }
307 }
308}
309
310#[derive(Debug, Clone, Serialize)]
312pub struct WikiAttachment {
313 pub id: u64,
314 pub name: String,
316 pub size: String,
318 pub mimetype: Option<String>,
319 pub created_at: Option<String>,
320 pub author: Option<String>,
322 pub download_url: Option<String>,
323}
324
325#[derive(Deserialize)]
326struct AttachmentAnswer {
327 id: u64,
328 name: String,
329 #[serde(default)]
330 size: serde_json::Value,
331 #[serde(default)]
332 mimetype: Option<String>,
333 #[serde(default)]
334 created_at: Option<String>,
335 #[serde(default)]
336 user: Option<Person>,
337 #[serde(default)]
338 download_url: Option<String>,
339}
340
341impl From<AttachmentAnswer> for WikiAttachment {
342 fn from(answer: AttachmentAnswer) -> Self {
343 Self {
344 id: answer.id,
345 name: answer.name,
346 size: match answer.size {
349 serde_json::Value::String(size) => size,
350 serde_json::Value::Null => "-".to_owned(),
351 other => other.to_string(),
352 },
353 mimetype: answer.mimetype,
354 created_at: answer.created_at,
355 author: answer.user.map(|person| person.username),
356 download_url: answer.download_url,
357 }
358 }
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct WikiGridRef {
364 #[serde(deserialize_with = "text_id")]
366 pub id: String,
367 pub title: String,
368 #[serde(default)]
369 pub created_at: Option<String>,
370}
371
372#[derive(Debug, Clone, Serialize)]
374pub struct WikiResource {
375 pub kind: String,
377 pub id: String,
378 pub name: String,
380 pub created_at: Option<String>,
381}
382
383#[derive(Deserialize)]
384struct ResourceAnswer {
385 #[serde(rename = "type")]
386 kind: String,
387 item: serde_json::Value,
388}
389
390impl From<ResourceAnswer> for WikiResource {
391 fn from(answer: ResourceAnswer) -> Self {
392 let text = |field: &str| {
393 answer.item.get(field).and_then(|value| match value {
394 serde_json::Value::String(text) => Some(text.clone()),
395 serde_json::Value::Null => None,
396 other => Some(other.to_string()),
397 })
398 };
399 Self {
400 id: text("id").unwrap_or_default(),
401 name: text("name").or_else(|| text("title")).unwrap_or_default(),
402 created_at: text("created_at"),
403 kind: answer.kind,
404 }
405 }
406}
407
408#[derive(Debug, Clone, Serialize)]
414pub struct WikiGrid {
415 pub id: String,
416 pub title: String,
417 pub page: Option<WikiPageRef>,
418 pub revision: String,
420 pub columns: Vec<GridColumn>,
421 pub rows: Vec<GridRow>,
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize)]
425pub struct GridColumn {
426 pub slug: String,
428 pub title: String,
429 #[serde(rename = "type")]
432 pub kind: String,
433}
434
435#[derive(Debug, Clone, Serialize)]
436pub struct GridRow {
437 pub id: String,
438 pub cells: Vec<serde_json::Value>,
439}
440
441#[derive(Deserialize)]
442struct GridAnswer {
443 #[serde(deserialize_with = "text_id")]
444 id: String,
445 title: String,
446 #[serde(default)]
447 page: Option<WikiPageRef>,
448 #[serde(default, deserialize_with = "text_id")]
449 revision: String,
450 #[serde(default)]
452 structure: Structure,
453 #[serde(default)]
454 rows: Vec<RowAnswer>,
455}
456
457#[derive(Default, Deserialize)]
458struct Structure {
459 #[serde(default)]
460 columns: Vec<GridColumn>,
461}
462
463#[derive(Deserialize)]
464struct RowAnswer {
465 #[serde(deserialize_with = "text_id")]
466 id: String,
467 #[serde(default)]
468 row: Vec<serde_json::Value>,
469}
470
471impl From<GridAnswer> for WikiGrid {
472 fn from(answer: GridAnswer) -> Self {
473 Self {
474 id: answer.id,
475 title: answer.title,
476 page: answer.page,
477 revision: answer.revision,
478 columns: answer.structure.columns,
479 rows: answer
480 .rows
481 .into_iter()
482 .map(|row| GridRow {
483 id: row.id,
484 cells: row.row,
485 })
486 .collect(),
487 }
488 }
489}
490
491fn text_id<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
493 Ok(match serde_json::Value::deserialize(deserializer)? {
494 serde_json::Value::String(text) => text,
495 serde_json::Value::Null => String::new(),
496 other => other.to_string(),
497 })
498}
499
500#[derive(Debug, Clone, Copy, Default)]
503pub struct GridQuery<'a> {
504 pub filter: Option<&'a str>,
506 pub sort: Option<&'a str>,
508 pub columns: Option<&'a str>,
510 pub rows: Option<&'a str>,
512 pub revision: Option<u64>,
513}
514
515#[derive(Debug, Clone, Serialize)]
521pub struct WikiAccess {
522 pub slug: String,
523 pub policy: Option<String>,
525 pub inherited_policy: Option<String>,
527 pub all_staff_role: Option<String>,
529 pub entries: Vec<AccessEntry>,
530}
531
532#[derive(Debug, Clone, Serialize)]
534pub struct AccessEntry {
535 pub id: String,
537 pub role: String,
539 pub kind: String,
541 pub who: String,
543 pub via: String,
545 pub inheritance: Option<String>,
546}
547
548fn scalar(value: Option<&serde_json::Value>) -> Option<String> {
550 match value? {
551 serde_json::Value::String(text) => Some(text.clone()),
552 serde_json::Value::Null => None,
553 other => Some(other.to_string()),
554 }
555}
556
557impl WikiAccess {
558 fn from_page(slug: &str, page: &serde_json::Value) -> Self {
559 let policy = page.get("access_policy");
560 let field = |name: &str| scalar(policy.and_then(|policy| policy.get(name)));
561 let mut entries = Vec::new();
562 if let Some(lists) = page.get("access_lists") {
563 for via in ["direct", "by_link", "inherited"] {
564 let items = lists.get(via).and_then(serde_json::Value::as_array);
565 for item in items.into_iter().flatten() {
566 entries.push(AccessEntry::from_item(item, via));
567 }
568 }
569 }
570 Self {
571 slug: slug.to_owned(),
572 policy: field("access_type"),
573 inherited_policy: field("inherited_access_type"),
574 all_staff_role: field("all_staff_role"),
575 entries,
576 }
577 }
578}
579
580impl AccessEntry {
581 fn from_item(item: &serde_json::Value, via: &str) -> Self {
582 let present = |name: &str| item.get(name).filter(|value| !value.is_null());
583 let (kind, who) = if let Some(user) = present("user") {
584 ("user", scalar(user.get("username")))
585 } else if let Some(group) = present("group") {
586 ("group", scalar(group.get("name")))
587 } else {
588 ("-", None)
589 };
590 Self {
591 id: scalar(item.get("id")).unwrap_or_default(),
592 role: scalar(item.get("role")).unwrap_or_default(),
593 kind: kind.to_owned(),
594 who: who.unwrap_or_default(),
595 via: via.to_owned(),
596 inheritance: scalar(item.get("inheritance")),
597 }
598 }
599}
600
601#[derive(Debug, Clone, Serialize, Deserialize)]
603pub struct WikiOperation {
604 pub id: String,
605 #[serde(rename = "type")]
607 pub kind: String,
608}
609
610#[derive(Debug, Clone, Serialize)]
612pub struct OperationStatus {
613 pub status: String,
615 pub percentage: Option<f64>,
616 pub details: Option<String>,
617 pub result: Option<serde_json::Value>,
619}
620
621impl OperationStatus {
622 #[must_use]
623 pub fn is_done(&self) -> bool {
624 matches!(self.status.as_str(), "success" | "failed")
625 }
626
627 #[must_use]
629 pub fn page_slug(&self) -> Option<&str> {
630 self.result.as_ref()?.get("page")?.get("slug")?.as_str()
631 }
632
633 #[must_use]
635 pub fn grid_id(&self) -> Option<String> {
636 scalar(self.result.as_ref()?.get("grid_id"))
637 }
638}
639
640#[derive(Debug, Clone, Deserialize)]
642pub struct UploadSession {
643 pub session_id: String,
644}
645
646pub const UPLOAD_PART: usize = 8 * 1024 * 1024;
651
652#[must_use]
655pub fn upload_parts(len: usize, size: usize) -> Vec<std::ops::Range<usize>> {
656 if len == 0 {
657 return std::iter::once(0..0).collect();
659 }
660 let size = size.max(1);
661 (0..len)
662 .step_by(size)
663 .map(|start| start..(start + size).min(len))
664 .collect()
665}
666
667fn write_refused(error: ApiError) -> ApiError {
669 match error {
670 ApiError::Forbidden | ApiError::Unauthorized => ApiError::WikiWriteForbidden,
671 other => other,
672 }
673}
674
675#[derive(Debug, Clone, Copy)]
677pub enum CommentScope<'a> {
678 Page { status: Option<&'a str> },
680 Thread(u64),
682}
683
684impl Client {
685 pub async fn wiki_page_id(&self, slug: &str) -> Result<i64, ApiError> {
690 #[derive(Deserialize)]
691 struct Identity {
692 id: i64,
693 }
694
695 let url = format!("{}/v1/pages?slug={}", self.wiki_url, encode(slug));
696 let (value, _) = self
697 .send_url(
698 reqwest::Method::GET,
699 &url,
700 None,
701 &format!("wiki page `{slug}`"),
702 )
703 .await
704 .map_err(refused)?;
705 serde_json::from_value::<Identity>(value)
706 .map(|identity| identity.id)
707 .map_err(ApiError::Decode)
708 }
709
710 pub async fn wiki_comments(
712 &self,
713 slug: &str,
714 scope: CommentScope<'_>,
715 cursor: Option<&str>,
716 page_size: u32,
717 ) -> Result<CursorPage<WikiComment>, ApiError> {
718 let id = self.wiki_page_id(slug).await?;
719 let (tail, what) = match scope {
720 CommentScope::Page { status } => (
721 format!(
722 "comments?{}",
723 status.map_or_else(String::new, |status| format!("status_filter={status}&"))
724 ),
725 format!("comments on wiki page `{slug}`"),
726 ),
727 CommentScope::Thread(comment) => (
728 format!("comments/{comment}/thread?"),
729 format!("comment {comment} on wiki page `{slug}`"),
730 ),
731 };
732 let page: CursorPage<CommentAnswer> = self
733 .wiki_listing(id, &tail, cursor, page_size, &what)
734 .await?;
735 Ok(CursorPage {
736 results: page.results.into_iter().map(WikiComment::from).collect(),
737 next_cursor: page.next_cursor,
738 })
739 }
740
741 pub async fn wiki_attachments(
743 &self,
744 slug: &str,
745 cursor: Option<&str>,
746 page_size: u32,
747 ) -> Result<CursorPage<WikiAttachment>, ApiError> {
748 let id = self.wiki_page_id(slug).await?;
749 let page: CursorPage<AttachmentAnswer> = self
750 .wiki_listing(
751 id,
752 "attachments?",
753 cursor,
754 page_size,
755 &format!("attachments of wiki page `{slug}`"),
756 )
757 .await?;
758 Ok(CursorPage {
759 results: page.results.into_iter().map(WikiAttachment::from).collect(),
760 next_cursor: page.next_cursor,
761 })
762 }
763
764 pub async fn wiki_grids(
766 &self,
767 slug: &str,
768 cursor: Option<&str>,
769 page_size: u32,
770 ) -> Result<CursorPage<WikiGridRef>, ApiError> {
771 let id = self.wiki_page_id(slug).await?;
772 self.wiki_listing(
773 id,
774 "grids?",
775 cursor,
776 page_size,
777 &format!("grids of wiki page `{slug}`"),
778 )
779 .await
780 }
781
782 pub async fn wiki_resources(
785 &self,
786 slug: &str,
787 kind: Option<&str>,
788 query: Option<&str>,
789 cursor: Option<&str>,
790 page_size: u32,
791 ) -> Result<CursorPage<WikiResource>, ApiError> {
792 use std::fmt::Write as _;
793
794 let id = self.wiki_page_id(slug).await?;
795 let mut tail = "resources?".to_owned();
796 if let Some(kind) = kind {
797 let _ = write!(tail, "types={}&", encode(kind));
798 }
799 if let Some(query) = query {
800 let _ = write!(tail, "q={}&", encode(query));
801 }
802 let page: CursorPage<ResourceAnswer> = self
803 .wiki_listing(
804 id,
805 &tail,
806 cursor,
807 page_size,
808 &format!("resources of wiki page `{slug}`"),
809 )
810 .await?;
811 Ok(CursorPage {
812 results: page.results.into_iter().map(WikiResource::from).collect(),
813 next_cursor: page.next_cursor,
814 })
815 }
816
817 pub async fn wiki_grid(&self, id: &str, query: GridQuery<'_>) -> Result<WikiGrid, ApiError> {
820 use std::fmt::Write as _;
821
822 let mut url = format!("{}/v1/grids/{}", self.wiki_url, encode(id));
823 let mut separator = '?';
824 for (name, value) in [
825 ("filter", query.filter),
826 ("sort", query.sort),
827 ("only_cols", query.columns),
828 ("only_rows", query.rows),
829 ] {
830 if let Some(value) = value {
831 let _ = write!(url, "{separator}{name}={}", encode(value));
832 separator = '&';
833 }
834 }
835 if let Some(revision) = query.revision {
836 let _ = write!(url, "{separator}revision={revision}");
837 }
838 let (value, _) = self
839 .send_url(
840 reqwest::Method::GET,
841 &url,
842 None,
843 &format!("wiki grid `{id}`"),
844 )
845 .await
846 .map_err(refused)?;
847 serde_json::from_value::<GridAnswer>(value)
848 .map(WikiGrid::from)
849 .map_err(ApiError::Decode)
850 }
851
852 pub async fn wiki_attachment_named(
857 &self,
858 slug: &str,
859 wanted: &str,
860 ) -> Result<(i64, WikiAttachment), ApiError> {
861 const PAGES: usize = 20;
864
865 let id = self.wiki_page_id(slug).await?;
866 let what = format!("attachments of wiki page `{slug}`");
867 let mut cursor: Option<String> = None;
868 for _ in 0..PAGES {
869 let page: CursorPage<AttachmentAnswer> = self
870 .wiki_listing(id, "attachments?", cursor.as_deref(), 100, &what)
871 .await?;
872 if let Some(found) = page
873 .results
874 .into_iter()
875 .map(WikiAttachment::from)
876 .find(|file| file.id.to_string() == wanted || file.name == wanted)
877 {
878 return Ok((id, found));
879 }
880 match page.next_cursor {
881 Some(next) => cursor = Some(next),
882 None => break,
883 }
884 }
885 Err(ApiError::NotFound(format!(
886 "attachment `{wanted}` on wiki page `{slug}`"
887 )))
888 }
889
890 pub async fn wiki_attachment_bytes(&self, page: i64, file: u64) -> Result<Vec<u8>, ApiError> {
892 let url = format!(
893 "{}/v1/pages/{page}/attachments/{file}/download",
894 self.wiki_url
895 );
896 self.wiki_bytes(&url, &format!("attachment {file}")).await
897 }
898
899 pub async fn wiki_file_bytes(&self, path: &str) -> Result<Vec<u8>, ApiError> {
902 let url = format!(
903 "{}/v1/pages/attachments/download_by_url?url={}",
904 self.wiki_url,
905 encode(path)
906 );
907 self.wiki_bytes(&url, &format!("wiki file `{path}`")).await
908 }
909
910 async fn wiki_bytes(&self, url: &str, what: &str) -> Result<Vec<u8>, ApiError> {
916 let response = self.http.get(url).send().await?;
917 let status = response.status();
918 if !status.is_success() {
919 return Err(match status.as_u16() {
920 401 | 403 => ApiError::WikiForbidden,
921 404 => ApiError::NotFound(what.to_owned()),
922 _ => ApiError::Rejected {
923 status,
924 message: String::new(),
925 },
926 });
927 }
928 Ok(response.bytes().await?.to_vec())
929 }
930
931 async fn wiki_listing<T: serde::de::DeserializeOwned>(
934 &self,
935 id: i64,
936 tail: &str,
937 cursor: Option<&str>,
938 page_size: u32,
939 what: &str,
940 ) -> Result<CursorPage<T>, ApiError> {
941 use std::fmt::Write as _;
942
943 let mut url = format!(
944 "{}/v1/pages/{id}/{tail}page_size={page_size}",
945 self.wiki_url
946 );
947 if let Some(cursor) = cursor {
948 let _ = write!(url, "&cursor={}", encode(cursor));
949 }
950 let (value, _) = self
951 .send_url(reqwest::Method::GET, &url, None, what)
952 .await
953 .map_err(refused)?;
954 let page: CursorPage<T> = serde_json::from_value(value).map_err(ApiError::Decode)?;
955 Ok(CursorPage {
958 next_cursor: page.next_cursor.filter(|cursor| !cursor.is_empty()),
959 results: page.results,
960 })
961 }
962}
963
964#[derive(Debug, Clone, Serialize, Deserialize)]
966pub struct WikiRestored {
967 pub id: i64,
968 pub slug: String,
969 #[serde(default)]
971 pub pages_count: Option<u64>,
972}
973
974impl Client {
975 pub async fn wiki_create(
977 &self,
978 body: &serde_json::Value,
979 silent: bool,
980 ) -> Result<WikiPageRef, ApiError> {
981 let url = format!(
982 "{}/v1/pages{}",
983 self.wiki_url,
984 query(&[silent.then_some("is_silent=true")])
985 );
986 self.wiki_write(reqwest::Method::POST, &url, Some(body), "the new wiki page")
987 .await
988 }
989
990 pub async fn wiki_update(
994 &self,
995 id: i64,
996 body: &serde_json::Value,
997 merge: bool,
998 silent: bool,
999 ) -> Result<WikiPageRef, ApiError> {
1000 let url = format!(
1001 "{}/v1/pages/{id}{}",
1002 self.wiki_url,
1003 query(&[
1004 merge.then_some("allow_merge=true"),
1005 silent.then_some("is_silent=true"),
1006 ])
1007 );
1008 self.wiki_write(
1009 reqwest::Method::POST,
1010 &url,
1011 Some(body),
1012 &format!("wiki page {id}"),
1013 )
1014 .await
1015 }
1016
1017 pub async fn wiki_append(
1019 &self,
1020 id: i64,
1021 body: &serde_json::Value,
1022 silent: bool,
1023 ) -> Result<WikiPageRef, ApiError> {
1024 let url = format!(
1025 "{}/v1/pages/{id}/append-content{}",
1026 self.wiki_url,
1027 query(&[silent.then_some("is_silent=true")])
1028 );
1029 self.wiki_write(
1030 reqwest::Method::POST,
1031 &url,
1032 Some(body),
1033 &format!("wiki page {id}"),
1034 )
1035 .await
1036 }
1037
1038 pub async fn wiki_delete(&self, id: i64, recursive: bool) -> Result<String, ApiError> {
1040 #[derive(Deserialize)]
1041 struct Deleted {
1042 recovery_token: String,
1043 }
1044
1045 let url = format!(
1048 "{}/v1/pages/{id}{}",
1049 self.wiki_url,
1050 query(&[
1051 recursive.then_some("recursive=true"),
1052 recursive.then_some("allow_recursive=true"),
1053 ])
1054 );
1055 let deleted: Deleted = self
1056 .wiki_write(
1057 reqwest::Method::DELETE,
1058 &url,
1059 None,
1060 &format!("wiki page {id}"),
1061 )
1062 .await?;
1063 Ok(deleted.recovery_token)
1064 }
1065
1066 pub async fn wiki_comment(
1069 &self,
1070 page: i64,
1071 body: &serde_json::Value,
1072 ) -> Result<WikiComment, ApiError> {
1073 let url = format!("{}/v1/pages/{page}/comments", self.wiki_url);
1074 let answer: CommentAnswer = self
1075 .wiki_write(
1076 reqwest::Method::POST,
1077 &url,
1078 Some(body),
1079 &format!("wiki page {page}"),
1080 )
1081 .await?;
1082 Ok(WikiComment::from(answer))
1083 }
1084
1085 pub async fn wiki_delete_comment(
1088 &self,
1089 page: i64,
1090 comment: u64,
1091 ) -> Result<Option<u64>, ApiError> {
1092 #[derive(Deserialize)]
1093 struct Left {
1094 #[serde(default)]
1095 comments_count: Option<u64>,
1096 }
1097
1098 let url = format!("{}/v1/pages/{page}/comments/{comment}", self.wiki_url);
1099 let left: Left = self
1100 .wiki_write(
1101 reqwest::Method::DELETE,
1102 &url,
1103 None,
1104 &format!("comment {comment} on wiki page {page}"),
1105 )
1106 .await?;
1107 Ok(left.comments_count)
1108 }
1109
1110 pub async fn wiki_access(&self, slug: &str) -> Result<WikiAccess, ApiError> {
1112 let url = format!(
1113 "{}/v1/pages?slug={}&fields=access_policy,access_lists",
1114 self.wiki_url,
1115 encode(slug)
1116 );
1117 let (value, _) = self
1118 .send_url(
1119 reqwest::Method::GET,
1120 &url,
1121 None,
1122 &format!("wiki page `{slug}`"),
1123 )
1124 .await
1125 .map_err(refused)?;
1126 Ok(WikiAccess::from_page(slug, &value))
1127 }
1128
1129 pub async fn wiki_grant(
1132 &self,
1133 page: i64,
1134 body: &serde_json::Value,
1135 allow_selflock: bool,
1136 ) -> Result<AccessEntry, ApiError> {
1137 let url = format!(
1138 "{}/v1/pages/{page}/access{}",
1139 self.wiki_url,
1140 query(&[(!allow_selflock).then_some("prevent_selflock=true")])
1141 );
1142 let item: serde_json::Value = self
1143 .wiki_write(
1144 reqwest::Method::POST,
1145 &url,
1146 Some(body),
1147 &format!("access to wiki page {page}"),
1148 )
1149 .await?;
1150 Ok(AccessEntry::from_item(&item, "direct"))
1151 }
1152
1153 pub async fn wiki_regrant(
1155 &self,
1156 page: i64,
1157 access: &str,
1158 body: &serde_json::Value,
1159 allow_selflock: bool,
1160 ) -> Result<AccessEntry, ApiError> {
1161 let url = format!(
1162 "{}/v1/pages/{page}/access/{}{}",
1163 self.wiki_url,
1164 encode(access),
1165 query(&[(!allow_selflock).then_some("prevent_selflock=true")])
1166 );
1167 let item: serde_json::Value = self
1168 .wiki_write(
1169 reqwest::Method::POST,
1170 &url,
1171 Some(body),
1172 &format!("access {access} on wiki page {page}"),
1173 )
1174 .await?;
1175 Ok(AccessEntry::from_item(&item, "direct"))
1176 }
1177
1178 pub async fn wiki_revoke(
1181 &self,
1182 page: i64,
1183 access: Option<&str>,
1184 allow_selflock: bool,
1185 ) -> Result<(), ApiError> {
1186 let one = access.map_or_else(String::new, |access| format!("/{}", encode(access)));
1187 let url = format!(
1188 "{}/v1/pages/{page}/access{one}{}",
1189 self.wiki_url,
1190 query(&[(!allow_selflock).then_some("prevent_selflock=true")])
1191 );
1192 let _: serde_json::Value = self
1193 .wiki_write(
1194 reqwest::Method::DELETE,
1195 &url,
1196 None,
1197 &format!("access to wiki page {page}"),
1198 )
1199 .await?;
1200 Ok(())
1201 }
1202
1203 pub async fn wiki_clone_page(
1205 &self,
1206 page: i64,
1207 body: &serde_json::Value,
1208 ) -> Result<WikiOperation, ApiError> {
1209 let url = format!("{}/v1/pages/{page}/clone", self.wiki_url);
1210 self.wiki_started(&url, body, &format!("wiki page {page}"))
1211 .await
1212 }
1213
1214 pub async fn wiki_clone_grid(
1216 &self,
1217 grid: &str,
1218 body: &serde_json::Value,
1219 ) -> Result<WikiOperation, ApiError> {
1220 let url = format!("{}/v1/grids/{}/clone", self.wiki_url, encode(grid));
1221 self.wiki_started(&url, body, &format!("wiki grid `{grid}`"))
1222 .await
1223 }
1224
1225 async fn wiki_started(
1226 &self,
1227 url: &str,
1228 body: &serde_json::Value,
1229 what: &str,
1230 ) -> Result<WikiOperation, ApiError> {
1231 #[derive(Deserialize)]
1232 struct Started {
1233 operation: WikiOperation,
1234 }
1235
1236 let started: Started = self
1237 .wiki_write(reqwest::Method::POST, url, Some(body), what)
1238 .await?;
1239 Ok(started.operation)
1240 }
1241
1242 pub async fn wiki_operation(
1244 &self,
1245 operation: &WikiOperation,
1246 ) -> Result<OperationStatus, ApiError> {
1247 let url = format!(
1248 "{}/v1/operations/{}/{}",
1249 self.wiki_url,
1250 encode(&operation.kind),
1251 encode(&operation.id)
1252 );
1253 let (value, _) = self
1254 .send_url(
1255 reqwest::Method::GET,
1256 &url,
1257 None,
1258 &format!("operation {}/{}", operation.kind, operation.id),
1259 )
1260 .await
1261 .map_err(refused)?;
1262 let progress = value.get("progress");
1263 Ok(OperationStatus {
1264 status: scalar(value.get("status")).unwrap_or_default(),
1265 percentage: progress
1266 .and_then(|progress| progress.get("percentage"))
1267 .and_then(serde_json::Value::as_f64),
1268 details: scalar(progress.and_then(|progress| progress.get("details")))
1269 .filter(|details| !details.is_empty()),
1270 result: value
1271 .get("result")
1272 .filter(|result| !result.is_null())
1273 .cloned(),
1274 })
1275 }
1276
1277 pub async fn wiki_grid_create(&self, body: &serde_json::Value) -> Result<WikiGrid, ApiError> {
1279 let url = format!("{}/v1/grids", self.wiki_url);
1280 let answer: GridAnswer = self
1281 .wiki_write(reqwest::Method::POST, &url, Some(body), "the new wiki grid")
1282 .await?;
1283 Ok(WikiGrid::from(answer))
1284 }
1285
1286 pub async fn wiki_grid_write(
1289 &self,
1290 method: reqwest::Method,
1291 grid: &str,
1292 tail: &str,
1293 body: Option<&serde_json::Value>,
1294 ) -> Result<serde_json::Value, ApiError> {
1295 let url = format!("{}/v1/grids/{}{tail}", self.wiki_url, encode(grid));
1296 self.wiki_write(method, &url, body, &format!("wiki grid `{grid}`"))
1297 .await
1298 }
1299
1300 pub async fn wiki_upload_start(
1302 &self,
1303 name: &str,
1304 size: usize,
1305 ) -> Result<UploadSession, ApiError> {
1306 let url = format!("{}/v1/upload_sessions", self.wiki_url);
1307 let body = serde_json::json!({ "file_name": name, "file_size": size });
1308 self.wiki_write(
1309 reqwest::Method::POST,
1310 &url,
1311 Some(&body),
1312 &format!("an upload of {name}"),
1313 )
1314 .await
1315 }
1316
1317 pub async fn wiki_upload_part(
1320 &self,
1321 session: &str,
1322 part: u32,
1323 bytes: Vec<u8>,
1324 ) -> Result<(), ApiError> {
1325 let url = format!(
1326 "{}/v1/upload_sessions/{}/upload_part?part_number={part}",
1327 self.wiki_url,
1328 encode(session)
1329 );
1330 let response = self
1331 .http
1332 .put(&url)
1333 .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
1334 .body(bytes)
1335 .send()
1336 .await?;
1337 super::classify(response, &format!("upload session {session}"))
1338 .await
1339 .map(|_| ())
1340 .map_err(write_refused)
1341 }
1342
1343 pub async fn wiki_upload_finish(&self, session: &str) -> Result<(), ApiError> {
1345 let url = format!(
1346 "{}/v1/upload_sessions/{}/finish",
1347 self.wiki_url,
1348 encode(session)
1349 );
1350 let _: serde_json::Value = self
1351 .wiki_write(
1352 reqwest::Method::POST,
1353 &url,
1354 None,
1355 &format!("upload session {session}"),
1356 )
1357 .await?;
1358 Ok(())
1359 }
1360
1361 pub async fn wiki_upload_abort(&self, session: &str) -> Result<(), ApiError> {
1364 let url = format!(
1365 "{}/v1/upload_sessions/{}/abort",
1366 self.wiki_url,
1367 encode(session)
1368 );
1369 let _: serde_json::Value = self
1370 .wiki_write(
1371 reqwest::Method::POST,
1372 &url,
1373 None,
1374 &format!("upload session {session}"),
1375 )
1376 .await?;
1377 Ok(())
1378 }
1379
1380 pub async fn wiki_attach(
1383 &self,
1384 page: i64,
1385 sessions: &[String],
1386 ) -> Result<Vec<WikiAttachment>, ApiError> {
1387 #[derive(Deserialize)]
1388 struct Attached {
1389 #[serde(default)]
1390 results: Vec<AttachmentAnswer>,
1391 }
1392
1393 let url = format!("{}/v1/pages/{page}/attachments", self.wiki_url);
1394 let body = serde_json::json!({ "upload_sessions": sessions });
1395 let attached: Attached = self
1396 .wiki_write(
1397 reqwest::Method::POST,
1398 &url,
1399 Some(&body),
1400 &format!("wiki page {page}"),
1401 )
1402 .await?;
1403 Ok(attached
1404 .results
1405 .into_iter()
1406 .map(WikiAttachment::from)
1407 .collect())
1408 }
1409
1410 pub async fn wiki_delete_attachment(&self, page: i64, file: u64) -> Result<(), ApiError> {
1412 let url = format!("{}/v1/pages/{page}/attachments/{file}", self.wiki_url);
1413 let _: serde_json::Value = self
1414 .wiki_write(
1415 reqwest::Method::DELETE,
1416 &url,
1417 None,
1418 &format!("attachment {file}"),
1419 )
1420 .await?;
1421 Ok(())
1422 }
1423
1424 pub async fn wiki_restore(&self, token: &str) -> Result<WikiRestored, ApiError> {
1426 let url = format!(
1427 "{}/v1/recovery_tokens/{}/recover",
1428 self.wiki_url,
1429 encode(token)
1430 );
1431 self.wiki_write(
1432 reqwest::Method::POST,
1433 &url,
1434 Some(&serde_json::json!({})),
1435 &format!("recovery token `{token}`"),
1436 )
1437 .await
1438 }
1439
1440 async fn wiki_write<T: serde::de::DeserializeOwned>(
1441 &self,
1442 method: reqwest::Method,
1443 url: &str,
1444 body: Option<&serde_json::Value>,
1445 what: &str,
1446 ) -> Result<T, ApiError> {
1447 let (value, _) =
1448 self.send_url(method, url, body, what)
1449 .await
1450 .map_err(|error| match error {
1451 ApiError::Forbidden | ApiError::Unauthorized => ApiError::WikiWriteForbidden,
1452 other => other,
1453 })?;
1454 serde_json::from_value(value).map_err(ApiError::Decode)
1455 }
1456}
1457
1458fn query(parts: &[Option<&str>]) -> String {
1460 let present: Vec<&str> = parts.iter().flatten().copied().collect();
1461 if present.is_empty() {
1462 String::new()
1463 } else {
1464 format!("?{}", present.join("&"))
1465 }
1466}
1467
1468fn refused(error: ApiError) -> ApiError {
1475 match error {
1476 ApiError::Forbidden | ApiError::Unauthorized => ApiError::WikiForbidden,
1477 other => other,
1478 }
1479}
1480
1481#[must_use]
1488pub fn slug_of(target: &str) -> String {
1489 let path = match target.split_once("://") {
1490 Some((_, rest)) => rest.split_once('/').map_or("", |(_, path)| path),
1491 None => target,
1492 };
1493 decode(
1494 path.split(['?', '#'])
1495 .next()
1496 .unwrap_or_default()
1497 .trim_matches('/'),
1498 )
1499}
1500
1501fn decode(text: &str) -> String {
1505 let bytes = text.as_bytes();
1506 let mut decoded = Vec::with_capacity(bytes.len());
1507 let mut at = 0;
1508 while at < bytes.len() {
1509 let hex = bytes
1510 .get(at + 1..at + 3)
1511 .and_then(|pair| std::str::from_utf8(pair).ok())
1512 .and_then(|pair| u8::from_str_radix(pair, 16).ok());
1513 match (bytes[at], hex) {
1514 (b'%', Some(byte)) => {
1515 decoded.push(byte);
1516 at += 3;
1517 }
1518 (byte, _) => {
1519 decoded.push(byte);
1520 at += 1;
1521 }
1522 }
1523 }
1524 String::from_utf8(decoded).unwrap_or_else(|_| text.to_owned())
1525}
1526
1527fn encode(text: &str) -> String {
1530 use std::fmt::Write as _;
1531
1532 let mut encoded = String::with_capacity(text.len());
1533 for byte in text.bytes() {
1534 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
1535 encoded.push(char::from(byte));
1536 } else {
1537 let _ = write!(encoded, "%{byte:02X}");
1538 }
1539 }
1540 encoded
1541}
1542
1543#[cfg(test)]
1544mod tests {
1545 use super::*;
1546
1547 #[test]
1550 fn an_upload_is_cut_into_parts_the_wiki_accepts() {
1551 let one = |range: std::ops::Range<usize>| std::iter::once(range).collect::<Vec<_>>();
1552 assert_eq!(upload_parts(5, UPLOAD_PART), one(0..5));
1553 assert_eq!(upload_parts(0, UPLOAD_PART), one(0..0));
1554 assert_eq!(upload_parts(10, 4), vec![0..4, 4..8, 8..10]);
1555 assert_eq!(upload_parts(8, 4), vec![0..4, 4..8]);
1556 }
1557
1558 #[test]
1559 fn a_slug_is_taken_as_it_is() {
1560 assert_eq!(
1561 slug_of("users/ilubenets/runbook"),
1562 "users/ilubenets/runbook"
1563 );
1564 }
1565
1566 #[test]
1568 fn an_address_is_reduced_to_its_slug() {
1569 assert_eq!(
1570 slug_of("https://wiki.yandex.ru/users/ilubenets/runbook/?from=search#deploy"),
1571 "users/ilubenets/runbook"
1572 );
1573 }
1574
1575 #[test]
1578 fn a_copied_address_is_decoded() {
1579 assert_eq!(
1580 slug_of(
1581 "https://wiki.yandex.ru/users/%D1%8F%D0%BD/%D0%B7%D0%B0%D0%BC%D0%B5%D1%82%D0%BA%D0%B8/"
1582 ),
1583 "users/ян/заметки"
1584 );
1585 assert_eq!(slug_of("users/100%/x"), "users/100%/x");
1587 assert_eq!(slug_of("users/%FF"), "users/%FF");
1588 }
1589
1590 #[test]
1591 fn a_bare_host_names_no_page() {
1592 assert_eq!(slug_of("https://wiki.yandex.ru/"), "");
1593 assert_eq!(slug_of("https://wiki.yandex.ru"), "");
1594 }
1595
1596 #[test]
1598 fn a_slug_survives_being_put_in_a_query() {
1599 assert_eq!(
1600 encode("users/ян/заметки"),
1601 "users%2F%D1%8F%D0%BD%2F%D0%B7%D0%B0%D0%BC%D0%B5%D1%82%D0%BA%D0%B8"
1602 );
1603 }
1604}