1use crate::edit::{EditResponse, SaveOptions, Saveable};
19#[cfg(feature = "upload")]
20#[cfg_attr(docsrs, doc(cfg(feature = "upload")))]
21use crate::file::File;
22#[cfg(feature = "generators")]
23#[cfg_attr(docsrs, doc(cfg(feature = "generators")))]
24use crate::generators::{
25 categories::Categories, langlinks::LangLinks, templates::Templates,
26 Generator,
27};
28use crate::parsoid::ImmutableWikicode;
29use crate::{Bot, Error, Result, Title};
30use mwapi_responses::prelude::*;
31use mwtimestamp::Timestamp;
32use once_cell::sync::OnceCell;
33use serde_json::Value;
34#[cfg(feature = "generators")]
35use std::collections::HashMap;
36use std::fmt::Display;
37use std::future::Future;
38use std::sync::Arc;
39use tokio::sync::OnceCell as AsyncOnceCell;
40use tracing::info;
41
42#[derive(Debug, Clone)]
59pub struct Page {
60 pub(crate) bot: Bot,
61 pub(crate) title: Title,
62 pub(crate) title_text: OnceCell<String>,
63 pub(crate) info: Arc<AsyncOnceCell<InfoResponseItem>>,
64 pub(crate) baserevid: OnceCell<u64>,
65}
66
67#[doc(hidden)]
68#[non_exhaustive]
69#[query(prop = "info", inprop = "associatedpage|url")]
70pub struct InfoResponse {}
71
72impl Page {
73 pub fn title(&self) -> &str {
75 self.title_text.get_or_init(|| {
76 let codec = &self.bot.config.codec;
77 codec.to_pretty(&self.title)
78 })
79 }
80
81 pub fn as_title(&self) -> &Title {
83 &self.title
84 }
85
86 pub fn namespace(&self) -> i32 {
88 self.title.namespace()
89 }
90
91 pub fn is_file(&self) -> bool {
93 self.title.is_file()
94 }
95
96 #[cfg(feature = "upload")]
98 #[cfg_attr(docsrs, doc(cfg(feature = "upload")))]
99 pub fn as_file(&self) -> Option<File> {
100 if self.is_file() {
101 Some(File::new(self))
102 } else {
103 None
104 }
105 }
106
107 pub fn is_category(&self) -> bool {
109 self.title.is_category()
110 }
111
112 async fn info(&self) -> Result<&InfoResponseItem> {
114 self.info
115 .get_or_try_init(|| async {
116 let mut resp: InfoResponse = mwapi_responses::query_api(
117 &self.bot.api,
118 [("titles", self.title())],
119 )
120 .await?;
121 let info = resp
122 .query
123 .pages
124 .pop()
125 .expect("API response returned 0 pages");
126 if let Some(revid) = info.lastrevid {
127 let _ = self.baserevid.set(revid);
128 }
129 Ok(info)
130 })
131 .await
132 }
133
134 pub async fn exists(&self) -> Result<bool> {
136 Ok(!self.info().await?.missing)
137 }
138
139 pub async fn id(&self) -> Result<Option<u32>> {
141 Ok(self.info().await?.pageid)
142 }
143
144 pub async fn url(&self) -> Result<&str> {
146 Ok(&self.info().await?.canonicalurl)
147 }
148
149 pub async fn is_redirect(&self) -> Result<bool> {
151 Ok(self.info().await?.redirect)
152 }
153
154 pub async fn associated_page(&self) -> Result<Page> {
157 self.bot.page(&self.info().await?.associatedpage)
158 }
159
160 pub async fn touched(&self) -> Result<Option<Timestamp>> {
167 Ok(self.info().await?.touched)
168 }
169
170 pub async fn latest_revision_id(&self) -> Result<Option<u64>> {
172 Ok(self.info().await?.lastrevid)
173 }
174
175 pub async fn redirect_target(&self) -> Result<Option<Page>> {
177 if self.info.initialized() && !self.is_redirect().await? {
179 return Ok(None);
180 }
181 let mut resp: InfoResponse = mwapi_responses::query_api(
183 &self.bot.api,
184 [("titles", self.title()), ("redirects", "1")],
185 )
186 .await?;
187 match resp.title_map().get(self.title()) {
188 Some(redirect) => {
189 let page = self.bot.page(redirect)?;
190 page.info
191 .set(
192 resp.query
193 .pages
194 .pop()
195 .expect("API response returned 0 pages"),
196 )
197 .unwrap();
199 Ok(Some(page))
200 }
201 None => Ok(None),
202 }
203 }
204
205 #[cfg(feature = "generators")]
207 #[cfg_attr(docsrs, doc(cfg(feature = "generators")))]
208 pub async fn language_links(
209 &self,
210 ) -> Result<Option<HashMap<String, String>>> {
211 if self.info.initialized() && !self.exists().await? {
212 return Ok(None);
213 }
214 let mut gen =
215 LangLinks::new(vec![self.title().to_string()]).generate(&self.bot);
216 let page = gen.recv().await.unwrap()?;
217 debug_assert_eq!(page.title, self.title());
218 debug_assert!(gen.recv().await.is_none());
219 if page.missing || page.invalid {
220 return Ok(None);
221 }
222 let links = page
223 .langlinks
224 .into_iter()
225 .map(|item| (item.lang, item.title))
226 .collect();
227 Ok(Some(links))
228 }
229
230 #[cfg(feature = "generators")]
232 #[cfg_attr(docsrs, doc(cfg(feature = "generators")))]
233 pub async fn categories(&self) -> Result<Option<Vec<String>>> {
234 if self.info.initialized() && !self.exists().await? {
235 return Ok(None);
236 }
237 let mut gen =
238 Categories::new(vec![self.title().to_string()]).generate(&self.bot);
239 let mut found = Vec::new();
240
241 while let Some(page) = gen.recv().await {
242 found.push(page?.title().to_string());
243 }
244 Ok(Some(found))
245 }
246
247 #[cfg(feature = "generators")]
249 #[cfg_attr(docsrs, doc(cfg(feature = "generators")))]
250 pub async fn templates(
251 &self,
252 only: Option<Vec<String>>,
253 ) -> Result<Option<Vec<String>>> {
254 if self.info.initialized() && !self.exists().await? {
255 return Ok(None);
256 }
257 let mut gen = Templates::new(vec![self.title().to_string()])
258 .with_templates(only)
259 .generate(&self.bot);
260 let mut found = Vec::new();
261
262 while let Some(page) = gen.recv().await {
263 found.push(page?.title().to_string());
264 }
265 Ok(Some(found))
266 }
267
268 pub async fn html(&self) -> Result<ImmutableWikicode> {
270 match self.baserevid.get() {
271 None => {
272 let resp = self.bot.parsoid.get(self.title()).await?;
273 if let Some(revid) = &resp.revision_id() {
275 let _ = self.baserevid.set(*revid);
276 }
277 Ok(resp)
278 }
279 Some(revid) => {
280 Ok(self.bot.parsoid.get_revision(self.title(), *revid).await?)
281 }
282 }
283 }
284
285 pub async fn revision_html(&self, revid: u64) -> Result<ImmutableWikicode> {
287 Ok(self.bot.parsoid.get_revision(self.title(), revid).await?)
288 }
289
290 pub async fn wikitext(&self) -> Result<String> {
292 let mut params: Vec<(&'static str, String)> = vec![
293 ("action", "query".to_string()),
294 ("titles", self.title().to_string()),
295 ("prop", "revisions".to_string()),
296 ("rvprop", "content|ids".to_string()),
297 ("rvslots", "main".to_string()),
298 ];
299 if let Some(revid) = self.baserevid.get() {
300 params.push(("rvstartid", revid.to_string()));
301 params.push(("rvendid", revid.to_string()));
302 }
303 let resp = self.bot.api.get_value(¶ms).await?;
304 let page = resp["query"]["pages"][0].as_object().unwrap();
305 if page.contains_key("missing") {
306 Err(Error::PageDoesNotExist(self.title().to_string()))
307 } else {
308 match page.get("revisions") {
309 Some(revisions) => {
310 let revision = &revisions[0];
311 let _ =
312 self.baserevid.set(revision["revid"].as_u64().unwrap());
313 Ok(revision["slots"]["main"]["content"]
314 .as_str()
315 .unwrap()
316 .to_string())
317 }
318 None => {
319 Err(Error::PageDoesNotExist(self.title().to_string()))
322 }
323 }
324 }
325 }
326
327 pub async fn save<S: Into<Saveable>>(
329 self,
330 edit: S,
331 opts: &SaveOptions,
332 ) -> Result<(Page, EditResponse)> {
333 let mut exists: Option<bool> = None;
334 if self.bot.config.respect_nobots {
335 match self.html().await {
337 Ok(html) => {
338 exists = Some(true);
339 self.nobot_check(html)?;
340 }
341 Err(Error::PageDoesNotExist(_)) => {
342 exists = Some(false);
343 }
344 Err(error) => {
345 return Err(error);
346 }
347 }
348 } else if self.info.initialized() {
349 exists = Some(self.exists().await?);
350 }
351
352 let edit = edit.into();
353 let wikitext = match edit {
354 Saveable::Html(html) => {
355 self.bot.parsoid.transform_to_wikitext(&html).await?
356 }
357 Saveable::Wikitext(wikitext) => wikitext,
358 };
359
360 let mut params: Vec<(&'static str, String)> = vec![
361 ("action", "edit".to_string()),
362 ("title", self.title().to_string()),
363 ("text", wikitext),
364 ("summary", opts.summary.to_string()),
365 ];
366
367 if let Some(revid) = self.baserevid.get() {
369 params.push(("baserevid", revid.to_string()));
370 }
371 match exists {
373 Some(true) => {
374 params.push(("nocreate", "1".to_string()));
376 }
377 Some(false) => {
378 params.push(("createonly", "1".to_string()));
380 }
381 None => {} }
383
384 if let Some(section) = &opts.section {
385 params.push(("section", section.to_string()));
386 }
387 if opts.mark_as_bot.unwrap_or(self.bot.config.mark_as_bot) {
388 params.push(("bot", "1".to_string()));
389 }
390 if !opts.tags.is_empty() {
391 params.push(("tags", opts.tags.join("|")));
392 }
393 if let Some(minor) = opts.minor {
394 if minor {
395 params.push(("minor", "1".to_string()));
396 } else {
397 params.push(("notminor", "1".to_string()));
398 }
399 }
400
401 let resp: Value = self
402 .with_save_lock(async {
403 info!("Saving [[{}]]", self.title());
404 self.bot.api.post_with_token("csrf", ¶ms).await
405 })
406 .await?;
407
408 self.page_from_response(resp)
409 }
410
411 pub async fn undo(
414 self,
415 from: u64,
416 to: Option<u64>,
417 opts: &SaveOptions,
418 ) -> Result<(Page, EditResponse)> {
419 if self.bot.config.respect_nobots {
420 let html = self.html().await?;
421 self.nobot_check(html)?;
422 } else if self.info.initialized() && self.exists().await? {
423 return Err(Error::PageDoesNotExist(self.title().to_string()));
424 }
425
426 let mut params: Vec<(&'static str, String)> = vec![
427 ("action", "edit".to_string()),
428 ("title", self.title().to_string()),
429 ("undo", from.to_string()),
430 ("summary", opts.summary.to_string()),
431 ("nocreate", "1".to_string()), ];
433
434 if let Some(revid) = self.baserevid.get() {
436 params.push(("baserevid", revid.to_string()));
437 }
438
439 if let Some(to) = to {
440 params.push(("undoafter", to.to_string()));
441 }
442 if opts.mark_as_bot.unwrap_or(self.bot.config.mark_as_bot) {
443 params.push(("bot", "1".to_string()));
444 }
445 if !opts.tags.is_empty() {
446 params.push(("tags", opts.tags.join("|")));
447 }
448 if let Some(minor) = opts.minor {
449 if minor {
450 params.push(("minor", "1".to_string()));
451 } else {
452 params.push(("notminor", "1".to_string()));
453 }
454 }
455
456 let resp: Value = self
457 .with_save_lock(async {
458 info!("Undoing edit [[{}]]", self.title());
459 self.bot.api.post_with_token("csrf", ¶ms).await
460 })
461 .await?;
462
463 self.page_from_response(resp)
464 }
465
466 fn page_from_response(self, resp: Value) -> Result<(Page, EditResponse)> {
468 match resp["edit"]["result"].as_str() {
469 Some("Success") => {
470 let edit_response: EditResponse =
471 serde_json::from_value(resp["edit"].clone())?;
472 if !edit_response.nochange {
473 let page = Page {
474 bot: self.bot,
475 title: self.title,
476 title_text: self.title_text,
477 info: Default::default(),
478 baserevid: OnceCell::from(
479 edit_response.newrevid.unwrap(),
480 ),
481 };
482 Ok((page, edit_response))
483 } else {
484 Ok((self, edit_response))
485 }
486 }
487 _ => Err(Error::UnknownSaveFailure(resp)),
492 }
493 }
494
495 fn nobot_check(&self, html: ImmutableWikicode) -> Result<()> {
497 let username = self
498 .bot
499 .config
500 .username
501 .clone()
502 .unwrap_or_else(|| "unknown".to_string());
503 if !crate::utils::nobots(&html, &username)? {
504 return Err(Error::Nobots);
505 }
506 Ok(())
507 }
508
509 async fn with_save_lock<F: Future<Output = T>, T>(&self, action: F) -> T {
510 let _save_lock = if let Some(save_timer) = &self.bot.state.save_timer {
512 let mut save_lock = save_timer.lock().await;
513 save_lock.tick().await;
516 Some(save_lock)
517 } else {
518 None
519 };
520
521 action.await
522 }
523}
524
525impl Display for Page {
526 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
527 f.write_str(self.title())
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use std::time::{Duration, SystemTime};
534
535 use crate::tests::{has_userright, is_authenticated, testwp};
536 use crate::Error;
537
538 use super::*;
539
540 #[tokio::test]
541 async fn test_exists() {
542 let bot = testwp().await;
543 let page = bot.page("Main Page").unwrap();
544 assert!(page.exists().await.unwrap());
545 let page2 = bot.page("DoesNotExistPlease").unwrap();
546 assert!(!page2.exists().await.unwrap());
547 }
548
549 #[tokio::test]
550 async fn test_title() {
551 let bot = testwp().await;
552 let page = bot.page("Main Page ").unwrap();
554 assert_eq!(page.title(), "Main Page");
555 assert_eq!(page.as_title().dbkey(), "Main_Page");
556 }
557
558 #[tokio::test]
559 async fn test_get_redirect_target() {
560 let bot = testwp().await;
561 let redir = bot.page("Mwbot-rs/Redirect").unwrap();
562 let target = redir.redirect_target().await.unwrap().unwrap();
563 assert_eq!(target.title(), "Main Page");
565 assert!(target.redirect_target().await.unwrap().is_none());
567 }
568
569 #[tokio::test]
570 async fn test_get_content() {
571 let bot = testwp().await;
572 let page = bot.page("Main Page").unwrap();
573 let html = page.html().await.unwrap().into_mutable();
574 assert_eq!(html.title().unwrap(), "Main Page".to_string());
575 assert_eq!(
576 html.select_first("b").unwrap().text_contents(),
577 "test wiki".to_string()
578 );
579 let wikitext = page.wikitext().await.unwrap();
580 assert!(wikitext.contains("'''test wiki'''"));
581 }
582
583 #[tokio::test]
584 async fn test_set_baserevid() {
585 let bot = testwp().await;
586 let page = bot.page("Main Page").unwrap();
587 assert!(page.baserevid.get().is_none());
588 page.info().await.unwrap();
589 assert!(page.baserevid.get().is_some());
590 }
591
592 #[tokio::test]
593 async fn test_missing_page() {
594 let bot = testwp().await;
595 let page = bot.page("DoesNotExistPlease").unwrap();
596 let err = page.html().await.unwrap_err();
597 match err {
598 Error::PageDoesNotExist(page) => {
599 assert_eq!(&page, "DoesNotExistPlease")
600 }
601 err => {
602 panic!("Unexpected error: {err:?}")
603 }
604 }
605 let err2 = page.wikitext().await.unwrap_err();
606 match err2 {
607 Error::PageDoesNotExist(page) => {
608 assert_eq!(&page, "DoesNotExistPlease")
609 }
610 err => {
611 panic!("Unexpected error: {err:?}")
612 }
613 }
614 }
615
616 #[tokio::test]
617 async fn test_save() {
618 if !is_authenticated() {
619 return;
620 }
621
622 let bot = testwp().await;
623 let wikitext = format!(
624 "It has been {} seconds since the epoch.",
625 SystemTime::now()
626 .duration_since(SystemTime::UNIX_EPOCH)
627 .unwrap()
628 .as_secs()
629 );
630 let mut retries = 0;
631 loop {
632 let page = bot.page("mwbot-rs/Save").unwrap();
633 let resp = page
634 .save(
635 wikitext.to_string(),
636 &SaveOptions::summary("Test suite edit"),
637 )
638 .await;
639 match resp {
640 Ok(resp) => {
641 assert_eq!(&resp.1.title, "Mwbot-rs/Save");
642 return;
643 }
644 Err(Error::EditConflict) => {
645 if retries > 5 {
646 panic!("hit more than 5 edit conflicts");
647 }
648 retries += 1;
649 tokio::time::sleep(Duration::from_secs(5)).await;
650 continue;
651 }
652 Err(ref err) => {
653 dbg!(&resp);
654 panic!("{}", err);
655 }
656 }
657 }
658 }
659
660 #[tokio::test]
661 async fn test_undo() {
662 if !is_authenticated() {
663 return;
664 }
665
666 let bot = testwp().await;
667 let page = bot.page("mwbot-rs/Undo").unwrap();
668 let wikitext = format!(
669 "It has been {} seconds since the epoch.",
670 SystemTime::now()
671 .duration_since(SystemTime::UNIX_EPOCH)
672 .unwrap()
673 .as_secs()
674 );
675 let (page, _) = page
676 .save(wikitext, &SaveOptions::summary("Test suite edit"))
677 .await
678 .unwrap();
679
680 let revision_id = page.info().await.unwrap().lastrevid.unwrap();
681
682 let (page, _) = page
683 .undo(revision_id, None, &SaveOptions::summary("Test suite edit"))
684 .await
685 .unwrap();
686 let undoed_wikitext = page.wikitext().await.unwrap();
687
688 assert_eq!(undoed_wikitext, "This page is used to test undo.");
689 }
690
691 #[tokio::test]
692 async fn test_protected() {
693 if !is_authenticated() {
694 return;
695 }
696
697 let bot = testwp().await;
698 let page = bot.page("mwbot-rs/Protected").unwrap();
699 let wikitext = "Wait, I can edit this page?".to_string();
700 let error = page
701 .save(wikitext, &SaveOptions::summary("Test suite edit"))
702 .await
703 .unwrap_err();
704 dbg!(&error);
705 assert!(matches!(error, Error::ProtectedPage));
706 }
707
708 #[tokio::test]
709 async fn test_spamfilter() {
710 let bot = testwp().await;
711 if !is_authenticated() || !has_userright(&bot, "sboverride").await {
712 return;
713 }
714
715 let page = bot.page("mwbot-rs/SpamBlacklist").unwrap();
716 let wikitext = "https://bitly.com/12345".to_string();
717 let error = page
718 .save(wikitext, &SaveOptions::summary("Test suite edit"))
719 .await
720 .unwrap_err();
721 dbg!(&error);
722 if let Error::SpamFilter { matches, .. } = error {
723 assert_eq!(matches, vec!["bitly.com/1".to_string()])
724 } else {
725 panic!("{error:?} doesn't match")
726 }
727 }
728
729 #[tokio::test]
730 async fn test_partialblock() {
731 if !is_authenticated() {
732 return;
733 }
734 let bot = testwp().await;
735 let page = bot.page("Mwbot-rs/Partially blocked").unwrap();
736 let error = page
737 .save(
738 "I shouldn't be able to edit this".to_string(),
739 &SaveOptions::summary("Test suite edit"),
740 )
741 .await
742 .unwrap_err();
743 dbg!(&error);
744 if let Error::PartiallyBlocked { info, .. } = error {
745 assert!(info.starts_with("<strong>Your username or IP address is blocked from doing this"));
746 } else {
747 panic!("{error:?} doesn't match");
748 }
749 }
750
751 #[tokio::test]
756 async fn test_invalidtitle() {
757 let bot = testwp().await;
758 let err = bot.page("<invalid title>").unwrap_err();
760 assert!(matches!(err, Error::InvalidTitle(_)));
761 let err = bot.page("Special:BlankPage").unwrap_err();
762 assert!(matches!(err, Error::InvalidPage));
763 }
764
765 #[tokio::test]
766 async fn test_editconflict() {
767 if !is_authenticated() {
768 return;
769 }
770 let bot = testwp().await;
771 let page = bot.page("mwbot-rs/Edit conflict").unwrap();
772 page.baserevid.set(498547).unwrap();
774 let err = page
775 .save(
776 "This should fail",
777 &SaveOptions::summary("this should fail"),
778 )
779 .await
780 .unwrap_err();
781 dbg!(&err);
782 assert!(matches!(err, Error::EditConflict));
783 }
784
785 #[tokio::test]
786 async fn test_associated_page() {
787 let bot = testwp().await;
788 let page = bot.page("Main Page").unwrap();
789 assert_eq!(
790 page.associated_page().await.unwrap().title(),
791 "Talk:Main Page"
792 );
793 }
794
795 #[tokio::test]
796 async fn test_nobots() {
797 if !is_authenticated() {
798 return;
799 }
800 let bot = testwp().await;
801 let page = bot.page("Mwbot-rs/Nobots").unwrap();
802 let error = page
803 .save(
804 "This edit should not go through due to the {{nobots}} template".to_string(),
805 &SaveOptions::summary("Test suite edit"),
806 )
807 .await
808 .unwrap_err();
809 assert!(matches!(error, Error::Nobots));
810 }
811
812 #[tokio::test]
813 async fn test_display() {
814 let bot = testwp().await;
815 let page = bot.page("Main Page").unwrap();
816 assert_eq!(format!("{}", page), "Main Page");
817 }
818
819 #[tokio::test]
820 async fn test_touched() {
821 let bot = testwp().await;
822 assert!(bot
823 .page("Main Page")
824 .unwrap()
825 .touched()
826 .await
827 .unwrap()
828 .is_some());
829 }
830
831 #[tokio::test]
832 async fn test_latest_revision_id() {
833 let bot = testwp().await;
834 assert!(bot
835 .page("Main Page")
836 .unwrap()
837 .latest_revision_id()
838 .await
839 .unwrap()
840 .is_some());
841 }
842
843 #[tokio::test]
844 async fn test_language_links() {
845 let bot = testwp().await;
846 assert_eq!(
847 bot.page("Mwbot-rs/Langlink")
848 .unwrap()
849 .language_links()
850 .await
851 .unwrap()
852 .unwrap()
853 .into_iter()
854 .collect::<Vec<_>>(),
855 [("en".to_string(), "Stick style".to_string())]
856 );
857 }
858
859 #[tokio::test]
860 async fn test_categories() {
861 let bot = testwp().await;
862 assert_eq!(
863 bot.page("Mwbot-rs/Categorized")
864 .unwrap()
865 .categories()
866 .await
867 .unwrap()
868 .unwrap(),
869 ["Category:Mwbot-rs"]
870 );
871 }
872
873 #[tokio::test]
874 async fn test_templates() {
875 let bot = testwp().await;
876 assert!(bot
877 .page("Mwbot-rs/Transcluded")
878 .unwrap()
879 .templates(None)
880 .await
881 .unwrap()
882 .unwrap()
883 .contains(&"Main Page".to_string()));
884 }
885}