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