Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go stable) (pull_request) Successful in 1m21s
Tests / Test (Go 1.25) (pull_request) Successful in 1m27s
The conversion / link / sheet commits predate the tree cleanup, so their comments and README/test examples still carried internal refs. Re-apply the genericization (no internal hosts, personal names or client file ids).
931 lines
28 KiB
Go
931 lines
28 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
|
"github.com/eslider/go-onlyoffice/internal/docpipe"
|
|
"github.com/eslider/go-onlyoffice/internal/xlspipe"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(docsCmd())
|
|
}
|
|
|
|
func docsCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "docs",
|
|
Short: "Local document pipeline: md↔docx, OCR→PDF, extract Markdown",
|
|
Long: `Agent-friendly conversions (requires pandoc / ocrmypdf / pdftotext on PATH).
|
|
|
|
OnlyOffice Documents UI is poor for .md/.txt — keep sources in git, store .docx in OO.
|
|
Upload Markdown as DOCX: oo docs put-md PROJECT_ID file.md
|
|
Upload plain text: oo docs put-txt PROJECT_ID file.txt (preserves line breaks)
|
|
Upload/generate XLSX: oo docs put-xlsx PROJECT_ID [--template cutover-portugal | FILE.xlsx]
|
|
Read an OO file as MD: oo docs as-md FILE_ID
|
|
OCR a scan locally: oo docs ocr scan.pdf --md out.md
|
|
Structured OCR (hOCR→MD): oo docs hocr scan.jpg --md out.md --yaml out.yml`,
|
|
}
|
|
cmd.AddCommand(docsConvertCmd())
|
|
cmd.AddCommand(docsPDFCmd())
|
|
cmd.AddCommand(docsPresignedCmd())
|
|
cmd.AddCommand(docsCSVCmd())
|
|
cmd.AddCommand(docsJSONCmd())
|
|
cmd.AddCommand(docsOptimizeCmd())
|
|
cmd.AddCommand(docsOCRCmd())
|
|
cmd.AddCommand(docsHOCRCmd())
|
|
cmd.AddCommand(docsAsMDCmd())
|
|
cmd.AddCommand(docsPutMDCmd())
|
|
cmd.AddCommand(docsPutTxtCmd())
|
|
cmd.AddCommand(docsPutXlsxCmd())
|
|
cmd.AddCommand(docsToolsCmd())
|
|
return cmd
|
|
}
|
|
|
|
func docsToolsCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "tools",
|
|
Short: "Show which converter binaries are on PATH",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
t := docpipe.LookPath()
|
|
printObject(map[string]any{
|
|
"pandoc": strOrNil(t.Pandoc),
|
|
"ocrmypdf": strOrNil(t.OCRMyPDF),
|
|
"pdftotext": strOrNil(t.PDFToText),
|
|
"tesseract": strOrNil(t.Tesseract),
|
|
"ghostscript": strOrNil(t.Ghostscript),
|
|
})
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func docsPDFCmd() *cobra.Command {
|
|
var out, docsURL, secret, output, folder string
|
|
var stream, pipe bool
|
|
cmd := &cobra.Command{
|
|
Use: "pdf FILE_ID | PATH [ARG...]",
|
|
Short: "Convert files to PDF via the DocumentServer converter (OO file ids or local paths)",
|
|
Long: `Native OnlyOffice conversion (the engine behind the portal's "Download as PDF"):
|
|
|
|
1. GET /api/2.0/files/file/{id}/presigneduri → fetchable source URL
|
|
2. POST <docs>/converter with a JWT → converted file URL
|
|
3. download the result
|
|
|
|
Arguments may be OnlyOffice file ids OR local file paths. A local path is
|
|
uploaded to the scratch folder (--folder, default 2 = "My documents"), converted,
|
|
downloaded and then removed — so any local document yields a PDF on the fly.
|
|
|
|
Docs base defaults to $ONLYOFFICE_DOCS_URL, else $ONLYOFFICE_URL + "/ds-vpath"
|
|
(/ds-vpath is the usual reverse-proxy mount for the DocumentServer). The JWT secret is
|
|
$ONLYOFFICE_DS_SECRET (DocumentServer services.CoAuthoring.secret).`,
|
|
Args: cobra.MinimumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newOO(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
base := docsBaseURL(docsURL)
|
|
if base == "" {
|
|
return fmt.Errorf("docs base url unknown; set --docs-url or ONLYOFFICE_DOCS_URL")
|
|
}
|
|
sec := secret
|
|
if sec == "" {
|
|
sec = firstEnv("ONLYOFFICE_DS_SECRET", "OO_DS_SECRET")
|
|
}
|
|
if sec == "" {
|
|
return fmt.Errorf("JWT secret required: --secret or ONLYOFFICE_DS_SECRET")
|
|
}
|
|
ot := output
|
|
if ot == "" {
|
|
ot = "pdf"
|
|
}
|
|
toStdout := stream || pipe
|
|
if toStdout && len(args) > 1 {
|
|
return fmt.Errorf("--stream/--pipe writes one file to stdout; pass a single input")
|
|
}
|
|
for _, arg := range args {
|
|
id, local := arg, false
|
|
title := ""
|
|
if fi, statErr := os.Stat(arg); statErr == nil && !fi.IsDir() {
|
|
// Local file → temporary upload into the scratch folder.
|
|
ent, uerr := c.UploadToFolder(cmd.Context(), folder, arg)
|
|
if uerr != nil {
|
|
return fmt.Errorf("upload %s: %w", arg, uerr)
|
|
}
|
|
id = strconv.FormatInt(onlyoffice.FileEntryNumericID(ent), 10)
|
|
local = true
|
|
title = filepath.Base(arg)
|
|
} else {
|
|
if f, ferr := c.GetFile(cmd.Context(), id); ferr == nil && f != nil && f.Title != nil {
|
|
title = *f.Title
|
|
}
|
|
}
|
|
src, err := c.PresignedURI(cmd.Context(), id)
|
|
if err != nil {
|
|
return fmt.Errorf("presigneduri %s: %w", id, err)
|
|
}
|
|
res, err := c.ConvertDocument(cmd.Context(), base, sec, onlyoffice.ConvertRequest{
|
|
URL: src,
|
|
OutputType: ot,
|
|
FileType: strings.TrimPrefix(filepath.Ext(title), "."),
|
|
Title: title,
|
|
Key: fmt.Sprintf("oo-%s-%d", id, time.Now().UnixNano()),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("convert %s: %w", id, err)
|
|
}
|
|
var w io.Writer
|
|
dst := out
|
|
if toStdout {
|
|
w = os.Stdout
|
|
} else {
|
|
if dst == "" {
|
|
stem := strings.TrimSuffix(title, filepath.Ext(title))
|
|
if stem == "" {
|
|
stem = "file-" + id
|
|
}
|
|
dst = stem + "." + ot
|
|
}
|
|
fh, err := os.Create(dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
w = fh
|
|
defer fh.Close()
|
|
}
|
|
n, derr := c.DownloadURLTo(cmd.Context(), res.FileURL, w)
|
|
if local {
|
|
// Best-effort cleanup of the temporary upload.
|
|
if nid, e := strconv.Atoi(id); e == nil {
|
|
_ = c.DeleteFiles(cmd.Context(), []int{nid})
|
|
}
|
|
}
|
|
if derr != nil {
|
|
return fmt.Errorf("download: %w", derr)
|
|
}
|
|
if toStdout {
|
|
// Keep stdout byte-clean for pipes; status goes to stderr.
|
|
fmt.Fprintf(os.Stderr, "converted %s -> stdout (%d bytes, %s)\n", arg, n, ot)
|
|
} else {
|
|
printObject(map[string]any{"source": arg, "fileid": id, "title": title, "output": dst, "bytes": n, "type": ot})
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&out, "out", "", "output path (default: ./<title>.<format>)")
|
|
cmd.Flags().StringVar(&output, "to", "pdf", "output format (pdf, docx, xlsx, …)")
|
|
cmd.Flags().StringVar(&docsURL, "docs-url", "", "DocumentServer base (default $ONLYOFFICE_DOCS_URL or $ONLYOFFICE_URL/ds-vpath)")
|
|
cmd.Flags().StringVar(&secret, "secret", "", "JWT secret (default $ONLYOFFICE_DS_SECRET)")
|
|
cmd.Flags().StringVar(&folder, "folder", "2", "scratch folder id for local-file uploads")
|
|
cmd.Flags().BoolVar(&stream, "stream", false, "write the converted bytes to stdout (pipe-friendly)")
|
|
cmd.Flags().BoolVar(&pipe, "pipe", false, "alias for --stream")
|
|
return cmd
|
|
}
|
|
|
|
func docsPresignedCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "presigned FILE_ID",
|
|
Short: "Print a short-lived fetchable URI for a portal file",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newOO(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
u, err := c.PresignedURI(cmd.Context(), args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
printObject(map[string]any{"fileid": args[0], "uri": u})
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// loadWorkbookBytes reads an argument that is either an OnlyOffice file id
|
|
// (downloaded via the client) or a local path.
|
|
func loadWorkbookBytes(cmd *cobra.Command, c *onlyoffice.Client, arg string) ([]byte, error) {
|
|
if fi, err := os.Stat(arg); err == nil && !fi.IsDir() {
|
|
return os.ReadFile(arg)
|
|
}
|
|
var buf bytes.Buffer
|
|
if _, err := c.DownloadFile(cmd.Context(), arg, &buf); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// parseDelimiter maps a flag value to a CSV delimiter rune ("," default).
|
|
func parseDelimiter(s string) rune {
|
|
switch s {
|
|
case "", ",":
|
|
return ','
|
|
case "\\t", "tab", "\t":
|
|
return '\t'
|
|
case ";":
|
|
return ';'
|
|
case "|":
|
|
return '|'
|
|
default:
|
|
r := []rune(s)
|
|
return r[0]
|
|
}
|
|
}
|
|
|
|
func docsCSVCmd() *cobra.Command {
|
|
var sheet int
|
|
var delim, out string
|
|
cmd := &cobra.Command{
|
|
Use: "csv SRC [SRC...]",
|
|
Short: "Export a worksheet (XLS/XLSX/ODS) to CSV (sheet-aware)",
|
|
Long: `SRC is an OnlyOffice file id or a local path. --sheet is 1-based (default 1 = first).
|
|
|
|
Why not the DocumentServer: its csv output covers only the first worksheet and
|
|
ignores a sheet selector (verified). Sheet selection and JSON use a local reader.`,
|
|
Args: cobra.MinimumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newOO(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
d := parseDelimiter(delim)
|
|
for _, arg := range args {
|
|
data, err := loadWorkbookBytes(cmd, c, arg)
|
|
if err != nil {
|
|
return fmt.Errorf("read %s: %w", arg, err)
|
|
}
|
|
text, err := onlyoffice.WorkbookSheetCSV(data, sheet-1, d)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", arg, err)
|
|
}
|
|
if out != "" {
|
|
if err := os.WriteFile(out, []byte(text), 0o644); err != nil {
|
|
return err
|
|
}
|
|
printObject(map[string]any{"source": arg, "sheet": sheet, "output": out, "bytes": len(text)})
|
|
continue
|
|
}
|
|
fmt.Print(text)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().IntVar(&sheet, "sheet", 1, "worksheet number (1-based, default first)")
|
|
cmd.Flags().StringVar(&delim, "delimiter", ",", "CSV delimiter (',', ';', '|', 'tab')")
|
|
cmd.Flags().StringVar(&out, "out", "", "write to this file instead of stdout (single input)")
|
|
return cmd
|
|
}
|
|
|
|
func docsJSONCmd() *cobra.Command {
|
|
var sheet int
|
|
var out string
|
|
cmd := &cobra.Command{
|
|
Use: "json SRC [SRC...]",
|
|
Short: "Export a worksheet (XLS/XLSX/ODS) to JSON rows (first row = header)",
|
|
Long: `SRC is an OnlyOffice file id or a local path. --sheet is 1-based (default 1 = first).
|
|
Each data row becomes an object keyed by the header cells of that sheet.`,
|
|
Args: cobra.MinimumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newOO(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, arg := range args {
|
|
data, err := loadWorkbookBytes(cmd, c, arg)
|
|
if err != nil {
|
|
return fmt.Errorf("read %s: %w", arg, err)
|
|
}
|
|
rows, err := onlyoffice.WorkbookSheetJSON(data, sheet-1)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", arg, err)
|
|
}
|
|
b, err := json.MarshalIndent(rows, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b = append(b, '\n')
|
|
if out != "" {
|
|
if err := os.WriteFile(out, b, 0o644); err != nil {
|
|
return err
|
|
}
|
|
printObject(map[string]any{"source": arg, "sheet": sheet, "rows": len(rows), "output": out})
|
|
continue
|
|
}
|
|
os.Stdout.Write(b)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().IntVar(&sheet, "sheet", 1, "worksheet number (1-based, default first)")
|
|
cmd.Flags().StringVar(&out, "out", "", "write to this file instead of stdout (single input)")
|
|
return cmd
|
|
}
|
|
|
|
// docsBaseURL resolves the DocumentServer base: --docs-url, $ONLYOFFICE_DOCS_URL,
|
|
// else the standard /ds-vpath reverse-proxy mount.
|
|
func docsBaseURL(flag string) string {
|
|
if flag != "" {
|
|
return flag
|
|
}
|
|
if v := os.Getenv("ONLYOFFICE_DOCS_URL"); v != "" {
|
|
return v
|
|
}
|
|
if v := firstEnv("ONLYOFFICE_URL", "ONLYOFFICE_HOST", "OO_URL"); v != "" {
|
|
return strings.TrimRight(v, "/") + "/ds-vpath"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func firstEnv(keys ...string) string {
|
|
for _, k := range keys {
|
|
if v := os.Getenv(k); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func strOrNil(s string) any {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
func docsConvertCmd() *cobra.Command {
|
|
var to string
|
|
cmd := &cobra.Command{
|
|
Use: "convert PATH",
|
|
Short: "Convert a local file with pandoc (md↔docx by default)",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
in := args[0]
|
|
t := docpipe.LookPath()
|
|
out := to
|
|
if out == "" {
|
|
switch docpipe.Ext(in) {
|
|
case ".md", ".markdown":
|
|
out = docpipe.SiblingDOCX(in)
|
|
case ".docx":
|
|
out = strings.TrimSuffix(in, docpipe.Ext(in)) + ".md"
|
|
default:
|
|
return fmt.Errorf("--to required for input type %s", docpipe.Ext(in))
|
|
}
|
|
}
|
|
if err := docpipe.EnsureDir(out); err != nil {
|
|
return err
|
|
}
|
|
if err := t.ConvertFile(in, out); err != nil {
|
|
return err
|
|
}
|
|
printObject(map[string]any{"in": in, "out": out})
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&to, "to", "", "output path (default: sibling .docx or .md)")
|
|
return cmd
|
|
}
|
|
|
|
func docsOptimizeCmd() *cobra.Command {
|
|
var out string
|
|
cmd := &cobra.Command{
|
|
Use: "optimize PDF_PATH",
|
|
Short: "Rewrite PDF via Ghostscript (PostScript pdfwrite) for OO-friendly size/text",
|
|
Long: `Use for InDesign/iText PDFs with a good text layer — avoids ocrmypdf invisible
|
|
text overlays that break OnlyOffice DocEditor. Skips OCR; rewrites via gs pdfwrite.`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
in := args[0]
|
|
t := docpipe.LookPath()
|
|
if out == "" {
|
|
base := strings.TrimSuffix(filepath.Base(in), filepath.Ext(in))
|
|
out = filepath.Join(filepath.Dir(in), base+".optimized.pdf")
|
|
}
|
|
if err := docpipe.EnsureDir(out); err != nil {
|
|
return err
|
|
}
|
|
chars, _ := t.PDFTextLayerChars(in)
|
|
if err := t.OptimizePDF(in, out); err != nil {
|
|
return err
|
|
}
|
|
outChars, _ := t.PDFTextLayerChars(out)
|
|
printObject(map[string]any{
|
|
"in": in,
|
|
"pdf": out,
|
|
"text_chars_in": chars,
|
|
"text_chars_out": outChars,
|
|
"note": "native text layer preserved; no OCR overlay",
|
|
})
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&out, "out", "", "output PDF (default: <name>.optimized.pdf)")
|
|
return cmd
|
|
}
|
|
|
|
func docsOCRCmd() *cobra.Command {
|
|
var out, mdOut, lang string
|
|
var force bool
|
|
var writeMD bool
|
|
cmd := &cobra.Command{
|
|
Use: "ocr PATH",
|
|
Short: "OCR image/PDF → searchable PDF (and optional Markdown)",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
in := args[0]
|
|
t := docpipe.LookPath()
|
|
if out == "" {
|
|
base := strings.TrimSuffix(filepath.Base(in), filepath.Ext(in))
|
|
out = filepath.Join(filepath.Dir(in), base+".ocr.pdf")
|
|
}
|
|
if err := docpipe.EnsureDir(out); err != nil {
|
|
return err
|
|
}
|
|
if err := t.OCRToPDF(in, out, force, lang); err != nil {
|
|
return err
|
|
}
|
|
res := map[string]any{"in": in, "pdf": out}
|
|
if writeMD || mdOut != "" {
|
|
if mdOut == "" {
|
|
mdOut = strings.TrimSuffix(out, filepath.Ext(out)) + ".md"
|
|
}
|
|
text, err := t.ExtractPDFText(out)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body := "# " + filepath.Base(in) + "\n\n" + strings.TrimSpace(text) + "\n"
|
|
if err := os.WriteFile(mdOut, []byte(body), 0o644); err != nil {
|
|
return err
|
|
}
|
|
res["md"] = mdOut
|
|
}
|
|
printObject(res)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&out, "out", "", "output searchable PDF (default: <name>.ocr.pdf)")
|
|
cmd.Flags().StringVar(&mdOut, "md", "", "write Markdown extraction to this path")
|
|
cmd.Flags().BoolVar(&writeMD, "markdown", false, "also write sibling .md next to OCR PDF")
|
|
cmd.Flags().StringVar(&lang, "lang", "eng", "OCR language(s) for tesseract/ocrmypdf")
|
|
cmd.Flags().BoolVar(&force, "force", false, "force OCR even if a text layer exists (default: skip when pdftotext finds enough text)")
|
|
return cmd
|
|
}
|
|
|
|
func docsHOCRCmd() *cobra.Command {
|
|
var mdOut, yamlOut, hocrOut, lang string
|
|
var dpi int
|
|
var minConf float32
|
|
cmd := &cobra.Command{
|
|
Use: "hocr PATH",
|
|
Short: "Tesseract hOCR → structured Markdown/YAML (via go-hocr)",
|
|
Long: `Runs tesseract with hOCR output, parses with go-hocr, writes Markdown
|
|
(and optional YAML). Better reading order / confidence than plain pdftotext.
|
|
|
|
For Spanish scans: --lang spa or spa+eng (needs tesseract-ocr-spa / TESSDATA_PREFIX).
|
|
Phone photos: --dpi 200..300.`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
in := args[0]
|
|
t := docpipe.LookPath()
|
|
dir, err := os.MkdirTemp("", "oo-docs-hocr-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(dir)
|
|
|
|
res, err := t.ToHOCRMarkdown(in, dir, lang, dpi, minConf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if mdOut == "" {
|
|
base := strings.TrimSuffix(filepath.Base(in), filepath.Ext(in))
|
|
mdOut = filepath.Join(filepath.Dir(in), base+".hocr.md")
|
|
}
|
|
if err := docpipe.EnsureDir(mdOut); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(mdOut, []byte(res.Markdown), 0o644); err != nil {
|
|
return err
|
|
}
|
|
obj := map[string]any{
|
|
"in": in,
|
|
"md": mdOut,
|
|
"did_ocr": res.DidOCR,
|
|
}
|
|
if hocrOut != "" {
|
|
if err := docpipe.EnsureDir(hocrOut); err != nil {
|
|
return err
|
|
}
|
|
b, err := os.ReadFile(res.HOCRPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(hocrOut, b, 0o644); err != nil {
|
|
return err
|
|
}
|
|
obj["hocr"] = hocrOut
|
|
} else {
|
|
obj["hocr_tmp"] = res.HOCRPath
|
|
}
|
|
if yamlOut != "" {
|
|
if err := docpipe.EnsureDir(yamlOut); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(yamlOut, []byte(res.YAML), 0o644); err != nil {
|
|
return err
|
|
}
|
|
obj["yaml"] = yamlOut
|
|
}
|
|
printObject(obj)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&mdOut, "md", "", "Markdown output (default: <name>.hocr.md)")
|
|
cmd.Flags().StringVar(&yamlOut, "yaml", "", "also write structured YAML from go-hocr")
|
|
cmd.Flags().StringVar(&hocrOut, "hocr", "", "also keep raw .hocr file at this path")
|
|
cmd.Flags().StringVar(&lang, "lang", "eng", "tesseract language(s), e.g. spa+eng")
|
|
cmd.Flags().IntVar(&dpi, "dpi", 220, "hint DPI for phone photos / scans (0 = tesseract default)")
|
|
cmd.Flags().Float32Var(&minConf, "min-conf", 0, "drop words with OCR confidence below this (0 = keep all)")
|
|
return cmd
|
|
}
|
|
|
|
func docsAsMDCmd() *cobra.Command {
|
|
var to, lang string
|
|
var minChars int
|
|
var uploadOCR bool
|
|
var useHOCR bool
|
|
var dpi int
|
|
var minConf float32
|
|
cmd := &cobra.Command{
|
|
Use: "as-md FILE_ID",
|
|
Short: "Download an OO Documents file and emit Markdown (OCR PDF/image if needed)",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newOO(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ctx := cmd.Context()
|
|
meta, err := c.GetFile(ctx, args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
title := onlyoffice.FileEntryTitle(meta)
|
|
dir, err := os.MkdirTemp("", "oo-docs-as-md-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(dir)
|
|
|
|
local := filepath.Join(dir, onlyoffice.SafeLocalFileName(title))
|
|
f, err := os.Create(local)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := c.DownloadFile(ctx, args[0], f); err != nil {
|
|
_ = f.Close()
|
|
return err
|
|
}
|
|
_ = f.Close()
|
|
|
|
tools := docpipe.LookPath()
|
|
var res docpipe.Result
|
|
if useHOCR {
|
|
hr, err := tools.ToHOCRMarkdown(local, dir, lang, dpi, minConf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res = docpipe.Result{Markdown: hr.Markdown, DidOCR: hr.DidOCR, Source: hr.Source}
|
|
} else {
|
|
res, err = tools.ToMarkdown(local, dir, lang, minChars)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
outPath := to
|
|
if outPath == "" {
|
|
base := strings.TrimSuffix(onlyoffice.SafeLocalFileName(title), filepath.Ext(onlyoffice.SafeLocalFileName(title)))
|
|
if base == "" || base == "download" {
|
|
base = "file-" + args[0]
|
|
}
|
|
outPath = base + ".md"
|
|
}
|
|
if err := docpipe.EnsureDir(outPath); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(outPath, []byte(res.Markdown), 0o644); err != nil {
|
|
return err
|
|
}
|
|
|
|
obj := map[string]any{
|
|
"file_id": args[0],
|
|
"title": title,
|
|
"md": outPath,
|
|
"did_ocr": res.DidOCR,
|
|
}
|
|
if res.OCRPDFPath != "" && uploadOCR {
|
|
folderID := onlyoffice.FileFolderID(meta)
|
|
upName := strings.TrimSuffix(onlyoffice.SafeLocalFileName(title), filepath.Ext(onlyoffice.SafeLocalFileName(title))) + ".ocr.pdf"
|
|
tmpUp := filepath.Join(dir, upName)
|
|
data, err := os.ReadFile(res.OCRPDFPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(tmpUp, data, 0o644); err != nil {
|
|
return err
|
|
}
|
|
if folderID == "" {
|
|
obj["ocr_pdf_local"] = res.OCRPDFPath
|
|
obj["note"] = "file has no folderId; OCR PDF left local — pass after moving into a folder"
|
|
} else {
|
|
ent, err := c.UploadToFolder(ctx, folderID, tmpUp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
obj["ocr_pdf_file_id"] = fileIDStr(ent)
|
|
obj["ocr_pdf_title"] = onlyoffice.FileEntryTitle(ent)
|
|
}
|
|
} else if res.OCRPDFPath != "" {
|
|
// Keep OCR PDF outside temp by copying beside md if requested via env-less default:
|
|
kept := strings.TrimSuffix(outPath, filepath.Ext(outPath)) + ".ocr.pdf"
|
|
if b, err := os.ReadFile(res.OCRPDFPath); err == nil {
|
|
_ = os.WriteFile(kept, b, 0o644)
|
|
obj["ocr_pdf_local"] = kept
|
|
} else {
|
|
obj["ocr_pdf_local"] = res.OCRPDFPath
|
|
}
|
|
}
|
|
printObject(obj)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&to, "to", "", "write Markdown to this path (default: ./<title>.md)")
|
|
cmd.Flags().StringVar(&lang, "lang", "eng", "OCR language")
|
|
cmd.Flags().IntVar(&minChars, "min-chars", docpipe.DefaultMinTextChars, "OCR PDF if text layer shorter than this")
|
|
cmd.Flags().BoolVar(&uploadOCR, "upload-ocr", false, "upload searchable OCR PDF back into the same OO folder")
|
|
cmd.Flags().BoolVar(&useHOCR, "hocr", false, "use tesseract hOCR + go-hocr instead of ocrmypdf/pdftotext")
|
|
cmd.Flags().IntVar(&dpi, "dpi", 220, "DPI hint when --hocr (phone photos)")
|
|
cmd.Flags().Float32Var(&minConf, "min-conf", 0, "drop low-confidence words when --hocr")
|
|
return cmd
|
|
}
|
|
|
|
func docsPutMDCmd() *cobra.Command {
|
|
var folderID string
|
|
var keepLocalDOCX string
|
|
var replace bool
|
|
cmd := &cobra.Command{
|
|
Use: "put-md PROJECT_ID MARKDOWN_PATH",
|
|
Short: "Convert Markdown→DOCX and upload DOCX into a project (OO-friendly)",
|
|
Long: `Agents edit .md locally; this uploads .docx so OnlyOffice can open/version it.`,
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
pid, mdPath := args[0], args[1]
|
|
c, err := newOO(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tools := docpipe.LookPath()
|
|
dir, err := os.MkdirTemp("", "oo-docs-put-md-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(dir)
|
|
docxName := strings.TrimSuffix(filepath.Base(mdPath), filepath.Ext(mdPath)) + ".docx"
|
|
docxPath := filepath.Join(dir, docxName)
|
|
if err := tools.MDToDOCX(mdPath, docxPath); err != nil {
|
|
return err
|
|
}
|
|
if keepLocalDOCX != "" {
|
|
if err := docpipe.EnsureDir(keepLocalDOCX); err != nil {
|
|
return err
|
|
}
|
|
b, err := os.ReadFile(docxPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(keepLocalDOCX, b, 0o644); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
ctx := cmd.Context()
|
|
ent, deleted, err := uploadProjectDoc(ctx, c, pid, docxPath, folderID, replace)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
obj := map[string]any{
|
|
"project_id": pid,
|
|
"md": mdPath,
|
|
"uploaded": fileEntryToMap(ent),
|
|
}
|
|
if folderID != "" {
|
|
obj["folder_id"] = folderID
|
|
}
|
|
if len(deleted) > 0 {
|
|
obj["replaced_file_ids"] = deleted
|
|
}
|
|
printObject(obj)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)")
|
|
cmd.Flags().StringVar(&keepLocalDOCX, "keep-docx", "", "also write the generated DOCX to this local path")
|
|
cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext before upload (default); false = fail if name taken")
|
|
return cmd
|
|
}
|
|
|
|
func docsPutTxtCmd() *cobra.Command {
|
|
var folderID string
|
|
var keepLocalDOCX string
|
|
var replace bool
|
|
cmd := &cobra.Command{
|
|
Use: "put-txt PROJECT_ID TEXT_PATH",
|
|
Short: "Convert plain text→DOCX (preserve line breaks) and upload into a project",
|
|
Long: `OnlyOffice cannot render .txt well. This keeps each source line on its own DOCX line.`,
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
pid, txtPath := args[0], args[1]
|
|
c, err := newOO(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tools := docpipe.LookPath()
|
|
dir, err := os.MkdirTemp("", "oo-docs-put-txt-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(dir)
|
|
docxName := strings.TrimSuffix(filepath.Base(txtPath), filepath.Ext(txtPath)) + ".docx"
|
|
docxPath := filepath.Join(dir, docxName)
|
|
if err := tools.TXTToDOCX(txtPath, docxPath); err != nil {
|
|
return err
|
|
}
|
|
if keepLocalDOCX != "" {
|
|
if err := docpipe.EnsureDir(keepLocalDOCX); err != nil {
|
|
return err
|
|
}
|
|
b, err := os.ReadFile(docxPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(keepLocalDOCX, b, 0o644); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
ctx := cmd.Context()
|
|
ent, deleted, err := uploadProjectDoc(ctx, c, pid, docxPath, folderID, replace)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
obj := map[string]any{
|
|
"project_id": pid,
|
|
"txt": txtPath,
|
|
"uploaded": fileEntryToMap(ent),
|
|
}
|
|
if folderID != "" {
|
|
obj["folder_id"] = folderID
|
|
}
|
|
if len(deleted) > 0 {
|
|
obj["replaced_file_ids"] = deleted
|
|
}
|
|
printObject(obj)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)")
|
|
cmd.Flags().StringVar(&keepLocalDOCX, "keep-docx", "", "also write the generated DOCX to this local path")
|
|
cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext before upload (default); false = fail if name taken")
|
|
return cmd
|
|
}
|
|
|
|
func docsPutXlsxCmd() *cobra.Command {
|
|
var folderID, template, title, keepLocal string
|
|
var replace bool
|
|
cmd := &cobra.Command{
|
|
Use: "put-xlsx PROJECT_ID [LOCAL_XLSX]",
|
|
Short: "Upload or generate an XLSX workbook into a project (excelize templates with formulas)",
|
|
Long: `Spreadsheets live in OnlyOffice — not in git. Generate multi-sheet workbooks with
|
|
formulas (SUM/AVG, cross-sheet refs, named inputs) via --template, or upload an existing .xlsx.
|
|
|
|
oo docs put-xlsx 218 --template cutover-portugal
|
|
oo docs put-xlsx 218 --template cutover-portugal --title 2026-08-28-cutover-budget.xlsx
|
|
oo docs put-xlsx 218 ./my.xlsx`,
|
|
Args: cobra.RangeArgs(1, 2),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
pid := args[0]
|
|
c, err := newOO(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dir, err := os.MkdirTemp("", "oo-docs-put-xlsx-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(dir)
|
|
|
|
var xlsxPath string
|
|
var srcLabel string
|
|
switch {
|
|
case template != "":
|
|
wb, err := xlspipe.BuildTemplate(template)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
name := title
|
|
if name == "" {
|
|
name = "cutover-budget.xlsx"
|
|
if template == xlspipe.TemplateCutoverPortugal {
|
|
name = "2026-08-28-cutover-budget.xlsx"
|
|
}
|
|
}
|
|
if !strings.HasSuffix(strings.ToLower(name), ".xlsx") {
|
|
name += ".xlsx"
|
|
}
|
|
xlsxPath = filepath.Join(dir, name)
|
|
if err := xlspipe.Save(wb, xlsxPath); err != nil {
|
|
wb.Close()
|
|
return err
|
|
}
|
|
wb.Close()
|
|
srcLabel = "template:" + template
|
|
case len(args) == 2:
|
|
xlsxPath = args[1]
|
|
if docpipe.Ext(xlsxPath) != ".xlsx" {
|
|
return fmt.Errorf("expected .xlsx, got %s", docpipe.Ext(xlsxPath))
|
|
}
|
|
srcLabel = xlsxPath
|
|
default:
|
|
return fmt.Errorf("pass LOCAL_XLSX or --template")
|
|
}
|
|
|
|
if keepLocal != "" {
|
|
b, err := os.ReadFile(xlsxPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := docpipe.EnsureDir(keepLocal); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(keepLocal, b, 0o644); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
ctx := cmd.Context()
|
|
ent, deleted, err := uploadProjectDoc(ctx, c, pid, xlsxPath, folderID, replace)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
obj := map[string]any{
|
|
"project_id": pid,
|
|
"source": srcLabel,
|
|
"uploaded": fileEntryToMap(ent),
|
|
}
|
|
if folderID != "" {
|
|
obj["folder_id"] = folderID
|
|
}
|
|
if len(deleted) > 0 {
|
|
obj["replaced_file_ids"] = deleted
|
|
}
|
|
printObject(obj)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)")
|
|
cmd.Flags().StringVar(&template, "template", "", "built-in workbook template (cutover-portugal)")
|
|
cmd.Flags().StringVar(&title, "title", "", "upload file name when using --template")
|
|
cmd.Flags().StringVar(&keepLocal, "keep-xlsx", "", "also write generated/uploaded bytes to this local path")
|
|
cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext before upload (default); false = fail if name taken")
|
|
return cmd
|
|
}
|
|
|
|
// uploadProjectDoc upserts (--replace, default) or no-clobbers into project/folder Documents.
|
|
func uploadProjectDoc(ctx context.Context, c *onlyoffice.Client, pid, localPath, folderID string, replace bool) (*onlyoffice.FileEntry, []int, error) {
|
|
if folderID != "" {
|
|
if replace {
|
|
return c.UploadToFolderReplacing(ctx, folderID, localPath)
|
|
}
|
|
if err := c.AssertNoFileConflict(ctx, folderID, localPath); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
ent, err := c.UploadToFolder(ctx, folderID, localPath)
|
|
return ent, nil, err
|
|
}
|
|
if replace {
|
|
return c.UploadProjectFileReplacing(ctx, pid, localPath)
|
|
}
|
|
ent, err := c.UploadProjectFileNoClobber(ctx, pid, localPath)
|
|
return ent, nil, err
|
|
}
|