1use std::path::{Path, PathBuf};
10
11use clap::Subcommand;
12
13use crate::api::wiki::{CommentScope, GridQuery, LAST_SEARCH_PAGE, slug_of};
14use crate::cli::attachment::safe_filename;
15use crate::cli::write::{Gate, Intent, check};
16use crate::cli::{Session, emit, report};
17use crate::exit::ExitCode;
18use crate::render::{Format, RenderError, machine, wiki as render};
19
20#[derive(Debug, Subcommand)]
21pub enum WikiCommand {
22 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_GET))]
24 Get {
25 page: String,
27 },
28 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_LIST))]
30 List {
31 page: String,
33 #[arg(long)]
35 cursor: Option<String>,
36 },
37 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_FIND))]
39 Find {
40 text: String,
42 #[arg(long = "type", value_parser = ["page", "file"])]
44 kind: Option<String>,
45 #[arg(long, default_value_t = 1)]
47 page: u32,
48 },
49 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_COMMENTS))]
51 Comments {
52 page: String,
54 #[arg(long, conflicts_with = "status")]
56 thread: Option<u64>,
57 #[arg(long, value_parser = ["resolved", "unresolved"])]
59 status: Option<String>,
60 #[arg(long)]
62 cursor: Option<String>,
63 },
64 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_ATTACHMENTS))]
66 Attachments {
67 page: String,
69 #[arg(long)]
71 cursor: Option<String>,
72 },
73 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_GRIDS))]
75 Grids {
76 page: String,
78 #[arg(long)]
80 cursor: Option<String>,
81 },
82 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_GRID))]
84 Grid {
85 grid: String,
87 #[arg(long)]
89 filter: Option<String>,
90 #[arg(long, allow_hyphen_values = true)]
94 sort: Option<String>,
95 #[arg(long)]
97 columns: Option<String>,
98 #[arg(long)]
100 rows: Option<String>,
101 #[arg(long)]
103 revision: Option<u64>,
104 },
105 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_RESOURCES))]
107 Resources {
108 page: String,
110 #[arg(long = "type", value_parser = ["attachment", "grid"])]
112 kind: Option<String>,
113 #[arg(long)]
115 query: Option<String>,
116 #[arg(long)]
118 cursor: Option<String>,
119 },
120 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_CREATE))]
122 Create {
123 page: String,
125 #[arg(long, short = 't')]
126 title: String,
127 #[arg(long, value_name = "PATH")]
129 from: Option<String>,
130 #[arg(long)]
132 silent: bool,
133 },
134 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_UPDATE))]
136 Update {
137 page: String,
139 #[arg(long, short = 't')]
140 title: Option<String>,
141 #[arg(long, value_name = "PATH")]
143 from: Option<String>,
144 #[arg(long)]
146 merge: bool,
147 #[arg(long)]
149 silent: bool,
150 },
151 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_APPEND))]
153 Append {
154 page: String,
156 #[arg(long, value_name = "PATH")]
158 from: String,
159 #[arg(long, conflicts_with = "anchor")]
161 top: bool,
162 #[arg(long)]
164 anchor: Option<String>,
165 #[arg(long)]
167 silent: bool,
168 },
169 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_DELETE))]
171 Delete {
172 page: String,
174 #[arg(long)]
176 recursive: bool,
177 },
178 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_RESTORE))]
180 Restore { token: String },
181 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_COMMENT))]
183 Comment {
184 page: String,
186 text: String,
188 #[arg(long, value_name = "ID")]
190 reply_to: Option<u64>,
191 #[arg(long)]
193 quote: Option<String>,
194 },
195 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_DELETE_COMMENT))]
197 DeleteComment {
198 page: String,
200 comment: u64,
202 },
203 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_ACCESS))]
205 Access {
206 page: String,
208 },
209 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_GRANT))]
211 #[command(group(clap::ArgGroup::new("who").required(true).multiple(false)))]
212 Grant {
213 page: String,
215 #[arg(long, value_parser = ["reader", "editor", "extra_editor", "author"])]
217 role: String,
218 #[arg(long, group = "who")]
220 user: Option<String>,
221 #[arg(long, group = "who")]
223 uid: Option<String>,
224 #[arg(long, group = "who", value_name = "ID")]
226 cloud_uid: Option<String>,
227 #[arg(long, group = "who", value_name = "SOURCE:ID")]
229 group: Option<String>,
230 #[arg(long)]
232 no_inherit: bool,
233 #[arg(long)]
235 allow_selflock: bool,
236 },
237 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_REGRANT))]
239 Regrant {
240 page: String,
242 access: String,
244 #[arg(long, value_parser = ["reader", "editor", "extra_editor", "author"])]
245 role: Option<String>,
246 #[arg(long, value_parser = ["inherited", "not_inherited"])]
247 inheritance: Option<String>,
248 #[arg(long)]
250 allow_selflock: bool,
251 },
252 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_REVOKE))]
254 Revoke {
255 page: String,
257 #[arg(required_unless_present = "all")]
259 access: Option<String>,
260 #[arg(long, conflicts_with = "access")]
262 all: bool,
263 #[arg(long)]
265 allow_selflock: bool,
266 },
267 #[command(name = "clone", long_about = crate::cli::help::md(crate::cli::help::WIKI_CLONE))]
269 ClonePage {
270 page: String,
272 target: String,
274 #[arg(long, short = 't')]
276 title: Option<String>,
277 #[arg(long)]
279 subscribe: bool,
280 #[arg(long)]
282 no_wait: bool,
283 },
284 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_CLONE_GRID))]
286 CloneGrid {
287 grid: String,
289 target: String,
291 #[arg(long, short = 't')]
292 title: Option<String>,
293 #[arg(long)]
295 with_data: bool,
296 #[arg(long)]
298 no_wait: bool,
299 },
300 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_OPERATION))]
302 Operation {
303 #[arg(value_parser = ["clone", "clone_inline_grid"])]
305 kind: String,
306 id: String,
307 },
308 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_GRID_CREATE))]
310 CreateGrid {
311 page: String,
313 #[arg(long, short = 't')]
314 title: String,
315 },
316 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_GRID_UPDATE))]
318 UpdateGrid {
319 grid: String,
321 #[arg(long, short = 't')]
322 title: Option<String>,
323 #[arg(long, value_name = "SLUG:DIR,...")]
325 sort: Option<String>,
326 #[arg(long)]
328 revision: Option<String>,
329 },
330 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_GRID_DELETE))]
332 DeleteGrid {
333 grid: String,
335 },
336 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_ROWS_ADD))]
338 RowsAdd {
339 grid: String,
341 #[arg(long, value_name = "PATH")]
343 from: String,
344 #[arg(long, value_name = "ROW", conflicts_with = "position")]
346 after: Option<String>,
347 #[arg(long)]
349 position: Option<u64>,
350 #[arg(long)]
352 revision: Option<String>,
353 },
354 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_ROWS_DELETE))]
356 RowsDelete {
357 grid: String,
359 #[arg(required = true, value_name = "ROW")]
360 rows: Vec<String>,
361 #[arg(long)]
363 revision: Option<String>,
364 },
365 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_ROWS_MOVE))]
367 RowsMove {
368 grid: String,
370 row: String,
371 #[arg(
373 long,
374 value_name = "ROW",
375 conflicts_with = "position",
376 required_unless_present = "position"
377 )]
378 after: Option<String>,
379 #[arg(long)]
381 position: Option<u64>,
382 #[arg(long)]
384 count: Option<u64>,
385 #[arg(long)]
387 revision: Option<String>,
388 },
389 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_COLUMNS_ADD))]
391 ColumnsAdd {
392 grid: String,
394 #[arg(long, value_name = "PATH")]
396 from: String,
397 #[arg(long)]
399 position: Option<u64>,
400 #[arg(long)]
402 revision: Option<String>,
403 },
404 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_COLUMNS_DELETE))]
406 ColumnsDelete {
407 grid: String,
409 #[arg(required = true, value_name = "SLUG")]
410 columns: Vec<String>,
411 #[arg(long)]
413 revision: Option<String>,
414 },
415 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_COLUMNS_MOVE))]
417 ColumnsMove {
418 grid: String,
420 column: String,
421 #[arg(long)]
423 position: u64,
424 #[arg(long)]
426 count: Option<u64>,
427 #[arg(long)]
429 revision: Option<String>,
430 },
431 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_CELLS_SET))]
433 CellsSet {
434 grid: String,
436 #[arg(long = "set", value_name = "ROW:SLUG=VALUE", required = true)]
437 set: Vec<String>,
438 #[arg(long)]
440 revision: Option<String>,
441 },
442 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_UPLOAD))]
444 Upload {
445 page: String,
447 #[arg(required = true, value_name = "FILE")]
448 files: Vec<PathBuf>,
449 },
450 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_DELETE_ATTACHMENT))]
452 DeleteAttachment {
453 page: String,
455 file: String,
457 },
458 #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_DOWNLOAD))]
460 Download {
461 page: String,
463 file: Option<String>,
466 #[arg(long, short = 'o')]
468 out: PathBuf,
469 #[arg(long)]
471 force: bool,
472 },
473}
474
475#[allow(
476 clippy::too_many_lines,
477 reason = "one arm per verb; splitting the dispatch would only hide the list"
478)]
479pub async fn run(command: &WikiCommand, session: &Session) -> ExitCode {
480 match command {
481 WikiCommand::Get { page } => get(page, session).await,
482 WikiCommand::List { page, cursor } => list(page, cursor.as_deref(), session).await,
483 WikiCommand::Find { text, kind, page } => find(text, kind.as_deref(), *page, session).await,
484 WikiCommand::Comments {
485 page,
486 thread,
487 status,
488 cursor,
489 } => {
490 let scope = match thread {
491 Some(comment) => CommentScope::Thread(*comment),
492 None => CommentScope::Page {
493 status: status.as_deref(),
494 },
495 };
496 comments(page, scope, cursor.as_deref(), session).await
497 }
498 WikiCommand::Attachments { page, cursor } => {
499 attachments(page, cursor.as_deref(), session).await
500 }
501 WikiCommand::Grids { page, cursor } => grids(page, cursor.as_deref(), session).await,
502 WikiCommand::Grid {
503 grid: id,
504 filter,
505 sort,
506 columns,
507 rows,
508 revision,
509 } => {
510 let query = GridQuery {
511 filter: filter.as_deref(),
512 sort: sort.as_deref(),
513 columns: columns.as_deref(),
514 rows: rows.as_deref(),
515 revision: *revision,
516 };
517 grid(id, query, session).await
518 }
519 WikiCommand::Resources {
520 page,
521 kind,
522 query,
523 cursor,
524 } => {
525 resources(
526 page,
527 kind.as_deref(),
528 query.as_deref(),
529 cursor.as_deref(),
530 session,
531 )
532 .await
533 }
534 WikiCommand::Create {
535 page,
536 title,
537 from,
538 silent,
539 } => create(page, title, from.as_deref(), *silent, session).await,
540 WikiCommand::Update {
541 page,
542 title,
543 from,
544 merge,
545 silent,
546 } => {
547 let change = Change {
548 title: title.as_deref(),
549 from: from.as_deref(),
550 merge: *merge,
551 silent: *silent,
552 };
553 update(page, change, session).await
554 }
555 WikiCommand::Append {
556 page,
557 from,
558 top,
559 anchor,
560 silent,
561 } => append(page, from, place(*top, anchor.as_deref()), *silent, session).await,
562 WikiCommand::Delete { page, recursive } => delete(page, *recursive, session).await,
563 WikiCommand::Restore { token } => restore(token, session).await,
564 WikiCommand::Comment {
565 page,
566 text,
567 reply_to,
568 quote,
569 } => comment(page, text, *reply_to, quote.as_deref(), session).await,
570 WikiCommand::DeleteComment { page, comment } => {
571 delete_comment(page, *comment, session).await
572 }
573 WikiCommand::Access { page } => show_access(page, session).await,
574 WikiCommand::Grant {
575 page,
576 role,
577 user,
578 uid,
579 cloud_uid,
580 group,
581 no_inherit,
582 allow_selflock,
583 } => {
584 let who = match (user, uid, cloud_uid, group) {
585 (Some(login), ..) => Grantee::Login(login),
586 (_, Some(uid), ..) => Grantee::Uid(uid),
587 (_, _, Some(id), _) => Grantee::CloudUid(id),
588 (.., Some(group)) => Grantee::Group(group),
589 _ => {
590 return report(
591 &"name who: --user, --uid, --cloud-uid or --group",
592 ExitCode::ConfirmationRequired,
593 );
594 }
595 };
596 grant(page, role, who, *no_inherit, *allow_selflock, session).await
597 }
598 WikiCommand::Regrant {
599 page,
600 access,
601 role,
602 inheritance,
603 allow_selflock,
604 } => {
605 let body = match (role, inheritance) {
606 (None, None) => {
607 return report(
608 &"nothing to change: pass --role, --inheritance, or both",
609 ExitCode::ConfirmationRequired,
610 );
611 }
612 (role, inheritance) => {
613 let mut body = serde_json::json!({});
614 if let Some(role) = role {
615 body["role"] = serde_json::Value::String(role.clone());
616 }
617 if let Some(inheritance) = inheritance {
618 body["inheritance"] = serde_json::Value::String(inheritance.clone());
619 }
620 body
621 }
622 };
623 regrant(page, access, &body, *allow_selflock, session).await
624 }
625 WikiCommand::Revoke {
626 page,
627 access,
628 all: _,
629 allow_selflock,
630 } => revoke(page, access.as_deref(), *allow_selflock, session).await,
631 WikiCommand::ClonePage {
632 page,
633 target,
634 title,
635 subscribe,
636 no_wait,
637 } => {
638 let mut body = serde_json::json!({});
639 if let Some(title) = title {
640 body["title"] = serde_json::Value::String(title.clone());
641 }
642 if *subscribe {
643 body["subscribe_me"] = serde_json::Value::Bool(true);
644 }
645 clone_page(page, target, body, *no_wait, session).await
646 }
647 WikiCommand::CloneGrid {
648 grid,
649 target,
650 title,
651 with_data,
652 no_wait,
653 } => {
654 let mut body = serde_json::json!({});
655 if let Some(title) = title {
656 body["title"] = serde_json::Value::String(title.clone());
657 }
658 if *with_data {
659 body["with_data"] = serde_json::Value::Bool(true);
660 }
661 clone_grid(grid, target, body, *no_wait, session).await
662 }
663 WikiCommand::Operation { kind, id } => {
664 let operation = crate::api::wiki::WikiOperation {
665 id: id.clone(),
666 kind: kind.clone(),
667 };
668 show_operation(&operation, session).await
669 }
670 WikiCommand::CreateGrid { page, title } => grid_create(page, title, session).await,
671 WikiCommand::UpdateGrid {
672 grid,
673 title,
674 sort,
675 revision,
676 } => {
677 grid_update(
678 grid,
679 title.as_deref(),
680 sort.as_deref(),
681 revision.as_deref(),
682 session,
683 )
684 .await
685 }
686 WikiCommand::DeleteGrid { grid } => grid_delete(grid, session).await,
687 WikiCommand::RowsAdd {
688 grid,
689 from,
690 after,
691 position,
692 revision,
693 } => {
694 let mut place = serde_json::json!({});
695 if let Some(after) = after {
696 place["after_row_id"] = serde_json::Value::String(after.clone());
697 }
698 if let Some(position) = position {
699 place["position"] = serde_json::json!(position);
700 }
701 rows_add(grid, from, place, revision.as_deref(), session).await
702 }
703 WikiCommand::RowsDelete {
704 grid,
705 rows,
706 revision,
707 } => {
708 let change = GridChange {
709 grid,
710 action: format!(
711 "delete {} from wiki grid `{grid}`",
712 counted(rows.len(), "row")
713 ),
714 done: format!("deleted {} from grid {grid}", counted(rows.len(), "row")),
715 method: reqwest::Method::DELETE,
716 tail: "/rows",
717 body: serde_json::json!({ "row_ids": rows }),
718 confirm: true,
719 revision: revision.as_deref(),
720 };
721 change_grid(change, session).await
722 }
723 WikiCommand::RowsMove {
724 grid,
725 row,
726 after,
727 position,
728 count,
729 revision,
730 } => {
731 let mut body = serde_json::json!({ "row_id": row });
732 if let Some(after) = after {
733 body["after_row_id"] = serde_json::Value::String(after.clone());
734 }
735 if let Some(position) = position {
736 body["position"] = serde_json::json!(position);
737 }
738 if let Some(count) = count {
739 body["rows_count"] = serde_json::json!(count);
740 }
741 let change = GridChange {
742 grid,
743 action: format!("move row {row} in wiki grid `{grid}`"),
744 done: format!("moved row {row} in grid {grid}"),
745 method: reqwest::Method::POST,
746 tail: "/rows/move",
747 body,
748 confirm: false,
749 revision: revision.as_deref(),
750 };
751 change_grid(change, session).await
752 }
753 WikiCommand::ColumnsAdd {
754 grid,
755 from,
756 position,
757 revision,
758 } => columns_add(grid, from, *position, revision.as_deref(), session).await,
759 WikiCommand::ColumnsDelete {
760 grid,
761 columns,
762 revision,
763 } => {
764 let change = GridChange {
765 grid,
766 action: format!(
767 "delete {} from wiki grid `{grid}`",
768 counted(columns.len(), "column")
769 ),
770 done: format!(
771 "deleted {} from grid {grid}",
772 counted(columns.len(), "column")
773 ),
774 method: reqwest::Method::DELETE,
775 tail: "/columns",
776 body: serde_json::json!({ "column_slugs": columns }),
777 confirm: true,
778 revision: revision.as_deref(),
779 };
780 change_grid(change, session).await
781 }
782 WikiCommand::ColumnsMove {
783 grid,
784 column,
785 position,
786 count,
787 revision,
788 } => {
789 let mut body = serde_json::json!({ "column_slug": column, "position": position });
790 if let Some(count) = count {
791 body["columns_count"] = serde_json::json!(count);
792 }
793 let change = GridChange {
794 grid,
795 action: format!("move column {column} in wiki grid `{grid}`"),
796 done: format!("moved column {column} in grid {grid}"),
797 method: reqwest::Method::POST,
798 tail: "/columns/move",
799 body,
800 confirm: false,
801 revision: revision.as_deref(),
802 };
803 change_grid(change, session).await
804 }
805 WikiCommand::CellsSet {
806 grid,
807 set,
808 revision,
809 } => cells_set(grid, set, revision.as_deref(), session).await,
810 WikiCommand::Upload { page, files } => upload(page, files, session).await,
811 WikiCommand::DeleteAttachment { page, file } => {
812 delete_attachment(page, file, session).await
813 }
814 WikiCommand::Download {
815 page,
816 file,
817 out,
818 force,
819 } => download(page, file.as_deref(), out, *force, session).await,
820 }
821}
822
823async fn get(page: &str, session: &Session) -> ExitCode {
825 let slug = match named(page) {
826 Ok(slug) => slug,
827 Err(code) => return code,
828 };
829 let client = match session.client() {
830 Ok(client) => client,
831 Err(code) => return code,
832 };
833
834 match client.wiki_page(&slug).await {
835 Ok(found) => finish(match session.render.format {
836 Format::Text => Ok(render::page(&found, &session.render)),
837 Format::JsonRaw => machine(&found, Format::Json),
838 other => machine(&found, other),
839 }),
840 Err(error) => {
841 let code = error.exit_code();
842 report(&error, code)
843 }
844 }
845}
846
847async fn list(page: &str, cursor: Option<&str>, session: &Session) -> ExitCode {
849 let slug = match named(page) {
850 Ok(slug) => slug,
851 Err(code) => return code,
852 };
853 let client = match session.client() {
854 Ok(client) => client,
855 Err(code) => return code,
856 };
857
858 match client
859 .wiki_descendants(&slug, cursor, page_size(session))
860 .await
861 {
862 Ok(found) => finish(match session.render.format {
863 Format::Text => Ok(render::pages(&found, &session.render)),
864 Format::JsonRaw => machine(&found, Format::Json),
865 other => machine(&found, other),
866 }),
867 Err(error) => {
868 let code = error.exit_code();
869 report(&error, code)
870 }
871 }
872}
873
874async fn find(text: &str, kind: Option<&str>, page: u32, session: &Session) -> ExitCode {
876 if !(1..=LAST_SEARCH_PAGE).contains(&page) {
877 return report(
878 &format!("--page runs from 1 to {LAST_SEARCH_PAGE}: the Wiki's search stops there"),
879 ExitCode::ConfirmationRequired,
880 );
881 }
882 let client = match session.client() {
883 Ok(client) => client,
884 Err(code) => return code,
885 };
886
887 let limit = u32::try_from(session.display().limit.clamp(1, 50)).unwrap_or(50);
889
890 match client.wiki_search(text, kind, page, limit).await {
891 Ok(found) => finish(match session.render.format {
892 Format::Text => Ok(render::hits(&found, &session.render)),
893 Format::JsonRaw => machine(&found, Format::Json),
894 other => machine(&found, other),
895 }),
896 Err(error) => {
897 let code = error.exit_code();
898 report(&error, code)
899 }
900 }
901}
902
903async fn comments(
905 page: &str,
906 scope: CommentScope<'_>,
907 cursor: Option<&str>,
908 session: &Session,
909) -> ExitCode {
910 let slug = match named(page) {
911 Ok(slug) => slug,
912 Err(code) => return code,
913 };
914 let client = match session.client() {
915 Ok(client) => client,
916 Err(code) => return code,
917 };
918
919 match client
920 .wiki_comments(&slug, scope, cursor, page_size(session))
921 .await
922 {
923 Ok(found) => finish(match session.render.format {
924 Format::Text => Ok(render::comments(&slug, &found, &session.render)),
925 Format::JsonRaw => machine(&found, Format::Json),
926 other => machine(&found, other),
927 }),
928 Err(error) => {
929 let code = error.exit_code();
930 report(&error, code)
931 }
932 }
933}
934
935async fn attachments(page: &str, cursor: Option<&str>, session: &Session) -> ExitCode {
937 let slug = match named(page) {
938 Ok(slug) => slug,
939 Err(code) => return code,
940 };
941 let client = match session.client() {
942 Ok(client) => client,
943 Err(code) => return code,
944 };
945
946 match client
947 .wiki_attachments(&slug, cursor, page_size(session))
948 .await
949 {
950 Ok(found) => finish(match session.render.format {
951 Format::Text => Ok(render::attachments(&found, &session.render)),
952 Format::JsonRaw => machine(&found, Format::Json),
953 other => machine(&found, other),
954 }),
955 Err(error) => {
956 let code = error.exit_code();
957 report(&error, code)
958 }
959 }
960}
961
962async fn grids(page: &str, cursor: Option<&str>, session: &Session) -> ExitCode {
964 let slug = match named(page) {
965 Ok(slug) => slug,
966 Err(code) => return code,
967 };
968 let client = match session.client() {
969 Ok(client) => client,
970 Err(code) => return code,
971 };
972
973 match client.wiki_grids(&slug, cursor, page_size(session)).await {
974 Ok(found) => finish(match session.render.format {
975 Format::Text => Ok(render::grids(&found, &session.render)),
976 Format::JsonRaw => machine(&found, Format::Json),
977 other => machine(&found, other),
978 }),
979 Err(error) => {
980 let code = error.exit_code();
981 report(&error, code)
982 }
983 }
984}
985
986async fn grid(id: &str, query: GridQuery<'_>, session: &Session) -> ExitCode {
988 let client = match session.client() {
989 Ok(client) => client,
990 Err(code) => return code,
991 };
992
993 match client.wiki_grid(id.trim(), query).await {
994 Ok(found) => finish(match session.render.format {
995 Format::Text => Ok(render::grid(&found, &session.render)),
996 Format::JsonRaw => machine(&found, Format::Json),
997 other => machine(&found, other),
998 }),
999 Err(error) => {
1000 let code = error.exit_code();
1001 report(&error, code)
1002 }
1003 }
1004}
1005
1006async fn resources(
1008 page: &str,
1009 kind: Option<&str>,
1010 query: Option<&str>,
1011 cursor: Option<&str>,
1012 session: &Session,
1013) -> ExitCode {
1014 let slug = match named(page) {
1015 Ok(slug) => slug,
1016 Err(code) => return code,
1017 };
1018 let client = match session.client() {
1019 Ok(client) => client,
1020 Err(code) => return code,
1021 };
1022
1023 match client
1024 .wiki_resources(&slug, kind, query, cursor, page_size(session))
1025 .await
1026 {
1027 Ok(found) => finish(match session.render.format {
1028 Format::Text => Ok(render::resources(&found, &session.render)),
1029 Format::JsonRaw => machine(&found, Format::Json),
1030 other => machine(&found, other),
1031 }),
1032 Err(error) => {
1033 let code = error.exit_code();
1034 report(&error, code)
1035 }
1036 }
1037}
1038
1039fn content(from: &str) -> Result<String, ExitCode> {
1042 if from == "-" {
1043 let mut text = String::new();
1044 return match std::io::Read::read_to_string(&mut std::io::stdin(), &mut text) {
1045 Ok(_) => Ok(text),
1046 Err(error) => Err(report(&error, ExitCode::Failure)),
1047 };
1048 }
1049 std::fs::read_to_string(from)
1050 .map_err(|error| report(&format!("cannot read {from}: {error}"), ExitCode::Failure))
1051}
1052
1053fn gated(
1056 action: &str,
1057 body: &serde_json::Value,
1058 confirm: bool,
1059 session: &Session,
1060) -> Option<ExitCode> {
1061 let intent = Intent {
1062 action,
1063 targets: &[],
1064 body,
1065 always_confirm: confirm,
1066 };
1067 match check(&intent, session) {
1068 Gate::Proceed => None,
1069 Gate::Stop(code) => Some(code),
1070 }
1071}
1072
1073fn failed(error: &crate::api::error::ApiError) -> ExitCode {
1074 report(error, error.exit_code())
1075}
1076
1077async fn create(
1079 page: &str,
1080 title: &str,
1081 from: Option<&str>,
1082 silent: bool,
1083 session: &Session,
1084) -> ExitCode {
1085 let slug = match named(page) {
1086 Ok(slug) => slug,
1087 Err(code) => return code,
1088 };
1089 let client = match session.client() {
1090 Ok(client) => client,
1091 Err(code) => return code,
1092 };
1093 let mut body = serde_json::json!({ "slug": slug, "title": title });
1094 if let Some(from) = from {
1095 match content(from) {
1096 Ok(text) => body["content"] = serde_json::Value::String(text),
1097 Err(code) => return code,
1098 }
1099 }
1100 if let Some(code) = gated(&format!("create wiki page `{slug}`"), &body, false, session) {
1101 return code;
1102 }
1103
1104 match client.wiki_create(&body, silent).await {
1105 Ok(made) => done(
1106 session,
1107 format!("created {} (id {})\n", made.slug, made.id),
1108 &serde_json::json!({ "action": "created", "slug": made.slug, "id": made.id }),
1109 ),
1110 Err(error) => failed(&error),
1111 }
1112}
1113
1114struct Change<'a> {
1116 title: Option<&'a str>,
1117 from: Option<&'a str>,
1118 merge: bool,
1119 silent: bool,
1120}
1121
1122async fn update(page: &str, change: Change<'_>, session: &Session) -> ExitCode {
1124 let slug = match named(page) {
1125 Ok(slug) => slug,
1126 Err(code) => return code,
1127 };
1128 if change.title.is_none() && change.from.is_none() {
1129 return report(
1130 &"nothing to change: pass --title, --from, or both",
1131 ExitCode::ConfirmationRequired,
1132 );
1133 }
1134 let client = match session.client() {
1135 Ok(client) => client,
1136 Err(code) => return code,
1137 };
1138 let mut body = serde_json::json!({});
1139 if let Some(title) = change.title {
1140 body["title"] = serde_json::Value::String(title.to_owned());
1141 }
1142 if let Some(from) = change.from {
1143 match content(from) {
1144 Ok(text) => body["content"] = serde_json::Value::String(text),
1145 Err(code) => return code,
1146 }
1147 }
1148 if let Some(code) = gated(&format!("update wiki page `{slug}`"), &body, false, session) {
1149 return code;
1150 }
1151
1152 let id = match client.wiki_page_id(&slug).await {
1153 Ok(id) => id,
1154 Err(error) => return failed(&error),
1155 };
1156 match client
1157 .wiki_update(id, &body, change.merge, change.silent)
1158 .await
1159 {
1160 Ok(page) => done(
1161 session,
1162 format!("updated {} (id {})\n", page.slug, page.id),
1163 &serde_json::json!({ "action": "updated", "slug": page.slug, "id": page.id }),
1164 ),
1165 Err(error) => failed(&error),
1166 }
1167}
1168
1169fn place(top: bool, anchor: Option<&str>) -> serde_json::Value {
1171 match anchor {
1172 Some(anchor) => serde_json::json!({ "anchor": { "name": anchor } }),
1173 None => serde_json::json!({
1174 "body": { "location": if top { "top" } else { "bottom" } }
1175 }),
1176 }
1177}
1178
1179async fn append(
1181 page: &str,
1182 from: &str,
1183 place: serde_json::Value,
1184 silent: bool,
1185 session: &Session,
1186) -> ExitCode {
1187 let slug = match named(page) {
1188 Ok(slug) => slug,
1189 Err(code) => return code,
1190 };
1191 let client = match session.client() {
1192 Ok(client) => client,
1193 Err(code) => return code,
1194 };
1195 let text = match content(from) {
1196 Ok(text) => text,
1197 Err(code) => return code,
1198 };
1199 if text.is_empty() {
1200 return report(
1201 &"nothing to append: the text is empty",
1202 ExitCode::ConfirmationRequired,
1203 );
1204 }
1205 let mut body = place;
1206 body["content"] = serde_json::Value::String(text);
1207 if let Some(code) = gated(
1208 &format!("append to wiki page `{slug}`"),
1209 &body,
1210 false,
1211 session,
1212 ) {
1213 return code;
1214 }
1215
1216 let id = match client.wiki_page_id(&slug).await {
1217 Ok(id) => id,
1218 Err(error) => return failed(&error),
1219 };
1220 match client.wiki_append(id, &body, silent).await {
1221 Ok(page) => done(
1222 session,
1223 format!("appended to {} (id {})\n", page.slug, page.id),
1224 &serde_json::json!({ "action": "appended", "slug": page.slug, "id": page.id }),
1225 ),
1226 Err(error) => failed(&error),
1227 }
1228}
1229
1230async fn delete(page: &str, recursive: bool, session: &Session) -> ExitCode {
1236 let slug = match named(page) {
1237 Ok(slug) => slug,
1238 Err(code) => return code,
1239 };
1240 let client = match session.client() {
1241 Ok(client) => client,
1242 Err(code) => return code,
1243 };
1244 let body = serde_json::json!({ "recursive": recursive });
1245 let action = if recursive {
1246 format!("delete wiki page `{slug}` and every page under it")
1247 } else {
1248 format!("delete wiki page `{slug}`")
1249 };
1250 if let Some(code) = gated(&action, &body, recursive, session) {
1251 return code;
1252 }
1253
1254 let id = match client.wiki_page_id(&slug).await {
1255 Ok(id) => id,
1256 Err(error) => return failed(&error),
1257 };
1258 match client.wiki_delete(id, recursive).await {
1259 Ok(token) => done(
1260 session,
1261 format!(
1262 "deleted {slug} (id {id})\n\
1263 recovery token {token} — shown only now; to restore:\n \
1264 ytcli wiki restore {token}\n"
1265 ),
1266 &serde_json::json!({
1267 "action": "deleted", "slug": slug, "id": id, "recovery_token": token
1268 }),
1269 ),
1270 Err(error) => failed(&error),
1271 }
1272}
1273
1274async fn restore(token: &str, session: &Session) -> ExitCode {
1276 let client = match session.client() {
1277 Ok(client) => client,
1278 Err(code) => return code,
1279 };
1280 let body = serde_json::json!({});
1281 if let Some(code) = gated(
1282 &format!("restore the wiki page deleted under token {token}"),
1283 &body,
1284 false,
1285 session,
1286 ) {
1287 return code;
1288 }
1289
1290 match client.wiki_restore(token.trim()).await {
1291 Ok(restored) => {
1292 let pages = restored
1293 .pages_count
1294 .map_or_else(String::new, |count| format!(", {count} pages"));
1295 done(
1296 session,
1297 format!("restored {} (id {}{pages})\n", restored.slug, restored.id),
1298 &serde_json::json!({
1299 "action": "restored", "slug": restored.slug, "id": restored.id,
1300 "pages": restored.pages_count
1301 }),
1302 )
1303 }
1304 Err(error) => failed(&error),
1305 }
1306}
1307
1308async fn comment(
1313 page: &str,
1314 text: &str,
1315 reply_to: Option<u64>,
1316 quote: Option<&str>,
1317 session: &Session,
1318) -> ExitCode {
1319 let slug = match named(page) {
1320 Ok(slug) => slug,
1321 Err(code) => return code,
1322 };
1323 let client = match session.client() {
1324 Ok(client) => client,
1325 Err(code) => return code,
1326 };
1327 let said = if text == "-" {
1328 match content("-") {
1329 Ok(text) => text,
1330 Err(code) => return code,
1331 }
1332 } else {
1333 text.to_owned()
1334 };
1335 if said.trim().is_empty() {
1336 return report(
1337 &"nothing to say: the comment is empty",
1338 ExitCode::ConfirmationRequired,
1339 );
1340 }
1341 let mut body = serde_json::json!({ "body": said });
1342 if let Some(parent) = reply_to {
1343 body["parent_id"] = serde_json::json!(parent);
1344 }
1345 if let Some(quote) = quote {
1346 body["inline_text"] = serde_json::Value::String(quote.to_owned());
1347 }
1348 let action = match reply_to {
1349 Some(parent) => format!("reply to comment {parent} on wiki page `{slug}`"),
1350 None => format!("comment on wiki page `{slug}`"),
1351 };
1352 if let Some(code) = gated(&action, &body, false, session) {
1353 return code;
1354 }
1355
1356 let id = match client.wiki_page_id(&slug).await {
1357 Ok(id) => id,
1358 Err(error) => return failed(&error),
1359 };
1360 match client.wiki_comment(id, &body).await {
1361 Ok(made) => done(
1362 session,
1363 format!("commented on {slug}: comment {}\n", made.id),
1364 &serde_json::json!({ "action": "commented", "slug": slug, "comment": made.id }),
1365 ),
1366 Err(error) => failed(&error),
1367 }
1368}
1369
1370async fn delete_comment(page: &str, comment: u64, session: &Session) -> ExitCode {
1372 let slug = match named(page) {
1373 Ok(slug) => slug,
1374 Err(code) => return code,
1375 };
1376 let client = match session.client() {
1377 Ok(client) => client,
1378 Err(code) => return code,
1379 };
1380 let body = serde_json::json!({ "comment": comment });
1381 if let Some(code) = gated(
1382 &format!("delete comment {comment} on wiki page `{slug}`"),
1383 &body,
1384 true,
1385 session,
1386 ) {
1387 return code;
1388 }
1389
1390 let id = match client.wiki_page_id(&slug).await {
1391 Ok(id) => id,
1392 Err(error) => return failed(&error),
1393 };
1394 match client.wiki_delete_comment(id, comment).await {
1395 Ok(left) => {
1396 let left = left.map_or_else(String::new, |count| format!("; {count} left"));
1397 done(
1398 session,
1399 format!("deleted comment {comment} on {slug}{left}\n"),
1400 &serde_json::json!({ "action": "deleted comment", "slug": slug, "comment": comment }),
1401 )
1402 }
1403 Err(error) => failed(&error),
1404 }
1405}
1406
1407async fn show_access(page: &str, session: &Session) -> ExitCode {
1409 let slug = match named(page) {
1410 Ok(slug) => slug,
1411 Err(code) => return code,
1412 };
1413 let client = match session.client() {
1414 Ok(client) => client,
1415 Err(code) => return code,
1416 };
1417
1418 match client.wiki_access(&slug).await {
1419 Ok(found) => finish(match session.render.format {
1420 Format::Text => Ok(render::access(&found, &session.render)),
1421 Format::JsonRaw => machine(&found, Format::Json),
1422 other => machine(&found, other),
1423 }),
1424 Err(error) => failed(&error),
1425 }
1426}
1427
1428enum Grantee<'a> {
1430 Login(&'a str),
1432 Uid(&'a str),
1433 CloudUid(&'a str),
1434 Group(&'a str),
1436}
1437
1438async fn grant(
1445 page: &str,
1446 role: &str,
1447 who: Grantee<'_>,
1448 no_inherit: bool,
1449 allow_selflock: bool,
1450 session: &Session,
1451) -> ExitCode {
1452 let slug = match named(page) {
1453 Ok(slug) => slug,
1454 Err(code) => return code,
1455 };
1456 let client = match session.client() {
1457 Ok(client) => client,
1458 Err(code) => return code,
1459 };
1460
1461 let mut body = serde_json::json!({ "role": role });
1462 let named_as = match who {
1463 Grantee::Login(login) => {
1464 body["user"] = serde_json::json!({ "uid": format!("<uid of {login}, from Tracker>") });
1465 login.to_owned()
1466 }
1467 Grantee::Uid(uid) => {
1468 body["user"] = serde_json::json!({ "uid": uid });
1469 format!("uid {uid}")
1470 }
1471 Grantee::CloudUid(id) => {
1472 body["user"] = serde_json::json!({ "cloud_uid": id });
1473 format!("cloud uid {id}")
1474 }
1475 Grantee::Group(spec) => {
1476 let Some((source, id)) = spec.split_once(':').filter(|(source, id)| {
1477 matches!(*source, "dir" | "cloud" | "com" | "staff") && !id.is_empty()
1478 }) else {
1479 return report(
1480 &format!(
1481 "--group takes SOURCE:ID, the source one of dir, cloud, com, staff; got `{spec}`"
1482 ),
1483 ExitCode::ConfirmationRequired,
1484 );
1485 };
1486 body["group"] = serde_json::json!({ "id": id, "src": source });
1487 format!("group {id}")
1488 }
1489 };
1490 if no_inherit {
1491 body["inheritance"] = serde_json::Value::String("not_inherited".to_owned());
1492 }
1493 if let Some(code) = gated(
1494 &format!("grant {role} on wiki page `{slug}` to {named_as}"),
1495 &body,
1496 false,
1497 session,
1498 ) {
1499 return code;
1500 }
1501
1502 if let Grantee::Login(login) = who {
1503 match client.user(login).await {
1504 Ok(person) if !person.uid.is_empty() => {
1505 body["user"] = serde_json::json!({ "uid": person.uid });
1506 }
1507 Ok(_) => {
1508 return report(
1509 &format!("Tracker knows {login} but gives no uid for them; pass --uid"),
1510 ExitCode::NotFound,
1511 );
1512 }
1513 Err(error) => return failed(&error),
1514 }
1515 }
1516 let id = match client.wiki_page_id(&slug).await {
1517 Ok(id) => id,
1518 Err(error) => return failed(&error),
1519 };
1520 match client.wiki_grant(id, &body, allow_selflock).await {
1521 Ok(entry) => done(
1522 session,
1523 format!(
1524 "granted {role} on {slug} to {named_as} (access {})\n",
1525 entry.id
1526 ),
1527 &serde_json::json!({
1528 "action": "granted", "slug": slug, "role": role, "who": named_as,
1529 "access": entry.id
1530 }),
1531 ),
1532 Err(error) => failed(&error),
1533 }
1534}
1535
1536async fn regrant(
1538 page: &str,
1539 access: &str,
1540 body: &serde_json::Value,
1541 allow_selflock: bool,
1542 session: &Session,
1543) -> ExitCode {
1544 let slug = match named(page) {
1545 Ok(slug) => slug,
1546 Err(code) => return code,
1547 };
1548 let client = match session.client() {
1549 Ok(client) => client,
1550 Err(code) => return code,
1551 };
1552 if let Some(code) = gated(
1553 &format!("change access {access} on wiki page `{slug}`"),
1554 body,
1555 false,
1556 session,
1557 ) {
1558 return code;
1559 }
1560
1561 let id = match client.wiki_page_id(&slug).await {
1562 Ok(id) => id,
1563 Err(error) => return failed(&error),
1564 };
1565 match client.wiki_regrant(id, access, body, allow_selflock).await {
1566 Ok(entry) => {
1567 let role = if entry.role.is_empty() {
1568 "-"
1569 } else {
1570 &entry.role
1571 };
1572 done(
1573 session,
1574 format!("changed access {access} on {slug}: {role}\n"),
1575 &serde_json::json!({
1576 "action": "changed access", "slug": slug, "access": access, "role": entry.role
1577 }),
1578 )
1579 }
1580 Err(error) => failed(&error),
1581 }
1582}
1583
1584async fn revoke(
1587 page: &str,
1588 access: Option<&str>,
1589 allow_selflock: bool,
1590 session: &Session,
1591) -> ExitCode {
1592 let slug = match named(page) {
1593 Ok(slug) => slug,
1594 Err(code) => return code,
1595 };
1596 let client = match session.client() {
1597 Ok(client) => client,
1598 Err(code) => return code,
1599 };
1600 let (action, body) = match access {
1601 Some(access) => (
1602 format!("revoke access {access} on wiki page `{slug}`"),
1603 serde_json::json!({ "access": access }),
1604 ),
1605 None => (
1606 format!("revoke every personal access on wiki page `{slug}`"),
1607 serde_json::json!({ "access": "all personal" }),
1608 ),
1609 };
1610 if let Some(code) = gated(&action, &body, access.is_none(), session) {
1611 return code;
1612 }
1613
1614 let id = match client.wiki_page_id(&slug).await {
1615 Ok(id) => id,
1616 Err(error) => return failed(&error),
1617 };
1618 match client.wiki_revoke(id, access, allow_selflock).await {
1619 Ok(()) => done(
1620 session,
1621 match access {
1622 Some(access) => format!("revoked access {access} on {slug}\n"),
1623 None => format!("revoked every personal access on {slug}\n"),
1624 },
1625 &serde_json::json!({ "action": "revoked", "slug": slug, "access": access }),
1626 ),
1627 Err(error) => failed(&error),
1628 }
1629}
1630
1631const CLONE_WAIT: std::time::Duration = std::time::Duration::from_secs(600);
1633
1634const CLONE_REFUSALS: [(&str, &str); 6] = [
1636 (
1637 "IS_CLOUD_PAGE",
1638 "the page is a cloud page, which the Wiki cannot clone",
1639 ),
1640 ("SLUG_OCCUPIED", "a page already exists at the target"),
1641 ("SLUG_RESERVED", "the target address is reserved"),
1642 (
1643 "FORBIDDEN",
1644 "this account may not create a page at the target",
1645 ),
1646 ("QUOTA_EXCEEDED", "the organisation's Wiki quota is used up"),
1647 (
1648 "CLUSTER_BLOCKED",
1649 "the Wiki is not taking writes here right now",
1650 ),
1651];
1652
1653fn clone_refused(error: &crate::api::error::ApiError) -> ExitCode {
1655 if let crate::api::error::ApiError::Rejected { message, .. } = error
1656 && let Some((code, meaning)) = CLONE_REFUSALS
1657 .iter()
1658 .find(|(code, _)| message.contains(code))
1659 {
1660 return report(
1661 &format!("the Wiki would not clone it: {meaning} ({code})"),
1662 ExitCode::ApiRejected,
1663 );
1664 }
1665 failed(error)
1666}
1667
1668async fn clone_page(
1670 page: &str,
1671 target: &str,
1672 mut body: serde_json::Value,
1673 no_wait: bool,
1674 session: &Session,
1675) -> ExitCode {
1676 let slug = match named(page) {
1677 Ok(slug) => slug,
1678 Err(code) => return code,
1679 };
1680 let target = match named(target) {
1681 Ok(target) => target,
1682 Err(code) => return code,
1683 };
1684 let client = match session.client() {
1685 Ok(client) => client,
1686 Err(code) => return code,
1687 };
1688 body["target"] = serde_json::Value::String(target.clone());
1689 if let Some(code) = gated(
1690 &format!("clone wiki page `{slug}` to `{target}`"),
1691 &body,
1692 false,
1693 session,
1694 ) {
1695 return code;
1696 }
1697
1698 let id = match client.wiki_page_id(&slug).await {
1699 Ok(id) => id,
1700 Err(error) => return failed(&error),
1701 };
1702 let operation = match client.wiki_clone_page(id, &body).await {
1703 Ok(operation) => operation,
1704 Err(error) => return clone_refused(&error),
1705 };
1706 followed(&client, &operation, no_wait, session, |done| {
1707 format!("cloned {slug} to {}\n", done.page_slug().unwrap_or(&target))
1708 })
1709 .await
1710}
1711
1712async fn clone_grid(
1714 grid: &str,
1715 target: &str,
1716 mut body: serde_json::Value,
1717 no_wait: bool,
1718 session: &Session,
1719) -> ExitCode {
1720 let target = match named(target) {
1721 Ok(target) => target,
1722 Err(code) => return code,
1723 };
1724 let client = match session.client() {
1725 Ok(client) => client,
1726 Err(code) => return code,
1727 };
1728 body["target"] = serde_json::Value::String(target.clone());
1729 if let Some(code) = gated(
1730 &format!("clone wiki grid `{grid}` onto `{target}`"),
1731 &body,
1732 false,
1733 session,
1734 ) {
1735 return code;
1736 }
1737
1738 let operation = match client.wiki_clone_grid(grid.trim(), &body).await {
1739 Ok(operation) => operation,
1740 Err(error) => return clone_refused(&error),
1741 };
1742 followed(&client, &operation, no_wait, session, |done| {
1743 format!(
1744 "cloned grid {grid} to {}: grid {}\n",
1745 done.page_slug().unwrap_or(&target),
1746 done.grid_id().as_deref().unwrap_or("-")
1747 )
1748 })
1749 .await
1750}
1751
1752async fn followed(
1758 client: &crate::api::Client,
1759 operation: &crate::api::wiki::WikiOperation,
1760 no_wait: bool,
1761 session: &Session,
1762 describe: impl FnOnce(&crate::api::wiki::OperationStatus) -> String,
1763) -> ExitCode {
1764 let ask_again = format!("ytcli wiki operation {} {}", operation.kind, operation.id);
1765 if no_wait {
1766 return done(
1767 session,
1768 format!(
1769 "started operation {} {}; follow it with `{ask_again}`\n",
1770 operation.kind, operation.id
1771 ),
1772 &serde_json::json!({
1773 "action": "started", "operation": { "type": operation.kind, "id": operation.id }
1774 }),
1775 );
1776 }
1777
1778 let walk = crate::render::progress::Walk::start("cloning");
1779 let deadline = std::time::Instant::now() + CLONE_WAIT;
1780 let status = loop {
1781 let status = match client.wiki_operation(operation).await {
1782 Ok(status) => status,
1783 Err(error) => {
1784 walk.finish();
1785 return failed(&error);
1786 }
1787 };
1788 if status.is_done() {
1789 break status;
1790 }
1791 walk.say(&status.percentage.map_or_else(
1792 || format!("cloning: {}", status.status),
1793 |percentage| format!("cloning: {percentage:.0}%"),
1794 ));
1795 if std::time::Instant::now() >= deadline {
1796 walk.finish();
1797 return report(
1798 &format!("the Wiki is still working on it; ask again with `{ask_again}`"),
1799 ExitCode::Failure,
1800 );
1801 }
1802 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1803 };
1804 walk.finish();
1805
1806 if status.status == "failed" {
1807 return report(
1808 &format!(
1809 "the clone failed: {}",
1810 status
1811 .details
1812 .as_deref()
1813 .unwrap_or("the Wiki gave no reason")
1814 ),
1815 ExitCode::ApiRejected,
1816 );
1817 }
1818 done(
1819 session,
1820 describe(&status),
1821 &serde_json::to_value(&status).unwrap_or_default(),
1822 )
1823}
1824
1825async fn show_operation(
1827 operation: &crate::api::wiki::WikiOperation,
1828 session: &Session,
1829) -> ExitCode {
1830 let client = match session.client() {
1831 Ok(client) => client,
1832 Err(code) => return code,
1833 };
1834
1835 match client.wiki_operation(operation).await {
1836 Ok(status) => finish(match session.render.format {
1837 Format::Text => Ok(render::operation(operation, &status)),
1838 Format::JsonRaw => machine(&status, Format::Json),
1839 other => machine(&status, other),
1840 }),
1841 Err(error) => failed(&error),
1842 }
1843}
1844
1845fn counted(count: usize, what: &str) -> String {
1847 if count == 1 {
1848 format!("1 {what}")
1849 } else {
1850 format!("{count} {what}s")
1851 }
1852}
1853
1854struct GridChange<'a> {
1863 grid: &'a str,
1864 action: String,
1865 done: String,
1866 method: reqwest::Method,
1867 tail: &'static str,
1868 body: serde_json::Value,
1869 confirm: bool,
1870 revision: Option<&'a str>,
1871}
1872
1873async fn change_grid(change: GridChange<'_>, session: &Session) -> ExitCode {
1874 let client = match session.client() {
1875 Ok(client) => client,
1876 Err(code) => return code,
1877 };
1878 let grid = change.grid.trim();
1879 let mut body = change.body;
1880 body["revision"] = serde_json::Value::String(
1881 change
1882 .revision
1883 .map_or_else(|| "<current, read first>".to_owned(), str::to_owned),
1884 );
1885 if let Some(code) = gated(&change.action, &body, change.confirm, session) {
1886 return code;
1887 }
1888
1889 if change.revision.is_none() {
1890 match client.wiki_grid(grid, GridQuery::default()).await {
1891 Ok(current) => body["revision"] = serde_json::Value::String(current.revision),
1892 Err(error) => return failed(&error),
1893 }
1894 }
1895 match client
1896 .wiki_grid_write(change.method, grid, change.tail, Some(&body))
1897 .await
1898 {
1899 Ok(answer) => done(
1900 session,
1901 changed(&change.done, &answer),
1902 &serde_json::json!({ "action": change.done, "grid": grid, "result": answer }),
1903 ),
1904 Err(error) => failed(&error),
1905 }
1906}
1907
1908fn changed(done: &str, answer: &serde_json::Value) -> String {
1910 let text = |value: Option<&serde_json::Value>| match value {
1911 Some(serde_json::Value::String(text)) => Some(text.clone()),
1912 Some(serde_json::Value::Null) | None => None,
1913 Some(other) => Some(other.to_string()),
1914 };
1915 let made: Vec<String> = answer
1916 .get("results")
1917 .and_then(serde_json::Value::as_array)
1918 .into_iter()
1919 .flatten()
1920 .filter_map(|row| text(row.get("id")))
1921 .collect();
1922 let made = if made.is_empty() {
1923 String::new()
1924 } else {
1925 format!(" (rows {})", made.join(", "))
1926 };
1927 let revision = text(answer.get("revision")).unwrap_or_else(|| "-".to_owned());
1928 format!("{done}{made}; revision {revision}\n")
1929}
1930
1931fn json_list(from: &str, what: &str) -> Result<Vec<serde_json::Value>, ExitCode> {
1933 let text = content(from)?;
1934 match serde_json::from_str::<serde_json::Value>(&text) {
1935 Ok(serde_json::Value::Array(items)) if !items.is_empty() => Ok(items),
1936 Ok(_) => Err(report(
1937 &format!("{from} holds no {what}: expected a JSON array with at least one"),
1938 ExitCode::ConfirmationRequired,
1939 )),
1940 Err(error) => Err(report(
1941 &format!("{from} is not JSON: {error}"),
1942 ExitCode::ConfirmationRequired,
1943 )),
1944 }
1945}
1946
1947async fn grid_create(page: &str, title: &str, session: &Session) -> ExitCode {
1948 let slug = match named(page) {
1949 Ok(slug) => slug,
1950 Err(code) => return code,
1951 };
1952 let client = match session.client() {
1953 Ok(client) => client,
1954 Err(code) => return code,
1955 };
1956 let body = serde_json::json!({ "page": { "slug": slug }, "title": title });
1957 if let Some(code) = gated(
1958 &format!("create a grid on wiki page `{slug}`"),
1959 &body,
1960 false,
1961 session,
1962 ) {
1963 return code;
1964 }
1965
1966 match client.wiki_grid_create(&body).await {
1967 Ok(grid) => done(
1968 session,
1969 format!(
1970 "created grid {} on {slug}; revision {}\n",
1971 grid.id, grid.revision
1972 ),
1973 &serde_json::json!({
1974 "action": "created grid", "slug": slug, "grid": grid.id,
1975 "revision": grid.revision
1976 }),
1977 ),
1978 Err(error) => failed(&error),
1979 }
1980}
1981
1982async fn grid_update(
1983 grid: &str,
1984 title: Option<&str>,
1985 sort: Option<&str>,
1986 revision: Option<&str>,
1987 session: &Session,
1988) -> ExitCode {
1989 let mut body = serde_json::json!({});
1990 if let Some(title) = title {
1991 body["title"] = serde_json::Value::String(title.to_owned());
1992 }
1993 if let Some(sort) = sort {
1994 let mut order = serde_json::Map::new();
1995 for part in sort
1996 .split(',')
1997 .map(str::trim)
1998 .filter(|part| !part.is_empty())
1999 {
2000 match part.split_once(':') {
2001 Some((slug, direction @ ("asc" | "desc"))) if !slug.is_empty() => {
2002 order.insert(
2003 slug.to_owned(),
2004 serde_json::Value::String(direction.to_owned()),
2005 );
2006 }
2007 _ => {
2008 return report(
2009 &format!(
2010 "--sort takes slug:asc or slug:desc, comma-separated; got `{part}`"
2011 ),
2012 ExitCode::ConfirmationRequired,
2013 );
2014 }
2015 }
2016 }
2017 body["default_sort"] = serde_json::Value::Object(order);
2018 }
2019 if title.is_none() && sort.is_none() {
2020 return report(
2021 &"nothing to change: pass --title, --sort, or both",
2022 ExitCode::ConfirmationRequired,
2023 );
2024 }
2025 let change = GridChange {
2026 grid,
2027 action: format!("change wiki grid `{grid}`"),
2028 done: format!("changed grid {grid}"),
2029 method: reqwest::Method::POST,
2030 tail: "",
2031 body,
2032 confirm: false,
2033 revision,
2034 };
2035 change_grid(change, session).await
2036}
2037
2038async fn grid_delete(grid: &str, session: &Session) -> ExitCode {
2040 let client = match session.client() {
2041 Ok(client) => client,
2042 Err(code) => return code,
2043 };
2044 let grid = grid.trim();
2045 let body = serde_json::json!({ "grid": grid });
2046 if let Some(code) = gated(&format!("delete wiki grid `{grid}`"), &body, true, session) {
2047 return code;
2048 }
2049
2050 match client
2051 .wiki_grid_write(reqwest::Method::DELETE, grid, "", None)
2052 .await
2053 {
2054 Ok(_) => done(
2055 session,
2056 format!("deleted grid {grid}\n"),
2057 &serde_json::json!({ "action": "deleted grid", "grid": grid }),
2058 ),
2059 Err(error) => failed(&error),
2060 }
2061}
2062
2063async fn rows_add(
2064 grid: &str,
2065 from: &str,
2066 mut body: serde_json::Value,
2067 revision: Option<&str>,
2068 session: &Session,
2069) -> ExitCode {
2070 let rows = match json_list(from, "rows") {
2071 Ok(rows) => rows,
2072 Err(code) => return code,
2073 };
2074 let count = counted(rows.len(), "row");
2075 body["rows"] = serde_json::Value::Array(rows);
2076 let change = GridChange {
2077 grid,
2078 action: format!("add {count} to wiki grid `{grid}`"),
2079 done: format!("added {count} to grid {grid}"),
2080 method: reqwest::Method::POST,
2081 tail: "/rows",
2082 body,
2083 confirm: false,
2084 revision,
2085 };
2086 change_grid(change, session).await
2087}
2088
2089async fn columns_add(
2090 grid: &str,
2091 from: &str,
2092 position: Option<u64>,
2093 revision: Option<&str>,
2094 session: &Session,
2095) -> ExitCode {
2096 let columns = match json_list(from, "columns") {
2097 Ok(columns) => columns,
2098 Err(code) => return code,
2099 };
2100 let count = counted(columns.len(), "column");
2101 let mut body = serde_json::json!({ "columns": columns });
2102 if let Some(position) = position {
2103 body["position"] = serde_json::json!(position);
2104 }
2105 let change = GridChange {
2106 grid,
2107 action: format!("add {count} to wiki grid `{grid}`"),
2108 done: format!("added {count} to grid {grid}"),
2109 method: reqwest::Method::POST,
2110 tail: "/columns",
2111 body,
2112 confirm: false,
2113 revision,
2114 };
2115 change_grid(change, session).await
2116}
2117
2118async fn cells_set(
2123 grid: &str,
2124 set: &[String],
2125 revision: Option<&str>,
2126 session: &Session,
2127) -> ExitCode {
2128 let mut cells = Vec::with_capacity(set.len());
2129 for raw in set {
2130 let parsed = raw.split_once(':').and_then(|(row, rest)| {
2131 let row: u64 = row.trim().parse().ok()?;
2132 Some((row, crate::cli::write::parse_assignment(rest)))
2133 });
2134 match parsed {
2135 Some((row, Ok((slug, value)))) => cells.push(serde_json::json!({
2137 "row_id": row, "column_slug": slug, "value": value
2138 })),
2139 Some((_, Err(problem))) => {
2140 return report(&problem, ExitCode::ConfirmationRequired);
2141 }
2142 None => {
2143 return report(
2144 &format!("--set takes ROW:SLUG=VALUE, the row a number; got `{raw}`"),
2145 ExitCode::ConfirmationRequired,
2146 );
2147 }
2148 }
2149 }
2150 let count = counted(cells.len(), "cell");
2151 let change = GridChange {
2152 grid,
2153 action: format!("set {count} in wiki grid `{grid}`"),
2154 done: format!("set {count} in grid {grid}"),
2155 method: reqwest::Method::POST,
2156 tail: "/cells",
2157 body: serde_json::json!({ "cells": cells }),
2158 confirm: false,
2159 revision,
2160 };
2161 change_grid(change, session).await
2162}
2163
2164async fn upload(page: &str, files: &[PathBuf], session: &Session) -> ExitCode {
2172 let slug = match named(page) {
2173 Ok(slug) => slug,
2174 Err(code) => return code,
2175 };
2176 let client = match session.client() {
2177 Ok(client) => client,
2178 Err(code) => return code,
2179 };
2180
2181 let mut loaded = Vec::with_capacity(files.len());
2182 for file in files {
2183 let bytes = match std::fs::read(file) {
2184 Ok(bytes) => bytes,
2185 Err(error) => {
2186 return report(
2187 &format!("cannot read {}: {error}", file.display()),
2188 ExitCode::Failure,
2189 );
2190 }
2191 };
2192 let name = file.file_name().map_or_else(
2193 || "upload".to_owned(),
2194 |name| name.to_string_lossy().into_owned(),
2195 );
2196 loaded.push((name, bytes));
2197 }
2198 let body = serde_json::json!({
2199 "files": loaded
2200 .iter()
2201 .map(|(name, bytes)| serde_json::json!({ "file_name": name, "file_size": bytes.len() }))
2202 .collect::<Vec<_>>()
2203 });
2204 if let Some(code) = gated(
2205 &format!(
2206 "upload {} to wiki page `{slug}`",
2207 counted(loaded.len(), "file")
2208 ),
2209 &body,
2210 false,
2211 session,
2212 ) {
2213 return code;
2214 }
2215
2216 let id = match client.wiki_page_id(&slug).await {
2217 Ok(id) => id,
2218 Err(error) => return failed(&error),
2219 };
2220 let mut uploaded = Vec::new();
2223 for (name, bytes) in &loaded {
2224 let upload = match sent(&client, name, bytes).await {
2225 Ok(upload) => upload,
2226 Err(code) => return code,
2227 };
2228 match client.wiki_attach(id, std::slice::from_ref(&upload)).await {
2229 Ok(attached) => {
2230 for file in attached {
2231 if session.render.format == Format::Text {
2232 emit(&format!(
2233 "uploaded {} to {slug}: attachment {}\n",
2234 file.name, file.id
2235 ));
2236 }
2237 uploaded.push(serde_json::json!({ "name": file.name, "id": file.id }));
2238 }
2239 }
2240 Err(error) => {
2241 abandon(&client, &upload).await;
2242 return failed(&error);
2243 }
2244 }
2245 }
2246 if session.render.format == Format::Text {
2247 return ExitCode::Success;
2248 }
2249 done(
2250 session,
2251 String::new(),
2252 &serde_json::json!({ "action": "uploaded", "slug": slug, "attachments": uploaded }),
2253 )
2254}
2255
2256async fn sent(client: &crate::api::Client, name: &str, bytes: &[u8]) -> Result<String, ExitCode> {
2259 use crate::api::wiki::{UPLOAD_PART, upload_parts};
2260
2261 let upload = match client.wiki_upload_start(name, bytes.len()).await {
2262 Ok(upload) => upload.session_id,
2263 Err(error) => return Err(failed(&error)),
2264 };
2265 let parts = upload_parts(bytes.len(), UPLOAD_PART);
2266 let walk = crate::render::progress::Walk::start(&format!("uploading {name}"));
2267 for (index, range) in parts.iter().enumerate() {
2268 walk.say(&format!(
2269 "uploading {name}: part {} of {}",
2270 index + 1,
2271 parts.len()
2272 ));
2273 let number = u32::try_from(index + 1).unwrap_or(u32::MAX);
2274 let part = bytes.get(range.clone()).unwrap_or_default().to_vec();
2275 if let Err(error) = client.wiki_upload_part(&upload, number, part).await {
2276 walk.finish();
2277 abandon(client, &upload).await;
2278 return Err(failed(&error));
2279 }
2280 }
2281 walk.finish();
2282 if let Err(error) = client.wiki_upload_finish(&upload).await {
2283 abandon(client, &upload).await;
2284 return Err(failed(&error));
2285 }
2286 Ok(upload)
2287}
2288
2289async fn abandon(client: &crate::api::Client, upload: &str) {
2292 let _ = client.wiki_upload_abort(upload).await;
2293}
2294
2295async fn delete_attachment(page: &str, file: &str, session: &Session) -> ExitCode {
2298 let slug = match named(page) {
2299 Ok(slug) => slug,
2300 Err(code) => return code,
2301 };
2302 let client = match session.client() {
2303 Ok(client) => client,
2304 Err(code) => return code,
2305 };
2306 let body = serde_json::json!({ "file": file });
2307 if let Some(code) = gated(
2308 &format!("delete attachment `{file}` from wiki page `{slug}`"),
2309 &body,
2310 true,
2311 session,
2312 ) {
2313 return code;
2314 }
2315
2316 let (id, found) = match client.wiki_attachment_named(&slug, file).await {
2317 Ok(pair) => pair,
2318 Err(error) => return failed(&error),
2319 };
2320 match client.wiki_delete_attachment(id, found.id).await {
2321 Ok(()) => done(
2322 session,
2323 format!(
2324 "deleted attachment {} ({}) from {slug}\n",
2325 found.name, found.id
2326 ),
2327 &serde_json::json!({
2328 "action": "deleted attachment", "slug": slug, "attachment": found.id,
2329 "name": found.name
2330 }),
2331 ),
2332 Err(error) => failed(&error),
2333 }
2334}
2335
2336enum Source {
2338 Attachment { page: i64, file: u64 },
2339 Address(String),
2340}
2341
2342async fn download(
2348 page: &str,
2349 file: Option<&str>,
2350 out: &Path,
2351 force: bool,
2352 session: &Session,
2353) -> ExitCode {
2354 let target = slug_of(page);
2355 let client = match session.client() {
2356 Ok(client) => client,
2357 Err(code) => return code,
2358 };
2359
2360 let (name, source) = if let Some(file) = file {
2361 let slug = match named(page) {
2362 Ok(slug) => slug,
2363 Err(code) => return code,
2364 };
2365 match client.wiki_attachment_named(&slug, file).await {
2366 Ok((page, found)) => (
2367 safe_filename(&found.name, &found.id.to_string()),
2368 Source::Attachment {
2369 page,
2370 file: found.id,
2371 },
2372 ),
2373 Err(error) => {
2374 let code = error.exit_code();
2375 return report(&error, code);
2376 }
2377 }
2378 } else if let Some((_, name)) = target.split_once("/.files/") {
2379 (
2380 safe_filename(name, "download"),
2381 Source::Address(target.clone()),
2382 )
2383 } else {
2384 return report(
2385 &format!(
2386 "`{page}` is a page, not a file: name the file too (`wiki attachments` lists them), \
2387 or pass the file's address, <slug>/.files/<name>"
2388 ),
2389 ExitCode::ConfirmationRequired,
2390 );
2391 };
2392
2393 let destination = out.join(name);
2394 if destination.exists() && !force {
2395 return report(
2396 &format!(
2397 "{} already exists; pass --force to overwrite",
2398 destination.display()
2399 ),
2400 ExitCode::ConfirmationRequired,
2401 );
2402 }
2403
2404 let fetched = match &source {
2405 Source::Attachment { page, file } => client.wiki_attachment_bytes(*page, *file).await,
2406 Source::Address(path) => client.wiki_file_bytes(path).await,
2407 };
2408 let bytes = match fetched {
2409 Ok(bytes) => bytes,
2410 Err(error) => {
2411 let code = error.exit_code();
2412 return report(&error, code);
2413 }
2414 };
2415
2416 if let Err(error) = std::fs::create_dir_all(out) {
2417 return report(&error, ExitCode::Failure);
2418 }
2419 if let Err(error) = std::fs::write(&destination, &bytes) {
2420 return report(&error, ExitCode::Failure);
2421 }
2422
2423 done(
2424 session,
2425 format!("{}\n", destination.display()),
2426 &serde_json::json!({ "action": "downloaded", "path": destination }),
2427 )
2428}
2429
2430fn page_size(session: &Session) -> u32 {
2432 u32::try_from(session.display().limit.clamp(1, 100)).unwrap_or(100)
2433}
2434
2435fn named(page: &str) -> Result<String, ExitCode> {
2437 let slug = slug_of(page);
2438 if slug.is_empty() {
2439 return Err(report(
2440 &format!(
2441 "`{page}` names no page: pass a slug such as users/me/notes, or the page's address"
2442 ),
2443 ExitCode::ConfirmationRequired,
2444 ));
2445 }
2446 Ok(slug)
2447}
2448
2449fn done(session: &Session, text: String, facts: &serde_json::Value) -> ExitCode {
2453 finish(match session.render.format {
2454 Format::Text => Ok(text),
2455 Format::JsonRaw => machine(facts, Format::Json),
2456 other => machine(facts, other),
2457 })
2458}
2459
2460fn finish(rendered: Result<String, RenderError>) -> ExitCode {
2461 match rendered {
2462 Ok(text) => {
2463 emit(&text);
2464 ExitCode::Success
2465 }
2466 Err(error) => report(&error, ExitCode::Failure),
2467 }
2468}