Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13a4b9f846 | ||
|
|
aad6315731 | ||
|
|
329c0b9561 | ||
|
|
74e72d0e06 | ||
|
|
534661dec7 | ||
|
|
55356558a1 |
@@ -15,7 +15,7 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
|
|||||||
- **CLI — `cmd/oo/` as `package main`.** Cobra wrapper that loads `.env` via `godotenv` at startup. **Subject-based command tree** mirroring [`tea`](https://gitea.com/gitea/tea):
|
- **CLI — `cmd/oo/` as `package main`.** Cobra wrapper that loads `.env` via `godotenv` at startup. **Subject-based command tree** mirroring [`tea`](https://gitea.com/gitea/tea):
|
||||||
- `main.go` — entry point (docstring lists the command tree).
|
- `main.go` — entry point (docstring lists the command tree).
|
||||||
- `common.go` — `rootCmd`, `newOO`, `printTable`/`printObject`, `--output table|json` flag.
|
- `common.go` — `rootCmd`, `newOO`, `printTable`/`printObject`, `--output table|json` flag.
|
||||||
- `calendar.go`, `projects.go`, `projects_files.go`, `tasks.go`, `tasks_files.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm.go`, `crm_tasks.go`, `catalog.go`, `docs.go`, `dav.go`, `search.go`, `index.go`, `mails.go`, `invoices.go` — one file per subject (or per subject facet), each registers in `init()`. `dav.go` exposes the Documents module by id (`oo dav ls|move|copy|mkdir|rename-file|rename-folder|download|fileops`); `search.go` runs `oo search QUERY` (name/content, `--backend oo|own`); `index.go` fills the own full-text index (`oo index folder|files`, see [`docs/unified-file-client.md`](docs/unified-file-client.md)).
|
- `calendar.go`, `projects.go`, `projects_files.go`, `tasks.go`, `tasks_files.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm.go`, `crm_tasks.go`, `catalog.go`, `docs.go`, `dav.go`, `search.go`, `index.go`, `mails.go`, `invoices.go` — one file per subject (or per subject facet), each registers in `init()`. `dav.go` exposes the Documents module by id (`oo dav ls|move|copy|mkdir|ensure-path|upload|rename-file|rename-folder|download|fileops`); `search.go` runs `oo search QUERY` (name/content, `--backend oo|own`); `index.go` fills the own full-text index (`oo index folder|files`, see [`docs/unified-file-client.md`](docs/unified-file-client.md)).
|
||||||
- CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library.
|
- CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library.
|
||||||
- **TUI — `cmd/office/` as `package main`.** Bubble Tea three-pane browser (module tree, selectable list, markdown preview). Reuses `cmd/internal/bootstrap` for env/auth and the root `onlyoffice` library for all API calls. UI logic in `cmd/office/ui/`; preview/formatting in `cmd/office/preview/`; list loaders in `cmd/office/fetch/`.
|
- **TUI — `cmd/office/` as `package main`.** Bubble Tea three-pane browser (module tree, selectable list, markdown preview). Reuses `cmd/internal/bootstrap` for env/auth and the root `onlyoffice` library for all API calls. UI logic in `cmd/office/ui/`; preview/formatting in `cmd/office/preview/`; list loaders in `cmd/office/fetch/`.
|
||||||
- **List table (`DataTable`)** — `cmd/office/ui/table*.go`. Column layout policies live in `cmd/office/model/table_layout.go` (`TableFlexLayoutFor`); cell rendering uses the bubbles/table inline pattern in `table_render.go` (`renderTableCell`, `padANSIWidth`). See `.cursor/skills/office-tui-table/SKILL.md` before changing center-pane tables.
|
- **List table (`DataTable`)** — `cmd/office/ui/table*.go`. Column layout policies live in `cmd/office/model/table_layout.go` (`TableFlexLayoutFor`); cell rendering uses the bubbles/table inline pattern in `table_render.go` (`renderTableCell`, `padANSIWidth`). See `.cursor/skills/office-tui-table/SKILL.md` before changing center-pane tables.
|
||||||
@@ -30,7 +30,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. The subject→verb tree in `cmd/oo/main.go` and the README table are documentation — update them with the code.
|
- 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. The subject→verb tree in `cmd/oo/main.go` and the README table are documentation — update them with the code.
|
||||||
- **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).
|
- **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` with the extension matched conversion-aware (legacy `.xls/.doc/.ppt` ↔ OOXML `.xlsx/.docx/.pptx`, since OnlyOffice converts them on upload), so a repeated `.xls` upload updates the saved `.xlsx` instead of appending a duplicate (`--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.
|
||||||
- **No host/client specifics in the tree.** Endpoints, IPs/ports, client mail
|
- **No host/client specifics in the tree.** Endpoints, IPs/ports, client mail
|
||||||
|
|||||||
@@ -741,6 +741,10 @@ surfaces such per-operation errors instead of a silent nil
|
|||||||
oo dav ls 659
|
oo dav ls 659
|
||||||
oo dav ls @root # virtual sections (Documents, Projects, …)
|
oo dav ls @root # virtual sections (Documents, Projects, …)
|
||||||
oo dav mkdir 659 "2026 inbox"
|
oo dav mkdir 659 "2026 inbox"
|
||||||
|
oo dav ensure-path "Banks/Caixa" # resolve-or-create; idempotent, prints the folder id
|
||||||
|
oo dav ensure-path "Banks/Caixa" --under 659 # start under an explicit folder id
|
||||||
|
oo dav upload 659 ./historico.xlsx # multipart upload, upsert by stem (legacy↔OOXML ext aware)
|
||||||
|
oo dav upload 659 ./a.xlsx ./b.pdf --replace=false # fail on name conflict instead of replacing
|
||||||
oo dav move 659 22881 22882 # DEST_FOLDER_ID FILE_ID…
|
oo dav move 659 22881 22882 # DEST_FOLDER_ID FILE_ID…
|
||||||
oo dav move 659 22881 --folders 670 # move folders along with files
|
oo dav move 659 22881 --folders 670 # move folders along with files
|
||||||
oo dav copy 659 22881
|
oo dav copy 659 22881
|
||||||
@@ -750,6 +754,11 @@ oo dav download 22881 --to ./copy.pdf # default path: ./<server title>
|
|||||||
oo dav fileops # active move/copy operations (status polling)
|
oo dav fileops # active move/copy operations (status polling)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`ensure-path` defaults to the concrete **My documents** section id (resolved
|
||||||
|
from `@root`, `rootFolderType=5`); `--under FOLDER_ID` overrides it. `upload`
|
||||||
|
reuses `UploadToFolderReplacing` (`--replace`, default) or `UploadToFolder`
|
||||||
|
after an `AssertNoFileConflict` check (`--no-replace`), so no new HTTP paths.
|
||||||
|
|
||||||
### Search and index (`oo search`, `oo index`)
|
### Search and index (`oo search`, `oo index`)
|
||||||
|
|
||||||
Full-text search over the Documents index. The REST endpoint
|
Full-text search over the Documents index. The REST endpoint
|
||||||
@@ -824,7 +833,7 @@ library. They build on the public client and the same `DoRetry` pacing.
|
|||||||
| `crm-tasks` | `list`, `create`, `delete`, `categories`, `reassign-self` |
|
| `crm-tasks` | `list`, `create`, `delete`, `categories`, `reassign-self` |
|
||||||
| `docs` | `tools`, `convert`, `pdf`, `presigned`, `csv`, `json`, `optimize`, `ocr`, `hocr`, `as-md`, `put-md`, `put-txt`, `put-xlsx` |
|
| `docs` | `tools`, `convert`, `pdf`, `presigned`, `csv`, `json`, `optimize`, `ocr`, `hocr`, `as-md`, `put-md`, `put-txt`, `put-xlsx` |
|
||||||
| `catalog` | `match`, `merge`, `apply`, `scan-contacts`, `scan-projects`, `scan-thunderbird` |
|
| `catalog` | `match`, `merge`, `apply`, `scan-contacts`, `scan-projects`, `scan-thunderbird` |
|
||||||
| `dav` | `ls`, `move`, `copy`, `mkdir`, `rename-file`, `rename-folder`, `download`, `fileops` |
|
| `dav` | `ls`, `move`, `copy`, `mkdir`, `ensure-path`, `upload`, `rename-file`, `rename-folder`, `download`, `fileops` |
|
||||||
| `search` | `QUERY` (`--content`, `--folder ID`, `--limit N`, `--backend oo\|own`, `--json`) |
|
| `search` | `QUERY` (`--content`, `--folder ID`, `--limit N`, `--backend oo\|own`, `--json`) |
|
||||||
| `index` | `folder FOLDER_ID`, `files FILE_ID...` (`--recursive`, `--exts pdf`, `--limit N`, `--dry-run`) |
|
| `index` | `folder FOLDER_ID`, `files FILE_ID...` (`--recursive`, `--exts pdf`, `--limit N`, `--dry-run`) |
|
||||||
|
|
||||||
|
|||||||
+190
@@ -1,8 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
@@ -26,6 +28,8 @@ func davCmd() *cobra.Command {
|
|||||||
cmd.AddCommand(davMoveCmd())
|
cmd.AddCommand(davMoveCmd())
|
||||||
cmd.AddCommand(davCopyCmd())
|
cmd.AddCommand(davCopyCmd())
|
||||||
cmd.AddCommand(davMkdirCmd())
|
cmd.AddCommand(davMkdirCmd())
|
||||||
|
cmd.AddCommand(davEnsurePathCmd())
|
||||||
|
cmd.AddCommand(davUploadCmd())
|
||||||
cmd.AddCommand(davRemoveCmd())
|
cmd.AddCommand(davRemoveCmd())
|
||||||
cmd.AddCommand(davRenameFileCmd())
|
cmd.AddCommand(davRenameFileCmd())
|
||||||
cmd.AddCommand(davRenameFolderCmd())
|
cmd.AddCommand(davRenameFolderCmd())
|
||||||
@@ -187,6 +191,192 @@ func davMkdirCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func davEnsurePathCmd() *cobra.Command {
|
||||||
|
var under string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "ensure-path PATH",
|
||||||
|
Short: "Resolve or create nested Documents folders (mkdir -p), print the final folder id",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
root := under
|
||||||
|
if root == "" {
|
||||||
|
root, err = myDocumentsID(cmd.Context(), c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f, err := ensurePath(cmd.Context(), c.FileStore(onlyoffice.ProviderDAV), root, args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(map[string]any{"id": f.ID, "title": f.Title, "under": root})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&under, "under", "", "parent folder id (default: My documents)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func davUploadCmd() *cobra.Command {
|
||||||
|
var replace bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "upload DEST_FOLDER_ID LOCAL_FILE [LOCAL_FILE...]",
|
||||||
|
Short: "Upload local file(s) into a Documents folder (multipart, upsert by name)",
|
||||||
|
Args: cobra.MinimumNArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
results, err := uploadLocal(cmd.Context(), c, args[0], args[1:], replace)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([]map[string]any, 0, len(results))
|
||||||
|
for _, r := range results {
|
||||||
|
replaced := make([]string, 0, len(r.Replaced))
|
||||||
|
for _, id := range r.Replaced {
|
||||||
|
replaced = append(replaced, fmt.Sprint(id))
|
||||||
|
}
|
||||||
|
rows = append(rows, map[string]any{
|
||||||
|
"id": r.Entry.ID,
|
||||||
|
"title": r.Entry.Title,
|
||||||
|
"size": r.Entry.Size,
|
||||||
|
"replaced": strings.Join(replaced, ", "),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printTable([]string{"id", "title", "size", "replaced"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&replace, "replace", true, "replace the same logical file (stem, legacy↔OOXML ext) before upload (default); false = fail if name taken")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadedFile pairs an upload result with the ids deleted to make room for it.
|
||||||
|
type uploadedFile struct {
|
||||||
|
Entry onlyoffice.Entry
|
||||||
|
Replaced []int
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploader is the slice of *onlyoffice.Client that dav upload needs, so the
|
||||||
|
// command logic is unit-testable with a fake.
|
||||||
|
type uploader interface {
|
||||||
|
UploadToFolderReplacing(ctx context.Context, folderID, localPath string) (*onlyoffice.FileEntry, []int, error)
|
||||||
|
AssertNoFileConflict(ctx context.Context, folderID, localPath string) error
|
||||||
|
UploadToFolder(ctx context.Context, folderID, localPath string) (*onlyoffice.FileEntry, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadLocal uploads each local file into folderID. With replace the existing
|
||||||
|
// same logical file is overwritten in place (the UploadToFolderReplacing
|
||||||
|
// upsert, which matches the server-converted extension too); without replace a
|
||||||
|
// name clash fails with ErrFileExists before anything is sent.
|
||||||
|
func uploadLocal(ctx context.Context, up uploader, folderID string, paths []string, replace bool) ([]uploadedFile, error) {
|
||||||
|
results := make([]uploadedFile, 0, len(paths))
|
||||||
|
for _, path := range paths {
|
||||||
|
var (
|
||||||
|
fe *onlyoffice.FileEntry
|
||||||
|
deleted []int
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if replace {
|
||||||
|
fe, deleted, err = up.UploadToFolderReplacing(ctx, folderID, path)
|
||||||
|
} else {
|
||||||
|
if err := up.AssertNoFileConflict(ctx, folderID, path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fe, err = up.UploadToFolder(ctx, folderID, path)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dav upload: %s: %w", path, err)
|
||||||
|
}
|
||||||
|
results = append(results, uploadedFile{
|
||||||
|
Entry: onlyoffice.FileEntryToEntry(fe, onlyoffice.ProviderDAV),
|
||||||
|
Replaced: deleted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensurePath resolves-or-creates every "/"-separated segment under rootID
|
||||||
|
// through store. Existing folders are reused by title (case-insensitive), so
|
||||||
|
// repeated calls return the same id without creating duplicates.
|
||||||
|
func ensurePath(ctx context.Context, store onlyoffice.FileStore, rootID, path string) (onlyoffice.Entry, error) {
|
||||||
|
segments, err := splitDavPath(path)
|
||||||
|
if err != nil {
|
||||||
|
return onlyoffice.Entry{}, err
|
||||||
|
}
|
||||||
|
parent := rootID
|
||||||
|
var current onlyoffice.Entry
|
||||||
|
for _, name := range segments {
|
||||||
|
entries, err := store.List(ctx, parent)
|
||||||
|
if err != nil {
|
||||||
|
return onlyoffice.Entry{}, fmt.Errorf("dav ensure-path: list %s: %w", parent, err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Kind == onlyoffice.Folder && strings.EqualFold(strings.TrimSpace(e.Title), name) {
|
||||||
|
current, found = e, true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
current, err = store.CreateFolder(ctx, parent, name)
|
||||||
|
if err != nil {
|
||||||
|
return onlyoffice.Entry{}, fmt.Errorf("dav ensure-path: mkdir %s/%s: %w", parent, name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parent = current.ID
|
||||||
|
}
|
||||||
|
return current, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitDavPath normalizes a Documents path into non-empty segments. "." is
|
||||||
|
// ignored; ".." is rejected rather than creating a literal folder named "..".
|
||||||
|
func splitDavPath(path string) ([]string, error) {
|
||||||
|
var segments []string
|
||||||
|
for _, s := range strings.Split(path, "/") {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
switch s {
|
||||||
|
case "", ".":
|
||||||
|
continue
|
||||||
|
case "..":
|
||||||
|
return nil, fmt.Errorf("dav ensure-path: %q not allowed", s)
|
||||||
|
}
|
||||||
|
segments = append(segments, s)
|
||||||
|
}
|
||||||
|
if len(segments) == 0 {
|
||||||
|
return nil, fmt.Errorf("dav ensure-path: empty path")
|
||||||
|
}
|
||||||
|
return segments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// myDocumentsID resolves the concrete id of the "My documents" section so
|
||||||
|
// ensure-path can create folders under it (the symbolic "@my" is list-only on
|
||||||
|
// some portals). rootFolderType 5 = My; the title match covers portals that
|
||||||
|
// omit rootFolderType.
|
||||||
|
func myDocumentsID(ctx context.Context, c *onlyoffice.Client) (string, error) {
|
||||||
|
sections, err := c.ListDavSections(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, s := range sections {
|
||||||
|
if s.RootType == 5 && s.ID != "" {
|
||||||
|
return s.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range sections {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(s.Title), "My documents") && s.ID != "" {
|
||||||
|
return s.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("dav: no My documents section in @root; pass --under FOLDER_ID")
|
||||||
|
}
|
||||||
|
|
||||||
func davRemoveCmd() *cobra.Command {
|
func davRemoveCmd() *cobra.Command {
|
||||||
var folderIDs []string
|
var folderIDs []string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIntegrationDavEnsurePathUpload exercises the dav ensure-path/upload
|
||||||
|
// helpers against a live instance: idempotent folder creation, upload, replace
|
||||||
|
// and no-replace conflict. Destructive (creates and removes a throwaway
|
||||||
|
// folder); skips cleanly without credentials. Only run against instances you
|
||||||
|
// own: go test -tags=integration ./cmd/oo -run TestIntegrationDavEnsurePathUpload
|
||||||
|
func TestIntegrationDavEnsurePathUpload(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
c, err := bootstrap.NewClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("no live OnlyOffice credentials: %v", err)
|
||||||
|
}
|
||||||
|
root, err := myDocumentsID(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("My documents root unavailable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := c.FileStore(onlyoffice.ProviderDAV)
|
||||||
|
suffix := time.Now().UTC().Format("20060102-150405")
|
||||||
|
base := "oo-it-" + suffix
|
||||||
|
|
||||||
|
first, err := ensurePath(ctx, store, root, base+"/nested")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ensurePath: %v", err)
|
||||||
|
}
|
||||||
|
baseID := findFolderID(t, ctx, store, root, base)
|
||||||
|
t.Cleanup(func() { _ = store.Delete(context.Background(), []string{baseID}) })
|
||||||
|
|
||||||
|
second, err := ensurePath(ctx, store, root, base+"/nested")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ensurePath (second): %v", err)
|
||||||
|
}
|
||||||
|
if second.ID != first.ID {
|
||||||
|
t.Fatalf("ensure-path not idempotent: %s != %s", second.ID, first.ID)
|
||||||
|
}
|
||||||
|
if n := countFolders(t, ctx, store, baseID, "nested"); n != 1 {
|
||||||
|
t.Fatalf("nested folder duplicated: %d children named nested, want 1", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
local := filepath.Join(t.TempDir(), "oo-it-"+suffix+".xlsx")
|
||||||
|
if err := os.WriteFile(local, []byte("integration dav upload "+suffix+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
name := filepath.Base(local)
|
||||||
|
|
||||||
|
if _, err := uploadLocal(ctx, c, first.ID, []string{local}, true); err != nil {
|
||||||
|
t.Fatalf("uploadLocal: %v", err)
|
||||||
|
}
|
||||||
|
if n := countFiles(t, ctx, store, first.ID, name); n != 1 {
|
||||||
|
t.Fatalf("after upload: %d files named %s, want 1", n, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := uploadLocal(ctx, c, first.ID, []string{local}, true); err != nil {
|
||||||
|
t.Fatalf("uploadLocal (replace): %v", err)
|
||||||
|
}
|
||||||
|
if n := countFiles(t, ctx, store, first.ID, name); n != 1 {
|
||||||
|
t.Fatalf("after replace: %d files named %s, want 1", n, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := uploadLocal(ctx, c, first.ID, []string{local}, false); !errors.Is(err, onlyoffice.ErrFileExists) {
|
||||||
|
t.Fatalf("no-replace conflict err = %v, want ErrFileExists", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findFolderID(t *testing.T, ctx context.Context, store onlyoffice.FileStore, parent, title string) string {
|
||||||
|
t.Helper()
|
||||||
|
for _, e := range mustList(t, ctx, store, parent) {
|
||||||
|
if e.Kind == onlyoffice.Folder && strings.EqualFold(e.Title, title) {
|
||||||
|
return e.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("folder %q not found under %s", title, parent)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func countFolders(t *testing.T, ctx context.Context, store onlyoffice.FileStore, parent, title string) int {
|
||||||
|
t.Helper()
|
||||||
|
n := 0
|
||||||
|
for _, e := range mustList(t, ctx, store, parent) {
|
||||||
|
if e.Kind == onlyoffice.Folder && strings.EqualFold(e.Title, title) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func countFiles(t *testing.T, ctx context.Context, store onlyoffice.FileStore, parent, title string) int {
|
||||||
|
t.Helper()
|
||||||
|
n := 0
|
||||||
|
for _, e := range mustList(t, ctx, store, parent) {
|
||||||
|
if e.Kind == onlyoffice.File && e.Title == title {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustList(t *testing.T, ctx context.Context, store onlyoffice.FileStore, parent string) []onlyoffice.Entry {
|
||||||
|
t.Helper()
|
||||||
|
entries, err := store.List(ctx, parent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List(%s): %v", parent, err)
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeFolderStore is an in-memory onlyoffice.FileStore for the ensure-path
|
||||||
|
// logic. It records created folders so idempotency can be asserted.
|
||||||
|
type fakeFolderStore struct {
|
||||||
|
nextID int
|
||||||
|
entries map[string][]onlyoffice.Entry
|
||||||
|
created []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeFolderStore() *fakeFolderStore {
|
||||||
|
return &fakeFolderStore{entries: map[string][]onlyoffice.Entry{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeFolderStore) Name() string { return "fake" }
|
||||||
|
|
||||||
|
func (f *fakeFolderStore) List(_ context.Context, parent string) ([]onlyoffice.Entry, error) {
|
||||||
|
return append([]onlyoffice.Entry(nil), f.entries[parent]...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeFolderStore) CreateFolder(_ context.Context, parent, title string) (onlyoffice.Entry, error) {
|
||||||
|
f.nextID++
|
||||||
|
e := onlyoffice.Entry{
|
||||||
|
ID: fmt.Sprintf("id-%d", f.nextID),
|
||||||
|
ParentID: parent,
|
||||||
|
Title: title,
|
||||||
|
Kind: onlyoffice.Folder,
|
||||||
|
}
|
||||||
|
f.entries[parent] = append(f.entries[parent], e)
|
||||||
|
f.created = append(f.created, parent+"/"+title)
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeFolderStore) Stat(context.Context, string) (onlyoffice.Entry, error) {
|
||||||
|
return onlyoffice.Entry{}, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeFolderStore) Upload(context.Context, string, string, io.Reader) (onlyoffice.Entry, error) {
|
||||||
|
return onlyoffice.Entry{}, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeFolderStore) Download(context.Context, string, io.Writer) (int64, error) {
|
||||||
|
return 0, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeFolderStore) Move(context.Context, []string, string) error {
|
||||||
|
return errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeFolderStore) Copy(context.Context, []string, string) error {
|
||||||
|
return errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeFolderStore) Rename(context.Context, string, string) error {
|
||||||
|
return errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeFolderStore) Delete(context.Context, []string) error {
|
||||||
|
return errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeUploader is the *onlyoffice.Client slice dav upload depends on.
|
||||||
|
type fakeUploader struct {
|
||||||
|
conflict bool
|
||||||
|
uploads []string
|
||||||
|
replaced []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeUploader) UploadToFolderReplacing(_ context.Context, folderID, localPath string) (*onlyoffice.FileEntry, []int, error) {
|
||||||
|
f.uploads = append(f.uploads, localPath)
|
||||||
|
ids := []int(nil)
|
||||||
|
if f.conflict {
|
||||||
|
ids = []int{7}
|
||||||
|
f.replaced = append(f.replaced, folderID+"#7")
|
||||||
|
}
|
||||||
|
return fakeFileEntry(99, localPath, folderID), ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeUploader) AssertNoFileConflict(_ context.Context, folderID, localPath string) error {
|
||||||
|
if f.conflict {
|
||||||
|
return fmt.Errorf("%w: conflict in folder %s for %s", onlyoffice.ErrFileExists, folderID, localPath)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeUploader) UploadToFolder(_ context.Context, folderID, localPath string) (*onlyoffice.FileEntry, error) {
|
||||||
|
f.uploads = append(f.uploads, localPath)
|
||||||
|
return fakeFileEntry(99, localPath, folderID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fakeFileEntry(id int, localPath, folderID string) *onlyoffice.FileEntry {
|
||||||
|
num := json.Number(fmt.Sprintf("%d", id))
|
||||||
|
title := localPath[strings.LastIndex(localPath, "/")+1:]
|
||||||
|
return &onlyoffice.FileEntry{
|
||||||
|
ID: &num,
|
||||||
|
Title: &title,
|
||||||
|
FolderID: &num,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsurePathCreatesNestedFolders(t *testing.T) {
|
||||||
|
store := newFakeFolderStore()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
got, err := ensurePath(ctx, store, "root", "Banks/Caixa")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ensurePath: %v", err)
|
||||||
|
}
|
||||||
|
if got.Kind != onlyoffice.Folder || got.ID == "" {
|
||||||
|
t.Fatalf("ensurePath returned %+v, want a folder with an id", got)
|
||||||
|
}
|
||||||
|
if want := []string{"root/Banks", "id-1/Caixa"}; !equalStrings(store.created, want) {
|
||||||
|
t.Fatalf("created %v, want %v", store.created, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsurePathIsIdempotent(t *testing.T) {
|
||||||
|
store := newFakeFolderStore()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
first, err := ensurePath(ctx, store, "root", "Banks/Caixa")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ensurePath first: %v", err)
|
||||||
|
}
|
||||||
|
created := len(store.created)
|
||||||
|
|
||||||
|
second, err := ensurePath(ctx, store, "root", "Banks/Caixa")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ensurePath second: %v", err)
|
||||||
|
}
|
||||||
|
if second.ID != first.ID {
|
||||||
|
t.Fatalf("second run id = %q, want %q (no duplicate)", second.ID, first.ID)
|
||||||
|
}
|
||||||
|
if len(store.created) != created {
|
||||||
|
t.Fatalf("second run created folders: %v", store.created)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsurePathReusesExistingFolder(t *testing.T) {
|
||||||
|
store := newFakeFolderStore()
|
||||||
|
store.entries["root"] = []onlyoffice.Entry{
|
||||||
|
{ID: "banks-id", Title: "Banks", Kind: onlyoffice.Folder},
|
||||||
|
}
|
||||||
|
store.entries["banks-id"] = []onlyoffice.Entry{
|
||||||
|
{ID: "caixa-id", Title: "Caixa", Kind: onlyoffice.Folder},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ensurePath(context.Background(), store, "root", "Banks/Caixa")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ensurePath: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != "caixa-id" {
|
||||||
|
t.Fatalf("id = %q, want caixa-id", got.ID)
|
||||||
|
}
|
||||||
|
if len(store.created) != 0 {
|
||||||
|
t.Fatalf("created %v, want none", store.created)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsurePathRejectsEmptyAndDotDot(t *testing.T) {
|
||||||
|
store := newFakeFolderStore()
|
||||||
|
for _, path := range []string{"", "/", "Banks/../Caixa"} {
|
||||||
|
if _, err := ensurePath(context.Background(), store, "root", path); err == nil {
|
||||||
|
t.Fatalf("ensurePath(%q) = nil error, want failure", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadLocalReplacesByDefault(t *testing.T) {
|
||||||
|
up := &fakeUploader{conflict: true}
|
||||||
|
results, err := uploadLocal(context.Background(), up, "folder-1", []string{"a/f.xlsx"}, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("uploadLocal: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 || results[0].Entry.Title != "f.xlsx" {
|
||||||
|
t.Fatalf("results = %+v", results)
|
||||||
|
}
|
||||||
|
if len(results[0].Replaced) != 1 || results[0].Replaced[0] != 7 {
|
||||||
|
t.Fatalf("replaced = %v, want [7]", results[0].Replaced)
|
||||||
|
}
|
||||||
|
if len(up.replaced) != 1 {
|
||||||
|
t.Fatalf("UploadToFolderReplacing not used for replace: %v", up.replaced)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadLocalNoReplaceFailsOnConflict(t *testing.T) {
|
||||||
|
up := &fakeUploader{conflict: true}
|
||||||
|
_, err := uploadLocal(context.Background(), up, "folder-1", []string{"a/f.xlsx"}, false)
|
||||||
|
if !errors.Is(err, onlyoffice.ErrFileExists) {
|
||||||
|
t.Fatalf("err = %v, want ErrFileExists", err)
|
||||||
|
}
|
||||||
|
if len(up.uploads) != 0 {
|
||||||
|
t.Fatalf("uploaded despite conflict: %v", up.uploads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadLocalNoReplaceUploadsWhenFree(t *testing.T) {
|
||||||
|
up := &fakeUploader{}
|
||||||
|
results, err := uploadLocal(context.Background(), up, "folder-1", []string{"a/f.xlsx", "a/g.pdf"}, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("uploadLocal: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 2 {
|
||||||
|
t.Fatalf("results = %+v", results)
|
||||||
|
}
|
||||||
|
if len(up.uploads) != 2 {
|
||||||
|
t.Fatalf("uploads = %v", up.uploads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDavRegistersUploadAndEnsurePath(t *testing.T) {
|
||||||
|
for _, name := range []string{"ensure-path", "upload"} {
|
||||||
|
cmd, _, err := rootCmd.Find([]string{"dav", name})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dav %s not registered: %v", name, err)
|
||||||
|
}
|
||||||
|
if cmd.Name() != name {
|
||||||
|
t.Fatalf("resolved %q, want %q", cmd.Name(), name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
upload, _, err := rootCmd.Find([]string{"dav", "upload"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
flag := upload.Flags().Lookup("replace")
|
||||||
|
if flag == nil || flag.DefValue != "true" {
|
||||||
|
t.Fatalf("upload --replace flag = %+v, want default true", flag)
|
||||||
|
}
|
||||||
|
ensure, _, err := rootCmd.Find([]string{"dav", "ensure-path"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ensure.Flags().Lookup("under") == nil {
|
||||||
|
t.Fatal("ensure-path missing --under flag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalStrings(a, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
// 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 | pdf | presigned | csv | json | optimize | ocr | hocr | as-md | put-md | put-txt | put-xlsx
|
// oo docs tools | convert | pdf | presigned | csv | json | optimize | ocr | hocr | as-md | put-md | put-txt | put-xlsx
|
||||||
// oo catalog match | merge | apply | scan-contacts | scan-projects | scan-thunderbird
|
// oo catalog match | merge | apply | scan-contacts | scan-projects | scan-thunderbird
|
||||||
// oo dav ls | move | copy | mkdir | rename-file | rename-folder | download | fileops
|
// oo dav ls | move | copy | mkdir | ensure-path | upload | rename-file | rename-folder | download | fileops
|
||||||
// oo search QUERY [--content] [--folder ID] [--limit N] [--backend oo|own] [--json]
|
// oo search QUERY [--content] [--folder ID] [--limit N] [--backend oo|own] [--json]
|
||||||
// oo index folder FOLDER_ID | files FILE_ID... [--recursive] [--exts pdf] [--dry-run]
|
// oo index folder FOLDER_ID | files FILE_ID... [--recursive] [--exts pdf] [--dry-run]
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -113,9 +113,11 @@ func prjFilesUploadCmd() *cobra.Command {
|
|||||||
var replace, allowDuplicate bool
|
var replace, allowDuplicate bool
|
||||||
cmd := &cobra.Command{
|
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 (upsert by stem|ext)",
|
Short: "Upload file(s) into the project's Documents folder (upsert by stem, legacy↔OOXML ext)",
|
||||||
Long: `Default: replace an existing file with the same logical name (stem|ext), like cp overwrite.
|
Long: `Default: replace an existing file with the same logical name (stem, treating
|
||||||
Pass --no-replace to fail when the name is taken; --allow-duplicate to always create a new file id.`,
|
legacy .xls/.doc/.ppt and their OOXML .xlsx/.docx/.pptx as one file), 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)
|
||||||
|
|||||||
@@ -237,7 +237,8 @@ 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.
|
// UploadProjectFileReplacing upserts by stem and (server-converted) extension
|
||||||
|
// in the project Documents folder.
|
||||||
func (c *Client) UploadProjectFileReplacing(ctx context.Context, projectID, localPath string) (*FileEntry, []int, error) {
|
func (c *Client) UploadProjectFileReplacing(ctx context.Context, projectID, localPath string) (*FileEntry, []int, error) {
|
||||||
folderID, err := c.projectFolderID(ctx, projectID)
|
folderID, err := c.projectFolderID(ctx, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -83,6 +83,85 @@ func UploadExtFromLocal(localPath string) string {
|
|||||||
return strings.ToLower(ext)
|
return strings.ToLower(ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// legacyToOOXMLExt maps the legacy binary Office extensions OnlyOffice accepts
|
||||||
|
// on upload to the OOXML extension the server converts them into.
|
||||||
|
var legacyToOOXMLExt = map[string]string{
|
||||||
|
".xls": ".xlsx",
|
||||||
|
".doc": ".docx",
|
||||||
|
".ppt": ".pptx",
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeExt lowercases an extension and ensures a leading dot.
|
||||||
|
func normalizeExt(ext string) string {
|
||||||
|
ext = strings.ToLower(strings.TrimSpace(ext))
|
||||||
|
if ext == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(ext, ".") {
|
||||||
|
ext = "." + ext
|
||||||
|
}
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
|
||||||
|
// EquivalentUploadExt reports whether two extensions designate the same
|
||||||
|
// document once the server-side conversion is taken into account: equal
|
||||||
|
// extensions, or a legacy binary Office format and its OOXML equivalent
|
||||||
|
// (.xls/.xlsx, .doc/.docx, .ppt/.pptx). Empty extensions only match each other.
|
||||||
|
func EquivalentUploadExt(a, b string) bool {
|
||||||
|
na, nb := normalizeExt(a), normalizeExt(b)
|
||||||
|
if na == nb {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if na == "" || nb == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return legacyToOOXMLExt[na] == nb || legacyToOOXMLExt[nb] == na
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindFilesByStemExt returns folder files matching stem and a same-or-converted
|
||||||
|
// extension (see EquivalentUploadExt). Unlike FindFilesByDedupKey, foo.xls and
|
||||||
|
// foo.xlsx are one logical file, so a replacing upload after OnlyOffice's
|
||||||
|
// legacy→OOXML conversion finds the saved file instead of appending a copy.
|
||||||
|
func FindFilesByStemExt(files []*FileEntry, stem, ext string) []*FileEntry {
|
||||||
|
stem = strings.TrimSpace(stem)
|
||||||
|
if stem == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []*FileEntry
|
||||||
|
for _, f := range files {
|
||||||
|
if FileEntryStem(f) != stem {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if EquivalentUploadExt(FileEntryExt(f), ext) {
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFilesByStemExt removes every folder file matching stem and a
|
||||||
|
// same-or-converted extension (legacy ↔ OOXML).
|
||||||
|
func (c *Client) DeleteFilesByStemExt(ctx context.Context, folderID, stem, ext string) ([]int, error) {
|
||||||
|
files, err := c.FolderFiles(ctx, folderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
matches := FindFilesByStemExt(files, stem, ext)
|
||||||
|
ids := make([]int, 0, len(matches))
|
||||||
|
for _, f := range matches {
|
||||||
|
if n := int(FileEntryNumericID(f)); n != 0 {
|
||||||
|
ids = append(ids, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err := c.DeleteFiles(ctx, ids); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
// IsTrashFolderTitle reports staging/trash folders (e.g. _trash-md).
|
// IsTrashFolderTitle reports staging/trash folders (e.g. _trash-md).
|
||||||
func IsTrashFolderTitle(title string) bool {
|
func IsTrashFolderTitle(title string) bool {
|
||||||
t := strings.ToLower(strings.TrimSpace(title))
|
t := strings.ToLower(strings.TrimSpace(title))
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEquivalentUploadExt(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
a, b string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{".xls", ".xlsx", true},
|
||||||
|
{".XLS", ".xlsx", true},
|
||||||
|
{"xls", "xlsx", true},
|
||||||
|
{".doc", ".docx", true},
|
||||||
|
{".ppt", ".pptx", true},
|
||||||
|
{".pdf", ".pdf", true},
|
||||||
|
{"", "", true},
|
||||||
|
{".xls", ".docx", false},
|
||||||
|
{".xlsx", "", false},
|
||||||
|
{".csv", ".xlsx", false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := EquivalentUploadExt(tc.a, tc.b); got != tc.want {
|
||||||
|
t.Errorf("EquivalentUploadExt(%q,%q)=%v want %v", tc.a, tc.b, got, tc.want)
|
||||||
|
}
|
||||||
|
if got := EquivalentUploadExt(tc.b, tc.a); got != tc.want {
|
||||||
|
t.Errorf("EquivalentUploadExt(%q,%q)=%v want %v (symmetric)", tc.b, tc.a, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindFilesByStemExtMatchesConvertedXLS(t *testing.T) {
|
||||||
|
xlsx := &FileEntry{ID: jsonNum("3799"), Title: strPtr("ES29-extracto.xlsx"), FileExst: strPtr(".xlsx")}
|
||||||
|
pdf := &FileEntry{ID: jsonNum("5"), Title: strPtr("ES29-extracto.pdf"), FileExst: strPtr(".pdf")}
|
||||||
|
other := &FileEntry{ID: jsonNum("3887"), Title: strPtr("ES87-extracto.xlsx"), FileExst: strPtr(".xlsx")}
|
||||||
|
files := []*FileEntry{xlsx, pdf, other}
|
||||||
|
|
||||||
|
got := FindFilesByStemExt(files, "ES29-extracto", ".xls")
|
||||||
|
if len(got) != 1 || got[0] != xlsx {
|
||||||
|
t.Fatalf("converted .xls match = %+v, want the saved .xlsx only", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindFilesByStemExtKeepsExactMatch(t *testing.T) {
|
||||||
|
xlsx := &FileEntry{ID: jsonNum("3799"), Title: strPtr("foo.xlsx"), FileExst: strPtr(".xlsx")}
|
||||||
|
pdf := &FileEntry{ID: jsonNum("5"), Title: strPtr("foo.pdf"), FileExst: strPtr(".pdf")}
|
||||||
|
files := []*FileEntry{xlsx, pdf}
|
||||||
|
|
||||||
|
if got := FindFilesByStemExt(files, "foo", ".xlsx"); len(got) != 1 || got[0] != xlsx {
|
||||||
|
t.Fatalf("exact .xlsx match = %+v, want only xlsx", got)
|
||||||
|
}
|
||||||
|
if got := FindFilesByStemExt(files, "foo", ".pdf"); len(got) != 1 || got[0] != pdf {
|
||||||
|
t.Fatalf("exact .pdf match = %+v, want only pdf", got)
|
||||||
|
}
|
||||||
|
if got := FindFilesByStemExt(files, "foo", ".docx"); len(got) != 0 {
|
||||||
|
t.Fatalf("unrelated ext matched %+v, want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindFilesByStemExtEmptyStem(t *testing.T) {
|
||||||
|
f := &FileEntry{ID: jsonNum("1"), Title: strPtr("foo.xlsx"), FileExst: strPtr(".xlsx")}
|
||||||
|
if got := FindFilesByStemExt([]*FileEntry{f}, "", ".xlsx"); len(got) != 0 {
|
||||||
|
t.Fatalf("empty stem matched %+v, want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanUploadReplacementFreshUpload(t *testing.T) {
|
||||||
|
plan := planUploadReplacement(nil, "ES29-extracto", ".xls")
|
||||||
|
if plan.UpdateID != "" || len(plan.DeleteIDs) != 0 {
|
||||||
|
t.Fatalf("empty folder plan = %+v, want a fresh upload", plan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlanUploadReplacementUpdatesExactExt covers the in-place path: only a
|
||||||
|
// stored file with the same extension is updated via UpdateFile. PDF over PDF
|
||||||
|
// and OOXML over OOXML keep the id and rewrite the body.
|
||||||
|
func TestPlanUploadReplacementUpdatesExactExt(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
file *FileEntry
|
||||||
|
stem string
|
||||||
|
ext string
|
||||||
|
id string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "xlsx over xlsx",
|
||||||
|
file: &FileEntry{ID: jsonNum("3799"), Title: strPtr("ES29-extracto.xlsx"), FileExst: strPtr(".xlsx")},
|
||||||
|
stem: "ES29-extracto", ext: ".xlsx", id: "3799",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pdf over pdf",
|
||||||
|
file: &FileEntry{ID: jsonNum("42"), Title: strPtr("extracto.pdf"), FileExst: strPtr(".pdf")},
|
||||||
|
stem: "extracto", ext: ".pdf", id: "42",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "legacy xls over xls",
|
||||||
|
file: &FileEntry{ID: jsonNum("11"), Title: strPtr("legacy.xls"), FileExst: strPtr(".xls")},
|
||||||
|
stem: "legacy", ext: ".xls", id: "11",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
plan := planUploadReplacement([]*FileEntry{tc.file}, tc.stem, tc.ext)
|
||||||
|
if plan.UpdateID != tc.id {
|
||||||
|
t.Fatalf("UpdateID = %q, want %q (same ext updates in place)", plan.UpdateID, tc.id)
|
||||||
|
}
|
||||||
|
if len(plan.DeleteIDs) != 0 {
|
||||||
|
t.Fatalf("DeleteIDs = %v, want none", plan.DeleteIDs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlanUploadReplacementConvertedExtDeletesThenUploads is the regression for
|
||||||
|
// #84: UpdateFile does not re-run the server-side legacy→OOXML conversion, so a
|
||||||
|
// .xls upload must not overwrite a stored .xlsx in place (raw OLE2 under an
|
||||||
|
// .xlsx name). The stale counterpart is deleted and the file uploaded afresh.
|
||||||
|
func TestPlanUploadReplacementConvertedExtDeletesThenUploads(t *testing.T) {
|
||||||
|
xlsx := &FileEntry{ID: jsonNum("3799"), Title: strPtr("ES29-extracto.xlsx"), FileExst: strPtr(".xlsx")}
|
||||||
|
plan := planUploadReplacement([]*FileEntry{xlsx}, "ES29-extracto", ".xls")
|
||||||
|
if plan.UpdateID != "" {
|
||||||
|
t.Fatalf("UpdateID = %q, want empty (do not update across conversion)", plan.UpdateID)
|
||||||
|
}
|
||||||
|
if len(plan.DeleteIDs) != 1 || plan.DeleteIDs[0] != 3799 {
|
||||||
|
t.Fatalf("DeleteIDs = %v, want [3799] (delete the stale .xlsx before upload)", plan.DeleteIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanUploadReplacementCollapsesDuplicates(t *testing.T) {
|
||||||
|
older := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
newer := older.Add(time.Hour)
|
||||||
|
first := &FileEntry{ID: jsonNum("3799"), Title: strPtr("ES29-extracto.xlsx"), FileExst: strPtr(".xlsx"), Updated: &older}
|
||||||
|
second := &FileEntry{ID: jsonNum("3887"), Title: strPtr("ES29-extracto.xlsx"), FileExst: strPtr(".xlsx"), Updated: &newer}
|
||||||
|
plan := planUploadReplacement([]*FileEntry{first, second}, "ES29-extracto", ".xlsx")
|
||||||
|
if plan.UpdateID != "3887" {
|
||||||
|
t.Fatalf("UpdateID = %q, want the newest duplicate 3887", plan.UpdateID)
|
||||||
|
}
|
||||||
|
if len(plan.DeleteIDs) != 1 || plan.DeleteIDs[0] != 3799 {
|
||||||
|
t.Fatalf("DeleteIDs = %v, want [3799]", plan.DeleteIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlanUploadReplacementDeletesConvertedDuplicates: with a converted match
|
||||||
|
// every stale copy is deleted (there is no keeper — the fresh upload replaces
|
||||||
|
// them all).
|
||||||
|
func TestPlanUploadReplacementDeletesConvertedDuplicates(t *testing.T) {
|
||||||
|
first := &FileEntry{ID: jsonNum("3799"), Title: strPtr("ES29-extracto.xlsx"), FileExst: strPtr(".xlsx")}
|
||||||
|
second := &FileEntry{ID: jsonNum("3887"), Title: strPtr("ES29-extracto.xlsx"), FileExst: strPtr(".xlsx")}
|
||||||
|
plan := planUploadReplacement([]*FileEntry{first, second}, "ES29-extracto", ".xls")
|
||||||
|
if plan.UpdateID != "" {
|
||||||
|
t.Fatalf("UpdateID = %q, want empty", plan.UpdateID)
|
||||||
|
}
|
||||||
|
if len(plan.DeleteIDs) != 2 {
|
||||||
|
t.Fatalf("DeleteIDs = %v, want both stale .xlsx ids", plan.DeleteIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlanUploadReplacementRepeatedXLS is the regression for #84: with the
|
||||||
|
// server-converted .xlsx already present, the second .xls upload deletes it and
|
||||||
|
// uploads anew so OnlyOffice converts again — UpdateFile would corrupt it.
|
||||||
|
func TestPlanUploadReplacementRepeatedXLS(t *testing.T) {
|
||||||
|
const stem = "ES29-extracto"
|
||||||
|
|
||||||
|
// First upload: nothing in the folder.
|
||||||
|
if plan := planUploadReplacement(nil, stem, ".xls"); plan.UpdateID != "" || len(plan.DeleteIDs) != 0 {
|
||||||
|
t.Fatalf("first upload plan = %+v, want create", plan)
|
||||||
|
}
|
||||||
|
// OnlyOffice converts .xls -> .xlsx on upload; the second upload must
|
||||||
|
// delete it and upload fresh, never UpdateFile it.
|
||||||
|
saved := &FileEntry{ID: jsonNum("3799"), Title: strPtr(stem + ".xlsx"), FileExst: strPtr(".xlsx")}
|
||||||
|
plan := planUploadReplacement([]*FileEntry{saved}, stem, ".xls")
|
||||||
|
if plan.UpdateID != "" {
|
||||||
|
t.Fatalf("second upload plan = %+v, want delete+upload (not UpdateFile)", plan)
|
||||||
|
}
|
||||||
|
if len(plan.DeleteIDs) != 1 || plan.DeleteIDs[0] != 3799 {
|
||||||
|
t.Fatalf("second upload DeleteIDs = %v, want [3799]", plan.DeleteIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
+95
-7
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -121,7 +122,9 @@ 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.
|
// AssertNoFileConflict reports ErrFileExists when localPath logical name is
|
||||||
|
// already in folderID. Matching is conversion-aware, so a .xls upload also
|
||||||
|
// conflicts with an existing .xlsx and does not create a hidden duplicate.
|
||||||
func (c *Client) AssertNoFileConflict(ctx context.Context, folderID, localPath string) error {
|
func (c *Client) AssertNoFileConflict(ctx context.Context, folderID, localPath string) error {
|
||||||
files, err := c.FolderFiles(ctx, folderID)
|
files, err := c.FolderFiles(ctx, folderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -129,7 +132,7 @@ func (c *Client) AssertNoFileConflict(ctx context.Context, folderID, localPath s
|
|||||||
}
|
}
|
||||||
stem := UploadStemFromLocal(localPath)
|
stem := UploadStemFromLocal(localPath)
|
||||||
ext := UploadExtFromLocal(localPath)
|
ext := UploadExtFromLocal(localPath)
|
||||||
matches := FindFilesByDedupKey(files, stem, ext)
|
matches := FindFilesByStemExt(files, stem, ext)
|
||||||
if len(matches) == 0 {
|
if len(matches) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -153,14 +156,99 @@ func (c *Client) UploadProjectFileNoClobber(ctx context.Context, projectID, loca
|
|||||||
return c.UploadProjectFile(ctx, projectID, localPath)
|
return c.UploadProjectFile(ctx, projectID, localPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadToFolderReplacing deletes same stem+ext files then uploads localPath.
|
// uploadReplacementPlan is how a replacing upload reconciles with a folder.
|
||||||
|
type uploadReplacementPlan struct {
|
||||||
|
// UpdateID is the existing file to overwrite in place; set only when the
|
||||||
|
// stored extension equals the local one. UpdateFile keeps the stored name,
|
||||||
|
// so overwriting across extensions would leave an unconverted body under a
|
||||||
|
// mismatched name.
|
||||||
|
UpdateID string
|
||||||
|
// DeleteIDs are the ids to remove before uploading. They are the redundant
|
||||||
|
// duplicates of an in-place update, or every conversion-equivalent
|
||||||
|
// counterpart when the body must be converted again by a fresh upload.
|
||||||
|
DeleteIDs []int
|
||||||
|
}
|
||||||
|
|
||||||
|
// planUploadReplacement matches an incoming local upload (stem + ext) against
|
||||||
|
// the files already in a folder and decides between a fresh upload, an in-place
|
||||||
|
// update and delete + reupload. Matching tolerates the legacy→OOXML conversion
|
||||||
|
// OnlyOffice performs on upload, so a repeated upload of f.xls finds the saved
|
||||||
|
// f.xlsx instead of creating a second file. Because UpdateFile replaces the body
|
||||||
|
// without re-running that conversion, an equivalent-but-different extension is
|
||||||
|
// deleted and re-uploaded rather than updated in place (#84).
|
||||||
|
func planUploadReplacement(files []*FileEntry, stem, ext string) uploadReplacementPlan {
|
||||||
|
matches := FindFilesByStemExt(files, stem, ext)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return uploadReplacementPlan{}
|
||||||
|
}
|
||||||
|
keep, remove := pickDuplicateKeeper(matches, false)
|
||||||
|
plan := uploadReplacementPlan{}
|
||||||
|
if FileEntryExt(keep) == normalizeExt(ext) {
|
||||||
|
if id := FileEntryNumericID(keep); id != 0 {
|
||||||
|
plan.UpdateID = strconv.FormatInt(id, 10)
|
||||||
|
}
|
||||||
|
} else if id := int(FileEntryNumericID(keep)); id != 0 {
|
||||||
|
plan.DeleteIDs = append(plan.DeleteIDs, id)
|
||||||
|
}
|
||||||
|
for _, f := range remove {
|
||||||
|
if id := int(FileEntryNumericID(f)); id != 0 {
|
||||||
|
plan.DeleteIDs = append(plan.DeleteIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plan
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadToFolderReplacing upserts localPath into folderID by logical name.
|
||||||
|
// Matching is stem + extension with OnlyOffice's server-side conversion
|
||||||
|
// accounted for: a local .xls is stored as .xlsx, so a repeated upload replaces
|
||||||
|
// the saved document instead of appending a duplicate. An exact-extension
|
||||||
|
// counterpart is updated in place (stable id, no window without the file); a
|
||||||
|
// conversion-equivalent one is deleted and uploaded afresh so the server
|
||||||
|
// converts the body again. Extra duplicates are collapsed either way.
|
||||||
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)
|
||||||
ext := UploadExtFromLocal(localPath)
|
ext := UploadExtFromLocal(localPath)
|
||||||
deleted, err := c.DeleteFilesByDedupKey(ctx, folderID, stem, ext)
|
files, err := c.FolderFiles(ctx, folderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, deleted, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
ent, err := c.UploadToFolder(ctx, folderID, localPath)
|
plan := planUploadReplacement(files, stem, ext)
|
||||||
return ent, deleted, err
|
|
||||||
|
if plan.UpdateID != "" {
|
||||||
|
ent, err := c.UpdateFile(ctx, plan.UpdateID, localPath)
|
||||||
|
if err == nil {
|
||||||
|
deleted, derr := c.deleteReplacementStale(ctx, plan.DeleteIDs)
|
||||||
|
return ent, deleted, derr
|
||||||
|
}
|
||||||
|
// Portal rejected the in-place update: delete + fresh upload still
|
||||||
|
// leaves one file (server-converted).
|
||||||
|
deleted, derr := c.DeleteFilesByStemExt(ctx, folderID, stem, ext)
|
||||||
|
if derr != nil {
|
||||||
|
return nil, deleted, derr
|
||||||
|
}
|
||||||
|
ent, uerr := c.UploadToFolder(ctx, folderID, localPath)
|
||||||
|
return ent, deleted, uerr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversion-equivalent (or no counterpart): remove the stale files first so
|
||||||
|
// the fresh upload is converted and exactly one file remains.
|
||||||
|
deleted, derr := c.deleteReplacementStale(ctx, plan.DeleteIDs)
|
||||||
|
if derr != nil {
|
||||||
|
return nil, deleted, derr
|
||||||
|
}
|
||||||
|
ent, uerr := c.UploadToFolder(ctx, folderID, localPath)
|
||||||
|
return ent, deleted, uerr
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteReplacementStale removes the ids collected by planUploadReplacement:
|
||||||
|
// duplicate files after an in-place update, or every stale counterpart before a
|
||||||
|
// converted reupload.
|
||||||
|
func (c *Client) deleteReplacementStale(ctx context.Context, ids []int) ([]int, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err := c.DeleteFiles(ctx, ids); err != nil {
|
||||||
|
return ids, err
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user