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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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.
|
- 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.
|
- 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 <subject> <verb>`), never `oo <verb>-<subject>`. Add new commands to the existing subject file if one fits; create a new `cmd/oo/<subject>.go` for a genuinely new domain.
|
- CLI commands follow **subject → verb** structure (`oo <subject> <verb>`), never `oo <verb>-<subject>`. Add new commands to the existing subject file if one fits; create a new `cmd/oo/<subject>.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.
|
- 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.
|
- 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`.
|
- Follow SemVer on tags; this repo is tagged at GitHub under `git@github.com:eSlider/go-onlyoffice.git`.
|
||||||
|
|||||||
+83
-1
@@ -21,8 +21,9 @@ func docsCmd() *cobra.Command {
|
|||||||
Short: "Local document pipeline: md↔docx, OCR→PDF, extract Markdown",
|
Short: "Local document pipeline: md↔docx, OCR→PDF, extract Markdown",
|
||||||
Long: `Agent-friendly conversions (requires pandoc / ocrmypdf / pdftotext on PATH).
|
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 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
|
Read an OO file as MD: oo docs as-md FILE_ID
|
||||||
OCR a scan locally: oo docs ocr scan.pdf --md out.md
|
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`,
|
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(docsHOCRCmd())
|
||||||
cmd.AddCommand(docsAsMDCmd())
|
cmd.AddCommand(docsAsMDCmd())
|
||||||
cmd.AddCommand(docsPutMDCmd())
|
cmd.AddCommand(docsPutMDCmd())
|
||||||
|
cmd.AddCommand(docsPutTxtCmd())
|
||||||
cmd.AddCommand(docsToolsCmd())
|
cmd.AddCommand(docsToolsCmd())
|
||||||
return cmd
|
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)")
|
cmd.Flags().BoolVar(&replace, "replace", true, "delete same-stem files in folder before upload (put-md upsert)")
|
||||||
return cmd
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ func projectFilesCmd() *cobra.Command {
|
|||||||
// Convenience aliases into oo docs (md↔docx / OCR pipeline).
|
// Convenience aliases into oo docs (md↔docx / OCR pipeline).
|
||||||
cmd.AddCommand(aliasDocsAsMD())
|
cmd.AddCommand(aliasDocsAsMD())
|
||||||
cmd.AddCommand(aliasDocsPutMD())
|
cmd.AddCommand(aliasDocsPutMD())
|
||||||
|
cmd.AddCommand(aliasDocsPutTxt())
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +46,13 @@ func aliasDocsPutMD() *cobra.Command {
|
|||||||
return c
|
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 {
|
func prjFilesListCmd() *cobra.Command {
|
||||||
var showFolders bool
|
var showFolders bool
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|||||||
@@ -76,6 +76,48 @@ func (t Tools) ConvertFile(inPath, outPath string) error {
|
|||||||
return nil
|
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.
|
// MDToDOCX writes a DOCX next to or at outPath from a Markdown file.
|
||||||
func (t Tools) MDToDOCX(mdPath, docxPath string) error {
|
func (t Tools) MDToDOCX(mdPath, docxPath string) error {
|
||||||
if Ext(mdPath) != ".md" && Ext(mdPath) != ".markdown" {
|
if Ext(mdPath) != ".md" && Ext(mdPath) != ".markdown" {
|
||||||
|
|||||||
@@ -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) {
|
func TestMDDocxRoundTrip(t *testing.T) {
|
||||||
tools := LookPath()
|
tools := LookPath()
|
||||||
if tools.Pandoc == "" {
|
if tools.Pandoc == "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user