use crate::edit::{EditResponse, SaveOptions, Saveable};
#[cfg(feature = "upload")]
#[cfg_attr(docsrs, doc(cfg(feature = "upload")))]
use crate::file::File;
#[cfg(feature = "generators")]
#[cfg_attr(docsrs, doc(cfg(feature = "generators")))]
use crate::generators::{
categories::Categories, langlinks::LangLinks, templates::Templates,
Generator,
};
use crate::parsoid::ImmutableWikicode;
use crate::{Bot, Error, Result, Title};
use mwapi_responses::prelude::*;
use mwtimestamp::Timestamp;
use once_cell::sync::OnceCell;
use serde_json::Value;
#[cfg(feature = "generators")]
use std::collections::HashMap;
use std::fmt::Display;
use std::future::Future;
use std::sync::Arc;
use tokio::sync::OnceCell as AsyncOnceCell;
use tracing::info;
#[derive(Debug, Clone)]
pub struct Page {
pub(crate) bot: Bot,
pub(crate) title: Title,
pub(crate) title_text: OnceCell<String>,
pub(crate) info: Arc<AsyncOnceCell<InfoResponseItem>>,
pub(crate) baserevid: OnceCell<u64>,
}
#[doc(hidden)]
#[non_exhaustive]
#[query(prop = "info", inprop = "associatedpage|protection|url")]
pub struct InfoResponse {}
impl Page {
pub fn title(&self) -> &str {
self.title_text.get_or_init(|| {
let codec = &self.bot.config.codec;
codec.to_pretty(&self.title)
})
}
pub fn as_title(&self) -> &Title {
&self.title
}
pub fn namespace(&self) -> i32 {
self.title.namespace()
}
pub fn is_file(&self) -> bool {
self.title.is_file()
}
#[cfg(feature = "upload")]
#[cfg_attr(docsrs, doc(cfg(feature = "upload")))]
pub fn as_file(&self) -> Option<File> {
if self.is_file() {
Some(File::new(self))
} else {
None
}
}
pub fn is_category(&self) -> bool {
self.title.is_category()
}
async fn info(&self) -> Result<&InfoResponseItem> {
self.info
.get_or_try_init(|| async {
let mut resp: InfoResponse = mwapi_responses::query_api(
&self.bot.api,
[("titles", self.title())],
)
.await?;
let info = resp
.query
.pages
.pop()
.expect("API response returned 0 pages");
if let Some(revid) = info.lastrevid {
let _ = self.baserevid.set(revid);
}
Ok(info)
})
.await
}
pub async fn exists(&self) -> Result<bool> {
Ok(!self.info().await?.missing)
}
pub async fn id(&self) -> Result<Option<u32>> {
Ok(self.info().await?.pageid)
}
pub async fn url(&self) -> Result<&str> {
Ok(&self.info().await?.canonicalurl)
}
pub async fn is_redirect(&self) -> Result<bool> {
Ok(self.info().await?.redirect)
}
pub async fn associated_page(&self) -> Result<Page> {
self.bot.page(&self.info().await?.associatedpage)
}
pub async fn touched(&self) -> Result<Option<Timestamp>> {
Ok(self.info().await?.touched)
}
pub async fn latest_revision_id(&self) -> Result<Option<u64>> {
Ok(self.info().await?.lastrevid)
}
pub async fn redirect_target(&self) -> Result<Option<Page>> {
if self.info.initialized() && !self.is_redirect().await? {
return Ok(None);
}
let mut resp: InfoResponse = mwapi_responses::query_api(
&self.bot.api,
[("titles", self.title()), ("redirects", "1")],
)
.await?;
match resp.title_map().get(self.title()) {
Some(redirect) => {
let page = self.bot.page(redirect)?;
page.info
.set(
resp.query
.pages
.pop()
.expect("API response returned 0 pages"),
)
.unwrap();
Ok(Some(page))
}
None => Ok(None),
}
}
#[cfg(feature = "generators")]
#[cfg_attr(docsrs, doc(cfg(feature = "generators")))]
pub async fn language_links(
&self,
) -> Result<Option<HashMap<String, String>>> {
if self.info.initialized() && !self.exists().await? {
return Ok(None);
}
let mut gen =
LangLinks::new(vec![self.title().to_string()]).generate(&self.bot);
let page = gen.recv().await.unwrap()?;
debug_assert_eq!(page.title, self.title());
debug_assert!(gen.recv().await.is_none());
if page.missing || page.invalid {
return Ok(None);
}
let links = page
.langlinks
.into_iter()
.map(|item| (item.lang, item.title))
.collect();
Ok(Some(links))
}
#[cfg(feature = "generators")]
#[cfg_attr(docsrs, doc(cfg(feature = "generators")))]
pub async fn categories(&self) -> Result<Option<Vec<String>>> {
if self.info.initialized() && !self.exists().await? {
return Ok(None);
}
let mut gen =
Categories::new(vec![self.title().to_string()]).generate(&self.bot);
let mut found = Vec::new();
while let Some(page) = gen.recv().await {
found.push(page?.title().to_string());
}
Ok(Some(found))
}
#[cfg(feature = "generators")]
#[cfg_attr(docsrs, doc(cfg(feature = "generators")))]
pub async fn templates(
&self,
only: Option<Vec<String>>,
) -> Result<Option<Vec<String>>> {
if self.info.initialized() && !self.exists().await? {
return Ok(None);
}
let mut gen = Templates::new(vec![self.title().to_string()])
.with_templates(only)
.generate(&self.bot);
let mut found = Vec::new();
while let Some(page) = gen.recv().await {
found.push(page?.title().to_string());
}
Ok(Some(found))
}
pub async fn html(&self) -> Result<ImmutableWikicode> {
match self.baserevid.get() {
None => {
let resp = self.bot.parsoid.get(self.title()).await?;
if let Some(revid) = &resp.revision_id() {
let _ = self.baserevid.set(*revid);
}
Ok(resp)
}
Some(revid) => {
Ok(self.bot.parsoid.get_revision(self.title(), *revid).await?)
}
}
}
pub async fn revision_html(&self, revid: u64) -> Result<ImmutableWikicode> {
Ok(self.bot.parsoid.get_revision(self.title(), revid).await?)
}
pub async fn wikitext(&self) -> Result<String> {
let mut params: Vec<(&'static str, String)> = vec![
("action", "query".to_string()),
("titles", self.title().to_string()),
("prop", "revisions".to_string()),
("rvprop", "content|ids".to_string()),
("rvslots", "main".to_string()),
];
if let Some(revid) = self.baserevid.get() {
params.push(("rvstartid", revid.to_string()));
params.push(("rvendid", revid.to_string()));
}
let resp = self.bot.api.get_value(¶ms).await?;
let page = resp["query"]["pages"][0].as_object().unwrap();
if page.contains_key("missing") {
Err(Error::PageDoesNotExist(self.title().to_string()))
} else {
match page.get("revisions") {
Some(revisions) => {
let revision = &revisions[0];
let _ =
self.baserevid.set(revision["revid"].as_u64().unwrap());
Ok(revision["slots"]["main"]["content"]
.as_str()
.unwrap()
.to_string())
}
None => {
Err(Error::PageDoesNotExist(self.title().to_string()))
}
}
}
}
pub async fn save<S: Into<Saveable>>(
self,
edit: S,
opts: &SaveOptions,
) -> Result<(Page, EditResponse)> {
let mut exists: Option<bool> = None;
if self.bot.config.respect_nobots {
match self.html().await {
Ok(html) => {
exists = Some(true);
self.nobot_check(html)?;
}
Err(Error::PageDoesNotExist(_)) => {
exists = Some(false);
}
Err(error) => {
return Err(error);
}
}
} else if self.info.initialized() {
exists = Some(self.exists().await?);
}
let edit = edit.into();
let wikitext = match edit {
Saveable::Html(html) => {
self.bot.parsoid.transform_to_wikitext(&html).await?
}
Saveable::Wikitext(wikitext) => wikitext,
};
let mut params: Vec<(&'static str, String)> = vec![
("action", "edit".to_string()),
("title", self.title().to_string()),
("text", wikitext),
("summary", opts.summary.to_string()),
];
if let Some(revid) = self.baserevid.get() {
params.push(("baserevid", revid.to_string()));
}
match exists {
Some(true) => {
params.push(("nocreate", "1".to_string()));
}
Some(false) => {
params.push(("createonly", "1".to_string()));
}
None => {} }
if let Some(section) = &opts.section {
params.push(("section", section.to_string()));
}
if opts.mark_as_bot.unwrap_or(self.bot.config.mark_as_bot) {
params.push(("bot", "1".to_string()));
}
if !opts.tags.is_empty() {
params.push(("tags", opts.tags.join("|")));
}
if let Some(minor) = opts.minor {
if minor {
params.push(("minor", "1".to_string()));
} else {
params.push(("notminor", "1".to_string()));
}
}
let resp: Value = self
.with_save_lock(async {
info!("Saving [[{}]]", self.title());
self.bot.api.post_with_token("csrf", ¶ms).await
})
.await?;
self.page_from_response(resp)
}
pub async fn undo(
self,
from: u64,
to: Option<u64>,
opts: &SaveOptions,
) -> Result<(Page, EditResponse)> {
if self.bot.config.respect_nobots {
let html = self.html().await?;
self.nobot_check(html)?;
} else if self.info.initialized() && self.exists().await? {
return Err(Error::PageDoesNotExist(self.title().to_string()));
}
let mut params: Vec<(&'static str, String)> = vec![
("action", "edit".to_string()),
("title", self.title().to_string()),
("undo", from.to_string()),
("summary", opts.summary.to_string()),
("nocreate", "1".to_string()), ];
if let Some(revid) = self.baserevid.get() {
params.push(("baserevid", revid.to_string()));
}
if let Some(to) = to {
params.push(("undoafter", to.to_string()));
}
if opts.mark_as_bot.unwrap_or(self.bot.config.mark_as_bot) {
params.push(("bot", "1".to_string()));
}
if !opts.tags.is_empty() {
params.push(("tags", opts.tags.join("|")));
}
if let Some(minor) = opts.minor {
if minor {
params.push(("minor", "1".to_string()));
} else {
params.push(("notminor", "1".to_string()));
}
}
let resp: Value = self
.with_save_lock(async {
info!("Undoing edit [[{}]]", self.title());
self.bot.api.post_with_token("csrf", ¶ms).await
})
.await?;
self.page_from_response(resp)
}
fn page_from_response(self, resp: Value) -> Result<(Page, EditResponse)> {
match resp["edit"]["result"].as_str() {
Some("Success") => {
let edit_response: EditResponse =
serde_json::from_value(resp["edit"].clone())?;
if !edit_response.nochange {
let page = Page {
bot: self.bot,
title: self.title,
title_text: self.title_text,
info: Default::default(),
baserevid: OnceCell::from(
edit_response.newrevid.unwrap(),
),
};
Ok((page, edit_response))
} else {
Ok((self, edit_response))
}
}
_ => Err(Error::UnknownSaveFailure(resp)),
}
}
fn nobot_check(&self, html: ImmutableWikicode) -> Result<()> {
let username = self
.bot
.config
.username
.clone()
.unwrap_or_else(|| "unknown".to_string());
if !crate::utils::nobots(&html, &username)? {
return Err(Error::Nobots);
}
Ok(())
}
async fn with_save_lock<F: Future<Output = T>, T>(&self, action: F) -> T {
let _save_lock = if let Some(save_timer) = &self.bot.state.save_timer {
let mut save_lock = save_timer.lock().await;
save_lock.tick().await;
Some(save_lock)
} else {
None
};
action.await
}
}
impl Display for Page {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.title())
}
}
#[cfg(test)]
mod tests {
use std::time::{Duration, SystemTime};
use crate::tests::{has_userright, is_authenticated, testwp};
use crate::Error;
use super::*;
#[tokio::test]
async fn test_exists() {
let bot = testwp().await;
let page = bot.page("Main Page").unwrap();
assert!(page.exists().await.unwrap());
let page2 = bot.page("DoesNotExistPlease").unwrap();
assert!(!page2.exists().await.unwrap());
}
#[tokio::test]
async fn test_title() {
let bot = testwp().await;
let page = bot.page("Main Page ").unwrap();
assert_eq!(page.title(), "Main Page");
assert_eq!(page.as_title().dbkey(), "Main_Page");
}
#[tokio::test]
async fn test_get_redirect_target() {
let bot = testwp().await;
let redir = bot.page("Mwbot-rs/Redirect").unwrap();
let target = redir.redirect_target().await.unwrap().unwrap();
assert_eq!(target.title(), "Main Page");
assert!(target.redirect_target().await.unwrap().is_none());
}
#[tokio::test]
async fn test_get_content() {
let bot = testwp().await;
let page = bot.page("Main Page").unwrap();
let html = page.html().await.unwrap().into_mutable();
assert_eq!(html.title().unwrap(), "Main Page".to_string());
assert_eq!(
html.select_first("b").unwrap().text_contents(),
"test wiki".to_string()
);
let wikitext = page.wikitext().await.unwrap();
assert!(wikitext.contains("'''test wiki'''"));
}
#[tokio::test]
async fn test_set_baserevid() {
let bot = testwp().await;
let page = bot.page("Main Page").unwrap();
assert!(page.baserevid.get().is_none());
page.info().await.unwrap();
assert!(page.baserevid.get().is_some());
}
#[tokio::test]
async fn test_missing_page() {
let bot = testwp().await;
let page = bot.page("DoesNotExistPlease").unwrap();
let err = page.html().await.unwrap_err();
match err {
Error::PageDoesNotExist(page) => {
assert_eq!(&page, "DoesNotExistPlease")
}
err => {
panic!("Unexpected error: {err:?}")
}
}
let err2 = page.wikitext().await.unwrap_err();
match err2 {
Error::PageDoesNotExist(page) => {
assert_eq!(&page, "DoesNotExistPlease")
}
err => {
panic!("Unexpected error: {err:?}")
}
}
}
#[tokio::test]
async fn test_save() {
if !is_authenticated() {
return;
}
let bot = testwp().await;
let wikitext = format!(
"It has been {} seconds since the epoch.",
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs()
);
let mut retries = 0;
loop {
let page = bot.page("mwbot-rs/Save").unwrap();
let resp = page
.save(
wikitext.to_string(),
&SaveOptions::summary("Test suite edit"),
)
.await;
match resp {
Ok(resp) => {
assert_eq!(&resp.1.title, "Mwbot-rs/Save");
return;
}
Err(Error::EditConflict) => {
if retries > 5 {
panic!("hit more than 5 edit conflicts");
}
retries += 1;
tokio::time::sleep(Duration::from_secs(5)).await;
continue;
}
Err(ref err) => {
dbg!(&resp);
panic!("{}", err);
}
}
}
}
#[tokio::test]
#[ignore = "T391120"]
async fn test_undo() {
if !is_authenticated() {
return;
}
let bot = testwp().await;
let page = bot.page("mwbot-rs/Undo").unwrap();
let wikitext = format!(
"It has been {} seconds since the epoch.",
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs()
);
let (page, _) = page
.save(wikitext, &SaveOptions::summary("Test suite edit"))
.await
.unwrap();
let revision_id = page.info().await.unwrap().lastrevid.unwrap();
let (page, _) = page
.undo(revision_id, None, &SaveOptions::summary("Test suite edit"))
.await
.unwrap();
let undoed_wikitext = page.wikitext().await.unwrap();
assert_eq!(undoed_wikitext, "This page is used to test undo.");
}
#[tokio::test]
async fn test_protected() {
if !is_authenticated() {
return;
}
let bot = testwp().await;
let page = bot.page("mwbot-rs/Protected").unwrap();
let wikitext = "Wait, I can edit this page?".to_string();
let error = page
.save(wikitext, &SaveOptions::summary("Test suite edit"))
.await
.unwrap_err();
dbg!(&error);
assert!(matches!(error, Error::ProtectedPage));
}
#[tokio::test]
async fn test_spamfilter() {
let bot = testwp().await;
if !is_authenticated() || !has_userright(&bot, "sboverride").await {
return;
}
let page = bot.page("mwbot-rs/SpamBlacklist").unwrap();
let wikitext = "https://bitly.com/12345".to_string();
let error = page
.save(wikitext, &SaveOptions::summary("Test suite edit"))
.await
.unwrap_err();
dbg!(&error);
if let Error::SpamFilter { matches, .. } = error {
assert_eq!(matches, vec!["bitly.com/1".to_string()])
} else {
panic!("{error:?} doesn't match")
}
}
#[tokio::test]
async fn test_partialblock() {
if !is_authenticated() {
return;
}
let bot = testwp().await;
let page = bot.page("Mwbot-rs/Partially blocked").unwrap();
let error = page
.save(
"I shouldn't be able to edit this".to_string(),
&SaveOptions::summary("Test suite edit"),
)
.await
.unwrap_err();
dbg!(&error);
if let Error::PartiallyBlocked { info, .. } = error {
assert!(info.starts_with("<strong>Your username or IP address is blocked from doing this"));
} else {
panic!("{error:?} doesn't match");
}
}
#[tokio::test]
async fn test_invalidtitle() {
let bot = testwp().await;
let err = bot.page("<invalid title>").unwrap_err();
assert!(matches!(err, Error::InvalidTitle(_)));
let err = bot.page("Special:BlankPage").unwrap_err();
assert!(matches!(err, Error::InvalidPage));
}
#[tokio::test]
async fn test_editconflict() {
if !is_authenticated() {
return;
}
let bot = testwp().await;
let page = bot.page("mwbot-rs/Edit conflict").unwrap();
page.baserevid.set(498547).unwrap();
let err = page
.save(
"This should fail",
&SaveOptions::summary("this should fail"),
)
.await
.unwrap_err();
dbg!(&err);
assert!(matches!(err, Error::EditConflict));
}
#[tokio::test]
async fn test_associated_page() {
let bot = testwp().await;
let page = bot.page("Main Page").unwrap();
assert_eq!(
page.associated_page().await.unwrap().title(),
"Talk:Main Page"
);
}
#[tokio::test]
async fn test_nobots() {
if !is_authenticated() {
return;
}
let bot = testwp().await;
let page = bot.page("Mwbot-rs/Nobots").unwrap();
let error = page
.save(
"This edit should not go through due to the {{nobots}} template".to_string(),
&SaveOptions::summary("Test suite edit"),
)
.await
.unwrap_err();
assert!(matches!(error, Error::Nobots));
}
#[tokio::test]
async fn test_display() {
let bot = testwp().await;
let page = bot.page("Main Page").unwrap();
assert_eq!(format!("{}", page), "Main Page");
}
#[tokio::test]
async fn test_touched() {
let bot = testwp().await;
assert!(bot
.page("Main Page")
.unwrap()
.touched()
.await
.unwrap()
.is_some());
}
#[tokio::test]
async fn test_latest_revision_id() {
let bot = testwp().await;
assert!(bot
.page("Main Page")
.unwrap()
.latest_revision_id()
.await
.unwrap()
.is_some());
}
#[tokio::test]
async fn test_language_links() {
let bot = testwp().await;
assert_eq!(
bot.page("Mwbot-rs/Langlink")
.unwrap()
.language_links()
.await
.unwrap()
.unwrap()
.into_iter()
.collect::<Vec<_>>(),
[("en".to_string(), "Stick style".to_string())]
);
}
#[tokio::test]
async fn test_categories() {
let bot = testwp().await;
assert_eq!(
bot.page("Mwbot-rs/Categorized")
.unwrap()
.categories()
.await
.unwrap()
.unwrap(),
["Category:Mwbot-rs"]
);
}
#[tokio::test]
async fn test_templates() {
let bot = testwp().await;
assert!(bot
.page("Mwbot-rs/Transcluded")
.unwrap()
.templates(None)
.await
.unwrap()
.unwrap()
.contains(&"Main Page".to_string()));
}
}