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 by_cloud_uid = matches!(who, Grantee::CloudUid(_));
1463 let named_as = match who {
1464 Grantee::Login(login) => {
1465 body["user"] = serde_json::json!({ "uid": format!("<uid of {login}, from Tracker>") });
1466 login.to_owned()
1467 }
1468 Grantee::Uid(uid) => {
1469 body["user"] = serde_json::json!({ "uid": uid });
1470 format!("uid {uid}")
1471 }
1472 Grantee::CloudUid(id) => {
1473 body["user"] = serde_json::json!({ "cloud_uid": id });
1474 format!("cloud uid {id}")
1475 }
1476 Grantee::Group(spec) => {
1477 let Some((source, id)) = spec.split_once(':').filter(|(source, id)| {
1478 matches!(*source, "dir" | "cloud" | "com" | "staff") && !id.is_empty()
1479 }) else {
1480 return report(
1481 &format!(
1482 "--group takes SOURCE:ID, the source one of dir, cloud, com, staff; got `{spec}`"
1483 ),
1484 ExitCode::ConfirmationRequired,
1485 );
1486 };
1487 body["group"] = serde_json::json!({ "id": id, "src": source });
1488 format!("group {id}")
1489 }
1490 };
1491 if no_inherit {
1492 body["inheritance"] = serde_json::Value::String("not_inherited".to_owned());
1493 }
1494 if let Some(code) = gated(
1495 &format!("grant {role} on wiki page `{slug}` to {named_as}"),
1496 &body,
1497 false,
1498 session,
1499 ) {
1500 return code;
1501 }
1502
1503 if let Grantee::Login(login) = who {
1504 match client.user(login).await {
1505 Ok(person) if !person.uid.is_empty() => {
1506 body["user"] = serde_json::json!({ "uid": person.uid });
1507 }
1508 Ok(_) => {
1509 return report(
1510 &format!("Tracker knows {login} but gives no uid for them; pass --uid"),
1511 ExitCode::NotFound,
1512 );
1513 }
1514 Err(error) => return failed(&error),
1515 }
1516 }
1517 let id = match client.wiki_page_id(&slug).await {
1518 Ok(id) => id,
1519 Err(error) => return failed(&error),
1520 };
1521 match client.wiki_grant(id, &body, allow_selflock).await {
1522 Ok(entry) => done(
1523 session,
1524 format!(
1525 "granted {role} on {slug} to {named_as} (access {})\n",
1526 entry.id
1527 ),
1528 &serde_json::json!({
1529 "action": "granted", "slug": slug, "role": role, "who": named_as,
1530 "access": entry.id
1531 }),
1532 ),
1533 Err(error) if is_user_not_found(&error) => {
1534 report(&user_not_found(&slug, by_cloud_uid), ExitCode::ApiRejected)
1535 }
1536 Err(error) => failed(&error),
1537 }
1538}
1539
1540fn is_user_not_found(error: &crate::api::error::ApiError) -> bool {
1545 matches!(
1546 error,
1547 crate::api::error::ApiError::WikiRejected { status, message }
1548 if status.as_u16() == 400 && message.contains("USER_NOT_FOUND")
1549 )
1550}
1551
1552fn user_not_found(slug: &str, by_cloud_uid: bool) -> String {
1553 let mut advice = format!(
1554 "the Wiki did not accept this identity for a grant on `{slug}` (USER_NOT_FOUND). \
1555 If the user exists in Tracker, you may not be an author of the page: only authors \
1556 grant, so ask one of those `ytcli wiki access {slug}` lists"
1557 );
1558 if !by_cloud_uid {
1559 advice.push_str("; or the organisation keys users by cloud uid: try --cloud-uid");
1560 }
1561 advice
1562}
1563
1564async fn regrant(
1566 page: &str,
1567 access: &str,
1568 body: &serde_json::Value,
1569 allow_selflock: bool,
1570 session: &Session,
1571) -> ExitCode {
1572 let slug = match named(page) {
1573 Ok(slug) => slug,
1574 Err(code) => return code,
1575 };
1576 let client = match session.client() {
1577 Ok(client) => client,
1578 Err(code) => return code,
1579 };
1580 if let Some(code) = gated(
1581 &format!("change access {access} on wiki page `{slug}`"),
1582 body,
1583 false,
1584 session,
1585 ) {
1586 return code;
1587 }
1588
1589 let id = match client.wiki_page_id(&slug).await {
1590 Ok(id) => id,
1591 Err(error) => return failed(&error),
1592 };
1593 match client.wiki_regrant(id, access, body, allow_selflock).await {
1594 Ok(entry) => {
1595 let role = if entry.role.is_empty() {
1596 "-"
1597 } else {
1598 &entry.role
1599 };
1600 done(
1601 session,
1602 format!("changed access {access} on {slug}: {role}\n"),
1603 &serde_json::json!({
1604 "action": "changed access", "slug": slug, "access": access, "role": entry.role
1605 }),
1606 )
1607 }
1608 Err(error) => failed(&error),
1609 }
1610}
1611
1612async fn revoke(
1615 page: &str,
1616 access: Option<&str>,
1617 allow_selflock: bool,
1618 session: &Session,
1619) -> ExitCode {
1620 let slug = match named(page) {
1621 Ok(slug) => slug,
1622 Err(code) => return code,
1623 };
1624 let client = match session.client() {
1625 Ok(client) => client,
1626 Err(code) => return code,
1627 };
1628 let (action, body) = match access {
1629 Some(access) => (
1630 format!("revoke access {access} on wiki page `{slug}`"),
1631 serde_json::json!({ "access": access }),
1632 ),
1633 None => (
1634 format!("revoke every personal access on wiki page `{slug}`"),
1635 serde_json::json!({ "access": "all personal" }),
1636 ),
1637 };
1638 if let Some(code) = gated(&action, &body, access.is_none(), session) {
1639 return code;
1640 }
1641
1642 let id = match client.wiki_page_id(&slug).await {
1643 Ok(id) => id,
1644 Err(error) => return failed(&error),
1645 };
1646 match client.wiki_revoke(id, access, allow_selflock).await {
1647 Ok(()) => done(
1648 session,
1649 match access {
1650 Some(access) => format!("revoked access {access} on {slug}\n"),
1651 None => format!("revoked every personal access on {slug}\n"),
1652 },
1653 &serde_json::json!({ "action": "revoked", "slug": slug, "access": access }),
1654 ),
1655 Err(error) => failed(&error),
1656 }
1657}
1658
1659const CLONE_WAIT: std::time::Duration = std::time::Duration::from_secs(600);
1661
1662const CLONE_REFUSALS: [(&str, &str); 6] = [
1664 (
1665 "IS_CLOUD_PAGE",
1666 "the page is a cloud page, which the Wiki cannot clone",
1667 ),
1668 ("SLUG_OCCUPIED", "a page already exists at the target"),
1669 ("SLUG_RESERVED", "the target address is reserved"),
1670 (
1671 "FORBIDDEN",
1672 "this account may not create a page at the target",
1673 ),
1674 ("QUOTA_EXCEEDED", "the organisation's Wiki quota is used up"),
1675 (
1676 "CLUSTER_BLOCKED",
1677 "the Wiki is not taking writes here right now",
1678 ),
1679];
1680
1681fn clone_refused(error: &crate::api::error::ApiError) -> ExitCode {
1683 if let crate::api::error::ApiError::WikiRejected { message, .. } = error
1684 && let Some((code, meaning)) = CLONE_REFUSALS
1685 .iter()
1686 .find(|(code, _)| message.contains(code))
1687 {
1688 return report(
1689 &format!("the Wiki would not clone it: {meaning} ({code})"),
1690 ExitCode::ApiRejected,
1691 );
1692 }
1693 failed(error)
1694}
1695
1696async fn clone_page(
1698 page: &str,
1699 target: &str,
1700 mut body: serde_json::Value,
1701 no_wait: bool,
1702 session: &Session,
1703) -> ExitCode {
1704 let slug = match named(page) {
1705 Ok(slug) => slug,
1706 Err(code) => return code,
1707 };
1708 let target = match named(target) {
1709 Ok(target) => target,
1710 Err(code) => return code,
1711 };
1712 let client = match session.client() {
1713 Ok(client) => client,
1714 Err(code) => return code,
1715 };
1716 body["target"] = serde_json::Value::String(target.clone());
1717 if let Some(code) = gated(
1718 &format!("clone wiki page `{slug}` to `{target}`"),
1719 &body,
1720 false,
1721 session,
1722 ) {
1723 return code;
1724 }
1725
1726 let id = match client.wiki_page_id(&slug).await {
1727 Ok(id) => id,
1728 Err(error) => return failed(&error),
1729 };
1730 let operation = match client.wiki_clone_page(id, &body).await {
1731 Ok(operation) => operation,
1732 Err(error) => return clone_refused(&error),
1733 };
1734 followed(&client, &operation, no_wait, session, |done| {
1735 format!("cloned {slug} to {}\n", done.page_slug().unwrap_or(&target))
1736 })
1737 .await
1738}
1739
1740async fn clone_grid(
1742 grid: &str,
1743 target: &str,
1744 mut body: serde_json::Value,
1745 no_wait: bool,
1746 session: &Session,
1747) -> ExitCode {
1748 let target = match named(target) {
1749 Ok(target) => target,
1750 Err(code) => return code,
1751 };
1752 let client = match session.client() {
1753 Ok(client) => client,
1754 Err(code) => return code,
1755 };
1756 body["target"] = serde_json::Value::String(target.clone());
1757 if let Some(code) = gated(
1758 &format!("clone wiki grid `{grid}` onto `{target}`"),
1759 &body,
1760 false,
1761 session,
1762 ) {
1763 return code;
1764 }
1765
1766 let operation = match client.wiki_clone_grid(grid.trim(), &body).await {
1767 Ok(operation) => operation,
1768 Err(error) => return clone_refused(&error),
1769 };
1770 followed(&client, &operation, no_wait, session, |done| {
1771 format!(
1772 "cloned grid {grid} to {}: grid {}\n",
1773 done.page_slug().unwrap_or(&target),
1774 done.grid_id().as_deref().unwrap_or("-")
1775 )
1776 })
1777 .await
1778}
1779
1780async fn followed(
1786 client: &crate::api::Client,
1787 operation: &crate::api::wiki::WikiOperation,
1788 no_wait: bool,
1789 session: &Session,
1790 describe: impl FnOnce(&crate::api::wiki::OperationStatus) -> String,
1791) -> ExitCode {
1792 let ask_again = format!("ytcli wiki operation {} {}", operation.kind, operation.id);
1793 if no_wait {
1794 return done(
1795 session,
1796 format!(
1797 "started operation {} {}; follow it with `{ask_again}`\n",
1798 operation.kind, operation.id
1799 ),
1800 &serde_json::json!({
1801 "action": "started", "operation": { "type": operation.kind, "id": operation.id }
1802 }),
1803 );
1804 }
1805
1806 let walk = crate::render::progress::Walk::start("cloning");
1807 let deadline = std::time::Instant::now() + CLONE_WAIT;
1808 let status = loop {
1809 let status = match client.wiki_operation(operation).await {
1810 Ok(status) => status,
1811 Err(error) => {
1812 walk.finish();
1813 return failed(&error);
1814 }
1815 };
1816 if status.is_done() {
1817 break status;
1818 }
1819 walk.say(&status.percentage.map_or_else(
1820 || format!("cloning: {}", status.status),
1821 |percentage| format!("cloning: {percentage:.0}%"),
1822 ));
1823 if std::time::Instant::now() >= deadline {
1824 walk.finish();
1825 return report(
1826 &format!("the Wiki is still working on it; ask again with `{ask_again}`"),
1827 ExitCode::Failure,
1828 );
1829 }
1830 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1831 };
1832 walk.finish();
1833
1834 if status.status == "failed" {
1835 return report(
1836 &format!(
1837 "the clone failed: {}",
1838 status
1839 .details
1840 .as_deref()
1841 .unwrap_or("the Wiki gave no reason")
1842 ),
1843 ExitCode::ApiRejected,
1844 );
1845 }
1846 done(
1847 session,
1848 describe(&status),
1849 &serde_json::to_value(&status).unwrap_or_default(),
1850 )
1851}
1852
1853async fn show_operation(
1855 operation: &crate::api::wiki::WikiOperation,
1856 session: &Session,
1857) -> ExitCode {
1858 let client = match session.client() {
1859 Ok(client) => client,
1860 Err(code) => return code,
1861 };
1862
1863 match client.wiki_operation(operation).await {
1864 Ok(status) => finish(match session.render.format {
1865 Format::Text => Ok(render::operation(operation, &status)),
1866 Format::JsonRaw => machine(&status, Format::Json),
1867 other => machine(&status, other),
1868 }),
1869 Err(error) => failed(&error),
1870 }
1871}
1872
1873fn counted(count: usize, what: &str) -> String {
1875 if count == 1 {
1876 format!("1 {what}")
1877 } else {
1878 format!("{count} {what}s")
1879 }
1880}
1881
1882struct GridChange<'a> {
1891 grid: &'a str,
1892 action: String,
1893 done: String,
1894 method: reqwest::Method,
1895 tail: &'static str,
1896 body: serde_json::Value,
1897 confirm: bool,
1898 revision: Option<&'a str>,
1899}
1900
1901async fn change_grid(change: GridChange<'_>, session: &Session) -> ExitCode {
1902 let client = match session.client() {
1903 Ok(client) => client,
1904 Err(code) => return code,
1905 };
1906 let grid = change.grid.trim();
1907 let mut body = change.body;
1908 body["revision"] = serde_json::Value::String(
1909 change
1910 .revision
1911 .map_or_else(|| "<current, read first>".to_owned(), str::to_owned),
1912 );
1913 if let Some(code) = gated(&change.action, &body, change.confirm, session) {
1914 return code;
1915 }
1916
1917 if change.revision.is_none() {
1918 match client.wiki_grid(grid, GridQuery::default()).await {
1919 Ok(current) => body["revision"] = serde_json::Value::String(current.revision),
1920 Err(error) => return failed(&error),
1921 }
1922 }
1923 match client
1924 .wiki_grid_write(change.method, grid, change.tail, Some(&body))
1925 .await
1926 {
1927 Ok(answer) => done(
1928 session,
1929 changed(&change.done, &answer),
1930 &serde_json::json!({ "action": change.done, "grid": grid, "result": answer }),
1931 ),
1932 Err(error) => failed(&error),
1933 }
1934}
1935
1936fn changed(done: &str, answer: &serde_json::Value) -> String {
1938 let text = |value: Option<&serde_json::Value>| match value {
1939 Some(serde_json::Value::String(text)) => Some(text.clone()),
1940 Some(serde_json::Value::Null) | None => None,
1941 Some(other) => Some(other.to_string()),
1942 };
1943 let made: Vec<String> = answer
1944 .get("results")
1945 .and_then(serde_json::Value::as_array)
1946 .into_iter()
1947 .flatten()
1948 .filter_map(|row| text(row.get("id")))
1949 .collect();
1950 let made = if made.is_empty() {
1951 String::new()
1952 } else {
1953 format!(" (rows {})", made.join(", "))
1954 };
1955 let revision = text(answer.get("revision")).unwrap_or_else(|| "-".to_owned());
1956 format!("{done}{made}; revision {revision}\n")
1957}
1958
1959fn json_list(from: &str, what: &str) -> Result<Vec<serde_json::Value>, ExitCode> {
1961 let text = content(from)?;
1962 match serde_json::from_str::<serde_json::Value>(&text) {
1963 Ok(serde_json::Value::Array(items)) if !items.is_empty() => Ok(items),
1964 Ok(_) => Err(report(
1965 &format!("{from} holds no {what}: expected a JSON array with at least one"),
1966 ExitCode::ConfirmationRequired,
1967 )),
1968 Err(error) => Err(report(
1969 &format!("{from} is not JSON: {error}"),
1970 ExitCode::ConfirmationRequired,
1971 )),
1972 }
1973}
1974
1975async fn grid_create(page: &str, title: &str, session: &Session) -> ExitCode {
1976 let slug = match named(page) {
1977 Ok(slug) => slug,
1978 Err(code) => return code,
1979 };
1980 let client = match session.client() {
1981 Ok(client) => client,
1982 Err(code) => return code,
1983 };
1984 let body = serde_json::json!({ "page": { "slug": slug }, "title": title });
1985 if let Some(code) = gated(
1986 &format!("create a grid on wiki page `{slug}`"),
1987 &body,
1988 false,
1989 session,
1990 ) {
1991 return code;
1992 }
1993
1994 match client.wiki_grid_create(&body).await {
1995 Ok(grid) => done(
1996 session,
1997 format!(
1998 "created grid {} on {slug}; revision {}\n",
1999 grid.id, grid.revision
2000 ),
2001 &serde_json::json!({
2002 "action": "created grid", "slug": slug, "grid": grid.id,
2003 "revision": grid.revision
2004 }),
2005 ),
2006 Err(error) => failed(&error),
2007 }
2008}
2009
2010async fn grid_update(
2011 grid: &str,
2012 title: Option<&str>,
2013 sort: Option<&str>,
2014 revision: Option<&str>,
2015 session: &Session,
2016) -> ExitCode {
2017 let mut body = serde_json::json!({});
2018 if let Some(title) = title {
2019 body["title"] = serde_json::Value::String(title.to_owned());
2020 }
2021 if let Some(sort) = sort {
2022 let mut order = serde_json::Map::new();
2023 for part in sort
2024 .split(',')
2025 .map(str::trim)
2026 .filter(|part| !part.is_empty())
2027 {
2028 match part.split_once(':') {
2029 Some((slug, direction @ ("asc" | "desc"))) if !slug.is_empty() => {
2030 order.insert(
2031 slug.to_owned(),
2032 serde_json::Value::String(direction.to_owned()),
2033 );
2034 }
2035 _ => {
2036 return report(
2037 &format!(
2038 "--sort takes slug:asc or slug:desc, comma-separated; got `{part}`"
2039 ),
2040 ExitCode::ConfirmationRequired,
2041 );
2042 }
2043 }
2044 }
2045 body["default_sort"] = serde_json::Value::Object(order);
2046 }
2047 if title.is_none() && sort.is_none() {
2048 return report(
2049 &"nothing to change: pass --title, --sort, or both",
2050 ExitCode::ConfirmationRequired,
2051 );
2052 }
2053 let change = GridChange {
2054 grid,
2055 action: format!("change wiki grid `{grid}`"),
2056 done: format!("changed grid {grid}"),
2057 method: reqwest::Method::POST,
2058 tail: "",
2059 body,
2060 confirm: false,
2061 revision,
2062 };
2063 change_grid(change, session).await
2064}
2065
2066async fn grid_delete(grid: &str, session: &Session) -> ExitCode {
2068 let client = match session.client() {
2069 Ok(client) => client,
2070 Err(code) => return code,
2071 };
2072 let grid = grid.trim();
2073 let body = serde_json::json!({ "grid": grid });
2074 if let Some(code) = gated(&format!("delete wiki grid `{grid}`"), &body, true, session) {
2075 return code;
2076 }
2077
2078 match client
2079 .wiki_grid_write(reqwest::Method::DELETE, grid, "", None)
2080 .await
2081 {
2082 Ok(_) => done(
2083 session,
2084 format!("deleted grid {grid}\n"),
2085 &serde_json::json!({ "action": "deleted grid", "grid": grid }),
2086 ),
2087 Err(error) => failed(&error),
2088 }
2089}
2090
2091async fn rows_add(
2092 grid: &str,
2093 from: &str,
2094 mut body: serde_json::Value,
2095 revision: Option<&str>,
2096 session: &Session,
2097) -> ExitCode {
2098 let rows = match json_list(from, "rows") {
2099 Ok(rows) => rows,
2100 Err(code) => return code,
2101 };
2102 let count = counted(rows.len(), "row");
2103 body["rows"] = serde_json::Value::Array(rows);
2104 let change = GridChange {
2105 grid,
2106 action: format!("add {count} to wiki grid `{grid}`"),
2107 done: format!("added {count} to grid {grid}"),
2108 method: reqwest::Method::POST,
2109 tail: "/rows",
2110 body,
2111 confirm: false,
2112 revision,
2113 };
2114 change_grid(change, session).await
2115}
2116
2117async fn columns_add(
2118 grid: &str,
2119 from: &str,
2120 position: Option<u64>,
2121 revision: Option<&str>,
2122 session: &Session,
2123) -> ExitCode {
2124 let columns = match json_list(from, "columns") {
2125 Ok(columns) => columns,
2126 Err(code) => return code,
2127 };
2128 let count = counted(columns.len(), "column");
2129 let mut body = serde_json::json!({ "columns": columns });
2130 if let Some(position) = position {
2131 body["position"] = serde_json::json!(position);
2132 }
2133 let change = GridChange {
2134 grid,
2135 action: format!("add {count} to wiki grid `{grid}`"),
2136 done: format!("added {count} to grid {grid}"),
2137 method: reqwest::Method::POST,
2138 tail: "/columns",
2139 body,
2140 confirm: false,
2141 revision,
2142 };
2143 change_grid(change, session).await
2144}
2145
2146async fn cells_set(
2151 grid: &str,
2152 set: &[String],
2153 revision: Option<&str>,
2154 session: &Session,
2155) -> ExitCode {
2156 let mut cells = Vec::with_capacity(set.len());
2157 for raw in set {
2158 let parsed = raw.split_once(':').and_then(|(row, rest)| {
2159 let row: u64 = row.trim().parse().ok()?;
2160 Some((row, crate::cli::write::parse_assignment(rest)))
2161 });
2162 match parsed {
2163 Some((row, Ok((slug, value)))) => cells.push(serde_json::json!({
2165 "row_id": row, "column_slug": slug, "value": value
2166 })),
2167 Some((_, Err(problem))) => {
2168 return report(&problem, ExitCode::ConfirmationRequired);
2169 }
2170 None => {
2171 return report(
2172 &format!("--set takes ROW:SLUG=VALUE, the row a number; got `{raw}`"),
2173 ExitCode::ConfirmationRequired,
2174 );
2175 }
2176 }
2177 }
2178 let count = counted(cells.len(), "cell");
2179 let change = GridChange {
2180 grid,
2181 action: format!("set {count} in wiki grid `{grid}`"),
2182 done: format!("set {count} in grid {grid}"),
2183 method: reqwest::Method::POST,
2184 tail: "/cells",
2185 body: serde_json::json!({ "cells": cells }),
2186 confirm: false,
2187 revision,
2188 };
2189 change_grid(change, session).await
2190}
2191
2192async fn upload(page: &str, files: &[PathBuf], session: &Session) -> ExitCode {
2200 let slug = match named(page) {
2201 Ok(slug) => slug,
2202 Err(code) => return code,
2203 };
2204 let client = match session.client() {
2205 Ok(client) => client,
2206 Err(code) => return code,
2207 };
2208
2209 let mut loaded = Vec::with_capacity(files.len());
2210 for file in files {
2211 let bytes = match std::fs::read(file) {
2212 Ok(bytes) => bytes,
2213 Err(error) => {
2214 return report(
2215 &format!("cannot read {}: {error}", file.display()),
2216 ExitCode::Failure,
2217 );
2218 }
2219 };
2220 let name = file.file_name().map_or_else(
2221 || "upload".to_owned(),
2222 |name| name.to_string_lossy().into_owned(),
2223 );
2224 loaded.push((name, bytes));
2225 }
2226 let body = serde_json::json!({
2227 "files": loaded
2228 .iter()
2229 .map(|(name, bytes)| serde_json::json!({ "file_name": name, "file_size": bytes.len() }))
2230 .collect::<Vec<_>>()
2231 });
2232 if let Some(code) = gated(
2233 &format!(
2234 "upload {} to wiki page `{slug}`",
2235 counted(loaded.len(), "file")
2236 ),
2237 &body,
2238 false,
2239 session,
2240 ) {
2241 return code;
2242 }
2243
2244 let id = match client.wiki_page_id(&slug).await {
2245 Ok(id) => id,
2246 Err(error) => return failed(&error),
2247 };
2248 let mut uploaded = Vec::new();
2251 for (name, bytes) in &loaded {
2252 let upload = match sent(&client, name, bytes).await {
2253 Ok(upload) => upload,
2254 Err(code) => return code,
2255 };
2256 match client.wiki_attach(id, std::slice::from_ref(&upload)).await {
2257 Ok(attached) => {
2258 for file in attached {
2259 if session.render.format == Format::Text {
2260 emit(&format!(
2261 "uploaded {} to {slug}: attachment {}\n",
2262 file.name, file.id
2263 ));
2264 }
2265 uploaded.push(serde_json::json!({ "name": file.name, "id": file.id }));
2266 }
2267 }
2268 Err(error) => {
2269 abandon(&client, &upload).await;
2270 return failed(&error);
2271 }
2272 }
2273 }
2274 if session.render.format == Format::Text {
2275 return ExitCode::Success;
2276 }
2277 done(
2278 session,
2279 String::new(),
2280 &serde_json::json!({ "action": "uploaded", "slug": slug, "attachments": uploaded }),
2281 )
2282}
2283
2284async fn sent(client: &crate::api::Client, name: &str, bytes: &[u8]) -> Result<String, ExitCode> {
2287 use crate::api::wiki::{UPLOAD_PART, upload_parts};
2288
2289 let upload = match client.wiki_upload_start(name, bytes.len()).await {
2290 Ok(upload) => upload.session_id,
2291 Err(error) => return Err(failed(&error)),
2292 };
2293 let parts = upload_parts(bytes.len(), UPLOAD_PART);
2294 let walk = crate::render::progress::Walk::start(&format!("uploading {name}"));
2295 for (index, range) in parts.iter().enumerate() {
2296 walk.say(&format!(
2297 "uploading {name}: part {} of {}",
2298 index + 1,
2299 parts.len()
2300 ));
2301 let number = u32::try_from(index + 1).unwrap_or(u32::MAX);
2302 let part = bytes.get(range.clone()).unwrap_or_default().to_vec();
2303 if let Err(error) = client.wiki_upload_part(&upload, number, part).await {
2304 walk.finish();
2305 abandon(client, &upload).await;
2306 return Err(failed(&error));
2307 }
2308 }
2309 walk.finish();
2310 if let Err(error) = client.wiki_upload_finish(&upload).await {
2311 abandon(client, &upload).await;
2312 return Err(failed(&error));
2313 }
2314 Ok(upload)
2315}
2316
2317async fn abandon(client: &crate::api::Client, upload: &str) {
2320 let _ = client.wiki_upload_abort(upload).await;
2321}
2322
2323async fn delete_attachment(page: &str, file: &str, session: &Session) -> ExitCode {
2326 let slug = match named(page) {
2327 Ok(slug) => slug,
2328 Err(code) => return code,
2329 };
2330 let client = match session.client() {
2331 Ok(client) => client,
2332 Err(code) => return code,
2333 };
2334 let body = serde_json::json!({ "file": file });
2335 if let Some(code) = gated(
2336 &format!("delete attachment `{file}` from wiki page `{slug}`"),
2337 &body,
2338 true,
2339 session,
2340 ) {
2341 return code;
2342 }
2343
2344 let (id, found) = match client.wiki_attachment_named(&slug, file).await {
2345 Ok(pair) => pair,
2346 Err(error) => return failed(&error),
2347 };
2348 match client.wiki_delete_attachment(id, found.id).await {
2349 Ok(()) => done(
2350 session,
2351 format!(
2352 "deleted attachment {} ({}) from {slug}\n",
2353 found.name, found.id
2354 ),
2355 &serde_json::json!({
2356 "action": "deleted attachment", "slug": slug, "attachment": found.id,
2357 "name": found.name
2358 }),
2359 ),
2360 Err(error) => failed(&error),
2361 }
2362}
2363
2364enum Source {
2366 Attachment { page: i64, file: u64 },
2367 Address(String),
2368}
2369
2370async fn download(
2376 page: &str,
2377 file: Option<&str>,
2378 out: &Path,
2379 force: bool,
2380 session: &Session,
2381) -> ExitCode {
2382 let target = slug_of(page);
2383 let client = match session.client() {
2384 Ok(client) => client,
2385 Err(code) => return code,
2386 };
2387
2388 let (name, source) = if let Some(file) = file {
2389 let slug = match named(page) {
2390 Ok(slug) => slug,
2391 Err(code) => return code,
2392 };
2393 match client.wiki_attachment_named(&slug, file).await {
2394 Ok((page, found)) => (
2395 safe_filename(&found.name, &found.id.to_string()),
2396 Source::Attachment {
2397 page,
2398 file: found.id,
2399 },
2400 ),
2401 Err(error) => {
2402 let code = error.exit_code();
2403 return report(&error, code);
2404 }
2405 }
2406 } else if let Some((_, name)) = target.split_once("/.files/") {
2407 (
2408 safe_filename(name, "download"),
2409 Source::Address(target.clone()),
2410 )
2411 } else {
2412 return report(
2413 &format!(
2414 "`{page}` is a page, not a file: name the file too (`wiki attachments` lists them), \
2415 or pass the file's address, <slug>/.files/<name>"
2416 ),
2417 ExitCode::ConfirmationRequired,
2418 );
2419 };
2420
2421 let destination = out.join(name);
2422 if destination.exists() && !force {
2423 return report(
2424 &format!(
2425 "{} already exists; pass --force to overwrite",
2426 destination.display()
2427 ),
2428 ExitCode::ConfirmationRequired,
2429 );
2430 }
2431
2432 let fetched = match &source {
2433 Source::Attachment { page, file } => client.wiki_attachment_bytes(*page, *file).await,
2434 Source::Address(path) => client.wiki_file_bytes(path).await,
2435 };
2436 let bytes = match fetched {
2437 Ok(bytes) => bytes,
2438 Err(error) => {
2439 let code = error.exit_code();
2440 return report(&error, code);
2441 }
2442 };
2443
2444 if let Err(error) = std::fs::create_dir_all(out) {
2445 return report(&error, ExitCode::Failure);
2446 }
2447 if let Err(error) = std::fs::write(&destination, &bytes) {
2448 return report(&error, ExitCode::Failure);
2449 }
2450
2451 done(
2452 session,
2453 format!("{}\n", destination.display()),
2454 &serde_json::json!({ "action": "downloaded", "path": destination }),
2455 )
2456}
2457
2458fn page_size(session: &Session) -> u32 {
2460 u32::try_from(session.display().limit.clamp(1, 100)).unwrap_or(100)
2461}
2462
2463fn named(page: &str) -> Result<String, ExitCode> {
2465 let slug = slug_of(page);
2466 if slug.is_empty() {
2467 return Err(report(
2468 &format!(
2469 "`{page}` names no page: pass a slug such as users/me/notes, or the page's address"
2470 ),
2471 ExitCode::ConfirmationRequired,
2472 ));
2473 }
2474 Ok(slug)
2475}
2476
2477fn done(session: &Session, text: String, facts: &serde_json::Value) -> ExitCode {
2481 finish(match session.render.format {
2482 Format::Text => Ok(text),
2483 Format::JsonRaw => machine(facts, Format::Json),
2484 other => machine(facts, other),
2485 })
2486}
2487
2488fn finish(rendered: Result<String, RenderError>) -> ExitCode {
2489 match rendered {
2490 Ok(text) => {
2491 emit(&text);
2492 ExitCode::Success
2493 }
2494 Err(error) => report(&error, ExitCode::Failure),
2495 }
2496}