feat(docs): add html export format (#141) (thanks @in-liberty420)

This commit is contained in:
Peter Steinberger 2026-03-08 04:34:36 +00:00
parent 647f3f6441
commit ffd8cd482b
8 changed files with 102 additions and 5 deletions

View File

@ -10,6 +10,7 @@
- Sheets: add `add-tab`, `rename-tab`, and `delete-tab` commands for managing spreadsheet tabs, with delete dry-run/confirmation guardrails. (#309) — thanks @JulienMalige.
- Docs: add `--tab-id` to editing commands so write/update/insert/delete/find-replace can target a specific Google Docs tab. (#330) — thanks @ignacioreyna.
- Docs: add native Google Docs Markdown export via `docs export --format md`. (#282) — thanks @fprochazka.
- Docs: add native Google Docs HTML export via `docs export --format html`. (#141) — thanks @in-liberty420.
- Auth: add `auth add --redirect-uri` for manual/remote OAuth flows, so custom callback hosts can be reused across the printed auth URL, state cache, and code exchange. (#398) — thanks @salmonumbrella.
- Auth: add `--extra-scopes` to `auth add` for appending custom OAuth scope URIs beyond the built-in service scopes. (#421) — thanks @peteradams2026.
- Gmail: add `gmail labels rename` to rename user labels by ID or exact name, with system-label guards and wrong-case ID safety. (#391) — thanks @adam-zethraeus.

View File

@ -1230,6 +1230,7 @@ gog docs export <docId> --format pdf --out ./doc.pdf
gog docs export <docId> --format docx --out ./doc.docx
gog docs export <docId> --format txt --out ./doc.txt
gog docs export <docId> --format md --out ./doc.md
gog docs export <docId> --format html --out ./doc.html
# Sed-style regex editing with Markdown formatting (sedmat)
gog docs sed <docId> 's/pattern/replacement/g'

View File

@ -16,7 +16,7 @@ Goal: one implementation for “export Google *Thing* via Drive”.
Each service command is a thin wrapper:
- `gog docs export <docId> --format pdf|docx|txt|md`
- `gog docs export <docId> --format pdf|docx|txt|md|html`
- `gog slides export <presentationId> --format pdf|pptx`
- `gog sheets export <spreadsheetId> --format pdf|xlsx|csv`

View File

@ -40,7 +40,7 @@ type DocsCmd struct {
type DocsExportCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
Output OutputPathFlag `embed:""`
Format string `name:"format" help:"Export format: pdf|docx|txt|md" default:"pdf"`
Format string `name:"format" help:"Export format: pdf|docx|txt|md|html" default:"pdf"`
}
func (c *DocsExportCmd) Run(ctx context.Context, flags *RootFlags) error {

View File

@ -45,6 +45,7 @@ const (
mimePNG = "image/png"
mimeTextPlain = "text/plain"
mimeTextMarkdown = "text/markdown"
mimeHTML = "text/html"
extPDF = ".pdf"
extCSV = ".csv"
extXlsx = ".xlsx"
@ -53,6 +54,7 @@ const (
extPNG = ".png"
extTXT = ".txt"
extMD = ".md"
extHTML = ".html"
formatAuto = "auto"
driveShareToAnyone = "anyone"
driveShareToUser = "user"
@ -1220,10 +1222,10 @@ func validateDriveDownloadFormatFlag(format string) error {
return nil
}
switch format {
case "pdf", "csv", "xlsx", "pptx", "txt", "png", "docx", "md":
case "pdf", "csv", "xlsx", "pptx", "txt", "png", "docx", "md", "html":
return nil
default:
return usagef("invalid --format %q (use pdf|csv|xlsx|pptx|txt|png|docx|md)", format)
return usagef("invalid --format %q (use pdf|csv|xlsx|pptx|txt|png|docx|md|html)", format)
}
}
@ -1286,8 +1288,10 @@ func driveExportMimeTypeForFormat(googleMimeType string, format string) (string,
return mimeTextPlain, nil
case "md":
return mimeTextMarkdown, nil
case "html":
return mimeHTML, nil
default:
return "", fmt.Errorf("invalid --format %q for Google Doc (use pdf|docx|txt|md)", format)
return "", fmt.Errorf("invalid --format %q for Google Doc (use pdf|docx|txt|md|html)", format)
}
case driveMimeGoogleSheet:
switch format {
@ -1344,6 +1348,8 @@ func driveExportExtension(mimeType string) string {
return extTXT
case mimeTextMarkdown:
return extMD
case mimeHTML:
return extHTML
default:
return extPDF
}

View File

@ -58,6 +58,12 @@ func TestDriveExportMimeTypeForFormat(t *testing.T) {
format: "md",
wantMime: "text/markdown",
},
{
name: "doc_html",
googleMime: "application/vnd.google-apps.document",
format: "html",
wantMime: "text/html",
},
{
name: "doc_invalid",
googleMime: "application/vnd.google-apps.document",

View File

@ -54,6 +54,9 @@ func TestDriveExportExtension(t *testing.T) {
if got := driveExportExtension("text/markdown"); got != ".md" {
t.Fatalf("unexpected: %q", got)
}
if got := driveExportExtension("text/html"); got != ".html" {
t.Fatalf("unexpected: %q", got)
}
if got := driveExportExtension("nope"); got != ".pdf" {
t.Fatalf("unexpected: %q", got)
}

View File

@ -175,6 +175,86 @@ func TestExecute_DocsExport_Markdown(t *testing.T) {
}
}
func TestExecute_DocsExport_HTML(t *testing.T) {
origNew := newDriveService
origExport := driveExportDownload
t.Cleanup(func() {
newDriveService = origNew
driveExportDownload = origExport
})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/files/id1") {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "id1",
"name": "Doc",
"mimeType": "application/vnd.google-apps.document",
})
}))
defer srv.Close()
svc, err := drive.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newDriveService = func(context.Context, string) (*drive.Service, error) { return svc, nil }
var gotExportMime string
driveExportDownload = func(_ context.Context, _ *drive.Service, _ string, mimeType string) (*http.Response, error) {
gotExportMime = mimeType
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Body: io.NopCloser(strings.NewReader("<h1>Doc</h1>\n")),
}, nil
}
outBase := filepath.Join(t.TempDir(), "out")
stdout := captureStdout(t, func() {
_ = captureStderr(t, func() {
if execErr := Execute([]string{
"--json",
"--account", "a@b.com",
"docs", "export", "id1",
"--out", outBase,
"--format", "html",
}); execErr != nil {
t.Fatalf("Execute: %v", execErr)
}
})
})
var parsed struct {
Path string `json:"path"`
Size int64 `json:"size"`
}
if unmarshalErr := json.Unmarshal([]byte(stdout), &parsed); unmarshalErr != nil {
t.Fatalf("json parse: %v\nout=%q", unmarshalErr, stdout)
}
if want := outBase + ".html"; parsed.Path != want || parsed.Size != 13 {
t.Fatalf("unexpected: %#v", parsed)
}
if gotExportMime != "text/html" {
t.Fatalf("unexpected export mime type: %q", gotExportMime)
}
b, err := os.ReadFile(outBase + ".html")
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(b) != "<h1>Doc</h1>\n" {
t.Fatalf("unexpected file contents: %q", string(b))
}
}
func TestExecute_DocsExport_TypeMismatch(t *testing.T) {
origNew := newDriveService
t.Cleanup(func() { newDriveService = origNew })