Skip to main content

ytcli/cli/
wiki.rs

1//! Wiki commands: the pages of the Yandex Wiki next to the organisation's
2//! Tracker, read through the same profile.
3//!
4//! The read verbs never write (`docs/adr/0007-yandex-wiki.md`). The writes —
5//! create, update, append, delete, restore — are verbs of their own and pass
6//! the same gate as Tracker's: profile and organisation announced first,
7//! `--dry-run` honoured, page text from a file or stdin.
8
9use 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    /// Show one page: its title and last change, then its text.
23    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_GET))]
24    Get {
25        /// The page's slug, or its address as copied from the browser.
26        page: String,
27    },
28    /// List the pages under one.
29    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_LIST))]
30    List {
31        /// The page's slug, or its address.
32        page: String,
33        /// Where the previous page of this listing ended, as its tally named it.
34        #[arg(long)]
35        cursor: Option<String>,
36    },
37    /// Search pages and files.
38    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_FIND))]
39    Find {
40        /// Words to look for.
41        text: String,
42        /// Only pages, or only attached files.
43        #[arg(long = "type", value_parser = ["page", "file"])]
44        kind: Option<String>,
45        /// Which page of hits, from 1; the Wiki's search stops at 500.
46        #[arg(long, default_value_t = 1)]
47        page: u32,
48    },
49    /// Show a page's comments.
50    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_COMMENTS))]
51    Comments {
52        /// The page's slug, or its address.
53        page: String,
54        /// Every post in one comment's thread, by the comment's id.
55        #[arg(long, conflicts_with = "status")]
56        thread: Option<u64>,
57        /// Only resolved, or only unresolved, comments.
58        #[arg(long, value_parser = ["resolved", "unresolved"])]
59        status: Option<String>,
60        /// Where the previous page of this listing ended, as its tally named it.
61        #[arg(long)]
62        cursor: Option<String>,
63    },
64    /// List a page's attachments.
65    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_ATTACHMENTS))]
66    Attachments {
67        /// The page's slug, or its address.
68        page: String,
69        /// Where the previous page of this listing ended, as its tally named it.
70        #[arg(long)]
71        cursor: Option<String>,
72    },
73    /// List the grids — dynamic tables — on a page.
74    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_GRIDS))]
75    Grids {
76        /// The page's slug, or its address.
77        page: String,
78        /// Where the previous page of this listing ended, as its tally named it.
79        #[arg(long)]
80        cursor: Option<String>,
81    },
82    /// Show one grid: its columns, then its rows.
83    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_GRID))]
84    Grid {
85        /// The grid's id, as `wiki grids` lists it.
86        grid: String,
87        /// Only matching rows, in the Wiki's syntax: `[slug] ~ text AND [n] < 3`.
88        #[arg(long)]
89        filter: Option<String>,
90        /// Order of the rows: `slug, -other`.
91        // A descending sort starts with `-`, which is the Wiki's syntax, not a
92        // flag of ours.
93        #[arg(long, allow_hyphen_values = true)]
94        sort: Option<String>,
95        /// Only these columns, by slug, comma-separated.
96        #[arg(long)]
97        columns: Option<String>,
98        /// Only these rows, by id, comma-separated.
99        #[arg(long)]
100        rows: Option<String>,
101        /// The grid as it was at this revision.
102        #[arg(long)]
103        revision: Option<u64>,
104    },
105    /// List what a page holds: files and grids together.
106    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_RESOURCES))]
107    Resources {
108        /// The page's slug, or its address.
109        page: String,
110        /// Only files, or only grids.
111        #[arg(long = "type", value_parser = ["attachment", "grid"])]
112        kind: Option<String>,
113        /// Only those whose name or title matches.
114        #[arg(long)]
115        query: Option<String>,
116        /// Where the previous page of this listing ended, as its tally named it.
117        #[arg(long)]
118        cursor: Option<String>,
119    },
120    /// Create a page; its parent is whatever the slug's path says.
121    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_CREATE))]
122    Create {
123        /// Where the page goes: users/me/notes, or an address.
124        page: String,
125        #[arg(long, short = 't')]
126        title: String,
127        /// The page's text: a file, or `-` for stdin.
128        #[arg(long, value_name = "PATH")]
129        from: Option<String>,
130        /// Do not notify subscribers.
131        #[arg(long)]
132        silent: bool,
133    },
134    /// Replace a page's text, or retitle it.
135    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_UPDATE))]
136    Update {
137        /// The page's slug, or its address.
138        page: String,
139        #[arg(long, short = 't')]
140        title: Option<String>,
141        /// The page's new text in full: a file, or `-` for stdin.
142        #[arg(long, value_name = "PATH")]
143        from: Option<String>,
144        /// Fold in edits made since, rather than be refused over them.
145        #[arg(long)]
146        merge: bool,
147        /// Do not notify subscribers.
148        #[arg(long)]
149        silent: bool,
150    },
151    /// Add text to a page: at the bottom, the top, or an anchor.
152    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_APPEND))]
153    Append {
154        /// The page's slug, or its address.
155        page: String,
156        /// The text to add: a file, or `-` for stdin.
157        #[arg(long, value_name = "PATH")]
158        from: String,
159        /// At the top rather than the bottom.
160        #[arg(long, conflicts_with = "anchor")]
161        top: bool,
162        /// At this anchor in the page, such as #deploy.
163        #[arg(long)]
164        anchor: Option<String>,
165        /// Do not notify subscribers.
166        #[arg(long)]
167        silent: bool,
168    },
169    /// Delete a page, printing the token that restores it.
170    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_DELETE))]
171    Delete {
172        /// The page's slug, or its address.
173        page: String,
174        /// Its subpages too. Needs --yes.
175        #[arg(long)]
176        recursive: bool,
177    },
178    /// Restore a deleted page by the token its deletion printed.
179    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_RESTORE))]
180    Restore { token: String },
181    /// Comment on a page, or reply to a comment.
182    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_COMMENT))]
183    Comment {
184        /// The page's slug, or its address.
185        page: String,
186        /// The comment; `-` reads it from stdin.
187        text: String,
188        /// Reply to this comment, by its id.
189        #[arg(long, value_name = "ID")]
190        reply_to: Option<u64>,
191        /// The passage of the page the comment is about.
192        #[arg(long)]
193        quote: Option<String>,
194    },
195    /// Delete a comment. There is no undo, so it needs --yes.
196    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_DELETE_COMMENT))]
197    DeleteComment {
198        /// The page's slug, or its address.
199        page: String,
200        /// The comment's id, as `wiki comments` shows it.
201        comment: u64,
202    },
203    /// Show who can read and edit a page.
204    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_ACCESS))]
205    Access {
206        /// The page's slug, or its address.
207        page: String,
208    },
209    /// Give a user or a group a role on a page.
210    #[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        /// The page's slug, or its address.
214        page: String,
215        /// `reader`, `editor`, `extra_editor` (may also manage access) or `author`.
216        #[arg(long, value_parser = ["reader", "editor", "extra_editor", "author"])]
217        role: String,
218        /// A login; its uid is looked up in Tracker.
219        #[arg(long, group = "who")]
220        user: Option<String>,
221        /// A user's uid, as the organisation's directory has it.
222        #[arg(long, group = "who")]
223        uid: Option<String>,
224        /// A user's Yandex Cloud id.
225        #[arg(long, group = "who", value_name = "ID")]
226        cloud_uid: Option<String>,
227        /// A group, as SOURCE:ID; the source is dir, cloud, com or staff.
228        #[arg(long, group = "who", value_name = "SOURCE:ID")]
229        group: Option<String>,
230        /// Keep the role off the subpages.
231        #[arg(long)]
232        no_inherit: bool,
233        /// Allow a change that could lock you yourself out of the page.
234        #[arg(long)]
235        allow_selflock: bool,
236    },
237    /// Change a grant's role, or whether subpages inherit it.
238    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_REGRANT))]
239    Regrant {
240        /// The page's slug, or its address.
241        page: String,
242        /// The grant's id, as `wiki access` lists it.
243        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        /// Allow a change that could lock you yourself out of the page.
249        #[arg(long)]
250        allow_selflock: bool,
251    },
252    /// Remove a grant, or every personal grant on a page.
253    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_REVOKE))]
254    Revoke {
255        /// The page's slug, or its address.
256        page: String,
257        /// The grant's id, as `wiki access` lists it.
258        #[arg(required_unless_present = "all")]
259        access: Option<String>,
260        /// Every personal grant on the page. Needs --yes.
261        #[arg(long, conflicts_with = "access")]
262        all: bool,
263        /// Allow a change that could lock you yourself out of the page.
264        #[arg(long)]
265        allow_selflock: bool,
266    },
267    /// Copy a page to a new address, and wait for the copy.
268    #[command(name = "clone", long_about = crate::cli::help::md(crate::cli::help::WIKI_CLONE))]
269    ClonePage {
270        /// The page's slug, or its address.
271        page: String,
272        /// Where the copy goes; a page there already is a refusal.
273        target: String,
274        /// The copy's title, when it should not keep the original's.
275        #[arg(long, short = 't')]
276        title: Option<String>,
277        /// Subscribe to the copy.
278        #[arg(long)]
279        subscribe: bool,
280        /// Print the operation and return, rather than wait for the copy.
281        #[arg(long)]
282        no_wait: bool,
283    },
284    /// Copy a grid onto a page, and wait for the copy.
285    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_CLONE_GRID))]
286    CloneGrid {
287        /// The grid's id, as `wiki grids` lists it.
288        grid: String,
289        /// The page the copy goes on; created if it is not there.
290        target: String,
291        #[arg(long, short = 't')]
292        title: Option<String>,
293        /// Copy the rows too, not only the columns.
294        #[arg(long)]
295        with_data: bool,
296        /// Print the operation and return, rather than wait for the copy.
297        #[arg(long)]
298        no_wait: bool,
299    },
300    /// Show where a clone has got to.
301    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_OPERATION))]
302    Operation {
303        /// `clone` for a page, `clone_inline_grid` for a grid.
304        #[arg(value_parser = ["clone", "clone_inline_grid"])]
305        kind: String,
306        id: String,
307    },
308    /// Create an empty grid on a page.
309    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_GRID_CREATE))]
310    CreateGrid {
311        /// The page's slug, or its address.
312        page: String,
313        #[arg(long, short = 't')]
314        title: String,
315    },
316    /// Retitle a grid, or set the order its rows show in.
317    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_GRID_UPDATE))]
318    UpdateGrid {
319        /// The grid's id, as `wiki grids` lists it.
320        grid: String,
321        #[arg(long, short = 't')]
322        title: Option<String>,
323        /// Default order: slug:asc,other:desc.
324        #[arg(long, value_name = "SLUG:DIR,...")]
325        sort: Option<String>,
326        /// The revision the change is made against; read first when omitted.
327        #[arg(long)]
328        revision: Option<String>,
329    },
330    /// Delete a grid. There is no undo, so it needs --yes.
331    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_GRID_DELETE))]
332    DeleteGrid {
333        /// The grid's id, as `wiki grids` lists it.
334        grid: String,
335    },
336    /// Add rows from JSON: an array of objects keyed by column slug.
337    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_ROWS_ADD))]
338    RowsAdd {
339        /// The grid's id, as `wiki grids` lists it.
340        grid: String,
341        /// The rows as JSON: a file, or `-` for stdin.
342        #[arg(long, value_name = "PATH")]
343        from: String,
344        /// Put them after this row.
345        #[arg(long, value_name = "ROW", conflicts_with = "position")]
346        after: Option<String>,
347        /// Put them at this position, from 0.
348        #[arg(long)]
349        position: Option<u64>,
350        /// The revision the change is made against; read first when omitted.
351        #[arg(long)]
352        revision: Option<String>,
353    },
354    /// Delete rows by id. There is no undo, so it needs --yes.
355    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_ROWS_DELETE))]
356    RowsDelete {
357        /// The grid's id, as `wiki grids` lists it.
358        grid: String,
359        #[arg(required = true, value_name = "ROW")]
360        rows: Vec<String>,
361        /// The revision the change is made against; read first when omitted.
362        #[arg(long)]
363        revision: Option<String>,
364    },
365    /// Move a row — and the rows after it, with --count.
366    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_ROWS_MOVE))]
367    RowsMove {
368        /// The grid's id, as `wiki grids` lists it.
369        grid: String,
370        row: String,
371        /// After this row.
372        #[arg(
373            long,
374            value_name = "ROW",
375            conflicts_with = "position",
376            required_unless_present = "position"
377        )]
378        after: Option<String>,
379        /// To this position, from 0.
380        #[arg(long)]
381        position: Option<u64>,
382        /// How many rows to move, starting with this one.
383        #[arg(long)]
384        count: Option<u64>,
385        /// The revision the change is made against; read first when omitted.
386        #[arg(long)]
387        revision: Option<String>,
388    },
389    /// Add columns from JSON: an array of column definitions.
390    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_COLUMNS_ADD))]
391    ColumnsAdd {
392        /// The grid's id, as `wiki grids` lists it.
393        grid: String,
394        /// The columns as JSON: a file, or `-` for stdin.
395        #[arg(long, value_name = "PATH")]
396        from: String,
397        /// Put them at this position, from 0.
398        #[arg(long)]
399        position: Option<u64>,
400        /// The revision the change is made against; read first when omitted.
401        #[arg(long)]
402        revision: Option<String>,
403    },
404    /// Delete columns by slug. There is no undo, so it needs --yes.
405    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_COLUMNS_DELETE))]
406    ColumnsDelete {
407        /// The grid's id, as `wiki grids` lists it.
408        grid: String,
409        #[arg(required = true, value_name = "SLUG")]
410        columns: Vec<String>,
411        /// The revision the change is made against; read first when omitted.
412        #[arg(long)]
413        revision: Option<String>,
414    },
415    /// Move a column to a position.
416    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_COLUMNS_MOVE))]
417    ColumnsMove {
418        /// The grid's id, as `wiki grids` lists it.
419        grid: String,
420        column: String,
421        /// Where to, from 0.
422        #[arg(long)]
423        position: u64,
424        /// How many columns to move, starting with this one.
425        #[arg(long)]
426        count: Option<u64>,
427        /// The revision the change is made against; read first when omitted.
428        #[arg(long)]
429        revision: Option<String>,
430    },
431    /// Set cells: --set ROW:SLUG=VALUE, as many as needed.
432    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_CELLS_SET))]
433    CellsSet {
434        /// The grid's id, as `wiki grids` lists it.
435        grid: String,
436        #[arg(long = "set", value_name = "ROW:SLUG=VALUE", required = true)]
437        set: Vec<String>,
438        /// The revision the change is made against; read first when omitted.
439        #[arg(long)]
440        revision: Option<String>,
441    },
442    /// Attach files to a page.
443    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_UPLOAD))]
444    Upload {
445        /// The page's slug, or its address.
446        page: String,
447        #[arg(required = true, value_name = "FILE")]
448        files: Vec<PathBuf>,
449    },
450    /// Delete a file attached to a page. There is no undo, so it needs --yes.
451    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_DELETE_ATTACHMENT))]
452    DeleteAttachment {
453        /// The page's slug, or its address.
454        page: String,
455        /// The file's id or name, as `wiki attachments` lists them.
456        file: String,
457    },
458    /// Download one file attached to a page.
459    #[command(long_about = crate::cli::help::md(crate::cli::help::WIKI_DOWNLOAD))]
460    Download {
461        /// The page's slug or address — or the file's own, `<slug>/.files/<name>`.
462        page: String,
463        /// The file's id or name, as `wiki attachments` lists them; not needed
464        /// when the first argument is the file's address.
465        file: Option<String>,
466        /// Directory to write into.
467        #[arg(long, short = 'o')]
468        out: PathBuf,
469        /// Overwrite a file that is already there.
470        #[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
823/// Show one page.
824async 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
847/// The pages under one, a page of them at a time.
848async 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
874/// Search pages and files.
875async 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    // The profile's list length, within the 1..50 search accepts.
888    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
903/// A page's comments, or one thread of them.
904async 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
935/// The files attached to a page.
936async 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
962/// The grids on a page.
963async 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
986/// One grid.
987async 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
1006/// What a page holds.
1007async 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
1039/// A page's text for a write: from a file, or `-` for stdin — never from an
1040/// argument, where a runbook would have to survive shell quoting.
1041fn 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
1053/// Announce the write and apply `--dry-run` and `--yes`, before any request:
1054/// a dry run of a write under a page does not even look the page up.
1055fn 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
1077/// Create a page.
1078async 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
1114/// What `wiki update` was asked to change.
1115struct Change<'a> {
1116    title: Option<&'a str>,
1117    from: Option<&'a str>,
1118    merge: bool,
1119    silent: bool,
1120}
1121
1122/// Replace a page's text, retitle it, or both.
1123async 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
1169/// Where appended text goes, as the Wiki names it.
1170fn 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
1179/// Add text to a page.
1180async 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
1230/// Delete a page, and print the only thing that can bring it back.
1231///
1232/// The recovery token is shown once, by this command, and by nothing else
1233/// ever again; so it goes to stdout with the exact command that uses it.
1234/// Taking the subpages too is a different size of mistake, and needs `--yes`.
1235async 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
1274/// Bring a deleted page back.
1275async 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
1308/// Comment on a page, or reply to one of its comments.
1309///
1310/// Like `issue comment`, the text is the argument, or stdin with `-`: a
1311/// comment is short more often than a page is.
1312async 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
1370/// Delete a comment. The Wiki keeps nothing to restore it from.
1371async 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
1407/// Who can read and edit a page.
1408async 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
1428/// Whom a grant is for, as it was named.
1429enum Grantee<'a> {
1430    /// A login, whose uid Tracker knows.
1431    Login(&'a str),
1432    Uid(&'a str),
1433    CloudUid(&'a str),
1434    /// `SOURCE:ID`.
1435    Group(&'a str),
1436}
1437
1438/// Give a user or a group a role on a page.
1439///
1440/// The Wiki takes a uid, and people know logins; so a login is looked up in
1441/// Tracker, which is in the same organisation. That lookup is a request, and
1442/// it happens after the gate, so a dry run shows where the uid will go
1443/// rather than making it.
1444async 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
1540/// The Wiki answers a grant with `USER_NOT_FOUND` for a user who plainly
1541/// exists: when the caller may not manage access on the page, and when the
1542/// organisation keys its users by cloud uid. Read literally, the code sends
1543/// people to check an identity that is fine.
1544fn 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
1564/// Change one grant.
1565async 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
1612/// Remove one grant, or — with no grant named — every personal one, which
1613/// is a larger mistake to make by accident and so needs `--yes`.
1614async 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
1659/// How long a clone is waited for before the command hands back its id.
1660const CLONE_WAIT: std::time::Duration = std::time::Duration::from_secs(600);
1661
1662/// The refusals the Wiki documents for a clone, in words a caller can act on.
1663const 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
1681/// A refused clone, told by its `error_code` when the Wiki gave one.
1682fn 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
1696/// Copy a page.
1697async 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
1740/// Copy a grid onto a page.
1741async 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
1780/// Wait for an operation to end and say what it made — or, with `--no-wait`
1781/// or past the deadline, hand back the command that asks again.
1782///
1783/// Progress goes to stderr, and only to a terminal; stdout carries the one
1784/// line of result.
1785async 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
1853/// Where an operation has got to.
1854async 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
1873/// `1 row`, `3 rows`.
1874fn counted(count: usize, what: &str) -> String {
1875    if count == 1 {
1876        format!("1 {what}")
1877    } else {
1878        format!("{count} {what}s")
1879    }
1880}
1881
1882/// One change to a grid, made against the revision it was read at.
1883///
1884/// The Wiki refuses a write whose revision is no longer the grid's, which is
1885/// the whole protection against overwriting somebody's edit made in between.
1886/// So every change carries one: the caller's, when they read the grid
1887/// themselves and say so with `--revision`; otherwise the grid's current one,
1888/// read just before. The read comes after the gate, so a dry run sends
1889/// nothing and says where the revision will come from.
1890struct 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
1936/// What a grid write did, the rows it made, and the revision it left.
1937fn 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
1959/// JSON from a file or stdin that has to be a non-empty array.
1960fn 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
2066/// Delete a grid. The Wiki keeps nothing to restore it from.
2067async 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
2146/// Set cells from `ROW:SLUG=VALUE` pairs.
2147///
2148/// The value is read the way `issue update --set` reads one: JSON when it
2149/// parses as JSON, text otherwise, and `ROW:SLUG:=json` to say which.
2150async 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            // The Wiki takes the row as a number here, and a string everywhere else.
2164            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
2192/// Attach files to a page, one upload each.
2193///
2194/// Every file is read before the gate, so a missing one is a refusal before
2195/// anything is sent, and the gate can say how many bytes are about to go.
2196/// Each file is then attached as soon as its upload finishes: a failure part
2197/// way through a list leaves the earlier files attached, and says so line by
2198/// line, rather than losing them all.
2199async 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    // A person sees each file as it lands; a script gets one object at the
2249    // end, since a stream of separate JSON documents is not one it can parse.
2250    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
2284/// One file through an upload session: opened, sent in parts, finished.
2285/// Anything failing after the session is open aborts it.
2286async 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
2317/// Give a failed upload's session back. Its own failure is not reported:
2318/// the error worth showing is the one that made it necessary.
2319async fn abandon(client: &crate::api::Client, upload: &str) {
2320    let _ = client.wiki_upload_abort(upload).await;
2321}
2322
2323/// Delete a file attached to a page. The Wiki keeps nothing to restore it
2324/// from, so it needs `--yes`.
2325async 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
2364/// Where a download's bytes come from.
2365enum Source {
2366    Attachment { page: i64, file: u64 },
2367    Address(String),
2368}
2369
2370/// Write one file into a directory the caller named.
2371///
2372/// The file keeps its own name, cleaned of anything that could steer it out of
2373/// that directory, exactly as Tracker's downloads do. The destination is
2374/// checked before a byte is fetched.
2375async 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
2458/// The profile's list length, within the 1..100 the Wiki's listings accept.
2459fn page_size(session: &Session) -> u32 {
2460    u32::try_from(session.display().limit.clamp(1, 100)).unwrap_or(100)
2461}
2462
2463/// The slug a command was given, or the refusal to guess one.
2464fn 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
2477/// What a write did: a line for a person, or under `-f json` or `toon` the same
2478/// facts as one object, so a script takes an id or a recovery token without
2479/// parsing a sentence.
2480fn 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}