From 309ae44ee4cc2ab8441cfce02a387d726cf35bf6 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Fri, 28 Aug 2026 09:57:55 +0100 Subject: [PATCH] feat(docs): put-txt preserves line breaks in DOCX TxtToMarkdown uses markdown hard breaks so pandoc keeps each source line on its own row. Adds oo docs put-txt and projects files alias. Co-authored-by: Cursor --- AGENTS.md | 2 +- cmd/oo/docs.go | 84 +++++++++++++++++++++++++++++++- cmd/oo/projects_files.go | 8 +++ internal/docpipe/docpipe.go | 42 ++++++++++++++++ internal/docpipe/docpipe_test.go | 45 +++++++++++++++++ 5 files changed, 179 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 83cc7e5..a9b9e31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the - Prefer `ResponseObject` / `postFormObject` / `putFormObject` / `deleteObject` over hand-rolled `json.Unmarshal(responseField(...))` blocks — they exist for DRY, use them. - Domain split is by file, **not** by subpackage. Don't introduce `internal/` or `pkg/*` subpackages inside the library — it flattens the `*Client` call surface for a reason. - CLI commands follow **subject → verb** structure (`oo `), never `oo -`. Add new commands to the existing subject file if one fits; create a new `cmd/oo/.go` for a genuinely new domain. -- **Documents for agents:** prefer Markdown in git; OnlyOffice UI is weak for `.md`. Use `oo docs put-md` (md→docx upload, `--replace` default upserts by stem|ext) and `oo docs as-md` (download→OCR if needed→markdown). `oo projects files dedupe PROJECT_ID` reports/removes duplicate stem|ext copies (`--apply`, `--cross`). Local converters live in `internal/docpipe` (pandoc / ocrmypdf / pdftotext / tesseract+go-hocr). For layout-aware extracts: `oo docs hocr` (or `as-md --hocr`). OO UI may show `title`+`fileExst` as double extension (e.g. `.docx.docx`) — cosmetic server quirk. +- **Documents for agents:** prefer Markdown in git; OnlyOffice UI is weak for `.md`/`.txt`. Use `oo docs put-md` (md→docx) and `oo docs put-txt` (txt→docx, preserves line breaks). `oo projects files dedupe PROJECT_ID` reports/removes duplicate stem|ext copies (`--apply`, `--cross`). - Every table output goes through `printTable(headers, rows)`; every single-object through `printObject(v)`. Do not `fmt.Println` rows ad-hoc or the `--output json` flag breaks for that command. - No secrets in the repo; use `.env` (gitignored). Commit `.env.example` only. - Follow SemVer on tags; this repo is tagged at GitHub under `git@github.com:eSlider/go-onlyoffice.git`. diff --git a/cmd/oo/docs.go b/cmd/oo/docs.go index 78d8b1c..f78ca39 100644 --- a/cmd/oo/docs.go +++ b/cmd/oo/docs.go @@ -21,8 +21,9 @@ func docsCmd() *cobra.Command { 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 — keep Markdown in git, store .docx in OO. +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) 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`, @@ -32,6 +33,7 @@ Structured OCR (hOCR→MD): oo docs hocr scan.jpg --md out.md --yaml out.yml`, cmd.AddCommand(docsHOCRCmd()) cmd.AddCommand(docsAsMDCmd()) cmd.AddCommand(docsPutMDCmd()) + cmd.AddCommand(docsPutTxtCmd()) cmd.AddCommand(docsToolsCmd()) return cmd } @@ -419,3 +421,83 @@ func docsPutMDCmd() *cobra.Command { cmd.Flags().BoolVar(&replace, "replace", true, "delete same-stem files in folder before upload (put-md upsert)") 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() + var ent *onlyoffice.FileEntry + var deleted []int + if folderID != "" { + if replace { + ent, deleted, err = c.UploadToFolderReplacing(ctx, folderID, docxPath) + } else { + ent, err = c.UploadToFolder(ctx, folderID, docxPath) + } + if err != nil { + return err + } + obj := map[string]any{ + "project_id": pid, + "txt": txtPath, + "folder_id": folderID, + "uploaded": fileEntryToMap(ent), + } + if len(deleted) > 0 { + obj["replaced_file_ids"] = deleted + } + printObject(obj) + return nil + } + ent, err = c.UploadProjectFile(ctx, pid, docxPath) + if err != nil { + return err + } + printObject(map[string]any{ + "project_id": pid, + "txt": txtPath, + "uploaded": fileEntryToMap(ent), + }) + 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, "delete same-stem files in folder before upload (put-txt upsert)") + return cmd +} diff --git a/cmd/oo/projects_files.go b/cmd/oo/projects_files.go index dad12db..346cb6c 100644 --- a/cmd/oo/projects_files.go +++ b/cmd/oo/projects_files.go @@ -28,6 +28,7 @@ func projectFilesCmd() *cobra.Command { // Convenience aliases into oo docs (md↔docx / OCR pipeline). cmd.AddCommand(aliasDocsAsMD()) cmd.AddCommand(aliasDocsPutMD()) + cmd.AddCommand(aliasDocsPutTxt()) return cmd } @@ -45,6 +46,13 @@ func aliasDocsPutMD() *cobra.Command { return c } +func aliasDocsPutTxt() *cobra.Command { + c := docsPutTxtCmd() + c.Use = "put-txt PROJECT_ID TEXT_PATH" + c.Short = "Alias of `oo docs put-txt` — plain text→DOCX upload into project" + return c +} + func prjFilesListCmd() *cobra.Command { var showFolders bool cmd := &cobra.Command{ diff --git a/internal/docpipe/docpipe.go b/internal/docpipe/docpipe.go index 16d1211..d65135f 100644 --- a/internal/docpipe/docpipe.go +++ b/internal/docpipe/docpipe.go @@ -76,6 +76,48 @@ func (t Tools) ConvertFile(inPath, outPath string) error { return nil } +// TXTToDOCX converts plain text to DOCX preserving line breaks (via markdown hard breaks). +func (t Tools) TXTToDOCX(txtPath, docxPath string) error { + if Ext(txtPath) != ".txt" { + return fmt.Errorf("expected .txt input, got %q", txtPath) + } + if docxPath == "" { + docxPath = strings.TrimSuffix(txtPath, Ext(txtPath)) + ".docx" + } + b, err := os.ReadFile(txtPath) + if err != nil { + return err + } + dir := filepath.Dir(docxPath) + if dir == "" || dir == "." { + dir = os.TempDir() + } + tmpMD := filepath.Join(dir, trimExt(filepath.Base(txtPath))+".txt2docx.md") + md := TxtToMarkdown(string(b)) + if err := os.WriteFile(tmpMD, []byte(md), 0o644); err != nil { + return err + } + defer os.Remove(tmpMD) + return t.MDToDOCX(tmpMD, docxPath) +} + +// TxtToMarkdown converts plain text to Markdown that preserves line breaks in DOCX output. +// Single newlines become hard breaks; blank lines stay paragraph separators. +func TxtToMarkdown(content string) string { + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\r", "\n") + var b strings.Builder + for _, line := range strings.Split(content, "\n") { + if strings.TrimSpace(line) == "" { + b.WriteByte('\n') + continue + } + b.WriteString(line) + b.WriteString(" \n") + } + return b.String() +} + // MDToDOCX writes a DOCX next to or at outPath from a Markdown file. func (t Tools) MDToDOCX(mdPath, docxPath string) error { if Ext(mdPath) != ".md" && Ext(mdPath) != ".markdown" { diff --git a/internal/docpipe/docpipe_test.go b/internal/docpipe/docpipe_test.go index fdbfc55..1d9726c 100644 --- a/internal/docpipe/docpipe_test.go +++ b/internal/docpipe/docpipe_test.go @@ -34,6 +34,51 @@ func TestNeedsOCR_Image(t *testing.T) { } } +func TestTxtToMarkdownPreservesLines(t *testing.T) { + in := "line1\nline2\n\nline4" + md := TxtToMarkdown(in) + if !strings.Contains(md, "line1 \n") || !strings.Contains(md, "line2 \n") { + t.Fatalf("hard breaks missing: %q", md) + } + if !strings.Contains(md, "line4 \n") { + t.Fatalf("last line: %q", md) + } +} + +func TestTXTToDOCXPreservesLines(t *testing.T) { + tools := LookPath() + if tools.Pandoc == "" { + t.Skip("pandoc not installed") + } + dir := t.TempDir() + txt := filepath.Join(dir, "sample.txt") + docx := filepath.Join(dir, "sample.docx") + body := "MyBox Auto — resumen\nNº contrato: 123\n\nEstado: Vigente\n" + if err := os.WriteFile(txt, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + if err := tools.TXTToDOCX(txt, docx); err != nil { + t.Fatal(err) + } + mdOut := filepath.Join(dir, "out.md") + if err := tools.DOCXToMD(docx, mdOut); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(mdOut) + if err != nil { + t.Fatal(err) + } + s := string(got) + for _, want := range []string{"MyBox Auto", "Nº contrato", "Estado: Vigente"} { + if !strings.Contains(s, want) { + t.Fatalf("missing %q in %q", want, s) + } + } + if strings.Contains(s, "MyBox Auto — resumen Nº") { + t.Fatalf("lines collapsed: %q", s) + } +} + func TestMDDocxRoundTrip(t *testing.T) { tools := LookPath() if tools.Pandoc == "" {