Merge pull request #40 from eSlider/feat/put-txt-line-breaks
feat(docs): put-txt with preserved line breaks
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.
|
||||
- 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.
|
||||
- **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`.
|
||||
|
||||
+83
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user