Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c576bfe2ee | ||
|
|
7f56332285 | ||
|
|
a5807b9c31 | ||
|
|
d650a16a36 | ||
|
|
3da11586f9 | ||
|
|
e9c969a89c | ||
|
|
4d91726179 | ||
|
|
4d8af7a2fe | ||
|
|
34745e349e | ||
|
|
dfea57a57b | ||
|
|
8595c17f25 | ||
|
|
61da2fb88b | ||
|
|
24ca144b22 | ||
|
|
93828ee19d | ||
|
|
35f0cb8d20 | ||
|
|
ca5c85a4e2 | ||
|
|
220c7e8265 | ||
|
|
ff4c0481ba | ||
|
|
68445b0dfd |
@@ -0,0 +1,6 @@
|
|||||||
|
github: eSlider
|
||||||
|
ko_fi: eslider
|
||||||
|
liberapay: eslider
|
||||||
|
patreon: eslider
|
||||||
|
custom:
|
||||||
|
- https://polar.sh/eslider
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.15.0"
|
".": "0.17.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`/`.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`).
|
- **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). All upload paths default to **upsert** by `stem|ext` (`--replace`, default true); `--no-replace` fails on conflict; `--allow-duplicate` opts into raw OO append. `oo projects files dedupe PROJECT_ID` reports/removes duplicate stem|ext copies (`--apply`, `--cross`; includes project root folder).
|
||||||
- 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`.
|
||||||
|
|||||||
@@ -6,6 +6,26 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## [0.17.0](https://github.com/eSlider/go-onlyoffice/compare/v0.16.0...v0.17.0) (2026-08-31)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **mailsync:** FetchMailFolder — integration-layer walk for ETL consumers ([35f0cb8](https://github.com/eSlider/go-onlyoffice/commit/35f0cb8d20076244141065c07e203e633bc3612a))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **files:** upsert uploads by default and dedupe project root ([24ca144](https://github.com/eSlider/go-onlyoffice/commit/24ca144b22abc5d056a5fd1ed9a1887f26a79d15))
|
||||||
|
|
||||||
|
## [0.16.0](https://github.com/eSlider/go-onlyoffice/compare/v0.15.0...v0.16.0) (2026-08-30)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **docs:** `put-xlsx` — multi-sheet бюджеты с named inputs, SUM/AVG/MIN
|
||||||
|
формулами, cross-sheet ссылками и cell comments (`internal/xlspipe`,
|
||||||
|
excelize) ([68445b0](https://github.com/eSlider/go-onlyoffice/commit/68445b0))
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
* **docs:** CRM association graph and OO quirks (`docs/crm-associations.md`)
|
* **docs:** CRM association graph and OO quirks (`docs/crm-associations.md`)
|
||||||
|
|||||||
+291
@@ -0,0 +1,291 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(davCmd())
|
||||||
|
}
|
||||||
|
|
||||||
|
// davCmd exposes the Documents module through the same Dav calls that back
|
||||||
|
// oo-webdav (ListDavFolder / MoveDavItems / CopyDavItems / DownloadDavFile).
|
||||||
|
// MoveDavItems sends resolveType=Skip + holdResult=true, which the legacy
|
||||||
|
// fileops/move call without those params silently ignores (200 without move).
|
||||||
|
func davCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "dav",
|
||||||
|
Short: "Documents module by folder/file id (oo-webdav proven path)",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(davLsCmd())
|
||||||
|
cmd.AddCommand(davMoveCmd())
|
||||||
|
cmd.AddCommand(davCopyCmd())
|
||||||
|
cmd.AddCommand(davMkdirCmd())
|
||||||
|
cmd.AddCommand(davRenameFileCmd())
|
||||||
|
cmd.AddCommand(davRenameFolderCmd())
|
||||||
|
cmd.AddCommand(davDownloadCmd())
|
||||||
|
cmd.AddCommand(davFileOpsCmd())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func davLsCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "ls FOLDER_ID",
|
||||||
|
Short: "List a Documents folder (@root for virtual sections)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx := cmd.Context()
|
||||||
|
if args[0] == "@root" {
|
||||||
|
sections, err := c.ListDavSections(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([]map[string]any, 0, len(sections))
|
||||||
|
for _, s := range sections {
|
||||||
|
rows = append(rows, map[string]any{
|
||||||
|
"id": s.ID,
|
||||||
|
"title": s.Title,
|
||||||
|
"filesCount": s.FilesCount,
|
||||||
|
"foldersCount": s.FoldersCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printTable([]string{"id", "title", "filesCount", "foldersCount"}, rows)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
l, err := c.ListDavFolder(ctx, args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if outputFormat == "json" {
|
||||||
|
folders := make([]map[string]any, 0, len(l.Folders))
|
||||||
|
for _, f := range l.Folders {
|
||||||
|
folders = append(folders, map[string]any{
|
||||||
|
"id": f.ID,
|
||||||
|
"title": f.Title,
|
||||||
|
"filesCount": f.FilesCount,
|
||||||
|
"foldersCount": f.FoldersCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
files := make([]map[string]any, 0, len(l.Files))
|
||||||
|
for _, f := range l.Files {
|
||||||
|
files = append(files, map[string]any{
|
||||||
|
"id": f.ID,
|
||||||
|
"title": f.Title,
|
||||||
|
"size": f.Size,
|
||||||
|
"updated": f.Updated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printObject(map[string]any{"folders": folders, "files": files})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(l.Folders) > 0 {
|
||||||
|
frows := make([]map[string]any, 0, len(l.Folders))
|
||||||
|
for _, f := range l.Folders {
|
||||||
|
frows = append(frows, map[string]any{
|
||||||
|
"id": f.ID,
|
||||||
|
"title": f.Title,
|
||||||
|
"filesCount": f.FilesCount,
|
||||||
|
"foldersCount": f.FoldersCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if outputFormat == "table" {
|
||||||
|
fmt.Println("folders:")
|
||||||
|
}
|
||||||
|
printTable([]string{"id", "title", "filesCount", "foldersCount"}, frows)
|
||||||
|
}
|
||||||
|
rows := make([]map[string]any, 0, len(l.Files))
|
||||||
|
for _, f := range l.Files {
|
||||||
|
rows = append(rows, map[string]any{
|
||||||
|
"id": f.ID,
|
||||||
|
"title": f.Title,
|
||||||
|
"size": f.Size,
|
||||||
|
"updated": f.Updated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if outputFormat == "table" {
|
||||||
|
fmt.Println("files:")
|
||||||
|
}
|
||||||
|
printTable([]string{"id", "title", "size", "updated"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func davMoveCmd() *cobra.Command {
|
||||||
|
var folderIDs []string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "move DEST_FOLDER_ID FILE_ID [FILE_ID...]",
|
||||||
|
Short: "Move file(s) into a Documents folder (resolveType=Skip, holdResult)",
|
||||||
|
Args: cobra.MinimumNArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.MoveDavItems(cmd.Context(), folderIDs, args[1:], args[0]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(map[string]any{"moved_files": args[1:], "moved_folders": folderIDs, "dest": args[0]})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringSliceVar(&folderIDs, "folders", nil, "folder ids to move along with the files")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func davCopyCmd() *cobra.Command {
|
||||||
|
var folderIDs []string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "copy DEST_FOLDER_ID FILE_ID [FILE_ID...]",
|
||||||
|
Short: "Copy file(s) into a Documents folder (conflictResolveType=Skip)",
|
||||||
|
Args: cobra.MinimumNArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.CopyDavItems(cmd.Context(), folderIDs, args[1:], args[0]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(map[string]any{"copied_files": args[1:], "copied_folders": folderIDs, "dest": args[0]})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringSliceVar(&folderIDs, "folders", nil, "folder ids to copy along with the files")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func davMkdirCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "mkdir PARENT_FOLDER_ID TITLE",
|
||||||
|
Short: "Create a subfolder in Documents",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := c.CreateDavFolder(cmd.Context(), args[0], args[1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(map[string]any{"id": f.ID, "title": f.Title, "parent": args[0]})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func davRenameFileCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "rename-file FILE_ID NEW_TITLE",
|
||||||
|
Short: "Rename a Documents file (include extension in NEW_TITLE)",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.RenameDavFile(cmd.Context(), args[0], args[1]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(map[string]any{"id": args[0], "title": args[1]})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func davRenameFolderCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "rename-folder FOLDER_ID NEW_TITLE",
|
||||||
|
Short: "Rename a Documents folder",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.RenameDavFolder(cmd.Context(), args[0], args[1]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(map[string]any{"id": args[0], "title": args[1]})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func davDownloadCmd() *cobra.Command {
|
||||||
|
var to string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "download FILE_ID",
|
||||||
|
Short: "Download Documents file bytes (default path: ./<title>)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx := cmd.Context()
|
||||||
|
f, err := c.GetFile(ctx, args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := to
|
||||||
|
if path == "" {
|
||||||
|
path = onlyoffice.SafeLocalFileName(onlyoffice.FileEntryTitle(f))
|
||||||
|
}
|
||||||
|
out, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
n, err := c.DownloadDavFile(ctx, args[0], out)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(map[string]any{"path": path, "bytes": n})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&to, "to", "", "output path (default: ./<server title>)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func davFileOpsCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "fileops",
|
||||||
|
Short: "List active file operations (move/copy status polling)",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ops, err := c.ListFileOps(cmd.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([]map[string]any, 0, len(ops))
|
||||||
|
for _, op := range ops {
|
||||||
|
rows = append(rows, map[string]any{
|
||||||
|
"id": fmt.Sprint(op["id"]),
|
||||||
|
"operation": fmt.Sprint(op["operation"]),
|
||||||
|
"progress": fmt.Sprint(op["progress"]),
|
||||||
|
"finished": fmt.Sprint(op["finished"]),
|
||||||
|
"error": fmt.Sprint(op["error"]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printTable([]string{"id", "operation", "progress", "finished", "error"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
+134
-42
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
|
|
||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
"github.com/eslider/go-onlyoffice/internal/docpipe"
|
"github.com/eslider/go-onlyoffice/internal/docpipe"
|
||||||
|
"github.com/eslider/go-onlyoffice/internal/xlspipe"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ func docsCmd() *cobra.Command {
|
|||||||
OnlyOffice Documents UI is poor for .md/.txt — keep sources 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)
|
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
|
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`,
|
||||||
@@ -35,6 +38,7 @@ Structured OCR (hOCR→MD): oo docs hocr scan.jpg --md out.md --yaml out.yml`,
|
|||||||
cmd.AddCommand(docsAsMDCmd())
|
cmd.AddCommand(docsAsMDCmd())
|
||||||
cmd.AddCommand(docsPutMDCmd())
|
cmd.AddCommand(docsPutMDCmd())
|
||||||
cmd.AddCommand(docsPutTxtCmd())
|
cmd.AddCommand(docsPutTxtCmd())
|
||||||
|
cmd.AddCommand(docsPutXlsxCmd())
|
||||||
cmd.AddCommand(docsToolsCmd())
|
cmd.AddCommand(docsToolsCmd())
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -420,44 +424,28 @@ func docsPutMDCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx := cmd.Context()
|
ctx := cmd.Context()
|
||||||
var ent *onlyoffice.FileEntry
|
ent, deleted, err := uploadProjectDoc(ctx, c, pid, docxPath, folderID, replace)
|
||||||
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
obj := map[string]any{
|
obj := map[string]any{
|
||||||
"project_id": pid,
|
"project_id": pid,
|
||||||
"folder_id": folderID,
|
|
||||||
"md": mdPath,
|
"md": mdPath,
|
||||||
"uploaded": fileEntryToMap(ent),
|
"uploaded": fileEntryToMap(ent),
|
||||||
}
|
}
|
||||||
|
if folderID != "" {
|
||||||
|
obj["folder_id"] = folderID
|
||||||
|
}
|
||||||
if len(deleted) > 0 {
|
if len(deleted) > 0 {
|
||||||
obj["replaced_file_ids"] = deleted
|
obj["replaced_file_ids"] = deleted
|
||||||
}
|
}
|
||||||
printObject(obj)
|
printObject(obj)
|
||||||
return nil
|
return nil
|
||||||
}
|
|
||||||
ent, err = c.UploadProjectFile(ctx, pid, docxPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
printObject(map[string]any{
|
|
||||||
"project_id": pid,
|
|
||||||
"md": mdPath,
|
|
||||||
"uploaded": fileEntryToMap(ent),
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)")
|
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().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-md upsert)")
|
cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext before upload (default); false = fail if name taken")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,43 +488,147 @@ func docsPutTxtCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx := cmd.Context()
|
ctx := cmd.Context()
|
||||||
var ent *onlyoffice.FileEntry
|
ent, deleted, err := uploadProjectDoc(ctx, c, pid, docxPath, folderID, replace)
|
||||||
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
obj := map[string]any{
|
obj := map[string]any{
|
||||||
"project_id": pid,
|
"project_id": pid,
|
||||||
"txt": txtPath,
|
"txt": txtPath,
|
||||||
"folder_id": folderID,
|
|
||||||
"uploaded": fileEntryToMap(ent),
|
"uploaded": fileEntryToMap(ent),
|
||||||
}
|
}
|
||||||
|
if folderID != "" {
|
||||||
|
obj["folder_id"] = folderID
|
||||||
|
}
|
||||||
if len(deleted) > 0 {
|
if len(deleted) > 0 {
|
||||||
obj["replaced_file_ids"] = deleted
|
obj["replaced_file_ids"] = deleted
|
||||||
}
|
}
|
||||||
printObject(obj)
|
printObject(obj)
|
||||||
return nil
|
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(&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().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)")
|
cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext before upload (default); false = fail if name taken")
|
||||||
return cmd
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
// oo mails accounts | folders | list | get | download-attachment | draft | attach | draft-invoice | delete
|
// oo mails accounts | folders | list | get | download-attachment | draft | attach | draft-invoice | delete
|
||||||
// oo invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items …
|
// oo invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items …
|
||||||
// oo docs tools | convert | ocr | as-md | put-md
|
// oo docs tools | convert | ocr | as-md | put-md
|
||||||
|
// oo dav ls | move | copy | mkdir | rename-file | rename-folder | download | fileops
|
||||||
//
|
//
|
||||||
// CRM association rules: docs/crm-associations.md
|
// CRM association rules: docs/crm-associations.md
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ func projectFilesCmd() *cobra.Command {
|
|||||||
cmd.AddCommand(aliasDocsAsMD())
|
cmd.AddCommand(aliasDocsAsMD())
|
||||||
cmd.AddCommand(aliasDocsPutMD())
|
cmd.AddCommand(aliasDocsPutMD())
|
||||||
cmd.AddCommand(aliasDocsPutTxt())
|
cmd.AddCommand(aliasDocsPutTxt())
|
||||||
|
cmd.AddCommand(aliasDocsPutXlsx())
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +54,13 @@ func aliasDocsPutTxt() *cobra.Command {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func aliasDocsPutXlsx() *cobra.Command {
|
||||||
|
c := docsPutXlsxCmd()
|
||||||
|
c.Use = "put-xlsx PROJECT_ID [LOCAL_XLSX]"
|
||||||
|
c.Short = "Alias of `oo docs put-xlsx` — generate/upload XLSX with formulas"
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
func prjFilesListCmd() *cobra.Command {
|
func prjFilesListCmd() *cobra.Command {
|
||||||
var showFolders bool
|
var showFolders bool
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
@@ -99,9 +107,12 @@ func prjFilesListCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func prjFilesUploadCmd() *cobra.Command {
|
func prjFilesUploadCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
var replace, allowDuplicate bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
Use: "upload PROJECT_ID LOCAL_PATH [LOCAL_PATH...]",
|
Use: "upload PROJECT_ID LOCAL_PATH [LOCAL_PATH...]",
|
||||||
Short: "Upload file(s) into the project's Documents folder",
|
Short: "Upload file(s) into the project's Documents folder (upsert by stem|ext)",
|
||||||
|
Long: `Default: replace an existing file with the same logical name (stem|ext), like cp overwrite.
|
||||||
|
Pass --no-replace to fail when the name is taken; --allow-duplicate to always create a new file id.`,
|
||||||
Args: cobra.MinimumNArgs(2),
|
Args: cobra.MinimumNArgs(2),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newOO(cmd)
|
c, err := newOO(cmd)
|
||||||
@@ -110,15 +121,31 @@ func prjFilesUploadCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
pid := args[0]
|
pid := args[0]
|
||||||
for _, p := range args[1:] {
|
for _, p := range args[1:] {
|
||||||
entry, err := c.UploadProjectFile(cmd.Context(), pid, p)
|
var entry *onlyoffice.FileEntry
|
||||||
|
var deleted []int
|
||||||
|
switch {
|
||||||
|
case allowDuplicate:
|
||||||
|
entry, err = c.UploadProjectFile(cmd.Context(), pid, p)
|
||||||
|
case replace:
|
||||||
|
entry, deleted, err = c.UploadProjectFileReplacing(cmd.Context(), pid, p)
|
||||||
|
default:
|
||||||
|
entry, err = c.UploadProjectFileNoClobber(cmd.Context(), pid, p)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
printObject(fileEntryToMap(entry))
|
obj := fileEntryToMap(entry)
|
||||||
|
if len(deleted) > 0 {
|
||||||
|
obj["replaced_file_ids"] = deleted
|
||||||
|
}
|
||||||
|
printObject(obj)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext in project folder (default)")
|
||||||
|
cmd.Flags().BoolVar(&allowDuplicate, "allow-duplicate", false, "always create a new file even when the name exists")
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func prjFilesDownloadCmd() *cobra.Command {
|
func prjFilesDownloadCmd() *cobra.Command {
|
||||||
|
|||||||
@@ -15,10 +15,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ListContacts returns a page of CRM contacts and the total count.
|
// ListContacts returns a page of CRM contacts and the total count.
|
||||||
|
// sortBy=id is always set: OnlyOffice filter.json without an explicit sort
|
||||||
|
// order is non-deterministic on large contact sets, so a paged walk
|
||||||
|
// (ListAllContacts, ListContactsByTag, FindCompany, FindPerson) can skip or
|
||||||
|
// duplicate contacts across page boundaries.
|
||||||
func (c *Client) ListContacts(ctx context.Context, count, startIndex int, search string) ([]map[string]any, int, error) {
|
func (c *Client) ListContacts(ctx context.Context, count, startIndex int, search string) ([]map[string]any, int, error) {
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("count", strconv.Itoa(count))
|
q.Set("count", strconv.Itoa(count))
|
||||||
q.Set("startIndex", strconv.Itoa(startIndex))
|
q.Set("startIndex", strconv.Itoa(startIndex))
|
||||||
|
q.Set("sortBy", "id")
|
||||||
if search != "" {
|
if search != "" {
|
||||||
q.Set("filterValue", search)
|
q.Set("filterValue", search)
|
||||||
}
|
}
|
||||||
@@ -243,6 +248,24 @@ func (c *Client) DeleteContact(ctx context.Context, contactID string) (map[strin
|
|||||||
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID)))
|
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateContactName renames a CRM company contact displayName.
|
||||||
|
// Uses the company endpoint (person names go through /crm/contact/person/{id}).
|
||||||
|
func (c *Client) UpdateContactName(ctx context.Context, contactID, newName string) (map[string]any, error) {
|
||||||
|
body := map[string]any{
|
||||||
|
"displayName": newName,
|
||||||
|
"companyName": newName,
|
||||||
|
"isCompany": true,
|
||||||
|
}
|
||||||
|
out, err := c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/company/%s.json", url.PathEscape(contactID)), body)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if fresh, gerr := c.GetContact(ctx, contactID); gerr == nil && fresh != nil {
|
||||||
|
out = fresh
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListContactTags returns all CRM contact tags (title + relativeItemsCount).
|
// ListContactTags returns all CRM contact tags (title + relativeItemsCount).
|
||||||
func (c *Client) ListContactTags(ctx context.Context) ([]map[string]any, error) {
|
func (c *Client) ListContactTags(ctx context.Context) ([]map[string]any, error) {
|
||||||
return c.ResponseArray(ctx, "/api/2.0/crm/contact/tag.json")
|
return c.ResponseArray(ctx, "/api/2.0/crm/contact/tag.json")
|
||||||
@@ -288,6 +311,7 @@ func (c *Client) ListContactsByTag(ctx context.Context, tagName string, count, s
|
|||||||
q.Set("count", strconv.Itoa(count))
|
q.Set("count", strconv.Itoa(count))
|
||||||
q.Set("startIndex", strconv.Itoa(startIndex))
|
q.Set("startIndex", strconv.Itoa(startIndex))
|
||||||
q.Set("tags", tagName)
|
q.Set("tags", tagName)
|
||||||
|
q.Set("sortBy", "id")
|
||||||
raw, err := c.getJSON(ctx, "/api/2.0/crm/contact/filter.json?"+q.Encode())
|
raw, err := c.getJSON(ctx, "/api/2.0/crm/contact/filter.json?"+q.Encode())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
@@ -717,6 +741,11 @@ func (c *Client) DeleteCRMTask(ctx context.Context, id string) (map[string]any,
|
|||||||
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)))
|
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CloseCRMTask closes (completes) a CRM task via the task close endpoint.
|
||||||
|
func (c *Client) CloseCRMTask(ctx context.Context, id string) (map[string]any, error) {
|
||||||
|
return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s/close.json", url.PathEscape(id)), url.Values{})
|
||||||
|
}
|
||||||
|
|
||||||
// ListTaskCategories returns CRM task categories.
|
// ListTaskCategories returns CRM task categories.
|
||||||
func (c *Client) ListTaskCategories(ctx context.Context) ([]map[string]any, error) {
|
func (c *Client) ListTaskCategories(ctx context.Context) ([]map[string]any, error) {
|
||||||
return c.ResponseArray(ctx, "/api/2.0/crm/task/category.json")
|
return c.ResponseArray(ctx, "/api/2.0/crm/task/category.json")
|
||||||
|
|||||||
@@ -237,6 +237,15 @@ func (c *Client) UploadProjectFile(ctx context.Context, projectID, localPath str
|
|||||||
return decodeResponseFileEntry(raw)
|
return decodeResponseFileEntry(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadProjectFileReplacing upserts by stem|ext in the project Documents folder.
|
||||||
|
func (c *Client) UploadProjectFileReplacing(ctx context.Context, projectID, localPath string) (*FileEntry, []int, error) {
|
||||||
|
folderID, err := c.projectFolderID(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.UploadToFolderReplacing(ctx, folderID, localPath)
|
||||||
|
}
|
||||||
|
|
||||||
// GetFile returns file metadata including viewUrl for download.
|
// GetFile returns file metadata including viewUrl for download.
|
||||||
func (c *Client) GetFile(ctx context.Context, fileID string) (*FileEntry, error) {
|
func (c *Client) GetFile(ctx context.Context, fileID string) (*FileEntry, error) {
|
||||||
if fileID == "" {
|
if fileID == "" {
|
||||||
@@ -312,11 +321,21 @@ func (c *Client) MoveFiles(ctx context.Context, destFolderID int, fileIDs []int)
|
|||||||
"folderIds": []int{},
|
"folderIds": []int{},
|
||||||
"fileIds": fileIDs,
|
"fileIds": fileIDs,
|
||||||
"destFolderId": destFolderID,
|
"destFolderId": destFolderID,
|
||||||
|
"resolveType": "Skip",
|
||||||
|
"holdResult": true,
|
||||||
}
|
}
|
||||||
out, err := c.putJSONObject(ctx, "/api/2.0/files/fileops/move.json", body)
|
out, err := c.putJSONObject(ctx, "/api/2.0/files/fileops/move.json", body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out, err = c.putJSONObject(ctx, "/api/2.0/files/fileops/move", body)
|
out, err = c.putJSONObject(ctx, "/api/2.0/files/fileops/move", body)
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if raw, merr := json.Marshal(out); merr == nil {
|
||||||
|
if ferr := fileopsError(raw); ferr != nil {
|
||||||
|
return nil, ferr
|
||||||
|
}
|
||||||
|
}
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +356,24 @@ func (c *Client) UploadToFolder(ctx context.Context, folderID, localPath string)
|
|||||||
return decodeResponseFileEntry(raw)
|
return decodeResponseFileEntry(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateFile uploads a new version of an existing file (same id, name and
|
||||||
|
// folder). It does not delete and does not create a second file.
|
||||||
|
func (c *Client) UpdateFile(ctx context.Context, fileID, localPath string) (*FileEntry, error) {
|
||||||
|
if fileID == "" || localPath == "" {
|
||||||
|
return nil, fmt.Errorf("file id and local path are required")
|
||||||
|
}
|
||||||
|
uploadPath := fmt.Sprintf("/api/2.0/files/%s/update", url.PathEscape(fileID))
|
||||||
|
raw, err := c.uploadMultipart(ctx, uploadPath, "file", localPath)
|
||||||
|
if err != nil {
|
||||||
|
uploadPath = fmt.Sprintf("/api/2.0/files/%s/update.json", url.PathEscape(fileID))
|
||||||
|
raw, err = c.uploadMultipart(ctx, uploadPath, "file", localPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return decodeResponseFileEntry(raw)
|
||||||
|
}
|
||||||
|
|
||||||
// FileFolderID returns the parent folder id string for a file entry, if known.
|
// FileFolderID returns the parent folder id string for a file entry, if known.
|
||||||
func FileFolderID(f *FileEntry) string {
|
func FileFolderID(f *FileEntry) string {
|
||||||
if f == nil || f.FolderID == nil {
|
if f == nil || f.FolderID == nil {
|
||||||
|
|||||||
+48
-2
@@ -2,6 +2,7 @@ package onlyoffice
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -310,25 +311,70 @@ func (c *Client) DeleteFilesByDedupKey(ctx context.Context, folderID, stem, ext
|
|||||||
return ids, nil
|
return ids, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeProjectRootForDedupe includes projectFolder files in dedupe scans. OO often lists
|
||||||
|
// root documents only in pf.Files while pf.Folders is empty.
|
||||||
|
func mergeProjectRootForDedupe(rootID string, folders []*FolderEntry, filesByFolder map[string][]*FileEntry, rootFiles []*FileEntry) ([]*FolderEntry, map[string][]*FileEntry) {
|
||||||
|
if rootID == "" {
|
||||||
|
return folders, filesByFolder
|
||||||
|
}
|
||||||
|
if filesByFolder == nil {
|
||||||
|
filesByFolder = map[string][]*FileEntry{}
|
||||||
|
}
|
||||||
|
for _, folder := range folders {
|
||||||
|
if folder != nil && folder.ID != nil && folder.ID.String() == rootID {
|
||||||
|
if len(rootFiles) > 0 {
|
||||||
|
filesByFolder[rootID] = rootFiles
|
||||||
|
}
|
||||||
|
return folders, filesByFolder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rootFiles) == 0 {
|
||||||
|
return folders, filesByFolder
|
||||||
|
}
|
||||||
|
id := json.Number(rootID)
|
||||||
|
title := "(project root)"
|
||||||
|
folders = append(folders, &FolderEntry{ID: &id, Title: &title})
|
||||||
|
filesByFolder[rootID] = rootFiles
|
||||||
|
return folders, filesByFolder
|
||||||
|
}
|
||||||
|
|
||||||
// DedupeProject scans project folders and optionally deletes duplicates.
|
// DedupeProject scans project folders and optionally deletes duplicates.
|
||||||
func (c *Client) DedupeProject(ctx context.Context, projectID string, opts DedupOptions, apply bool) ([]DedupGroup, []int, error) {
|
func (c *Client) DedupeProject(ctx context.Context, projectID string, opts DedupOptions, apply bool) ([]DedupGroup, []int, error) {
|
||||||
pf, err := c.GetProjectFiles(ctx, projectID)
|
pf, err := c.GetProjectFiles(ctx, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
filesByFolder := make(map[string][]*FileEntry, len(pf.Folders))
|
rootID, err := c.projectFolderID(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
var rootFiles []*FileEntry
|
||||||
|
if rootID != "" {
|
||||||
|
rootFiles, err = c.FolderFiles(ctx, rootID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filesByFolder := make(map[string][]*FileEntry, len(pf.Folders)+1)
|
||||||
|
folders := make([]*FolderEntry, 0, len(pf.Folders)+1)
|
||||||
for _, folder := range pf.Folders {
|
for _, folder := range pf.Folders {
|
||||||
if folder == nil || folder.ID == nil {
|
if folder == nil || folder.ID == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fid := folder.ID.String()
|
fid := folder.ID.String()
|
||||||
|
if fid == rootID {
|
||||||
|
filesByFolder[fid] = rootFiles
|
||||||
|
} else {
|
||||||
files, err := c.FolderFiles(ctx, fid)
|
files, err := c.FolderFiles(ctx, fid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
filesByFolder[fid] = files
|
filesByFolder[fid] = files
|
||||||
}
|
}
|
||||||
groups := FindProjectDuplicates(pf.Folders, filesByFolder, opts)
|
folders = append(folders, folder)
|
||||||
|
}
|
||||||
|
folders, filesByFolder = mergeProjectRootForDedupe(rootID, folders, filesByFolder, rootFiles)
|
||||||
|
groups := FindProjectDuplicates(folders, filesByFolder, opts)
|
||||||
if !apply || len(groups) == 0 {
|
if !apply || len(groups) == 0 {
|
||||||
return groups, nil, nil
|
return groups, nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,26 @@ func TestCrossFolderPrefersNonTrash(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeProjectRootForDedupe(t *testing.T) {
|
||||||
|
old := &FileEntry{ID: jsonNum("1"), Title: strPtr("a.docx"), FileExst: strPtr(".docx")}
|
||||||
|
newer := &FileEntry{ID: jsonNum("2"), Title: strPtr("a.docx"), FileExst: strPtr(".docx")}
|
||||||
|
rootFiles := []*FileEntry{old, newer}
|
||||||
|
folders, byFolder := mergeProjectRootForDedupe("489", nil, nil, rootFiles)
|
||||||
|
if len(folders) != 1 || folders[0].ID.String() != "489" {
|
||||||
|
t.Fatalf("folders=%+v", folders)
|
||||||
|
}
|
||||||
|
if len(byFolder["489"]) != 2 {
|
||||||
|
t.Fatalf("root files=%d", len(byFolder["489"]))
|
||||||
|
}
|
||||||
|
groups := findWithinFolderDuplicates([]ProjectFolderFile{
|
||||||
|
{FolderID: "489", FolderTitle: "(project root)", File: old},
|
||||||
|
{FolderID: "489", FolderTitle: "(project root)", File: newer},
|
||||||
|
})
|
||||||
|
if len(groups) != 1 {
|
||||||
|
t.Fatalf("groups=%d", len(groups))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsTrashFolderTitle(t *testing.T) {
|
func TestIsTrashFolderTitle(t *testing.T) {
|
||||||
if !IsTrashFolderTitle("_trash-md") {
|
if !IsTrashFolderTitle("_trash-md") {
|
||||||
t.Fatal("expected trash")
|
t.Fatal("expected trash")
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ package onlyoffice
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrFileExists is returned when --no-replace / no-clobber upload hits an existing stem|ext.
|
||||||
|
var ErrFileExists = errors.New("onlyoffice: file already exists in folder (use replace or delete first)")
|
||||||
|
|
||||||
// FileEntryStem returns the logical basename without duplicated extensions.
|
// FileEntryStem returns the logical basename without duplicated extensions.
|
||||||
// OO often stores title="foo.docx" and fileExst=".docx" (UI shows foo.docx.docx).
|
// OO often stores title="foo.docx" and fileExst=".docx" (UI shows foo.docx.docx).
|
||||||
func FileEntryStem(f *FileEntry) string {
|
func FileEntryStem(f *FileEntry) string {
|
||||||
@@ -116,6 +121,38 @@ func (c *Client) DeleteFilesByStem(ctx context.Context, folderID, stem string) (
|
|||||||
return ids, nil
|
return ids, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AssertNoFileConflict reports ErrFileExists when localPath stem|ext is already in folderID.
|
||||||
|
func (c *Client) AssertNoFileConflict(ctx context.Context, folderID, localPath string) error {
|
||||||
|
files, err := c.FolderFiles(ctx, folderID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stem := UploadStemFromLocal(localPath)
|
||||||
|
ext := UploadExtFromLocal(localPath)
|
||||||
|
matches := FindFilesByDedupKey(files, stem, ext)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(matches))
|
||||||
|
for _, f := range matches {
|
||||||
|
ids = append(ids, fmt.Sprintf("%d", FileEntryNumericID(f)))
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: %s%s in folder %s (existing file ids: %s)",
|
||||||
|
ErrFileExists, stem, ext, folderID, strings.Join(ids, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadProjectFileNoClobber uploads only when stem|ext is not already in the project folder.
|
||||||
|
func (c *Client) UploadProjectFileNoClobber(ctx context.Context, projectID, localPath string) (*FileEntry, error) {
|
||||||
|
folderID, err := c.projectFolderID(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.AssertNoFileConflict(ctx, folderID, localPath); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.UploadProjectFile(ctx, projectID, localPath)
|
||||||
|
}
|
||||||
|
|
||||||
// UploadToFolderReplacing deletes same stem+ext files then uploads localPath.
|
// UploadToFolderReplacing deletes same stem+ext files then uploads localPath.
|
||||||
func (c *Client) UploadToFolderReplacing(ctx context.Context, folderID, localPath string) (*FileEntry, []int, error) {
|
func (c *Client) UploadToFolderReplacing(ctx context.Context, folderID, localPath string) (*FileEntry, []int, error) {
|
||||||
stem := UploadStemFromLocal(localPath)
|
stem := UploadStemFromLocal(localPath)
|
||||||
|
|||||||
+60
-2
@@ -144,27 +144,85 @@ func (c *Client) RenameDavFile(ctx context.Context, id, title string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MoveDavItems moves the given folders and/or files into destFolderID.
|
// MoveDavItems moves the given folders and/or files into destFolderID.
|
||||||
|
// The fileops API answers 200 with per-operation error strings even when
|
||||||
|
// nothing moves (e.g. missing permission), so the response is parsed and the
|
||||||
|
// first operation error is returned instead of a silent nil.
|
||||||
func (c *Client) MoveDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error {
|
func (c *Client) MoveDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error {
|
||||||
_, err := c.putJSON(ctx, "/api/2.0/files/fileops/move", map[string]any{
|
raw, err := c.putJSON(ctx, "/api/2.0/files/fileops/move", map[string]any{
|
||||||
"folderIds": nums(folderIDs),
|
"folderIds": nums(folderIDs),
|
||||||
"fileIds": nums(fileIDs),
|
"fileIds": nums(fileIDs),
|
||||||
"destFolderId": num(destFolderID),
|
"destFolderId": num(destFolderID),
|
||||||
"resolveType": "Skip",
|
"resolveType": "Skip",
|
||||||
"holdResult": true,
|
"holdResult": true,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
return fileopsError(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CopyDavItems copies the given folders and/or files into destFolderID.
|
// CopyDavItems copies the given folders and/or files into destFolderID.
|
||||||
|
// Per-operation errors are surfaced like in MoveDavItems.
|
||||||
func (c *Client) CopyDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error {
|
func (c *Client) CopyDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error {
|
||||||
_, err := c.putJSON(ctx, "/api/2.0/files/fileops/copy", map[string]any{
|
raw, err := c.putJSON(ctx, "/api/2.0/files/fileops/copy", map[string]any{
|
||||||
"folderIds": nums(folderIDs),
|
"folderIds": nums(folderIDs),
|
||||||
"fileIds": nums(fileIDs),
|
"fileIds": nums(fileIDs),
|
||||||
"destFolderId": num(destFolderID),
|
"destFolderId": num(destFolderID),
|
||||||
"conflictResolveType": "Skip",
|
"conflictResolveType": "Skip",
|
||||||
"deleteAfter": true,
|
"deleteAfter": true,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
return fileopsError(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFileOps returns the currently active file operations
|
||||||
|
// (GET /api/2.0/files/fileops) for status polling.
|
||||||
|
func (c *Client) ListFileOps(ctx context.Context) ([]map[string]any, error) {
|
||||||
|
raw, err := c.getJSON(ctx, "/api/2.0/files/fileops")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := responseField(raw, "response")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(resp) == 0 || string(resp) == "null" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var ops []map[string]any
|
||||||
|
if err := json.Unmarshal(resp, &ops); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileopsError extracts per-operation "error" strings from a fileops/move or
|
||||||
|
// fileops/copy envelope. A 200 with error entries means nothing moved.
|
||||||
|
func fileopsError(raw json.RawMessage) error {
|
||||||
|
resp, err := responseField(raw, "response")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var ops []struct {
|
||||||
|
Error *string `json:"error"`
|
||||||
|
Finished *bool `json:"finished"`
|
||||||
|
Progress *int `json:"progress"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp, &ops); err != nil {
|
||||||
|
return nil // not an operations envelope — nothing to report
|
||||||
|
}
|
||||||
|
var errs []string
|
||||||
|
for _, op := range ops {
|
||||||
|
if op.Error != nil && *op.Error != "" {
|
||||||
|
errs = append(errs, *op.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return fmt.Errorf("onlyoffice: fileops: %s", strings.Join(errs, "; "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteDavItems deletes the given folders and/or files.
|
// DeleteDavItems deletes the given folders and/or files.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ require (
|
|||||||
github.com/mattn/go-runewidth v0.0.15
|
github.com/mattn/go-runewidth v0.0.15
|
||||||
github.com/muesli/termenv v0.16.0
|
github.com/muesli/termenv v0.16.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
|
github.com/xuri/excelize/v2 v2.11.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.56.0
|
modernc.org/sqlite v1.56.0
|
||||||
)
|
)
|
||||||
@@ -42,15 +43,21 @@ require (
|
|||||||
github.com/muesli/reflow v0.3.0 // indirect
|
github.com/muesli/reflow v0.3.0 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/richardlehane/mscfb v1.0.7 // indirect
|
||||||
|
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||||
|
github.com/xuri/efp v0.0.1 // indirect
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||||
github.com/yuin/goldmark v1.8.2 // indirect
|
github.com/yuin/goldmark v1.8.2 // indirect
|
||||||
github.com/yuin/goldmark-emoji v1.0.3 // indirect
|
github.com/yuin/goldmark-emoji v1.0.3 // indirect
|
||||||
golang.org/x/net v0.55.0 // indirect
|
golang.org/x/crypto v0.53.0 // indirect
|
||||||
|
golang.org/x/net v0.56.0 // indirect
|
||||||
golang.org/x/sync v0.21.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/term v0.43.0 // indirect
|
golang.org/x/term v0.44.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.38.0 // indirect
|
||||||
modernc.org/libc v1.74.4 // indirect
|
modernc.org/libc v1.74.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20240715153702-9ba8adf781c4/go.mod
|
|||||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
|
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
|
||||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
@@ -84,6 +86,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=
|
||||||
|
github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||||
|
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||||
|
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
@@ -97,25 +103,39 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
|||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||||
|
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||||
|
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||||
|
github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=
|
||||||
|
github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
|
github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
|
||||||
github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
|
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
||||||
|
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
package xlspipe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sheet names (Russian tabs) for cutover Portugal workbook.
|
||||||
|
const (
|
||||||
|
SheetInputs = "Ввод"
|
||||||
|
SheetDom6 = "Вс 6.09"
|
||||||
|
SheetJue3 = "Чт 3.09"
|
||||||
|
SheetWedHyp = "Ср гип"
|
||||||
|
SheetSummary = "Сводка"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CutoverPortugalDefaults holds live FO values (2026-08-28).
|
||||||
|
type CutoverPortugalDefaults struct {
|
||||||
|
FerryDom6 float64
|
||||||
|
FerryJue3 float64
|
||||||
|
FerrySuperiorDelta float64
|
||||||
|
HousingLow float64
|
||||||
|
HousingMid float64
|
||||||
|
HousingHigh float64
|
||||||
|
DriveLow float64
|
||||||
|
DriveMid float64
|
||||||
|
DriveHigh float64
|
||||||
|
BoardLow float64
|
||||||
|
BoardMid float64
|
||||||
|
BoardHigh float64
|
||||||
|
FoodLow float64
|
||||||
|
FoodMid float64
|
||||||
|
FoodHigh float64
|
||||||
|
SimLow float64
|
||||||
|
SimMid float64
|
||||||
|
SimHigh float64
|
||||||
|
MonthCap float64
|
||||||
|
ExtraNightsDom6 float64
|
||||||
|
ExtraNightsJue3 float64
|
||||||
|
ExtraNightsWed float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultCutoverPortugal returns FO live snapshot from portugal track (28.08.2026).
|
||||||
|
func DefaultCutoverPortugal() CutoverPortugalDefaults {
|
||||||
|
return CutoverPortugalDefaults{
|
||||||
|
FerryDom6: 457.89,
|
||||||
|
FerryJue3: 484.09,
|
||||||
|
FerrySuperiorDelta: 19.64,
|
||||||
|
HousingLow: 509,
|
||||||
|
HousingMid: 600,
|
||||||
|
HousingHigh: 650,
|
||||||
|
DriveLow: 70,
|
||||||
|
DriveMid: 85,
|
||||||
|
DriveHigh: 100,
|
||||||
|
BoardLow: 40,
|
||||||
|
BoardMid: 60,
|
||||||
|
BoardHigh: 80,
|
||||||
|
FoodLow: 150,
|
||||||
|
FoodMid: 220,
|
||||||
|
FoodHigh: 300,
|
||||||
|
SimLow: 50,
|
||||||
|
SimMid: 100,
|
||||||
|
SimHigh: 150,
|
||||||
|
MonthCap: 2500,
|
||||||
|
ExtraNightsDom6: 0,
|
||||||
|
ExtraNightsJue3: 0,
|
||||||
|
ExtraNightsWed: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type inputField struct {
|
||||||
|
name string // defined name (ASCII, for formulas)
|
||||||
|
label string
|
||||||
|
value float64
|
||||||
|
note string
|
||||||
|
comment string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildCutoverPortugalWorkbook creates a multi-sheet cutover budget with Russian labels,
|
||||||
|
// cell comments on non-obvious inputs, named ranges, and cross-sheet formulas.
|
||||||
|
func BuildCutoverPortugalWorkbook(d CutoverPortugalDefaults) (*excelize.File, error) {
|
||||||
|
f := excelize.NewFile()
|
||||||
|
defaultSheet := f.GetSheetName(0)
|
||||||
|
if err := f.SetSheetName(defaultSheet, SheetInputs); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, name := range []string{SheetDom6, SheetJue3, SheetWedHyp, SheetSummary} {
|
||||||
|
if _, err := f.NewSheet(name); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeInputsSheet(f, d); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
scenarios := []struct {
|
||||||
|
sheet, ferry, extra, note string
|
||||||
|
}{
|
||||||
|
{SheetDom6, "ferry_dom6", "extra_nights_dom6", "Живой слот вс 6.09 20:30; заезд в квартиру вт 8.09"},
|
||||||
|
{SheetJue3, "ferry_jue3", "extra_nights_jue3", "Живой чт 3.09; T1a 05–12 если TF-крыша кончается раньше вс"},
|
||||||
|
{SheetWedHyp, "ferry_jue3", "extra_nights_wed", "Гипотеза: ср 2.09 20:00 по тарифу Jue3; заезд пт 4.09"},
|
||||||
|
}
|
||||||
|
for _, sc := range scenarios {
|
||||||
|
if err := writeScenarioSheet(f, sc.sheet, sc.ferry, sc.extra, sc.note); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writeSummarySheet(f); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
f.SetActiveSheet(0)
|
||||||
|
if err := finalizeWorkbook(f); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeInputsSheet(f *excelize.File, d CutoverPortugalDefaults) error {
|
||||||
|
if err := setHeaders(f, SheetInputs, "Параметр", "Значение €", "Кратко"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fields := []inputField{
|
||||||
|
{
|
||||||
|
name: "ferry_dom6", label: "Паром вс 6.09 (Básica, без residencia)",
|
||||||
|
value: d.FerryDom6, note: "FO live: 2взр+младенец+авто",
|
||||||
|
comment: "Fred Olsen Dom 6.09 20:30 SC→Huelva, прибытие Mar 8 09:00. Butaca Normal/Básica без субсидии канарского residencia (−183€). Меняйте после нового live FO.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ferry_jue3", label: "Паром чт 3.09 (Básica, без residencia)",
|
||||||
|
value: d.FerryJue3, note: "FO live Jue 3 20:00",
|
||||||
|
comment: "Прямой рейс 35 ч. Дороже вс на ~26€. Используется также для листа «Ср гип», если своего рейса ср 2.09 нет в продаже.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ferry_superior_uplift", label: "Доплата VIP / Butaca Superior",
|
||||||
|
value: d.FerrySuperiorDelta, note: "Superior − Normal (Jue3 live)",
|
||||||
|
comment: "Разница между Superior и Normal на live Jue3 ≈19,64€. На Dom6 Superior в сессии не перевыбирали — оценка по этой дельте. VIP = salón, не каюта.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "housing_7n_low", label: "Крыша 7 ночей Setúbal — минимум",
|
||||||
|
value: d.HousingLow, note: "Airbnb low band",
|
||||||
|
comment: "Короткая аренда T1a (#11): 08–15.09, 1–2BR с парковкой. Низкая граница live Airbnb Setúbal.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "housing_7n_mid", label: "Крыша 7 ночей Setúbal — целевой mid",
|
||||||
|
value: d.HousingMid, note: "Цель #11: 500–650€/нед",
|
||||||
|
comment: "Рабочая оценка для брони. Основной столбец mid на листах сценариев.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "housing_7n_high", label: "Крыша 7 ночей Setúbal — максимум",
|
||||||
|
value: d.HousingHigh, note: "Airbnb high band",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "drive_low", label: "Проезд Huelva → Setúbal — мин",
|
||||||
|
value: d.DriveLow, note: "OSRM ~3,8 ч",
|
||||||
|
comment: "≈342 км: топливо + платные дороги. Низкая/средняя/высокая оценка.",
|
||||||
|
},
|
||||||
|
{name: "drive_mid", label: "Проезд Huelva → Setúbal — mid", value: d.DriveMid},
|
||||||
|
{name: "drive_high", label: "Проезд Huelva → Setúbal — макс", value: d.DriveHigh},
|
||||||
|
{
|
||||||
|
name: "board_low", label: "Еда на пароме — мин",
|
||||||
|
value: d.BoardLow, note: "Меню FO",
|
||||||
|
comment: "Питание на борту (Fred Olsen). George 0–3 обычно бесплатно как пассажир — еда отдельно.",
|
||||||
|
},
|
||||||
|
{name: "board_mid", label: "Еда на пароме — mid", value: d.BoardMid},
|
||||||
|
{name: "board_high", label: "Еда на пароме — макс", value: d.BoardHigh},
|
||||||
|
{
|
||||||
|
name: "food_7d_low", label: "Еда 7 дней в PT — мин",
|
||||||
|
value: d.FoodLow, note: "Готовим в apt",
|
||||||
|
comment: "Первая неделя в Setúbal после парома — продукты, не рестораны.",
|
||||||
|
},
|
||||||
|
{name: "food_7d_mid", label: "Еда 7 дней в PT — mid", value: d.FoodMid},
|
||||||
|
{name: "food_7d_high", label: "Еда 7 дней в PT — макс", value: d.FoodHigh},
|
||||||
|
{
|
||||||
|
name: "sim_low", label: "SIM + документы — мин",
|
||||||
|
value: d.SimLow, note: "Разовые cutover",
|
||||||
|
comment: "eSIM, копии, мелкие госпошлины при cutover. Не включает депозит аренды.",
|
||||||
|
},
|
||||||
|
{name: "sim_mid", label: "SIM + документы — mid", value: d.SimMid},
|
||||||
|
{name: "sim_high", label: "SIM + документы — макс", value: d.SimHigh},
|
||||||
|
{
|
||||||
|
name: "month_cap", label: "Потолок бюджета на месяц (€)",
|
||||||
|
value: d.MonthCap, note: "SoT: 2500€",
|
||||||
|
comment: "Жёсткий потолок Sep из source-of-truth. «Остаток» = потолок − итого mid сценария.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extra_nights_dom6", label: "Лишние ночи в PT (вс 6.09)",
|
||||||
|
value: d.ExtraNightsDom6, note: "0 = TF до вс",
|
||||||
|
comment: "Платные ночи в PT до начала 7-дневной крыши. Для Dom6 обычно 0: остаёмся на Тенерифе до вс, заезд вт 8.09.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extra_nights_jue3", label: "Лишние ночи в PT (чт 3.09)",
|
||||||
|
value: d.ExtraNightsJue3, note: "0 если TF до вс",
|
||||||
|
comment: "Если крыша TF кончается раньше вс — нужны ночи 05–07.09 до Airbnb. По умолчанию 0.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extra_nights_wed", label: "Лишние ночи в PT (ср гип)",
|
||||||
|
value: d.ExtraNightsWed, note: "Гип: заезд пт 4.09",
|
||||||
|
comment: "Гипотетический слот ср 2.09 → заезд пт 4.09 = 4 лишних ночи до типичного 7н блока. Рейса ср в FO нет — тариф как Jue3.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for i, fld := range fields {
|
||||||
|
row := i + 2
|
||||||
|
if err := f.SetCellStr(SheetInputs, fmt.Sprintf("A%d", row), fld.label); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := defineInput(f, SheetInputs, fld.name, row, fld.value, fld.note, fld.comment); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := f.SetCellStr(SheetInputs, "A25", "—"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.SetCellStr(SheetInputs, "B25", "Редактируйте жёлтые ячейки"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.SetCellStr(SheetInputs, "C25", "Формулы на листах сценариев и «Сводка» пересчитаются в OnlyOffice"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return styleSheet(f, SheetInputs, SheetInputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeScenarioSheet(f *excelize.File, sheet, ferryName, extraNightsName, scenarioNote string) error {
|
||||||
|
if err := setHeaders(f, sheet, "Статья", "Мин €", "Mid €", "Макс €", "Пояснение"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.SetCellStr(sheet, "A2", "Паром Básica"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for col, ref := range []string{ferryName, ferryName, ferryName} {
|
||||||
|
cell, _ := excelize.CoordinatesToCellName(col+2, 2)
|
||||||
|
if err := f.SetCellFormula(sheet, cell, "="+ref); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := addCellComment(f, sheet, "A2", "Тариф парома для этого сценария. Берётся с листа «Ввод»."); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
lines := []struct {
|
||||||
|
label string
|
||||||
|
low, mid, high, note string
|
||||||
|
comment string
|
||||||
|
}{
|
||||||
|
{"Крыша 7 н Setúbal", "housing_7n_low", "housing_7n_mid", "housing_7n_high", "Airbnb T1a #11", ""},
|
||||||
|
{"Huelva → Setúbal", "drive_low", "drive_mid", "drive_high", "OSRM ~3,8 ч", ""},
|
||||||
|
{"Еда на борту", "board_low", "board_mid", "board_high", "Меню FO", ""},
|
||||||
|
{"Еда 7 д в PT", "food_7d_low", "food_7d_mid", "food_7d_high", "Готовим дома", ""},
|
||||||
|
{"SIM / документы", "sim_low", "sim_mid", "sim_high", "Cutover", ""},
|
||||||
|
}
|
||||||
|
for i, ln := range lines {
|
||||||
|
row := i + 3
|
||||||
|
if err := setFormulaRow(f, sheet, row, ln.label,
|
||||||
|
"="+ln.low, "="+ln.mid, "="+ln.high, ln.note); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := f.SetCellStr(sheet, "A8", "Итого Básica"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for col := 2; col <= 4; col++ {
|
||||||
|
cell, _ := excelize.CoordinatesToCellName(col, 8)
|
||||||
|
colL, _ := excelize.CoordinatesToCellName(col, 2)
|
||||||
|
colH, _ := excelize.CoordinatesToCellName(col, 7)
|
||||||
|
if err := f.SetCellFormula(sheet, cell, fmt.Sprintf("=SUM(%s:%s)", colL, colH)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := f.SetCellStr(sheet, "E8", "SUM строк 2–7"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := setFormulaRow(f, sheet, 9, "Итого Superior",
|
||||||
|
"=B8+ferry_superior_uplift", "=C8+ferry_superior_uplift", "=D8+ferry_superior_uplift",
|
||||||
|
"Básica + VIP"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := addCellComment(f, sheet, "A9", "Butaca Superior / VIP salón. Доплата с листа «Ввод»."); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.SetCellStr(sheet, "A10", "Лишние ночи PT"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for col, housing := range []string{"housing_7n_low", "housing_7n_mid", "housing_7n_high"} {
|
||||||
|
cell, _ := excelize.CoordinatesToCellName(col+2, 10)
|
||||||
|
formula := fmt.Sprintf("=%s*%s/7", extraNightsName, housing)
|
||||||
|
if err := f.SetCellFormula(sheet, cell, formula); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := f.SetCellStr(sheet, "E10", "ночей × (крыша/7)"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := addCellComment(f, sheet, "A10", "Платное жильё до начала 7-дневной брони. Число ночей — на листе «Ввод» для этого сценария."); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := setFormulaRow(f, sheet, 11, "Итого с ночами",
|
||||||
|
"=B8+B10", "=C8+C10", "=D8+D10", scenarioNote); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.SetCellStr(sheet, "A12", "Остаток от потолка"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, col := range []string{"B", "C", "D"} {
|
||||||
|
if err := f.SetCellFormula(sheet, col+"12", "=month_cap-C11"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := f.SetCellStr(sheet, "E12", "потолок − mid итого"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := addCellComment(f, sheet, "C12", "Сколько остаётся от месячного потолка 2500€ после cutover (mid)."); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.SetCellStr(sheet, "A13", "Среднее по строкам"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for col := 2; col <= 4; col++ {
|
||||||
|
cell, _ := excelize.CoordinatesToCellName(col, 13)
|
||||||
|
colL, _ := excelize.CoordinatesToCellName(col, 2)
|
||||||
|
colH, _ := excelize.CoordinatesToCellName(col, 7)
|
||||||
|
if err := f.SetCellFormula(sheet, cell, fmt.Sprintf("=AVERAGE(%s:%s)", colL, colH)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := f.SetCellStr(sheet, "E13", "AVG статей 2–7"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return styleSheet(f, sheet, SheetInputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSummarySheet(f *excelize.File) error {
|
||||||
|
if err := setHeaders(f, SheetSummary,
|
||||||
|
"Сценарий", "Básica mid", "Superior mid", "Итого mid", "Остаток", "Δ vs вс"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := []struct {
|
||||||
|
label, sheet string
|
||||||
|
}{
|
||||||
|
{"Вс 6.09 (live)", SheetDom6},
|
||||||
|
{"Чт 3.09 (live)", SheetJue3},
|
||||||
|
{"Ср 2.09 (гипотеза)", SheetWedHyp},
|
||||||
|
}
|
||||||
|
qs := quoteSheet
|
||||||
|
for i, r := range rows {
|
||||||
|
row := i + 2
|
||||||
|
if err := f.SetCellStr(SheetSummary, fmt.Sprintf("A%d", row), r.label); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pairs := []struct {
|
||||||
|
col int
|
||||||
|
ref string
|
||||||
|
}{
|
||||||
|
{2, fmt.Sprintf("%s!C8", qs(r.sheet))},
|
||||||
|
{3, fmt.Sprintf("%s!C9", qs(r.sheet))},
|
||||||
|
{4, fmt.Sprintf("%s!C11", qs(r.sheet))},
|
||||||
|
{5, fmt.Sprintf("%s!C12", qs(r.sheet))},
|
||||||
|
}
|
||||||
|
for _, p := range pairs {
|
||||||
|
cell, _ := excelize.CoordinatesToCellName(p.col, row)
|
||||||
|
if err := f.SetCellFormula(SheetSummary, cell, "="+p.ref); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cell, _ := excelize.CoordinatesToCellName(6, row)
|
||||||
|
if err := f.SetCellFormula(SheetSummary, cell, fmt.Sprintf("=D%d-$D$2", row)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := f.SetCellStr(SheetSummary, "A6", "Потолок месяца"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.SetCellFormula(SheetSummary, "D6", "=month_cap"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.SetCellStr(SheetSummary, "A7", "Лучший итого mid"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.SetCellFormula(SheetSummary, "D7", "=MIN(D2:D4)"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.SetCellStr(SheetSummary, "E7", "MIN по сценариям"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := addCellComment(f, SheetSummary, "D7", "Минимальный mid «Итого с ночами» среди трёх сценариев. Сейчас обычно вс 6.09."); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return styleSheet(f, SheetSummary, SheetInputs)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package xlspipe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseCalcFloat(s string) (float64, error) {
|
||||||
|
s = strings.ReplaceAll(s, ",", "")
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
var val float64
|
||||||
|
_, err := fmt.Sscan(s, &val)
|
||||||
|
return val, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCutoverPortugalWorkbook_Formulas(t *testing.T) {
|
||||||
|
f, err := BuildCutoverPortugalWorkbook(DefaultCutoverPortugal())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "cutover.xlsx")
|
||||||
|
if err := Save(f, path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st, err := os.Stat(path)
|
||||||
|
if err != nil || st.Size() < 4096 {
|
||||||
|
t.Fatalf("xlsx too small: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opened, err := excelize.OpenFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer opened.Close()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
sheet, cell string
|
||||||
|
want float64
|
||||||
|
tol float64
|
||||||
|
}{
|
||||||
|
{SheetDom6, "C8", 1522.89, 0.02},
|
||||||
|
{SheetJue3, "C8", 1549.09, 0.02},
|
||||||
|
{SheetWedHyp, "C11", 1891.95, 0.05},
|
||||||
|
{SheetSummary, "D2", 1522.89, 0.02},
|
||||||
|
{SheetSummary, "D7", 1522.89, 0.02},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got, err := opened.CalcCellValue(tc.sheet, tc.cell)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s!%s calc: %v", tc.sheet, tc.cell, err)
|
||||||
|
}
|
||||||
|
val, err := parseCalcFloat(got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s!%s parse %q: %v", tc.sheet, tc.cell, got, err)
|
||||||
|
}
|
||||||
|
if diff := val - tc.want; diff < -tc.tol || diff > tc.tol {
|
||||||
|
t.Fatalf("%s!%s = %v want ~%v", tc.sheet, tc.cell, val, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package xlspipe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Template names for oo docs put-xlsx --template.
|
||||||
|
const (
|
||||||
|
TemplateCutoverPortugal = "cutover-portugal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildTemplate returns an xlsx workbook for a known template name.
|
||||||
|
func BuildTemplate(name string) (*File, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||||
|
case TemplateCutoverPortugal, "portugal-cutover", "cutover":
|
||||||
|
return BuildCutoverPortugalWorkbook(DefaultCutoverPortugal())
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown xlsx template %q (try: %s)", name, TemplateCutoverPortugal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// File is an alias so cmd/oo can refer to excelize.File without importing excelize in every handler.
|
||||||
|
type File = excelize.File
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package xlspipe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const commentAuthor = "oo"
|
||||||
|
|
||||||
|
// Save writes the workbook to path (creates parent dirs).
|
||||||
|
func Save(f *excelize.File, path string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return f.SaveAs(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// quoteSheet returns an Excel sheet reference safe for formulas ('Name'!A1).
|
||||||
|
func quoteSheet(name string) string {
|
||||||
|
return "'" + strings.ReplaceAll(name, "'", "''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
// defineInput registers a named range on sheet!B{row}, sets value, note, optional comment.
|
||||||
|
func defineInput(f *excelize.File, sheet, name string, row int, value float64, note, comment string) error {
|
||||||
|
cell := fmt.Sprintf("B%d", row)
|
||||||
|
if err := f.SetCellFloat(sheet, cell, value, 2, 64); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if note != "" {
|
||||||
|
if err := f.SetCellStr(sheet, fmt.Sprintf("C%d", row), note); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ref := fmt.Sprintf("%s!$B$%d", quoteSheet(sheet), row)
|
||||||
|
if err := f.SetDefinedName(&excelize.DefinedName{Name: name, RefersTo: ref}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if comment != "" {
|
||||||
|
return addCellComment(f, sheet, cell, comment)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addCellComment(f *excelize.File, sheet, cell, text string) error {
|
||||||
|
return f.AddComment(sheet, excelize.Comment{
|
||||||
|
Cell: cell,
|
||||||
|
Author: commentAuthor,
|
||||||
|
Text: text,
|
||||||
|
Width: 280,
|
||||||
|
Height: 120,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func setHeaders(f *excelize.File, sheet string, headers ...string) error {
|
||||||
|
for i, h := range headers {
|
||||||
|
cell, err := excelize.CoordinatesToCellName(i+1, 1)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.SetCellStr(sheet, cell, h); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setFormulaRow(f *excelize.File, sheet string, row int, label string, low, mid, high, note string) error {
|
||||||
|
if err := f.SetCellStr(sheet, fmt.Sprintf("A%d", row), label); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for col, formula := range []string{low, mid, high} {
|
||||||
|
cell, err := excelize.CoordinatesToCellName(col+2, row)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.SetCellFormula(sheet, cell, formula); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if note != "" {
|
||||||
|
return f.SetCellStr(sheet, fmt.Sprintf("E%d", row), note)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func styleSheet(f *excelize.File, sheet, inputsSheet string) error {
|
||||||
|
widths := map[string]float64{"A": 34, "B": 12, "C": 12, "D": 12, "E": 40}
|
||||||
|
for col, w := range widths {
|
||||||
|
if err := f.SetColWidth(sheet, col, col, w); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
headerStyle, err := f.NewStyle(&excelize.Style{
|
||||||
|
Font: &excelize.Font{Bold: true},
|
||||||
|
Fill: excelize.Fill{Type: "pattern", Color: []string{"#E8F0FE"}, Pattern: 1},
|
||||||
|
Alignment: &excelize.Alignment{Horizontal: "center", WrapText: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
euroStyle, err := f.NewStyle(&excelize.Style{NumFmt: 4})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
inputStyle, err := f.NewStyle(&excelize.Style{
|
||||||
|
NumFmt: 4,
|
||||||
|
Fill: excelize.Fill{Type: "pattern", Color: []string{"#FFF9E6"}, Pattern: 1},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.SetCellStyle(sheet, "A1", "E1", headerStyle); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if sheet == inputsSheet {
|
||||||
|
return f.SetCellStyle(sheet, "B2", "B30", inputStyle)
|
||||||
|
}
|
||||||
|
if err := f.SetCellStyle(sheet, "B2", "D20", euroStyle); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizeWorkbook(f *excelize.File) error {
|
||||||
|
if err := f.SetCalcProps(&excelize.CalcPropsOptions{FullCalcOnLoad: boolPtr(true)}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return f.UpdateLinkedValue()
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolPtr(v bool) *bool { return &v }
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
// High-level mail folder walk for ETL consumers (2dph brain mail-ingest,
|
||||||
|
// cv tools). This is the "integration layer" half of reusing the canonical
|
||||||
|
// client instead of private per-project OOClient copies: the caller gets a
|
||||||
|
// single hydrated stream instead of hand-rolling list → get → download
|
||||||
|
// against the raw API.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MailSyncAttachment is one attachment of a hydrated mail message.
|
||||||
|
type MailSyncAttachment struct {
|
||||||
|
ID string // id accepted by Client.DownloadMailAttachment
|
||||||
|
Name string
|
||||||
|
Size int64
|
||||||
|
Body []byte // non-nil only when MailSyncOptions.FetchBodies is set
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailSyncMessage is a hydrated mail message for sync pipelines.
|
||||||
|
type MailSyncMessage struct {
|
||||||
|
ID int64
|
||||||
|
Folder int
|
||||||
|
Subject string
|
||||||
|
From string // raw RFC 5322 header value ("Name" <addr>)
|
||||||
|
Date time.Time
|
||||||
|
IsNew bool
|
||||||
|
HasAttachments bool
|
||||||
|
Attachments []MailSyncAttachment
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailSyncOptions controls FetchMailFolder.
|
||||||
|
type MailSyncOptions struct {
|
||||||
|
Limit int // max messages to hydrate; 0 = whole folder
|
||||||
|
StartIndex int // skip this many messages before collecting
|
||||||
|
FetchBodies bool // eagerly download attachment bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchMailFolder walks a mail folder page by page and hydrates every
|
||||||
|
// message: list → get → (optionally) download attachments. It is the single
|
||||||
|
// entry point sync pipelines need on top of the mail API.
|
||||||
|
//
|
||||||
|
// Messages are returned in API order (newest first). The folder walk stops
|
||||||
|
// at the first empty or short page.
|
||||||
|
func (c *Client) FetchMailFolder(ctx context.Context, folderID int, opts MailSyncOptions) ([]MailSyncMessage, error) {
|
||||||
|
if folderID <= 0 {
|
||||||
|
folderID = MailFolderInbox
|
||||||
|
}
|
||||||
|
var out []MailSyncMessage
|
||||||
|
skipped := 0
|
||||||
|
for page := 1; ; page++ {
|
||||||
|
batch, err := c.ResponseArray(ctx,
|
||||||
|
mailMessagesPath(MailMessagesFilter{Folder: folderID}, page, mailMessagesPageSize))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("FetchMailFolder: %w", err)
|
||||||
|
}
|
||||||
|
if len(batch) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for _, raw := range batch {
|
||||||
|
if skipped < opts.StartIndex {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg, err := c.hydrateMailMessage(ctx, raw, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *msg)
|
||||||
|
if opts.Limit > 0 && len(out) >= opts.Limit {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(batch) < mailMessagesPageSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hydrateMailMessage converts one raw API message into a MailSyncMessage,
|
||||||
|
// fetching the full record when the list item does not carry the attachment
|
||||||
|
// metadata, and downloading bodies when requested.
|
||||||
|
func (c *Client) hydrateMailMessage(ctx context.Context, m map[string]any, opts MailSyncOptions) (*MailSyncMessage, error) {
|
||||||
|
msg := &MailSyncMessage{
|
||||||
|
ID: Int64FromMap(m, "id"),
|
||||||
|
Folder: int(Int64FromMap(m, "folder")),
|
||||||
|
Subject: stringFromMap(m, "subject"),
|
||||||
|
From: stringFromMap(m, "from"),
|
||||||
|
IsNew: boolFromMap(m, "isNew") == "true",
|
||||||
|
}
|
||||||
|
msg.Date = parseMailTime(stringFromMap(m, "date"))
|
||||||
|
|
||||||
|
atts, _ := m["attachments"].([]any)
|
||||||
|
hasFlag := boolFromMap(m, "hasAttachments") == "true"
|
||||||
|
if hasFlag && len(atts) == 0 {
|
||||||
|
// List items may omit the attachment array; pull the full record.
|
||||||
|
full, err := c.GetMailMessage(ctx, strconv.FormatInt(msg.ID, 10))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("FetchMailFolder: hydrate message %d: %w", msg.ID, err)
|
||||||
|
}
|
||||||
|
atts, _ = full["attachments"].([]any)
|
||||||
|
}
|
||||||
|
for _, a := range atts {
|
||||||
|
am, ok := a.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
att := MailSyncAttachment{
|
||||||
|
ID: mailAttachmentID(am),
|
||||||
|
Name: stringFromMap(am, "fileName"),
|
||||||
|
Size: Int64FromMap(am, "size"),
|
||||||
|
}
|
||||||
|
if att.Name == "" {
|
||||||
|
att.Name = stringFromMap(am, "name")
|
||||||
|
}
|
||||||
|
if att.ID != "" {
|
||||||
|
msg.Attachments = append(msg.Attachments, att)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg.HasAttachments = hasFlag || len(msg.Attachments) > 0
|
||||||
|
|
||||||
|
if opts.FetchBodies {
|
||||||
|
for i := range msg.Attachments {
|
||||||
|
body, err := c.DownloadMailAttachment(ctx, msg.Attachments[i].ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("FetchMailFolder: message %d attachment %q: %w",
|
||||||
|
msg.ID, msg.Attachments[i].Name, err)
|
||||||
|
}
|
||||||
|
msg.Attachments[i].Body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mailAttachmentID extracts the download id from an attachment object.
|
||||||
|
// OnlyOffice variants use "id", "fileId" or "attachmentId".
|
||||||
|
func mailAttachmentID(am map[string]any) string {
|
||||||
|
for _, key := range []string{"id", "fileId", "attachmentId"} {
|
||||||
|
switch v := am[key].(type) {
|
||||||
|
case string:
|
||||||
|
if s := v; s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
if n := int64(v); n != 0 {
|
||||||
|
return strconv.FormatInt(n, 10)
|
||||||
|
}
|
||||||
|
case int64:
|
||||||
|
if v != 0 {
|
||||||
|
return strconv.FormatInt(v, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMailTime accepts the OnlyOffice timestamp shapes seen in the wild:
|
||||||
|
// RFC3339 (with any fractional digits) and second-precision local form.
|
||||||
|
func parseMailTime(s string) time.Time {
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
if t, err := time.Parse("2006-01-02T15:04:05", s); err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mailsSyncMock serves a two-page inbox: page 1 has two list items (one
|
||||||
|
// reporting hasAttachments but omitting the attachment array, forcing the
|
||||||
|
// full-record fetch), page 2 is empty. The full record for message 102
|
||||||
|
// carries one attachment whose body is served by download.ashx.
|
||||||
|
func newMailSyncTestServer(t *testing.T, msgsPage1 string) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/api/2.0/authentication.json":
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: "sessionid", Value: "abc", Path: "/"})
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"response":{"token":"tok","expires":"2099-01-01T00:00:00.0000000+00:00"}}`))
|
||||||
|
|
||||||
|
case r.URL.Path == "/api/2.0/mail/messages":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if r.URL.Query().Get("page") > "1" {
|
||||||
|
_, _ = w.Write([]byte(`{"response":[]}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"response":[` + msgsPage1 + `]}`))
|
||||||
|
|
||||||
|
case r.URL.Path == "/api/2.0/mail/messages/102":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"response":{
|
||||||
|
"id":102,"subject":"Full record","from":"\"A\" <a@b.com>",
|
||||||
|
"date":"2026-08-22T10:15:00+02:00","folder":1,"isNew":false,
|
||||||
|
"hasAttachments":true,
|
||||||
|
"attachments":[{"id":77,"fileName":"report.pdf","size":3}]}}`))
|
||||||
|
|
||||||
|
case r.URL.Path == "/addons/mail/httphandlers/download.ashx":
|
||||||
|
if r.Header.Get("Cookie") == "" {
|
||||||
|
http.Error(w, "missing cookie", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte("PDF!"))
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchMailFolderHydratesAndDownloads(t *testing.T) {
|
||||||
|
page1 := `
|
||||||
|
{"id":101,"subject":"Plain","from":"x@y.z","date":"2026-08-21T09:00:00Z",
|
||||||
|
"folder":1,"isNew":true,"hasAttachments":false},
|
||||||
|
{"id":102,"subject":"With attachment (list item)","from":"a@b.com",
|
||||||
|
"date":"2026-08-22T10:15:00+02:00","folder":1,"isNew":false,
|
||||||
|
"hasAttachments":true}
|
||||||
|
`
|
||||||
|
srv := newMailSyncTestServer(t, page1)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"})
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
msgs, err := c.FetchMailFolder(ctx, MailFolderInbox, MailSyncOptions{FetchBodies: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchMailFolder: %v", err)
|
||||||
|
}
|
||||||
|
if len(msgs) != 2 {
|
||||||
|
t.Fatalf("got %d messages, want 2", len(msgs))
|
||||||
|
}
|
||||||
|
|
||||||
|
first := msgs[0]
|
||||||
|
if first.ID != 101 || first.Subject != "Plain" || !first.IsNew {
|
||||||
|
t.Fatalf("first = %+v", first)
|
||||||
|
}
|
||||||
|
if first.Date.IsZero() || first.Date.Year() != 2026 {
|
||||||
|
t.Fatalf("first date = %v", first.Date)
|
||||||
|
}
|
||||||
|
if first.HasAttachments {
|
||||||
|
t.Fatalf("first should have no attachments")
|
||||||
|
}
|
||||||
|
|
||||||
|
second := msgs[1]
|
||||||
|
if !second.HasAttachments || len(second.Attachments) != 1 {
|
||||||
|
t.Fatalf("second attachments = %+v", second.Attachments)
|
||||||
|
}
|
||||||
|
att := second.Attachments[0]
|
||||||
|
if att.ID != "77" || att.Name != "report.pdf" || att.Size != 3 || string(att.Body) != "PDF!" {
|
||||||
|
t.Fatalf("attachment = %+v", att)
|
||||||
|
}
|
||||||
|
if second.Date.Location() == time.UTC && second.Date.Hour() != 8 {
|
||||||
|
t.Fatalf("second date = %v (want +02:00 offset preserved)", second.Date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchMailFolderLimitAndStartIndex(t *testing.T) {
|
||||||
|
var items []string
|
||||||
|
for i := 1; i <= 5; i++ {
|
||||||
|
items = append(items, `{"id":`+string(rune('0'+i))+`,"subject":"m`+string(rune('0'+i))+`",
|
||||||
|
"from":"x@y.z","date":"2026-08-20T00:00:00Z","folder":1}`)
|
||||||
|
}
|
||||||
|
srv := newMailSyncTestServer(t, strings.Join(items, ","))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"})
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
got, err := c.FetchMailFolder(ctx, MailFolderInbox, MailSyncOptions{StartIndex: 1, Limit: 2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchMailFolder: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d messages, want 2", len(got))
|
||||||
|
}
|
||||||
|
if got[0].ID != 2 || got[1].ID != 3 {
|
||||||
|
t.Fatalf("ids = %d,%d want 2,3", got[0].ID, got[1].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMailTime(t *testing.T) {
|
||||||
|
fractions := "2026-08-22T10:15:00.1234567+02:00"
|
||||||
|
if parseMailTime(fractions).IsZero() {
|
||||||
|
t.Fatalf("RFC3339 with 7-digit fraction failed: %q", fractions)
|
||||||
|
}
|
||||||
|
if parseMailTime("2026-08-22T10:15:00").IsZero() {
|
||||||
|
t.Fatal("second-precision form failed")
|
||||||
|
}
|
||||||
|
if !parseMailTime("").IsZero() || !parseMailTime("garbage").IsZero() {
|
||||||
|
t.Fatal("unparseable input must yield zero time")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user