use std::path::{Path, PathBuf};
use clap::Subcommand;
use crate::api::wiki::{CommentScope, GridQuery, LAST_SEARCH_PAGE, slug_of};
use crate::cli::attachment::safe_filename;
use crate::cli::write::{Gate, Intent, check};
use crate::cli::{Session, emit, report};
use crate::exit::ExitCode;
use crate::render::{Format, RenderError, machine, wiki as render};
#[derive(Debug, Subcommand)]
pub enum WikiCommand {
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_GET))]
Get {
page: String,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_LIST))]
List {
page: String,
#[arg(long)]
cursor: Option<String>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_FIND))]
Find {
text: String,
#[arg(long = "type", value_parser = ["page", "file"])]
kind: Option<String>,
#[arg(long, default_value_t = 1)]
page: u32,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_COMMENTS))]
Comments {
page: String,
#[arg(long, conflicts_with = "status")]
thread: Option<u64>,
#[arg(long, value_parser = ["resolved", "unresolved"])]
status: Option<String>,
#[arg(long)]
cursor: Option<String>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_ATTACHMENTS))]
Attachments {
page: String,
#[arg(long)]
cursor: Option<String>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_GRIDS))]
Grids {
page: String,
#[arg(long)]
cursor: Option<String>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_GRID))]
Grid {
grid: String,
#[arg(long)]
filter: Option<String>,
#[arg(long, allow_hyphen_values = true)]
sort: Option<String>,
#[arg(long)]
columns: Option<String>,
#[arg(long)]
rows: Option<String>,
#[arg(long)]
revision: Option<u64>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_RESOURCES))]
Resources {
page: String,
#[arg(long = "type", value_parser = ["attachment", "grid"])]
kind: Option<String>,
#[arg(long)]
query: Option<String>,
#[arg(long)]
cursor: Option<String>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_CREATE))]
Create {
page: String,
#[arg(long, short = 't')]
title: String,
#[arg(long, value_name = "PATH")]
from: Option<String>,
#[arg(long)]
silent: bool,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_UPDATE))]
Update {
page: String,
#[arg(long, short = 't')]
title: Option<String>,
#[arg(long, value_name = "PATH")]
from: Option<String>,
#[arg(long)]
merge: bool,
#[arg(long)]
silent: bool,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_APPEND))]
Append {
page: String,
#[arg(long, value_name = "PATH")]
from: String,
#[arg(long, conflicts_with = "anchor")]
top: bool,
#[arg(long)]
anchor: Option<String>,
#[arg(long)]
silent: bool,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_DELETE))]
Delete {
page: String,
#[arg(long)]
recursive: bool,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_RESTORE))]
Restore { token: String },
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_COMMENT))]
Comment {
page: String,
text: String,
#[arg(long, value_name = "ID")]
reply_to: Option<u64>,
#[arg(long)]
quote: Option<String>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_DELETE_COMMENT))]
DeleteComment {
page: String,
comment: u64,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_ACCESS))]
Access {
page: String,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_GRANT))]
#[command(group(clap::ArgGroup::new("who").required(true).multiple(false)))]
Grant {
page: String,
#[arg(long, value_parser = ["reader", "editor", "extra_editor", "author"])]
role: String,
#[arg(long, group = "who")]
user: Option<String>,
#[arg(long, group = "who")]
uid: Option<String>,
#[arg(long, group = "who", value_name = "ID")]
cloud_uid: Option<String>,
#[arg(long, group = "who", value_name = "SOURCE:ID")]
group: Option<String>,
#[arg(long)]
no_inherit: bool,
#[arg(long)]
allow_selflock: bool,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_REGRANT))]
Regrant {
page: String,
access: String,
#[arg(long, value_parser = ["reader", "editor", "extra_editor", "author"])]
role: Option<String>,
#[arg(long, value_parser = ["inherited", "not_inherited"])]
inheritance: Option<String>,
#[arg(long)]
allow_selflock: bool,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_REVOKE))]
Revoke {
page: String,
#[arg(required_unless_present = "all")]
access: Option<String>,
#[arg(long, conflicts_with = "access")]
all: bool,
#[arg(long)]
allow_selflock: bool,
},
#[command(name = "clone", long_about = crate::cli::help::md(crate::cli::help::WIKI_CLONE))]
ClonePage {
page: String,
target: String,
#[arg(long, short = 't')]
title: Option<String>,
#[arg(long)]
subscribe: bool,
#[arg(long)]
no_wait: bool,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_CLONE_GRID))]
CloneGrid {
grid: String,
target: String,
#[arg(long, short = 't')]
title: Option<String>,
#[arg(long)]
with_data: bool,
#[arg(long)]
no_wait: bool,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_OPERATION))]
Operation {
#[arg(value_parser = ["clone", "clone_inline_grid"])]
kind: String,
id: String,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_GRID_CREATE))]
CreateGrid {
page: String,
#[arg(long, short = 't')]
title: String,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_GRID_UPDATE))]
UpdateGrid {
grid: String,
#[arg(long, short = 't')]
title: Option<String>,
#[arg(long, value_name = "SLUG:DIR,...")]
sort: Option<String>,
#[arg(long)]
revision: Option<String>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_GRID_DELETE))]
DeleteGrid {
grid: String,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_ROWS_ADD))]
RowsAdd {
grid: String,
#[arg(long, value_name = "PATH")]
from: String,
#[arg(long, value_name = "ROW", conflicts_with = "position")]
after: Option<String>,
#[arg(long)]
position: Option<u64>,
#[arg(long)]
revision: Option<String>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_ROWS_DELETE))]
RowsDelete {
grid: String,
#[arg(required = true, value_name = "ROW")]
rows: Vec<String>,
#[arg(long)]
revision: Option<String>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_ROWS_MOVE))]
RowsMove {
grid: String,
row: String,
#[arg(
long,
value_name = "ROW",
conflicts_with = "position",
required_unless_present = "position"
)]
after: Option<String>,
#[arg(long)]
position: Option<u64>,
#[arg(long)]
count: Option<u64>,
#[arg(long)]
revision: Option<String>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_COLUMNS_ADD))]
ColumnsAdd {
grid: String,
#[arg(long, value_name = "PATH")]
from: String,
#[arg(long)]
position: Option<u64>,
#[arg(long)]
revision: Option<String>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_COLUMNS_DELETE))]
ColumnsDelete {
grid: String,
#[arg(required = true, value_name = "SLUG")]
columns: Vec<String>,
#[arg(long)]
revision: Option<String>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_COLUMNS_MOVE))]
ColumnsMove {
grid: String,
column: String,
#[arg(long)]
position: u64,
#[arg(long)]
count: Option<u64>,
#[arg(long)]
revision: Option<String>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_CELLS_SET))]
CellsSet {
grid: String,
#[arg(long = "set", value_name = "ROW:SLUG=VALUE", required = true)]
set: Vec<String>,
#[arg(long)]
revision: Option<String>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_UPLOAD))]
Upload {
page: String,
#[arg(required = true, value_name = "FILE")]
files: Vec<PathBuf>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_DELETE_ATTACHMENT))]
DeleteAttachment {
page: String,
file: String,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_DOWNLOAD))]
Download {
page: String,
file: Option<String>,
#[arg(long, short = 'o')]
out: PathBuf,
#[arg(long)]
force: bool,
},
}
#[allow(
clippy::too_many_lines,
reason = "one arm per verb; splitting the dispatch would only hide the list"
)]
pub async fn run(command: &WikiCommand, session: &Session) -> ExitCode {
match command {
WikiCommand::Get { page } => get(page, session).await,
WikiCommand::List { page, cursor } => list(page, cursor.as_deref(), session).await,
WikiCommand::Find { text, kind, page } => find(text, kind.as_deref(), *page, session).await,
WikiCommand::Comments {
page,
thread,
status,
cursor,
} => {
let scope = match thread {
Some(comment) => CommentScope::Thread(*comment),
None => CommentScope::Page {
status: status.as_deref(),
},
};
comments(page, scope, cursor.as_deref(), session).await
}
WikiCommand::Attachments { page, cursor } => {
attachments(page, cursor.as_deref(), session).await
}
WikiCommand::Grids { page, cursor } => grids(page, cursor.as_deref(), session).await,
WikiCommand::Grid {
grid: id,
filter,
sort,
columns,
rows,
revision,
} => {
let query = GridQuery {
filter: filter.as_deref(),
sort: sort.as_deref(),
columns: columns.as_deref(),
rows: rows.as_deref(),
revision: *revision,
};
grid(id, query, session).await
}
WikiCommand::Resources {
page,
kind,
query,
cursor,
} => {
resources(
page,
kind.as_deref(),
query.as_deref(),
cursor.as_deref(),
session,
)
.await
}
WikiCommand::Create {
page,
title,
from,
silent,
} => create(page, title, from.as_deref(), *silent, session).await,
WikiCommand::Update {
page,
title,
from,
merge,
silent,
} => {
let change = Change {
title: title.as_deref(),
from: from.as_deref(),
merge: *merge,
silent: *silent,
};
update(page, change, session).await
}
WikiCommand::Append {
page,
from,
top,
anchor,
silent,
} => append(page, from, place(*top, anchor.as_deref()), *silent, session).await,
WikiCommand::Delete { page, recursive } => delete(page, *recursive, session).await,
WikiCommand::Restore { token } => restore(token, session).await,
WikiCommand::Comment {
page,
text,
reply_to,
quote,
} => comment(page, text, *reply_to, quote.as_deref(), session).await,
WikiCommand::DeleteComment { page, comment } => {
delete_comment(page, *comment, session).await
}
WikiCommand::Access { page } => show_access(page, session).await,
WikiCommand::Grant {
page,
role,
user,
uid,
cloud_uid,
group,
no_inherit,
allow_selflock,
} => {
let who = match (user, uid, cloud_uid, group) {
(Some(login), ..) => Grantee::Login(login),
(_, Some(uid), ..) => Grantee::Uid(uid),
(_, _, Some(id), _) => Grantee::CloudUid(id),
(.., Some(group)) => Grantee::Group(group),
_ => {
return report(
&"name who: --user, --uid, --cloud-uid or --group",
ExitCode::ConfirmationRequired,
);
}
};
grant(page, role, who, *no_inherit, *allow_selflock, session).await
}
WikiCommand::Regrant {
page,
access,
role,
inheritance,
allow_selflock,
} => {
let body = match (role, inheritance) {
(None, None) => {
return report(
&"nothing to change: pass --role, --inheritance, or both",
ExitCode::ConfirmationRequired,
);
}
(role, inheritance) => {
let mut body = serde_json::json!({});
if let Some(role) = role {
body["role"] = serde_json::Value::String(role.clone());
}
if let Some(inheritance) = inheritance {
body["inheritance"] = serde_json::Value::String(inheritance.clone());
}
body
}
};
regrant(page, access, &body, *allow_selflock, session).await
}
WikiCommand::Revoke {
page,
access,
all: _,
allow_selflock,
} => revoke(page, access.as_deref(), *allow_selflock, session).await,
WikiCommand::ClonePage {
page,
target,
title,
subscribe,
no_wait,
} => {
let mut body = serde_json::json!({});
if let Some(title) = title {
body["title"] = serde_json::Value::String(title.clone());
}
if *subscribe {
body["subscribe_me"] = serde_json::Value::Bool(true);
}
clone_page(page, target, body, *no_wait, session).await
}
WikiCommand::CloneGrid {
grid,
target,
title,
with_data,
no_wait,
} => {
let mut body = serde_json::json!({});
if let Some(title) = title {
body["title"] = serde_json::Value::String(title.clone());
}
if *with_data {
body["with_data"] = serde_json::Value::Bool(true);
}
clone_grid(grid, target, body, *no_wait, session).await
}
WikiCommand::Operation { kind, id } => {
let operation = crate::api::wiki::WikiOperation {
id: id.clone(),
kind: kind.clone(),
};
show_operation(&operation, session).await
}
WikiCommand::CreateGrid { page, title } => grid_create(page, title, session).await,
WikiCommand::UpdateGrid {
grid,
title,
sort,
revision,
} => {
grid_update(
grid,
title.as_deref(),
sort.as_deref(),
revision.as_deref(),
session,
)
.await
}
WikiCommand::DeleteGrid { grid } => grid_delete(grid, session).await,
WikiCommand::RowsAdd {
grid,
from,
after,
position,
revision,
} => {
let mut place = serde_json::json!({});
if let Some(after) = after {
place["after_row_id"] = serde_json::Value::String(after.clone());
}
if let Some(position) = position {
place["position"] = serde_json::json!(position);
}
rows_add(grid, from, place, revision.as_deref(), session).await
}
WikiCommand::RowsDelete {
grid,
rows,
revision,
} => {
let change = GridChange {
grid,
action: format!(
"delete {} from wiki grid `{grid}`",
counted(rows.len(), "row")
),
done: format!("deleted {} from grid {grid}", counted(rows.len(), "row")),
method: reqwest::Method::DELETE,
tail: "/rows",
body: serde_json::json!({ "row_ids": rows }),
confirm: true,
revision: revision.as_deref(),
};
change_grid(change, session).await
}
WikiCommand::RowsMove {
grid,
row,
after,
position,
count,
revision,
} => {
let mut body = serde_json::json!({ "row_id": row });
if let Some(after) = after {
body["after_row_id"] = serde_json::Value::String(after.clone());
}
if let Some(position) = position {
body["position"] = serde_json::json!(position);
}
if let Some(count) = count {
body["rows_count"] = serde_json::json!(count);
}
let change = GridChange {
grid,
action: format!("move row {row} in wiki grid `{grid}`"),
done: format!("moved row {row} in grid {grid}"),
method: reqwest::Method::POST,
tail: "/rows/move",
body,
confirm: false,
revision: revision.as_deref(),
};
change_grid(change, session).await
}
WikiCommand::ColumnsAdd {
grid,
from,
position,
revision,
} => columns_add(grid, from, *position, revision.as_deref(), session).await,
WikiCommand::ColumnsDelete {
grid,
columns,
revision,
} => {
let change = GridChange {
grid,
action: format!(
"delete {} from wiki grid `{grid}`",
counted(columns.len(), "column")
),
done: format!(
"deleted {} from grid {grid}",
counted(columns.len(), "column")
),
method: reqwest::Method::DELETE,
tail: "/columns",
body: serde_json::json!({ "column_slugs": columns }),
confirm: true,
revision: revision.as_deref(),
};
change_grid(change, session).await
}
WikiCommand::ColumnsMove {
grid,
column,
position,
count,
revision,
} => {
let mut body = serde_json::json!({ "column_slug": column, "position": position });
if let Some(count) = count {
body["columns_count"] = serde_json::json!(count);
}
let change = GridChange {
grid,
action: format!("move column {column} in wiki grid `{grid}`"),
done: format!("moved column {column} in grid {grid}"),
method: reqwest::Method::POST,
tail: "/columns/move",
body,
confirm: false,
revision: revision.as_deref(),
};
change_grid(change, session).await
}
WikiCommand::CellsSet {
grid,
set,
revision,
} => cells_set(grid, set, revision.as_deref(), session).await,
WikiCommand::Upload { page, files } => upload(page, files, session).await,
WikiCommand::DeleteAttachment { page, file } => {
delete_attachment(page, file, session).await
}
WikiCommand::Download {
page,
file,
out,
force,
} => download(page, file.as_deref(), out, *force, session).await,
}
}
async fn get(page: &str, session: &Session) -> ExitCode {
let slug = match named(page) {
Ok(slug) => slug,
Err(code) => return code,
};
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
match client.wiki_page(&slug).await {
Ok(found) => finish(match session.render.format {
Format::Text => Ok(render::page(&found, &session.render)),
Format::JsonRaw => machine(&found, Format::Json),
other => machine(&found, other),
}),
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
async fn list(page: &str, cursor: Option<&str>, session: &Session) -> ExitCode {
let slug = match named(page) {
Ok(slug) => slug,
Err(code) => return code,
};
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
match client
.wiki_descendants(&slug, cursor, page_size(session))
.await
{
Ok(found) => finish(match session.render.format {
Format::Text => Ok(render::pages(&found, &session.render)),
Format::JsonRaw => machine(&found, Format::Json),
other => machine(&found, other),
}),
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
async fn find(text: &str, kind: Option<&str>, page: u32, session: &Session) -> ExitCode {
if !(1..=LAST_SEARCH_PAGE).contains(&page) {
return report(
&format!("--page runs from 1 to {LAST_SEARCH_PAGE}: the Wiki's search stops there"),
ExitCode::ConfirmationRequired,
);
}
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
let limit = u32::try_from(session.display().limit.clamp(1, 50)).unwrap_or(50);
match client.wiki_search(text, kind, page, limit).await {
Ok(found) => finish(match session.render.format {
Format::Text => Ok(render::hits(&found, &session.render)),
Format::JsonRaw => machine(&found, Format::Json),
other => machine(&found, other),
}),
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
async fn comments(
page: &str,
scope: CommentScope<'_>,
cursor: Option<&str>,
session: &Session,
) -> ExitCode {
let slug = match named(page) {
Ok(slug) => slug,
Err(code) => return code,
};
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
match client
.wiki_comments(&slug, scope, cursor, page_size(session))
.await
{
Ok(found) => finish(match session.render.format {
Format::Text => Ok(render::comments(&slug, &found, &session.render)),
Format::JsonRaw => machine(&found, Format::Json),
other => machine(&found, other),
}),
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
async fn attachments(page: &str, cursor: Option<&str>, session: &Session) -> ExitCode {
let slug = match named(page) {
Ok(slug) => slug,
Err(code) => return code,
};
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
match client
.wiki_attachments(&slug, cursor, page_size(session))
.await
{
Ok(found) => finish(match session.render.format {
Format::Text => Ok(render::attachments(&found, &session.render)),
Format::JsonRaw => machine(&found, Format::Json),
other => machine(&found, other),
}),
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
async fn grids(page: &str, cursor: Option<&str>, session: &Session) -> ExitCode {
let slug = match named(page) {
Ok(slug) => slug,
Err(code) => return code,
};
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
match client.wiki_grids(&slug, cursor, page_size(session)).await {
Ok(found) => finish(match session.render.format {
Format::Text => Ok(render::grids(&found, &session.render)),
Format::JsonRaw => machine(&found, Format::Json),
other => machine(&found, other),
}),
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
async fn grid(id: &str, query: GridQuery<'_>, session: &Session) -> ExitCode {
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
match client.wiki_grid(id.trim(), query).await {
Ok(found) => finish(match session.render.format {
Format::Text => Ok(render::grid(&found, &session.render)),
Format::JsonRaw => machine(&found, Format::Json),
other => machine(&found, other),
}),
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
async fn resources(
page: &str,
kind: Option<&str>,
query: Option<&str>,
cursor: Option<&str>,
session: &Session,
) -> ExitCode {
let slug = match named(page) {
Ok(slug) => slug,
Err(code) => return code,
};
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
match client
.wiki_resources(&slug, kind, query, cursor, page_size(session))
.await
{
Ok(found) => finish(match session.render.format {
Format::Text => Ok(render::resources(&found, &session.render)),
Format::JsonRaw => machine(&found, Format::Json),
other => machine(&found, other),
}),
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
fn content(from: &str) -> Result<String, ExitCode> {
if from == "-" {
let mut text = String::new();
return match std::io::Read::read_to_string(&mut std::io::stdin(), &mut text) {
Ok(_) => Ok(text),
Err(error) => Err(report(&error, ExitCode::Failure)),
};
}
std::fs::read_to_string(from)
.map_err(|error| report(&format!("cannot read {from}: {error}"), ExitCode::Failure))
}
fn gated(
action: &str,
body: &serde_json::Value,
confirm: bool,
session: &Session,
) -> Option<ExitCode> {
let intent = Intent {
action,
targets: &[],
body,
always_confirm: confirm,
};
match check(&intent, session) {
Gate::Proceed => None,
Gate::Stop(code) => Some(code),
}
}
fn failed(error: &crate::api::error::ApiError) -> ExitCode {
report(error, error.exit_code())
}
async fn create(
page: &str,
title: &str,
from: Option<&str>,
silent: bool,
session: &Session,
) -> ExitCode {
let slug = match named(page) {
Ok(slug) => slug,
Err(code) => return code,
};
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
let mut body = serde_json::json!({ "slug": slug, "title": title });
if let Some(from) = from {
match content(from) {
Ok(text) => body["content"] = serde_json::Value::String(text),
Err(code) => return code,
}
}
if let Some(code) = gated(&format!("create wiki page `{slug}`"), &body, false, session) {
return code;
}
match client.wiki_create(&body, silent).await {
Ok(made) => done(
session,
format!("created {} (id {})\n", made.slug, made.id),
&serde_json::json!({ "action": "created", "slug": made.slug, "id": made.id }),
),
Err(error) => failed(&error),
}
}
struct Change<'a> {
title: Option<&'a str>,
from: Option<&'a str>,
merge: bool,
silent: bool,
}
async fn update(page: &str, change: Change<'_>, session: &Session) -> ExitCode {
let slug = match named(page) {
Ok(slug) => slug,
Err(code) => return code,
};
if change.title.is_none() && change.from.is_none() {
return report(
&"nothing to change: pass --title, --from, or both",
ExitCode::ConfirmationRequired,
);
}
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
let mut body = serde_json::json!({});
if let Some(title) = change.title {
body["title"] = serde_json::Value::String(title.to_owned());
}
if let Some(from) = change.from {
match content(from) {
Ok(text) => body["content"] = serde_json::Value::String(text),
Err(code) => return code,
}
}
if let Some(code) = gated(&format!("update wiki page `{slug}`"), &body, false, session) {
return code;
}
let id = match client.wiki_page_id(&slug).await {
Ok(id) => id,
Err(error) => return failed(&error),
};
match client
.wiki_update(id, &body, change.merge, change.silent)
.await
{
Ok(page) => done(
session,
format!("updated {} (id {})\n", page.slug, page.id),
&serde_json::json!({ "action": "updated", "slug": page.slug, "id": page.id }),
),
Err(error) => failed(&error),
}
}
fn place(top: bool, anchor: Option<&str>) -> serde_json::Value {
match anchor {
Some(anchor) => serde_json::json!({ "anchor": { "name": anchor } }),
None => serde_json::json!({
"body": { "location": if top { "top" } else { "bottom" } }
}),
}
}
async fn append(
page: &str,
from: &str,
place: serde_json::Value,
silent: bool,
session: &Session,
) -> ExitCode {
let slug = match named(page) {
Ok(slug) => slug,
Err(code) => return code,
};
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
let text = match content(from) {
Ok(text) => text,
Err(code) => return code,
};
if text.is_empty() {
return report(
&"nothing to append: the text is empty",
ExitCode::ConfirmationRequired,
);
}
let mut body = place;
body["content"] = serde_json::Value::String(text);
if let Some(code) = gated(
&format!("append to wiki page `{slug}`"),
&body,
false,
session,
) {
return code;
}
let id = match client.wiki_page_id(&slug).await {
Ok(id) => id,
Err(error) => return failed(&error),
};
match client.wiki_append(id, &body, silent).await {
Ok(page) => done(
session,
format!("appended to {} (id {})\n", page.slug, page.id),
&serde_json::json!({ "action": "appended", "slug": page.slug, "id": page.id }),
),
Err(error) => failed(&error),
}
}
async fn delete(page: &str, recursive: bool, session: &Session) -> ExitCode {
let slug = match named(page) {
Ok(slug) => slug,
Err(code) => return code,
};
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
let body = serde_json::json!({ "recursive": recursive });
let action = if recursive {
format!("delete wiki page `{slug}` and every page under it")
} else {
format!("delete wiki page `{slug}`")
};
if let Some(code) = gated(&action, &body, recursive, session) {
return code;
}
let id = match client.wiki_page_id(&slug).await {
Ok(id) => id,
Err(error) => return failed(&error),
};
match client.wiki_delete(id, recursive).await {
Ok(token) => done(
session,
format!(
"deleted {slug} (id {id})\n\
recovery token {token} — shown only now; to restore:\n \
ytcli wiki restore {token}\n"
),
&serde_json::json!({
"action": "deleted", "slug": slug, "id": id, "recovery_token": token
}),
),
Err(error) => failed(&error),
}
}
async fn restore(token: &str, session: &Session) -> ExitCode {
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
let body = serde_json::json!({});
if let Some(code) = gated(
&format!("restore the wiki page deleted under token {token}"),
&body,
false,
session,
) {
return code;
}
match client.wiki_restore(token.trim()).await {
Ok(restored) => {
let pages = restored
.pages_count
.map_or_else(String::new, |count| format!(", {count} pages"));
done(
session,
format!("restored {} (id {}{pages})\n", restored.slug, restored.id),
&serde_json::json!({
"action": "restored", "slug": restored.slug, "id": restored.id,
"pages": restored.pages_count
}),
)
}
Err(error) => failed(&error),
}
}
async fn comment(
page: &str,
text: &str,
reply_to: Option<u64>,
quote: Option<&str>,
session: &Session,
) -> ExitCode {
let slug = match named(page) {
Ok(slug) => slug,
Err(code) => return code,
};
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
let said = if text == "-" {
match content("-") {
Ok(text) => text,
Err(code) => return code,
}
} else {
text.to_owned()
};
if said.trim().is_empty() {
return report(
&"nothing to say: the comment is empty",
ExitCode::ConfirmationRequired,
);
}
let mut body = serde_json::json!({ "body": said });
if let Some(parent) = reply_to {
body["parent_id"] = serde_json::json!(parent);
}
if let Some(quote) = quote {
body["inline_text"] = serde_json::Value::String(quote.to_owned());
}
let action = match reply_to {
Some(parent) => format!("reply to comment {parent} on wiki page `{slug}`"),
None => format!("comment on wiki page `{slug}`"),
};
if let Some(code) = gated(&action, &body, false, session) {
return code;
}
let id = match client.wiki_page_id(&slug).await {
Ok(id) => id,
Err(error) => return failed(&error),
};
match client.wiki_comment(id, &body).await {
Ok(made) => done(
session,
format!("commented on {slug}: comment {}\n", made.id),
&serde_json::json!({ "action": "commented", "slug": slug, "comment": made.id }),
),
Err(error) => failed(&error),
}
}
async fn delete_comment(page: &str, comment: u64, session: &Session) -> ExitCode {
let slug = match named(page) {
Ok(slug) => slug,
Err(code) => return code,
};
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
let body = serde_json::json!({ "comment": comment });
if let Some(code) = gated(
&format!("delete comment {comment} on wiki page `{slug}`"),
&body,
true,
session,
) {
return code;
}
let id = match client.wiki_page_id(&slug).await {
Ok(id) => id,
Err(error) => return failed(&error),
};
match client.wiki_delete_comment(id, comment).await {
Ok(left) => {
let left = left.map_or_else(String::new, |count| format!("; {count} left"));
done(
session,
format!("deleted comment {comment} on {slug}{left}\n"),
&serde_json::json!({ "action": "deleted comment", "slug": slug, "comment": comment }),
)
}
Err(error) => failed(&error),
}
}
async fn show_access(page: &str, session: &Session) -> ExitCode {
let slug = match named(page) {
Ok(slug) => slug,
Err(code) => return code,
};
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
match client.wiki_access(&slug).await {
Ok(found) => finish(match session.render.format {
Format::Text => Ok(render::access(&found, &session.render)),
Format::JsonRaw => machine(&found, Format::Json),
other => machine(&found, other),
}),
Err(error) => failed(&error),
}
}
enum Grantee<'a> {
Login(&'a str),
Uid(&'a str),
CloudUid(&'a str),
Group(&'a str),
}
async fn grant(
page: &str,
role: &str,
who: Grantee<'_>,
no_inherit: bool,
allow_selflock: bool,
session: &Session,
) -> ExitCode {
let slug = match named(page) {
Ok(slug) => slug,
Err(code) => return code,
};
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
let mut body = serde_json::json!({ "role": role });
let named_as = match who {
Grantee::Login(login) => {
body["user"] = serde_json::json!({ "uid": format!("<uid of {login}, from Tracker>") });
login.to_owned()
}
Grantee::Uid(uid) => {
body["user"] = serde_json::json!({ "uid": uid });
format!("uid {uid}")
}
Grantee::CloudUid(id) => {
body["user"] = serde_json::json!({ "cloud_uid": id });
format!("cloud uid {id}")
}
Grantee::Group(spec) => {
let Some((source, id)) = spec.split_once(':').filter(|(source, id)| {
matches!(*source, "dir" | "cloud" | "com" | "staff") && !id.is_empty()
}) else {
return report(
&format!(
"--group takes SOURCE:ID, the source one of dir, cloud, com, staff; got `{spec}`"
),
ExitCode::ConfirmationRequired,
);
};
body["group"] = serde_json::json!({ "id": id, "src": source });
format!("group {id}")
}
};
if no_inherit {
body["inheritance"] = serde_json::Value::String("not_inherited".to_owned());
}
if let Some(code) = gated(
&format!("grant {role} on wiki page `{slug}` to {named_as}"),
&body,
false,
session,
) {
return code;
}
if let Grantee::Login(login) = who {
match client.user(login).await {
Ok(person) if !person.uid.is_empty() => {
body["user"] = serde_json::json!({ "uid": person.uid });
}
Ok(_) => {
return report(
&format!("Tracker knows {login} but gives no uid for them; pass --uid"),
ExitCode::NotFound,
);
}
Err(error) => return failed(&error),
}
}
let id = match client.wiki_page_id(&slug).await {
Ok(id) => id,
Err(error) => return failed(&error),
};
match client.wiki_grant(id, &body, allow_selflock).await {
Ok(entry) => done(
session,
format!(
"granted {role} on {slug} to {named_as} (access {})\n",
entry.id
),
&serde_json::json!({
"action": "granted", "slug": slug, "role": role, "who": named_as,
"access": entry.id
}),
),
Err(error) => failed(&error),
}
}
async fn regrant(
page: &str,
access: &str,
body: &serde_json::Value,
allow_selflock: bool,
session: &Session,
) -> ExitCode {
let slug = match named(page) {
Ok(slug) => slug,
Err(code) => return code,
};
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
if let Some(code) = gated(
&format!("change access {access} on wiki page `{slug}`"),
body,
false,
session,
) {
return code;
}
let id = match client.wiki_page_id(&slug).await {
Ok(id) => id,
Err(error) => return failed(&error),
};
match client.wiki_regrant(id, access, body, allow_selflock).await {
Ok(entry) => {
let role = if entry.role.is_empty() {
"-"
} else {
&entry.role
};
done(
session,
format!("changed access {access} on {slug}: {role}\n"),
&serde_json::json!({
"action": "changed access", "slug": slug, "access": access, "role": entry.role
}),
)
}
Err(error) => failed(&error),
}
}
async fn revoke(
page: &str,
access: Option<&str>,
allow_selflock: bool,
session: &Session,
) -> ExitCode {
let slug = match named(page) {
Ok(slug) => slug,
Err(code) => return code,
};
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
let (action, body) = match access {
Some(access) => (
format!("revoke access {access} on wiki page `{slug}`"),
serde_json::json!({ "access": access }),
),
None => (
format!("revoke every personal access on wiki page `{slug}`"),
serde_json::json!({ "access": "all personal" }),
),
};
if let Some(code) = gated(&action, &body, access.is_none(), session) {
return code;
}
let id = match client.wiki_page_id(&slug).await {
Ok(id) => id,
Err(error) => return failed(&error),
};
match client.wiki_revoke(id, access, allow_selflock).await {
Ok(()) => done(
session,
match access {
Some(access) => format!("revoked access {access} on {slug}\n"),
None => format!("revoked every personal access on {slug}\n"),
},
&serde_json::json!({ "action": "revoked", "slug": slug, "access": access }),
),
Err(error) => failed(&error),
}
}
const CLONE_WAIT: std::time::Duration = std::time::Duration::from_secs(600);
const CLONE_REFUSALS: [(&str, &str); 6] = [
(
"IS_CLOUD_PAGE",
"the page is a cloud page, which the Wiki cannot clone",
),
("SLUG_OCCUPIED", "a page already exists at the target"),
("SLUG_RESERVED", "the target address is reserved"),
(
"FORBIDDEN",
"this account may not create a page at the target",
),
("QUOTA_EXCEEDED", "the organisation's Wiki quota is used up"),
(
"CLUSTER_BLOCKED",
"the Wiki is not taking writes here right now",
),
];
fn clone_refused(error: &crate::api::error::ApiError) -> ExitCode {
if let crate::api::error::ApiError::Rejected { message, .. } = error
&& let Some((code, meaning)) = CLONE_REFUSALS
.iter()
.find(|(code, _)| message.contains(code))
{
return report(
&format!("the Wiki would not clone it: {meaning} ({code})"),
ExitCode::ApiRejected,
);
}
failed(error)
}
async fn clone_page(
page: &str,
target: &str,
mut body: serde_json::Value,
no_wait: bool,
session: &Session,
) -> ExitCode {
let slug = match named(page) {
Ok(slug) => slug,
Err(code) => return code,
};
let target = match named(target) {
Ok(target) => target,
Err(code) => return code,
};
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
body["target"] = serde_json::Value::String(target.clone());
if let Some(code) = gated(
&format!("clone wiki page `{slug}` to `{target}`"),
&body,
false,
session,
) {
return code;
}
let id = match client.wiki_page_id(&slug).await {
Ok(id) => id,
Err(error) => return failed(&error),
};
let operation = match client.wiki_clone_page(id, &body).await {
Ok(operation) => operation,
Err(error) => return clone_refused(&error),
};
followed(&client, &operation, no_wait, session, |done| {
format!("cloned {slug} to {}\n", done.page_slug().unwrap_or(&target))
})
.await
}
async fn clone_grid(
grid: &str,
target: &str,
mut body: serde_json::Value,
no_wait: bool,
session: &Session,
) -> ExitCode {
let target = match named(target) {
Ok(target) => target,
Err(code) => return code,
};
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
body["target"] = serde_json::Value::String(target.clone());
if let Some(code) = gated(
&format!("clone wiki grid `{grid}` onto `{target}`"),
&body,
false,
session,
) {
return code;
}
let operation = match client.wiki_clone_grid(grid.trim(), &body).await {
Ok(operation) => operation,
Err(error) => return clone_refused(&error),
};
followed(&client, &operation, no_wait, session, |done| {
format!(
"cloned grid {grid} to {}: grid {}\n",
done.page_slug().unwrap_or(&target),
done.grid_id().as_deref().unwrap_or("-")
)
})
.await
}
async fn followed(
client: &crate::api::Client,
operation: &crate::api::wiki::WikiOperation,
no_wait: bool,
session: &Session,
describe: impl FnOnce(&crate::api::wiki::OperationStatus) -> String,
) -> ExitCode {
let ask_again = format!("ytcli wiki operation {} {}", operation.kind, operation.id);
if no_wait {
return done(
session,
format!(
"started operation {} {}; follow it with `{ask_again}`\n",
operation.kind, operation.id
),
&serde_json::json!({
"action": "started", "operation": { "type": operation.kind, "id": operation.id }
}),
);
}
let walk = crate::render::progress::Walk::start("cloning");
let deadline = std::time::Instant::now() + CLONE_WAIT;
let status = loop {
let status = match client.wiki_operation(operation).await {
Ok(status) => status,
Err(error) => {
walk.finish();
return failed(&error);
}
};
if status.is_done() {
break status;
}
walk.say(&status.percentage.map_or_else(
|| format!("cloning: {}", status.status),
|percentage| format!("cloning: {percentage:.0}%"),
));
if std::time::Instant::now() >= deadline {
walk.finish();
return report(
&format!("the Wiki is still working on it; ask again with `{ask_again}`"),
ExitCode::Failure,
);
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
};
walk.finish();
if status.status == "failed" {
return report(
&format!(
"the clone failed: {}",
status
.details
.as_deref()
.unwrap_or("the Wiki gave no reason")
),
ExitCode::ApiRejected,
);
}
done(
session,
describe(&status),
&serde_json::to_value(&status).unwrap_or_default(),
)
}
async fn show_operation(
operation: &crate::api::wiki::WikiOperation,
session: &Session,
) -> ExitCode {
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
match client.wiki_operation(operation).await {
Ok(status) => finish(match session.render.format {
Format::Text => Ok(render::operation(operation, &status)),
Format::JsonRaw => machine(&status, Format::Json),
other => machine(&status, other),
}),
Err(error) => failed(&error),
}
}
fn counted(count: usize, what: &str) -> String {
if count == 1 {
format!("1 {what}")
} else {
format!("{count} {what}s")
}
}
struct GridChange<'a> {
grid: &'a str,
action: String,
done: String,
method: reqwest::Method,
tail: &'static str,
body: serde_json::Value,
confirm: bool,
revision: Option<&'a str>,
}
async fn change_grid(change: GridChange<'_>, session: &Session) -> ExitCode {
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
let grid = change.grid.trim();
let mut body = change.body;
body["revision"] = serde_json::Value::String(
change
.revision
.map_or_else(|| "<current, read first>".to_owned(), str::to_owned),
);
if let Some(code) = gated(&change.action, &body, change.confirm, session) {
return code;
}
if change.revision.is_none() {
match client.wiki_grid(grid, GridQuery::default()).await {
Ok(current) => body["revision"] = serde_json::Value::String(current.revision),
Err(error) => return failed(&error),
}
}
match client
.wiki_grid_write(change.method, grid, change.tail, Some(&body))
.await
{
Ok(answer) => done(
session,
changed(&change.done, &answer),
&serde_json::json!({ "action": change.done, "grid": grid, "result": answer }),
),
Err(error) => failed(&error),
}
}
fn changed(done: &str, answer: &serde_json::Value) -> String {
let text = |value: Option<&serde_json::Value>| match value {
Some(serde_json::Value::String(text)) => Some(text.clone()),
Some(serde_json::Value::Null) | None => None,
Some(other) => Some(other.to_string()),
};
let made: Vec<String> = answer
.get("results")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(|row| text(row.get("id")))
.collect();
let made = if made.is_empty() {
String::new()
} else {
format!(" (rows {})", made.join(", "))
};
let revision = text(answer.get("revision")).unwrap_or_else(|| "-".to_owned());
format!("{done}{made}; revision {revision}\n")
}
fn json_list(from: &str, what: &str) -> Result<Vec<serde_json::Value>, ExitCode> {
let text = content(from)?;
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(serde_json::Value::Array(items)) if !items.is_empty() => Ok(items),
Ok(_) => Err(report(
&format!("{from} holds no {what}: expected a JSON array with at least one"),
ExitCode::ConfirmationRequired,
)),
Err(error) => Err(report(
&format!("{from} is not JSON: {error}"),
ExitCode::ConfirmationRequired,
)),
}
}
async fn grid_create(page: &str, title: &str, session: &Session) -> ExitCode {
let slug = match named(page) {
Ok(slug) => slug,
Err(code) => return code,
};
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
let body = serde_json::json!({ "page": { "slug": slug }, "title": title });
if let Some(code) = gated(
&format!("create a grid on wiki page `{slug}`"),
&body,
false,
session,
) {
return code;
}
match client.wiki_grid_create(&body).await {
Ok(grid) => done(
session,
format!(
"created grid {} on {slug}; revision {}\n",
grid.id, grid.revision
),
&serde_json::json!({
"action": "created grid", "slug": slug, "grid": grid.id,
"revision": grid.revision
}),
),
Err(error) => failed(&error),
}
}
async fn grid_update(
grid: &str,
title: Option<&str>,
sort: Option<&str>,
revision: Option<&str>,
session: &Session,
) -> ExitCode {
let mut body = serde_json::json!({});
if let Some(title) = title {
body["title"] = serde_json::Value::String(title.to_owned());
}
if let Some(sort) = sort {
let mut order = serde_json::Map::new();
for part in sort
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
{
match part.split_once(':') {
Some((slug, direction @ ("asc" | "desc"))) if !slug.is_empty() => {
order.insert(
slug.to_owned(),
serde_json::Value::String(direction.to_owned()),
);
}
_ => {
return report(
&format!(
"--sort takes slug:asc or slug:desc, comma-separated; got `{part}`"
),
ExitCode::ConfirmationRequired,
);
}
}
}
body["default_sort"] = serde_json::Value::Object(order);
}
if title.is_none() && sort.is_none() {
return report(
&"nothing to change: pass --title, --sort, or both",
ExitCode::ConfirmationRequired,
);
}
let change = GridChange {
grid,
action: format!("change wiki grid `{grid}`"),
done: format!("changed grid {grid}"),
method: reqwest::Method::POST,
tail: "",
body,
confirm: false,
revision,
};
change_grid(change, session).await
}
async fn grid_delete(grid: &str, session: &Session) -> ExitCode {
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
let grid = grid.trim();
let body = serde_json::json!({ "grid": grid });
if let Some(code) = gated(&format!("delete wiki grid `{grid}`"), &body, true, session) {
return code;
}
match client
.wiki_grid_write(reqwest::Method::DELETE, grid, "", None)
.await
{
Ok(_) => done(
session,
format!("deleted grid {grid}\n"),
&serde_json::json!({ "action": "deleted grid", "grid": grid }),
),
Err(error) => failed(&error),
}
}
async fn rows_add(
grid: &str,
from: &str,
mut body: serde_json::Value,
revision: Option<&str>,
session: &Session,
) -> ExitCode {
let rows = match json_list(from, "rows") {
Ok(rows) => rows,
Err(code) => return code,
};
let count = counted(rows.len(), "row");
body["rows"] = serde_json::Value::Array(rows);
let change = GridChange {
grid,
action: format!("add {count} to wiki grid `{grid}`"),
done: format!("added {count} to grid {grid}"),
method: reqwest::Method::POST,
tail: "/rows",
body,
confirm: false,
revision,
};
change_grid(change, session).await
}
async fn columns_add(
grid: &str,
from: &str,
position: Option<u64>,
revision: Option<&str>,
session: &Session,
) -> ExitCode {
let columns = match json_list(from, "columns") {
Ok(columns) => columns,
Err(code) => return code,
};
let count = counted(columns.len(), "column");
let mut body = serde_json::json!({ "columns": columns });
if let Some(position) = position {
body["position"] = serde_json::json!(position);
}
let change = GridChange {
grid,
action: format!("add {count} to wiki grid `{grid}`"),
done: format!("added {count} to grid {grid}"),
method: reqwest::Method::POST,
tail: "/columns",
body,
confirm: false,
revision,
};
change_grid(change, session).await
}
async fn cells_set(
grid: &str,
set: &[String],
revision: Option<&str>,
session: &Session,
) -> ExitCode {
let mut cells = Vec::with_capacity(set.len());
for raw in set {
let parsed = raw.split_once(':').and_then(|(row, rest)| {
let row: u64 = row.trim().parse().ok()?;
Some((row, crate::cli::write::parse_assignment(rest)))
});
match parsed {
Some((row, Ok((slug, value)))) => cells.push(serde_json::json!({
"row_id": row, "column_slug": slug, "value": value
})),
Some((_, Err(problem))) => {
return report(&problem, ExitCode::ConfirmationRequired);
}
None => {
return report(
&format!("--set takes ROW:SLUG=VALUE, the row a number; got `{raw}`"),
ExitCode::ConfirmationRequired,
);
}
}
}
let count = counted(cells.len(), "cell");
let change = GridChange {
grid,
action: format!("set {count} in wiki grid `{grid}`"),
done: format!("set {count} in grid {grid}"),
method: reqwest::Method::POST,
tail: "/cells",
body: serde_json::json!({ "cells": cells }),
confirm: false,
revision,
};
change_grid(change, session).await
}
async fn upload(page: &str, files: &[PathBuf], session: &Session) -> ExitCode {
let slug = match named(page) {
Ok(slug) => slug,
Err(code) => return code,
};
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
let mut loaded = Vec::with_capacity(files.len());
for file in files {
let bytes = match std::fs::read(file) {
Ok(bytes) => bytes,
Err(error) => {
return report(
&format!("cannot read {}: {error}", file.display()),
ExitCode::Failure,
);
}
};
let name = file.file_name().map_or_else(
|| "upload".to_owned(),
|name| name.to_string_lossy().into_owned(),
);
loaded.push((name, bytes));
}
let body = serde_json::json!({
"files": loaded
.iter()
.map(|(name, bytes)| serde_json::json!({ "file_name": name, "file_size": bytes.len() }))
.collect::<Vec<_>>()
});
if let Some(code) = gated(
&format!(
"upload {} to wiki page `{slug}`",
counted(loaded.len(), "file")
),
&body,
false,
session,
) {
return code;
}
let id = match client.wiki_page_id(&slug).await {
Ok(id) => id,
Err(error) => return failed(&error),
};
let mut uploaded = Vec::new();
for (name, bytes) in &loaded {
let upload = match sent(&client, name, bytes).await {
Ok(upload) => upload,
Err(code) => return code,
};
match client.wiki_attach(id, std::slice::from_ref(&upload)).await {
Ok(attached) => {
for file in attached {
if session.render.format == Format::Text {
emit(&format!(
"uploaded {} to {slug}: attachment {}\n",
file.name, file.id
));
}
uploaded.push(serde_json::json!({ "name": file.name, "id": file.id }));
}
}
Err(error) => {
abandon(&client, &upload).await;
return failed(&error);
}
}
}
if session.render.format == Format::Text {
return ExitCode::Success;
}
done(
session,
String::new(),
&serde_json::json!({ "action": "uploaded", "slug": slug, "attachments": uploaded }),
)
}
async fn sent(client: &crate::api::Client, name: &str, bytes: &[u8]) -> Result<String, ExitCode> {
use crate::api::wiki::{UPLOAD_PART, upload_parts};
let upload = match client.wiki_upload_start(name, bytes.len()).await {
Ok(upload) => upload.session_id,
Err(error) => return Err(failed(&error)),
};
let parts = upload_parts(bytes.len(), UPLOAD_PART);
let walk = crate::render::progress::Walk::start(&format!("uploading {name}"));
for (index, range) in parts.iter().enumerate() {
walk.say(&format!(
"uploading {name}: part {} of {}",
index + 1,
parts.len()
));
let number = u32::try_from(index + 1).unwrap_or(u32::MAX);
let part = bytes.get(range.clone()).unwrap_or_default().to_vec();
if let Err(error) = client.wiki_upload_part(&upload, number, part).await {
walk.finish();
abandon(client, &upload).await;
return Err(failed(&error));
}
}
walk.finish();
if let Err(error) = client.wiki_upload_finish(&upload).await {
abandon(client, &upload).await;
return Err(failed(&error));
}
Ok(upload)
}
async fn abandon(client: &crate::api::Client, upload: &str) {
let _ = client.wiki_upload_abort(upload).await;
}
async fn delete_attachment(page: &str, file: &str, session: &Session) -> ExitCode {
let slug = match named(page) {
Ok(slug) => slug,
Err(code) => return code,
};
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
let body = serde_json::json!({ "file": file });
if let Some(code) = gated(
&format!("delete attachment `{file}` from wiki page `{slug}`"),
&body,
true,
session,
) {
return code;
}
let (id, found) = match client.wiki_attachment_named(&slug, file).await {
Ok(pair) => pair,
Err(error) => return failed(&error),
};
match client.wiki_delete_attachment(id, found.id).await {
Ok(()) => done(
session,
format!(
"deleted attachment {} ({}) from {slug}\n",
found.name, found.id
),
&serde_json::json!({
"action": "deleted attachment", "slug": slug, "attachment": found.id,
"name": found.name
}),
),
Err(error) => failed(&error),
}
}
enum Source {
Attachment { page: i64, file: u64 },
Address(String),
}
async fn download(
page: &str,
file: Option<&str>,
out: &Path,
force: bool,
session: &Session,
) -> ExitCode {
let target = slug_of(page);
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
let (name, source) = if let Some(file) = file {
let slug = match named(page) {
Ok(slug) => slug,
Err(code) => return code,
};
match client.wiki_attachment_named(&slug, file).await {
Ok((page, found)) => (
safe_filename(&found.name, &found.id.to_string()),
Source::Attachment {
page,
file: found.id,
},
),
Err(error) => {
let code = error.exit_code();
return report(&error, code);
}
}
} else if let Some((_, name)) = target.split_once("/.files/") {
(
safe_filename(name, "download"),
Source::Address(target.clone()),
)
} else {
return report(
&format!(
"`{page}` is a page, not a file: name the file too (`wiki attachments` lists them), \
or pass the file's address, <slug>/.files/<name>"
),
ExitCode::ConfirmationRequired,
);
};
let destination = out.join(name);
if destination.exists() && !force {
return report(
&format!(
"{} already exists; pass --force to overwrite",
destination.display()
),
ExitCode::ConfirmationRequired,
);
}
let fetched = match &source {
Source::Attachment { page, file } => client.wiki_attachment_bytes(*page, *file).await,
Source::Address(path) => client.wiki_file_bytes(path).await,
};
let bytes = match fetched {
Ok(bytes) => bytes,
Err(error) => {
let code = error.exit_code();
return report(&error, code);
}
};
if let Err(error) = std::fs::create_dir_all(out) {
return report(&error, ExitCode::Failure);
}
if let Err(error) = std::fs::write(&destination, &bytes) {
return report(&error, ExitCode::Failure);
}
done(
session,
format!("{}\n", destination.display()),
&serde_json::json!({ "action": "downloaded", "path": destination }),
)
}
fn page_size(session: &Session) -> u32 {
u32::try_from(session.display().limit.clamp(1, 100)).unwrap_or(100)
}
fn named(page: &str) -> Result<String, ExitCode> {
let slug = slug_of(page);
if slug.is_empty() {
return Err(report(
&format!(
"`{page}` names no page: pass a slug such as users/me/notes, or the page's address"
),
ExitCode::ConfirmationRequired,
));
}
Ok(slug)
}
fn done(session: &Session, text: String, facts: &serde_json::Value) -> ExitCode {
finish(match session.render.format {
Format::Text => Ok(text),
Format::JsonRaw => machine(facts, Format::Json),
other => machine(facts, other),
})
}
fn finish(rendered: Result<String, RenderError>) -> ExitCode {
match rendered {
Ok(text) => {
emit(&text);
ExitCode::Success
}
Err(error) => report(&error, ExitCode::Failure),
}
}