From f7f51c6117ae75c0e3159cb83f916562490cad9c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 22 May 2026 16:35:29 +0100 Subject: [PATCH] feat(docs): improve markdown layout controls --- CHANGELOG.md | 2 + docs/commands/gog-docs-page-layout.md | 6 + docs/commands/gog-docs-write.md | 6 + docs/docs-editing.md | 28 ++++ internal/cmd/docs_edit.go | 104 ++++++++++-- internal/cmd/docs_helpers.go | 20 --- internal/cmd/docs_layout.go | 191 ++++++++++++++++++++++ internal/cmd/docs_markdown.go | 90 +++++++++- internal/cmd/docs_markdown_links.go | 128 +++++++++++++++ internal/cmd/docs_markdown_test.go | 40 ++++- internal/cmd/docs_set_page_layout.go | 62 +++++-- internal/cmd/docs_set_page_layout_test.go | 112 ++++++++++++- internal/cmd/docs_write_markdown_test.go | 151 +++++++++++++++++ internal/cmd/docs_write_update_test.go | 80 +++++++++ 14 files changed, 957 insertions(+), 63 deletions(-) create mode 100644 internal/cmd/docs_layout.go create mode 100644 internal/cmd/docs_markdown_links.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 34524928e..aaac70f6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,12 @@ - Auth: add `auth credentials set --expand-env` for strict environment placeholder expansion in OAuth client JSON. (#599) - Auth: let `auth import` seed an initial access token and expiry, and round-trip cached access tokens through token export/import. (#598) - CLI: add XDG kind-aware config/data/state/cache paths with `GOG_HOME`, per-kind `GOG_*_DIR` overrides, and `--home` while preserving legacy auth/keyring/service-account reads. (#621, #622) — thanks @alexminza. +- Docs: add explicit `--page-width`, `--page-height`, and page margin flags to `docs write` and `docs page-layout`, keeping `--pageless` width unchanged unless requested. (#629, #630) — thanks @sebsnyk. ### Fixed - People: fall back to token identity when `gog me` / `gog whoami` hit a disabled People API on the OAuth client project. (#460, #461) +- Docs: drop all-whitespace Markdown table header rows during Docs markdown writes, and rewrite same-document `#heading-slug` links to native Google Docs heading links after Drive markdown import. (#632, #633) — thanks @sebsnyk. - Gmail: include attachment metadata in `gmail messages search --include-body --json` results. (#620) - Auth: let `auth service-account set` read service account keys from stdin (`--key=-` or `--key-stdin`) or an environment variable (`--key-env`). (#600) - Auth: serialize file-keyring reads and writes with a shared lock so concurrent `gog` processes cannot observe partial keyring entries or clobber multi-key token updates. (#597) diff --git a/docs/commands/gog-docs-page-layout.md b/docs/commands/gog-docs-page-layout.md index 0fa6c76a1..d9ddb9e25 100644 --- a/docs/commands/gog-docs-page-layout.md +++ b/docs/commands/gog-docs-page-layout.md @@ -31,7 +31,13 @@ gog docs (doc) page-layout (set-page-layout,page-setup) [flags] | `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | | `--layout` | `string` | pageless | Page layout: pageless or pages | +| `--margin-bottom` | `string` | | Set bottom page margin (points by default; supports pt, in, cm, mm) | +| `--margin-left` | `string` | | Set left page margin (points by default; supports pt, in, cm, mm) | +| `--margin-right` | `string` | | Set right page margin (points by default; supports pt, in, cm, mm) | +| `--margin-top` | `string` | | Set top page margin (points by default; supports pt, in, cm, mm) | | `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `--page-height` | `string` | | Set page height (points by default; supports pt, in, cm, mm) | +| `--page-width` | `string` | | Set page width (points by default; supports pt, in, cm, mm) | | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | diff --git a/docs/commands/gog-docs-write.md b/docs/commands/gog-docs-write.md index ff7b9918b..abd6711af 100644 --- a/docs/commands/gog-docs-write.md +++ b/docs/commands/gog-docs-write.md @@ -40,6 +40,10 @@ gog docs (doc) write [flags] | `--italic` | `bool` | | Set italic | | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | | `--line-spacing` | `float64` | | Paragraph line spacing percentage, for example 100 or 150 | +| `--margin-bottom` | `string` | | Set bottom page margin (points by default; supports pt, in, cm, mm) | +| `--margin-left` | `string` | | Set left page margin (points by default; supports pt, in, cm, mm) | +| `--margin-right` | `string` | | Set right page margin (points by default; supports pt, in, cm, mm) | +| `--margin-top` | `string` | | Set top page margin (points by default; supports pt, in, cm, mm) | | `--markdown` | `bool` | | Convert markdown to Google Docs formatting (requires --replace or --append) | | `--named-style` | `string` | | Set paragraph named style: NORMAL_TEXT, TITLE, SUBTITLE, HEADING_1..HEADING_6 | | `--no-bold` | `bool` | | Clear bold | @@ -47,6 +51,8 @@ gog docs (doc) write [flags] | `--no-italic` | `bool` | | Clear italic | | `--no-strikethrough`
`--no-strike` | `bool` | | Clear strikethrough | | `--no-underline` | `bool` | | Clear underline | +| `--page-height` | `string` | | Set page height (points by default; supports pt, in, cm, mm) | +| `--page-width` | `string` | | Set page width (points by default; supports pt, in, cm, mm) | | `--pageless` | `bool` | | Set document to pageless mode | | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | | `--replace` | `bool` | | Replace all content explicitly (required with --markdown unless --append is set) | diff --git a/docs/docs-editing.md b/docs/docs-editing.md index 809f5c7f2..461d17d03 100644 --- a/docs/docs-editing.md +++ b/docs/docs-editing.md @@ -72,6 +72,34 @@ Command page: - [`gog docs insert-page-break`](commands/gog-docs-insert-page-break.md) +## Page Layout + +Set an existing document to pageless or paged mode: + +```bash +gog docs page-layout --layout pageless +gog docs page-layout --layout pages +``` + +Use explicit page size and margin flags when the output width matters: + +```bash +gog docs page-layout --page-width 960 +gog docs page-layout --layout pages --page-width 8.5in --page-height 11in \ + --margin-left 0.5in --margin-right 0.5in +gog docs write --replace --markdown --file report.md --pageless --page-width 960 +``` + +Lengths default to points and also accept `pt`, `in`, `cm`, or `mm`. +`docs page-layout` preserves the current page mode when only size or margin +flags are supplied; pass `--layout` when you also want to toggle pageless/pages. +`--pageless` preserves Google Docs' existing width unless `--page-width` is set +explicitly. + +Command page: + +- [`gog docs page-layout`](commands/gog-docs-page-layout.md) + ## Tables Insert a native Google Docs table directly via the Docs API, bypassing the diff --git a/internal/cmd/docs_edit.go b/internal/cmd/docs_edit.go index 3349a6bec..15f4d94f3 100644 --- a/internal/cmd/docs_edit.go +++ b/internal/cmd/docs_edit.go @@ -40,6 +40,7 @@ type DocsWriteCmd struct { Markdown bool `name:"markdown" help:"Convert markdown to Google Docs formatting (requires --replace or --append)"` Append bool `name:"append" help:"Append instead of replacing the document body"` Pageless bool `name:"pageless" help:"Set document to pageless mode"` + Layout DocsLayoutFlags `embed:""` Tab string `name:"tab" help:"Target a specific tab by title or ID (see docs list-tabs)"` TabID string `name:"tab-id" hidden:"" help:"(deprecated) Use --tab"` Format DocsFormatFlags `embed:""` @@ -65,6 +66,10 @@ func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootF } c.Tab = tab + if err := c.validateDocumentStyle(); err != nil { + return err + } + if c.Markdown { if c.Format.any() { return usage("formatting flags are only supported for plain-text docs write; use markdown syntax or run docs format after writing") @@ -75,6 +80,21 @@ func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootF return c.writePlainText(ctx, flags, id, text) } +func (c *DocsWriteCmd) validateDocumentStyle() error { + if !c.Pageless && !c.Layout.any() { + return nil + } + mode := "" + if c.Pageless { + mode = docsDocumentModePageless + } + _, err := buildUpdateDocumentStyleRequest(docsDocumentStyleOptions{ + Mode: mode, + DocsLayoutFlags: c.Layout, + }) + return err +} + func (c *DocsWriteCmd) resolveWriteText(kctx *kong.Context) (string, error) { text, provided, err := resolveTextInput(c.Text, c.File, kctx, "text", "file") if err != nil { @@ -96,7 +116,7 @@ func (c *DocsWriteCmd) writePlainText(ctx context.Context, flags *RootFlags, doc } } - if err := dryRunExit(ctx, flags, "docs.write", map[string]any{ + dryRunPayload := map[string]any{ "document_id": docID, "written": len(text), "append": c.Append, @@ -104,7 +124,11 @@ func (c *DocsWriteCmd) writePlainText(ctx context.Context, flags *RootFlags, doc "markdown": false, "pageless": c.Pageless, "tab": c.Tab, - }); err != nil { + } + for k, v := range c.Layout.dryRunPayload() { + dryRunPayload[k] = v + } + if err := dryRunExit(ctx, flags, "docs.write", dryRunPayload); err != nil { return err } @@ -134,7 +158,7 @@ func (c *DocsWriteCmd) writePlainText(ctx context.Context, flags *RootFlags, doc } return err } - if err := c.applyPageless(ctx, svc, docID); err != nil { + if err := c.applyDocumentStyle(ctx, svc, docID); err != nil { return err } @@ -169,12 +193,19 @@ func (c *DocsWriteCmd) buildPlainWriteRequests(endIndex, insertIndex int64, text return reqs, nil } -func (c *DocsWriteCmd) applyPageless(ctx context.Context, svc *docs.Service, docID string) error { - if !c.Pageless { +func (c *DocsWriteCmd) applyDocumentStyle(ctx context.Context, svc *docs.Service, docID string) error { + if !c.Pageless && !c.Layout.any() { return nil } - if err := setDocumentPageless(ctx, svc, docID); err != nil { - return fmt.Errorf("set pageless mode: %w", err) + mode := "" + if c.Pageless { + mode = docsDocumentModePageless + } + if err := setDocumentStyle(ctx, svc, docID, docsDocumentStyleOptions{ + Mode: mode, + DocsLayoutFlags: c.Layout, + }); err != nil { + return fmt.Errorf("set document style: %w", err) } return nil } @@ -191,6 +222,9 @@ func (c *DocsWriteCmd) writePlainTextResult(ctx context.Context, resp *docs.Batc if c.Tab != "" { payload["tabId"] = c.Tab } + for k, v := range c.Layout.dryRunPayload() { + payload[k] = v + } if resp.WriteControl != nil { payload["writeControl"] = resp.WriteControl } @@ -227,7 +261,8 @@ func (c *DocsWriteCmd) writeMarkdown(ctx context.Context, flags *RootFlags, docI } cleaned, images := extractMarkdownImages(content) - if err := dryRunExit(ctx, flags, "docs.write", map[string]any{ + cleaned = normalizeMarkdownTablesForDriveImport(cleaned) + dryRunPayload := map[string]any{ "document_id": docID, "written": len(content), "append": false, @@ -235,7 +270,11 @@ func (c *DocsWriteCmd) writeMarkdown(ctx context.Context, flags *RootFlags, docI "markdown": true, "pageless": c.Pageless, "images": len(images), - }); err != nil { + } + for k, v := range c.Layout.dryRunPayload() { + dryRunPayload[k] = v + } + if err := dryRunExit(ctx, flags, "docs.write", dryRunPayload); err != nil { return err } @@ -255,21 +294,30 @@ func (c *DocsWriteCmd) writeMarkdown(ctx context.Context, flags *RootFlags, docI } var docsSvc *docs.Service - if len(images) > 0 || c.Pageless { + needsDocsSvc := len(images) > 0 || c.Pageless || c.Layout.any() || markdownMayContainHeadingLinks(cleaned) + if needsDocsSvc { var svcErr error docsSvc, svcErr = newDocsService(ctx, account) if svcErr != nil { return svcErr } } + rewrittenHeadingLinks := 0 + if markdownMayContainHeadingLinks(cleaned) { + count, rewriteErr := rewriteMarkdownHeadingLinks(ctx, docsSvc, docID) + if rewriteErr != nil { + return fmt.Errorf("rewrite heading links: %w", rewriteErr) + } + rewrittenHeadingLinks = count + } if len(images) > 0 { if err := insertImagesIntoDocs(ctx, docsSvc, docID, images, ""); err != nil { cleanupDocsImagePlaceholders(ctx, docsSvc, docID, images, "") return fmt.Errorf("insert images: %w", err) } } - if c.Pageless { - if err := c.applyPageless(ctx, docsSvc, docID); err != nil { + if c.Pageless || c.Layout.any() { + if err := c.applyDocumentStyle(ctx, docsSvc, docID); err != nil { return err } } @@ -284,6 +332,9 @@ func (c *DocsWriteCmd) writeMarkdown(ctx context.Context, flags *RootFlags, docI if c.Pageless { payload["pageless"] = true } + if rewrittenHeadingLinks > 0 { + payload["headingLinks"] = rewrittenHeadingLinks + } return outfmt.WriteJSON(ctx, os.Stdout, payload) } @@ -293,6 +344,9 @@ func (c *DocsWriteCmd) writeMarkdown(ctx context.Context, flags *RootFlags, docI if c.Pageless { u.Out().Linef("pageless\ttrue") } + if rewrittenHeadingLinks > 0 { + u.Out().Linef("headingLinks\t%d", rewrittenHeadingLinks) + } if updated.WebViewLink != "" { u.Out().Linef("link\t%s", updated.WebViewLink) } @@ -301,7 +355,7 @@ func (c *DocsWriteCmd) writeMarkdown(ctx context.Context, flags *RootFlags, docI func (c *DocsWriteCmd) appendMarkdown(ctx context.Context, flags *RootFlags, docID, content string) error { cleaned, images := extractMarkdownImages(content) - if err := dryRunExit(ctx, flags, "docs.write", map[string]any{ + dryRunPayload := map[string]any{ "document_id": docID, "written": len(cleaned), "append": true, @@ -310,7 +364,11 @@ func (c *DocsWriteCmd) appendMarkdown(ctx context.Context, flags *RootFlags, doc "pageless": c.Pageless, "tab": c.Tab, "images": len(images), - }); err != nil { + } + for k, v := range c.Layout.dryRunPayload() { + dryRunPayload[k] = v + } + if err := dryRunExit(ctx, flags, "docs.write", dryRunPayload); err != nil { return err } @@ -333,7 +391,7 @@ func (c *DocsWriteCmd) appendMarkdown(ctx context.Context, flags *RootFlags, doc } return err } - if err := c.applyPageless(ctx, svc, docID); err != nil { + if err := c.applyDocumentStyle(ctx, svc, docID); err != nil { return err } @@ -349,6 +407,9 @@ func (c *DocsWriteCmd) appendMarkdown(ctx context.Context, flags *RootFlags, doc if c.Pageless { payload["pageless"] = true } + for k, v := range c.Layout.dryRunPayload() { + payload[k] = v + } return outfmt.WriteJSON(ctx, os.Stdout, payload) } @@ -371,7 +432,7 @@ func (c *DocsWriteCmd) appendMarkdown(ctx context.Context, flags *RootFlags, doc // body content via DeleteContentRange. Other tabs are untouched. func (c *DocsWriteCmd) replaceMarkdownInTab(ctx context.Context, flags *RootFlags, docID, content string) error { cleaned, images := extractMarkdownImages(content) - if err := dryRunExit(ctx, flags, "docs.write", map[string]any{ + dryRunPayload := map[string]any{ "document_id": docID, "written": len(cleaned), "append": false, @@ -380,7 +441,11 @@ func (c *DocsWriteCmd) replaceMarkdownInTab(ctx context.Context, flags *RootFlag "pageless": c.Pageless, "tab": c.Tab, "images": len(images), - }); err != nil { + } + for k, v := range c.Layout.dryRunPayload() { + dryRunPayload[k] = v + } + if err := dryRunExit(ctx, flags, "docs.write", dryRunPayload); err != nil { return err } @@ -421,7 +486,7 @@ func (c *DocsWriteCmd) replaceMarkdownInTab(ctx context.Context, flags *RootFlag } return err } - if err := c.applyPageless(ctx, svc, docID); err != nil { + if err := c.applyDocumentStyle(ctx, svc, docID); err != nil { return err } @@ -437,6 +502,9 @@ func (c *DocsWriteCmd) replaceMarkdownInTab(ctx context.Context, flags *RootFlag if c.Pageless { payload["pageless"] = true } + for k, v := range c.Layout.dryRunPayload() { + payload[k] = v + } return outfmt.WriteJSON(ctx, os.Stdout, payload) } diff --git a/internal/cmd/docs_helpers.go b/internal/cmd/docs_helpers.go index a9fe85c2c..450fcc00e 100644 --- a/internal/cmd/docs_helpers.go +++ b/internal/cmd/docs_helpers.go @@ -60,26 +60,6 @@ const ( docsDocumentModePageless = "PAGELESS" ) -func setDocumentPageless(ctx context.Context, svc *docs.Service, docID string) error { - return setDocumentMode(ctx, svc, docID, docsDocumentModePageless) -} - -// setDocumentMode toggles documentStyle.documentFormat.documentMode via a -// single batchUpdate call. -func setDocumentMode(ctx context.Context, svc *docs.Service, docID, mode string) error { - _, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{ - Requests: []*docs.Request{{ - UpdateDocumentStyle: &docs.UpdateDocumentStyleRequest{ - DocumentStyle: &docs.DocumentStyle{ - DocumentFormat: &docs.DocumentFormat{DocumentMode: mode}, - }, - Fields: "documentFormat", - }, - }}, - }).Context(ctx).Do() - return err -} - func resolveTextInput(text, file string, kctx *kong.Context, textFlag, fileFlag string) (string, bool, error) { file = strings.TrimSpace(file) textProvided := text != "" || flagProvided(kctx, textFlag) diff --git a/internal/cmd/docs_layout.go b/internal/cmd/docs_layout.go new file mode 100644 index 000000000..8b15bc17d --- /dev/null +++ b/internal/cmd/docs_layout.go @@ -0,0 +1,191 @@ +package cmd + +import ( + "context" + "fmt" + "math" + "strconv" + "strings" + + "google.golang.org/api/docs/v1" +) + +type DocsLayoutFlags struct { + PageWidth string `name:"page-width" help:"Set page width (points by default; supports pt, in, cm, mm)"` + PageHeight string `name:"page-height" help:"Set page height (points by default; supports pt, in, cm, mm)"` + MarginLeft string `name:"margin-left" help:"Set left page margin (points by default; supports pt, in, cm, mm)"` + MarginRight string `name:"margin-right" help:"Set right page margin (points by default; supports pt, in, cm, mm)"` + MarginTop string `name:"margin-top" help:"Set top page margin (points by default; supports pt, in, cm, mm)"` + MarginBottom string `name:"margin-bottom" help:"Set bottom page margin (points by default; supports pt, in, cm, mm)"` +} + +func (f DocsLayoutFlags) any() bool { + return strings.TrimSpace(f.PageWidth) != "" || + strings.TrimSpace(f.PageHeight) != "" || + strings.TrimSpace(f.MarginLeft) != "" || + strings.TrimSpace(f.MarginRight) != "" || + strings.TrimSpace(f.MarginTop) != "" || + strings.TrimSpace(f.MarginBottom) != "" +} + +func (f DocsLayoutFlags) dryRunPayload() map[string]any { + payload := map[string]any{} + if strings.TrimSpace(f.PageWidth) != "" { + payload["pageWidth"] = f.PageWidth + } + if strings.TrimSpace(f.PageHeight) != "" { + payload["pageHeight"] = f.PageHeight + } + if strings.TrimSpace(f.MarginLeft) != "" { + payload["marginLeft"] = f.MarginLeft + } + if strings.TrimSpace(f.MarginRight) != "" { + payload["marginRight"] = f.MarginRight + } + if strings.TrimSpace(f.MarginTop) != "" { + payload["marginTop"] = f.MarginTop + } + if strings.TrimSpace(f.MarginBottom) != "" { + payload["marginBottom"] = f.MarginBottom + } + return payload +} + +type docsDocumentStyleOptions struct { + Mode string + DocsLayoutFlags +} + +func setDocumentPageless(ctx context.Context, svc *docs.Service, docID string) error { + return setDocumentMode(ctx, svc, docID, docsDocumentModePageless) +} + +func setDocumentMode(ctx context.Context, svc *docs.Service, docID, mode string) error { + return setDocumentStyle(ctx, svc, docID, docsDocumentStyleOptions{Mode: mode}) +} + +func setDocumentStyle(ctx context.Context, svc *docs.Service, docID string, opts docsDocumentStyleOptions) error { + req, err := buildUpdateDocumentStyleRequest(opts) + if err != nil { + return err + } + _, err = svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{ + Requests: []*docs.Request{{UpdateDocumentStyle: req}}, + }).Context(ctx).Do() + return err +} + +func buildUpdateDocumentStyleRequest(opts docsDocumentStyleOptions) (*docs.UpdateDocumentStyleRequest, error) { + style := &docs.DocumentStyle{} + fields := []string{} + + if strings.TrimSpace(opts.Mode) != "" { + style.DocumentFormat = &docs.DocumentFormat{DocumentMode: opts.Mode} + fields = append(fields, "documentFormat") + } + + // The Docs schema says pageSize/margins are not rendered in PAGELESS mode, + // but live Docs readback shows pageless table/content width still follows + // documentStyle.pageSize minus margins. Keep these flags explicit, not + // automatic, so --pageless never widens existing docs unless requested. + pageWidth, ok, err := parseDocsDimension("page-width", opts.PageWidth, false) + if err != nil { + return nil, err + } + if ok { + if style.PageSize == nil { + style.PageSize = &docs.Size{} + } + style.PageSize.Width = pageWidth + fields = append(fields, "pageSize.width") + } + + pageHeight, ok, err := parseDocsDimension("page-height", opts.PageHeight, false) + if err != nil { + return nil, err + } + if ok { + if style.PageSize == nil { + style.PageSize = &docs.Size{} + } + style.PageSize.Height = pageHeight + fields = append(fields, "pageSize.height") + } + + if err := setMarginDimension(&fields, "margin-left", "marginLeft", opts.MarginLeft, &style.MarginLeft); err != nil { + return nil, err + } + if err := setMarginDimension(&fields, "margin-right", "marginRight", opts.MarginRight, &style.MarginRight); err != nil { + return nil, err + } + if err := setMarginDimension(&fields, "margin-top", "marginTop", opts.MarginTop, &style.MarginTop); err != nil { + return nil, err + } + if err := setMarginDimension(&fields, "margin-bottom", "marginBottom", opts.MarginBottom, &style.MarginBottom); err != nil { + return nil, err + } + + if len(fields) == 0 { + return nil, usage("no document style changes requested") + } + return &docs.UpdateDocumentStyleRequest{ + DocumentStyle: style, + Fields: strings.Join(fields, ","), + }, nil +} + +func setMarginDimension(fields *[]string, flagName, fieldName, raw string, target **docs.Dimension) error { + dim, ok, err := parseDocsDimension(flagName, raw, true) + if err != nil { + return err + } + if !ok { + return nil + } + *target = dim + *fields = append(*fields, fieldName) + return nil +} + +func parseDocsDimension(flagName, raw string, allowZero bool) (*docs.Dimension, bool, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, false, nil + } + + unit := "pt" + number := raw + for _, suffix := range []string{"pt", "in", "cm", "mm"} { + if strings.HasSuffix(strings.ToLower(raw), suffix) { + unit = suffix + number = strings.TrimSpace(raw[:len(raw)-len(suffix)]) + break + } + } + if number == "" { + return nil, false, usage(fmt.Sprintf("invalid --%s %q", flagName, raw)) + } + value, err := strconv.ParseFloat(number, 64) + if err != nil || math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || (!allowZero && value == 0) { + expected := "positive length" + if allowZero { + expected = "non-negative length" + } + return nil, false, usage(fmt.Sprintf("invalid --%s %q (expected %s, e.g. 72, 1in, 2.54cm)", flagName, raw, expected)) + } + + switch unit { + case "pt": + case "in": + value *= 72 + case "cm": + value *= 72 / 2.54 + case "mm": + value *= 72 / 25.4 + } + dim := &docs.Dimension{Magnitude: value, Unit: "PT"} + if value == 0 { + dim.ForceSendFields = []string{"Magnitude"} + } + return dim, true, nil +} diff --git a/internal/cmd/docs_markdown.go b/internal/cmd/docs_markdown.go index 39a266027..367aa2057 100644 --- a/internal/cmd/docs_markdown.go +++ b/internal/cmd/docs_markdown.go @@ -193,7 +193,7 @@ func ParseMarkdown(text string) []MarkdownElement { TableCells: tableCells, }) // Skip all table lines - i += len(tableCells) // loop increment handles separator line offset + i += countMarkdownTableLines(lines[i:]) - 1 continue } } @@ -272,9 +272,24 @@ func parseMarkdownTable(lines []string) [][]string { } } + if len(rows) > 1 && isEmptyMarkdownTableRow(rows[0]) { + rows = rows[1:] + } return rows } +func countMarkdownTableLines(lines []string) int { + count := 0 + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || !strings.HasPrefix(line, "|") { + break + } + count++ + } + return count +} + // parseTableRow parses a single table row into cells func parseTableRow(line string) []string { // Remove outer pipes @@ -292,6 +307,79 @@ func parseTableRow(line string) []string { return cells } +func isEmptyMarkdownTableRow(cells []string) bool { + if len(cells) == 0 { + return false + } + for _, cell := range cells { + if strings.TrimSpace(cell) != "" { + return false + } + } + return true +} + +func normalizeMarkdownTablesForDriveImport(markdown string) string { + lines := strings.Split(markdown, "\n") + out := make([]string, 0, len(lines)) + inFence := false + fenceMarker := "" + for i := 0; i < len(lines); i++ { + line := lines[i] + if marker := docsMarkdownFenceMarker(line); marker != "" { + if !inFence { + inFence = true + fenceMarker = marker + } else if marker == fenceMarker { + inFence = false + fenceMarker = "" + } + out = append(out, line) + continue + } + if inFence || !isMarkdownTableCandidateLine(line) || i+2 >= len(lines) || !isTableSeparator(lines[i+1]) || isIndentedMarkdownCodeLine(lines[i+1]) { + out = append(out, line) + continue + } + header := parseTableRow(strings.TrimSpace(line)) + if !isEmptyMarkdownTableRow(header) || !isMarkdownTableCandidateLine(lines[i+2]) { + out = append(out, line) + continue + } + out = append(out, lines[i+2], lines[i+1]) + i += 2 + for i+1 < len(lines) { + next := lines[i+1] + if strings.TrimSpace(next) == "" || !strings.HasPrefix(strings.TrimSpace(next), "|") { + break + } + out = append(out, next) + i++ + } + } + return strings.Join(out, "\n") +} + +func docsMarkdownFenceMarker(line string) string { + trimmed := strings.TrimSpace(line) + switch { + case strings.HasPrefix(trimmed, "```"): + return "```" + case strings.HasPrefix(trimmed, "~~~"): + return "~~~" + default: + return "" + } +} + +func isMarkdownTableCandidateLine(line string) bool { + return !isIndentedMarkdownCodeLine(line) && strings.HasPrefix(strings.TrimSpace(line), "|") +} + +func isIndentedMarkdownCodeLine(line string) bool { + return strings.HasPrefix(line, "\t") || strings.HasPrefix(line, " ") +} + const inlineTypeCode = "code" // ParseInlineFormatting parses inline markdown formatting within text diff --git a/internal/cmd/docs_markdown_links.go b/internal/cmd/docs_markdown_links.go new file mode 100644 index 000000000..29225b472 --- /dev/null +++ b/internal/cmd/docs_markdown_links.go @@ -0,0 +1,128 @@ +package cmd + +import ( + "context" + "strconv" + "strings" + "unicode" + + "google.golang.org/api/docs/v1" +) + +func markdownMayContainHeadingLinks(markdown string) bool { + return strings.Contains(markdown, "](#") +} + +func rewriteMarkdownHeadingLinks(ctx context.Context, svc *docs.Service, docID string) (int, error) { + doc, err := svc.Documents.Get(docID). + Fields("body/content(startIndex,endIndex,paragraph(paragraphStyle(namedStyleType,headingId),elements(startIndex,endIndex,textRun(content,textStyle/link))))"). + Context(ctx). + Do() + if err != nil { + return 0, err + } + if doc == nil || doc.Body == nil { + return 0, nil + } + + headingBySlug := map[string]string{} + slugCounts := map[string]int{} + for _, el := range doc.Body.Content { + if el == nil || el.Paragraph == nil || el.Paragraph.ParagraphStyle == nil { + continue + } + style := el.Paragraph.ParagraphStyle + if !strings.HasPrefix(style.NamedStyleType, "HEADING_") || strings.TrimSpace(style.HeadingId) == "" { + continue + } + text := markdownHeadingParagraphText(el.Paragraph) + slug := markdownHeadingSlug(text, slugCounts) + if slug == "" { + continue + } + headingBySlug[slug] = style.HeadingId + } + if len(headingBySlug) == 0 { + return 0, nil + } + + var requests []*docs.Request + for _, el := range doc.Body.Content { + if el == nil || el.Paragraph == nil { + continue + } + for _, pe := range el.Paragraph.Elements { + if pe == nil || pe.TextRun == nil || pe.TextRun.TextStyle == nil || pe.TextRun.TextStyle.Link == nil { + continue + } + link := pe.TextRun.TextStyle.Link + if link.Url == "" || strings.HasPrefix(link.Url, "#heading=") { + continue + } + slug, ok := strings.CutPrefix(link.Url, "#") + if !ok || strings.TrimSpace(slug) == "" { + continue + } + headingID := headingBySlug[strings.TrimSpace(slug)] + if headingID == "" { + continue + } + requests = append(requests, &docs.Request{ + UpdateTextStyle: &docs.UpdateTextStyleRequest{ + Range: &docs.Range{ + StartIndex: pe.StartIndex, + EndIndex: pe.EndIndex, + }, + TextStyle: &docs.TextStyle{Link: &docs.Link{HeadingId: headingID}}, + Fields: "link", + }, + }) + } + } + if len(requests) == 0 { + return 0, nil + } + _, err = svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{Requests: requests}).Context(ctx).Do() + if err != nil { + return 0, err + } + return len(requests), nil +} + +func markdownHeadingParagraphText(p *docs.Paragraph) string { + var b strings.Builder + for _, el := range p.Elements { + if el != nil && el.TextRun != nil { + b.WriteString(el.TextRun.Content) + } + } + return strings.TrimSpace(b.String()) +} + +func markdownHeadingSlug(text string, seen map[string]int) string { + text = strings.ToLower(strings.TrimSpace(text)) + var b strings.Builder + lastHyphen := false + for _, r := range text { + switch { + case unicode.IsLetter(r) || unicode.IsNumber(r): + b.WriteRune(r) + lastHyphen = false + case unicode.IsSpace(r) || r == '-': + if b.Len() > 0 && !lastHyphen { + b.WriteByte('-') + lastHyphen = true + } + } + } + slug := strings.Trim(b.String(), "-") + if slug == "" { + return "" + } + n := seen[slug] + seen[slug] = n + 1 + if n == 0 { + return slug + } + return slug + "-" + strconv.Itoa(n) +} diff --git a/internal/cmd/docs_markdown_test.go b/internal/cmd/docs_markdown_test.go index 7455da178..ec8a73fa4 100644 --- a/internal/cmd/docs_markdown_test.go +++ b/internal/cmd/docs_markdown_test.go @@ -364,10 +364,11 @@ func TestIsTableSeparator_EmptyPipeRowRejected(t *testing.T) { } } -func TestParseMarkdown_EmptyHeaderTableKeepsAllDataRows(t *testing.T) { +func TestParseMarkdown_EmptyHeaderTableDropsBlankHeaderAndKeepsDataRows(t *testing.T) { // Regression for #609: an empty-header table previously had its last data // row re-parsed as a literal pipe paragraph (because the empty pipe row - // matched isTableSeparator and the outer loop advanced too far). + // matched isTableSeparator and the outer loop advanced too far). Regression + // for #632: the blank header itself should not render as a visible row. input := "| | |\n|-----|-----|\n| Label A | Value A |\n| Label B | Value B |" got := ParseMarkdown(input) if len(got) != 1 { @@ -376,11 +377,40 @@ func TestParseMarkdown_EmptyHeaderTableKeepsAllDataRows(t *testing.T) { if got[0].Type != MDTable { t.Fatalf("element type = %v, want MDTable", got[0].Type) } - if len(got[0].TableCells) != 3 { - t.Fatalf("expected 3 rows (empty header + 2 data), got %d: %#v", len(got[0].TableCells), got[0].TableCells) + if len(got[0].TableCells) != 2 { + t.Fatalf("expected 2 data rows, got %d: %#v", len(got[0].TableCells), got[0].TableCells) } - last := got[0].TableCells[2] + first := got[0].TableCells[0] + if len(first) != 2 || first[0] != "Label A" || first[1] != "Value A" { + t.Fatalf("first row = %#v, want [Label A, Value A]", first) + } + last := got[0].TableCells[1] if len(last) != 2 || last[0] != "Label B" || last[1] != "Value B" { t.Fatalf("last row = %#v, want [Label B, Value B]", last) } } + +func TestNormalizeMarkdownTablesForDriveImport_PromotesFirstDataRow(t *testing.T) { + input := "| | |\n|-----|-----|\n| Label A | Value A |\n| Label B | Value B |\n\nAfter" + got := normalizeMarkdownTablesForDriveImport(input) + want := "| Label A | Value A |\n|-----|-----|\n| Label B | Value B |\n\nAfter" + if got != want { + t.Fatalf("normalized markdown = %q, want %q", got, want) + } +} + +func TestNormalizeMarkdownTablesForDriveImport_SkipsCodeBlocks(t *testing.T) { + input := "```\n| | |\n|-----|-----|\n| A | B |\n```\n\n | | |\n |-----|-----|\n | A | B |\n" + got := normalizeMarkdownTablesForDriveImport(input) + if got != input { + t.Fatalf("code block markdown changed:\n got %q\nwant %q", got, input) + } +} + +func TestNormalizeMarkdownTablesForDriveImport_TracksFenceMarker(t *testing.T) { + input := "```\n~~~\n| | |\n|-----|-----|\n| A | B |\n```\n" + got := normalizeMarkdownTablesForDriveImport(input) + if got != input { + t.Fatalf("mixed-fence code block changed:\n got %q\nwant %q", got, input) + } +} diff --git a/internal/cmd/docs_set_page_layout.go b/internal/cmd/docs_set_page_layout.go index f3d484d88..65756c501 100644 --- a/internal/cmd/docs_set_page_layout.go +++ b/internal/cmd/docs_set_page_layout.go @@ -6,40 +6,52 @@ import ( "os" "strings" + "github.com/alecthomas/kong" + "github.com/steipete/gogcli/internal/outfmt" "github.com/steipete/gogcli/internal/ui" ) // DocsPageLayoutCmd toggles the page layout on an existing Google Doc. // The Docs UI exposes this via File → Page setup → Pageless/Pages. The Docs -// API exposes it via documents.batchUpdate with updateDocumentStyle on the -// documentFormat.documentMode field. See setDocumentMode in docs_helpers.go. +// API exposes it via documents.batchUpdate with updateDocumentStyle. // // Sibling to the --pageless flag on `docs create` / `docs write` for the case // where the doc already exists (e.g. created by Drive markdown conversion in // an upstream step that didn't set the layout). type DocsPageLayoutCmd struct { - DocID string `arg:"" name:"docId" help:"Doc ID"` - Layout string `name:"layout" enum:"pageless,pages,paged" default:"pageless" help:"Page layout: pageless or pages"` + DocID string `arg:"" name:"docId" help:"Doc ID"` + Layout string `name:"layout" enum:"pageless,pages,paged" default:"pageless" help:"Page layout: pageless or pages"` + LayoutFlags DocsLayoutFlags `embed:""` } -func (c *DocsPageLayoutCmd) Run(ctx context.Context, flags *RootFlags) error { +func (c *DocsPageLayoutCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error { u := ui.FromContext(ctx) docID := strings.TrimSpace(c.DocID) if docID == "" { return usage("empty docId") } - mode, err := normalizePageLayout(c.Layout) - if err != nil { - return err + mode := "" + if flagProvided(kctx, "layout") || !c.LayoutFlags.any() { + var err error + mode, err = normalizePageLayout(c.Layout) + if err != nil { + return err + } } - if dryRunErr := dryRunExit(ctx, flags, "docs.page-layout", map[string]any{ + dryRunPayload := map[string]any{ "documentId": docID, - "layout": c.Layout, - "mode": mode, - }); dryRunErr != nil { + } + if mode != "" { + dryRunPayload["layout"] = c.Layout + dryRunPayload["mode"] = mode + } + for k, v := range c.LayoutFlags.dryRunPayload() { + dryRunPayload[k] = v + } + if dryRunErr := dryRunExit(ctx, flags, "docs.page-layout", dryRunPayload); dryRunErr != nil { return dryRunErr } @@ -48,7 +60,10 @@ func (c *DocsPageLayoutCmd) Run(ctx context.Context, flags *RootFlags) error { return err } - if err := setDocumentMode(ctx, svc, docID, mode); err != nil { + if err := setDocumentStyle(ctx, svc, docID, docsDocumentStyleOptions{ + Mode: mode, + DocsLayoutFlags: c.LayoutFlags, + }); err != nil { if isDocsNotFound(err) { return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID) } @@ -56,15 +71,26 @@ func (c *DocsPageLayoutCmd) Run(ctx context.Context, flags *RootFlags) error { } if outfmt.IsJSON(ctx) { - return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + payload := map[string]any{ "documentId": docID, - "layout": c.Layout, - "mode": mode, - }) + } + if mode != "" { + payload["layout"] = c.Layout + payload["mode"] = mode + } + for k, v := range c.LayoutFlags.dryRunPayload() { + payload[k] = v + } + return outfmt.WriteJSON(ctx, os.Stdout, payload) } u.Out().Linef("documentId\t%s", docID) - u.Out().Linef("layout\t%s", c.Layout) + if mode != "" { + u.Out().Linef("layout\t%s", c.Layout) + } + for k, v := range c.LayoutFlags.dryRunPayload() { + u.Out().Linef("%s\t%s", k, v) + } return nil } diff --git a/internal/cmd/docs_set_page_layout_test.go b/internal/cmd/docs_set_page_layout_test.go index b1563ed76..54b602c09 100644 --- a/internal/cmd/docs_set_page_layout_test.go +++ b/internal/cmd/docs_set_page_layout_test.go @@ -170,7 +170,7 @@ func TestDocsPageLayoutCmd_DryRun(t *testing.T) { flags := &RootFlags{Account: "a@b.com", DryRun: true} ctx := newDocsJSONContext(t) - err := (&DocsPageLayoutCmd{DocID: "doc1", Layout: "pageless"}).Run(ctx, flags) + err := (&DocsPageLayoutCmd{DocID: "doc1", Layout: "pageless"}).Run(ctx, nil, flags) var exitErr *ExitError if err == nil { t.Fatalf("expected dry-run ExitError, got nil") @@ -179,3 +179,113 @@ func TestDocsPageLayoutCmd_DryRun(t *testing.T) { t.Fatalf("expected dry-run exit 0, got %v", err) } } + +func TestDocsPageLayoutCmd_PageSizeAndMargins(t *testing.T) { + origDocs := newDocsService + t.Cleanup(func() { newDocsService = origDocs }) + + var batchRequests [][]*docs.Request + + docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, ":batchUpdate"): + var req docs.BatchUpdateDocumentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode: %v", err) + } + batchRequests = append(batchRequests, req.Requests) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + default: + http.NotFound(w, r) + } + })) + defer cleanup() + newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsCmdContext(t) + + args := []string{"doc1", "--layout=pages", "--page-width=8.5in", "--page-height=11in", "--margin-left=0.5in", "--margin-right=36"} + if err := runKong(t, &DocsPageLayoutCmd{}, args, ctx, flags); err != nil { + t.Fatalf("page-layout margins: %v", err) + } + + if len(batchRequests) != 1 || len(batchRequests[0]) != 1 { + t.Fatalf("expected 1 batch request with 1 op, got %#v", batchRequests) + } + upd := batchRequests[0][0].UpdateDocumentStyle + if upd == nil || upd.DocumentStyle == nil { + t.Fatalf("expected UpdateDocumentStyle, got %#v", batchRequests[0][0]) + } + if upd.Fields != "documentFormat,pageSize.width,pageSize.height,marginLeft,marginRight" { + t.Fatalf("fields = %q", upd.Fields) + } + style := upd.DocumentStyle + if style.PageSize.Width.Magnitude != 612 || style.PageSize.Height.Magnitude != 792 { + t.Fatalf("unexpected page size: %#v", style.PageSize) + } + if style.MarginLeft.Magnitude != 36 || style.MarginRight.Magnitude != 36 { + t.Fatalf("unexpected margins: left=%#v right=%#v", style.MarginLeft, style.MarginRight) + } +} + +func TestDocsPageLayoutCmd_PageSizeWithoutLayoutPreservesMode(t *testing.T) { + origDocs := newDocsService + t.Cleanup(func() { newDocsService = origDocs }) + + var batchRequests [][]*docs.Request + + docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, ":batchUpdate"): + var req docs.BatchUpdateDocumentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode: %v", err) + } + batchRequests = append(batchRequests, req.Requests) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + default: + http.NotFound(w, r) + } + })) + defer cleanup() + newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsCmdContext(t) + + if err := runKong(t, &DocsPageLayoutCmd{}, []string{"doc1", "--page-width=960"}, ctx, flags); err != nil { + t.Fatalf("page-layout width: %v", err) + } + + upd := batchRequests[0][0].UpdateDocumentStyle + if upd.Fields != "pageSize.width" { + t.Fatalf("fields = %q", upd.Fields) + } + if upd.DocumentStyle.DocumentFormat != nil { + t.Fatalf("unexpected document mode update: %#v", upd.DocumentStyle.DocumentFormat) + } + if upd.DocumentStyle.PageSize.Width.Magnitude != 960 { + t.Fatalf("page width = %#v", upd.DocumentStyle.PageSize.Width) + } +} + +func TestBuildUpdateDocumentStyleRequest_ZeroMarginAllowed(t *testing.T) { + req, err := buildUpdateDocumentStyleRequest(docsDocumentStyleOptions{ + DocsLayoutFlags: DocsLayoutFlags{MarginLeft: "0", MarginRight: "0pt"}, + }) + if err != nil { + t.Fatalf("build request: %v", err) + } + if req.Fields != "marginLeft,marginRight" { + t.Fatalf("fields = %q", req.Fields) + } + if req.DocumentStyle.MarginLeft.Magnitude != 0 || req.DocumentStyle.MarginRight.Magnitude != 0 { + t.Fatalf("margins = left %#v right %#v", req.DocumentStyle.MarginLeft, req.DocumentStyle.MarginRight) + } + if len(req.DocumentStyle.MarginLeft.ForceSendFields) == 0 || req.DocumentStyle.MarginLeft.ForceSendFields[0] != "Magnitude" { + t.Fatalf("left margin should force-send zero magnitude: %#v", req.DocumentStyle.MarginLeft) + } +} diff --git a/internal/cmd/docs_write_markdown_test.go b/internal/cmd/docs_write_markdown_test.go index 2dc241fc8..dd6060d28 100644 --- a/internal/cmd/docs_write_markdown_test.go +++ b/internal/cmd/docs_write_markdown_test.go @@ -93,6 +93,157 @@ func TestDocsWrite_MarkdownReplaceUsesDriveUpdate(t *testing.T) { } } +func TestDocsWrite_MarkdownReplaceNormalizesEmptyTableHeaderForDrive(t *testing.T) { + origDocs := newDocsService + origDrive := newDriveService + t.Cleanup(func() { + newDocsService = origDocs + newDriveService = origDrive + }) + + var uploadBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/upload/drive/v3/files/doc1"): + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + uploadBody = string(body) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "doc1", "name": "Doc"}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + driveSvc, err := drive.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/drive/v3/"), + ) + if err != nil { + t.Fatalf("NewDriveService: %v", err) + } + newDriveService = func(context.Context, string) (*drive.Service, error) { return driveSvc, nil } + newDocsService = func(context.Context, string) (*docs.Service, error) { + t.Fatal("empty-header normalization should not require Docs service") + return nil, errors.New("unexpected Docs service call") + } + + markdown := "| | |\n|-----|-----|\n| Label A | Value A |\n| Label B | Value B |\n" + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsJSONContext(t) + if err := runKong(t, &DocsWriteCmd{}, []string{"doc1", "--text", markdown, "--replace", "--markdown"}, ctx, flags); err != nil { + t.Fatalf("markdown replace write: %v", err) + } + if strings.Contains(uploadBody, "| | |") { + t.Fatalf("expected blank header row to be removed, got: %q", uploadBody) + } + if !strings.Contains(uploadBody, "| Label A | Value A |\n|-----|-----|") { + t.Fatalf("expected first data row promoted to markdown header, got: %q", uploadBody) + } +} + +func TestDocsWrite_MarkdownReplaceRewritesHeadingSlugLinks(t *testing.T) { + origDocs := newDocsService + origDrive := newDriveService + t.Cleanup(func() { + newDocsService = origDocs + newDriveService = origDrive + }) + + var sawDocsGet bool + var batchReq docs.BatchUpdateDocumentRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/upload/drive/v3/files/doc1"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "doc1", "name": "Doc"}) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/documents/doc1"): + sawDocsGet = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(&docs.Document{ + DocumentId: "doc1", + Body: &docs.Body{Content: []*docs.StructuralElement{ + { + StartIndex: 1, + EndIndex: 20, + Paragraph: &docs.Paragraph{ + ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "HEADING_1", HeadingId: "h.heading1"}, + Elements: []*docs.ParagraphElement{{ + StartIndex: 1, + EndIndex: 19, + TextRun: &docs.TextRun{Content: "Executive Summary\n"}, + }}, + }, + }, + { + StartIndex: 20, + EndIndex: 25, + Paragraph: &docs.Paragraph{ + Elements: []*docs.ParagraphElement{{ + StartIndex: 20, + EndIndex: 24, + TextRun: &docs.TextRun{ + Content: "Jump", + TextStyle: &docs.TextStyle{Link: &docs.Link{Url: "#executive-summary"}}, + }, + }}, + }, + }, + }}, + }) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/documents/doc1:batchUpdate"): + if err := json.NewDecoder(r.Body).Decode(&batchReq); err != nil { + t.Fatalf("decode batch update: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + driveSvc, err := drive.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/drive/v3/"), + ) + if err != nil { + t.Fatalf("NewDriveService: %v", err) + } + docsSvc, err := docs.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewDocsService: %v", err) + } + newDriveService = func(context.Context, string) (*drive.Service, error) { return driveSvc, nil } + newDocsService = func(context.Context, string) (*docs.Service, error) { return docsSvc, nil } + + markdown := "# Executive Summary\n\n[Jump](#executive-summary)\n" + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsJSONContext(t) + if err := runKong(t, &DocsWriteCmd{}, []string{"doc1", "--text", markdown, "--replace", "--markdown"}, ctx, flags); err != nil { + t.Fatalf("markdown replace write: %v", err) + } + if !sawDocsGet { + t.Fatal("expected Docs get after Drive markdown import") + } + if len(batchReq.Requests) != 1 || batchReq.Requests[0].UpdateTextStyle == nil { + t.Fatalf("expected one UpdateTextStyle request, got %#v", batchReq.Requests) + } + styleReq := batchReq.Requests[0].UpdateTextStyle + if styleReq.Fields != "link" || styleReq.TextStyle.Link == nil || styleReq.TextStyle.Link.HeadingId != "h.heading1" { + t.Fatalf("unexpected link rewrite request: %#v", styleReq) + } +} + func TestDocsWrite_MarkdownImagesInsertedAfterDriveUpdate(t *testing.T) { origDocs := newDocsService origDrive := newDriveService diff --git a/internal/cmd/docs_write_update_test.go b/internal/cmd/docs_write_update_test.go index 8abade704..0a994c300 100644 --- a/internal/cmd/docs_write_update_test.go +++ b/internal/cmd/docs_write_update_test.go @@ -3,6 +3,7 @@ package cmd import ( "context" "encoding/json" + "errors" "net/http" "strings" "testing" @@ -176,3 +177,82 @@ func TestDocsWriteUpdate_Pageless(t *testing.T) { t.Fatalf("unexpected pageless update style request: %#v", got) } } + +func TestDocsWrite_PageSizeAndMargins(t *testing.T) { + origDocs := newDocsService + t.Cleanup(func() { newDocsService = origDocs }) + + var batchRequests [][]*docs.Request + + docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case r.Method == http.MethodPost && strings.Contains(path, ":batchUpdate"): + var req docs.BatchUpdateDocumentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + batchRequests = append(batchRequests, req.Requests) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + case r.Method == http.MethodGet && strings.HasPrefix(path, "/v1/documents/"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "documentId": "doc1", + "body": map[string]any{ + "content": []any{map[string]any{"startIndex": 1, "endIndex": 2}}, + }, + }) + default: + http.NotFound(w, r) + } + })) + defer cleanup() + newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsJSONContext(t) + + args := []string{"doc1", "--text", "hello", "--page-width=8.5in", "--margin-left=0.5in", "--margin-right=0.5in"} + if err := runKong(t, &DocsWriteCmd{}, args, ctx, flags); err != nil { + t.Fatalf("write margins: %v", err) + } + if len(batchRequests) != 2 { + t.Fatalf("expected write and style batch requests, got %d", len(batchRequests)) + } + upd := batchRequests[1][0].UpdateDocumentStyle + if upd == nil { + t.Fatalf("expected style update, got %#v", batchRequests[1]) + } + if upd.Fields != "pageSize.width,marginLeft,marginRight" { + t.Fatalf("fields = %q", upd.Fields) + } + if upd.DocumentStyle.PageSize.Width.Magnitude != 612 { + t.Fatalf("page width = %#v", upd.DocumentStyle.PageSize.Width) + } + if upd.DocumentStyle.MarginLeft.Magnitude != 36 || upd.DocumentStyle.MarginRight.Magnitude != 36 { + t.Fatalf("margins = left %#v right %#v", upd.DocumentStyle.MarginLeft, upd.DocumentStyle.MarginRight) + } +} + +func TestDocsWrite_InvalidLayoutValueFailsBeforeMutation(t *testing.T) { + origDocs := newDocsService + t.Cleanup(func() { newDocsService = origDocs }) + newDocsService = func(context.Context, string) (*docs.Service, error) { + t.Fatal("invalid layout value should fail before creating Docs service") + return nil, errors.New("unexpected Docs service creation") + } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsJSONContext(t) + + err := runKong(t, &DocsWriteCmd{}, []string{"doc1", "--text", "hello", "--page-width=bogus"}, ctx, flags) + if err == nil || !strings.Contains(err.Error(), "invalid --page-width") { + t.Fatalf("expected invalid page-width error, got %v", err) + } + + err = runKong(t, &DocsWriteCmd{}, []string{"doc1", "--text", "hello", "--page-width=NaN"}, ctx, flags) + if err == nil || !strings.Contains(err.Error(), "invalid --page-width") { + t.Fatalf("expected invalid page-width NaN error, got %v", err) + } +}