Compare commits
13
Commits
33b5c1ea97
...
b3dfff1a4c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3dfff1a4c | ||
|
|
b5a61ad420 | ||
|
|
1aba545e1d | ||
|
|
94951fc6c5 | ||
|
|
d95985e6d0 | ||
|
|
57b383d579 | ||
|
|
a73f6c172d | ||
|
|
e49693ad2c | ||
|
|
b86d71211f | ||
|
|
a2b919478e | ||
|
|
ccabc11153 | ||
|
|
47a2c256f3 | ||
|
|
c1881e1e61 |
@@ -34,3 +34,10 @@ ONLYOFFICE_PROJECT_ID=33
|
|||||||
# MINIO_BUCKET=office
|
# MINIO_BUCKET=office
|
||||||
# MINIO_ACCESS_KEY=
|
# MINIO_ACCESS_KEY=
|
||||||
# MINIO_SECRET_KEY=
|
# MINIO_SECRET_KEY=
|
||||||
|
|
||||||
|
# oo search — direct Elasticsearch access for name + content search. ES lives
|
||||||
|
# inside the OnlyOffice VM on localhost:9200; expose it with an SSH tunnel
|
||||||
|
# (see docs/elasticsearch.md). ONLYOFFICE_ES_INDEX defaults to files_file.
|
||||||
|
# ONLYOFFICE_ES_URL=http://127.0.0.1:9200
|
||||||
|
# ONLYOFFICE_ES_INDEX=files_file
|
||||||
|
# ONLYOFFICE_TENANT=
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.17.0"
|
".": "0.18.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,23 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## [0.18.0](https://github.com/eSlider/go-onlyoffice/compare/v0.17.0...v0.18.0) (2026-09-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **crm:** add UpdateContactName and CloseCRMTask helpers ([4d8af7a](https://github.com/eSlider/go-onlyoffice/commit/4d8af7a2fef1fcfd96ad913cefc6497e3389963b))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **crm:** deterministic sortBy=id in contact paged lists ([4d91726](https://github.com/eSlider/go-onlyoffice/commit/4d917261792203732ac739644c9c82124b2166eb))
|
||||||
|
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* **funding:** eSlider support links (reverse-import GitHub e9c969a) ([d650a16](https://github.com/eSlider/go-onlyoffice/commit/d650a16a36037949505eb017c92bd3283e6e2f0f))
|
||||||
|
|
||||||
## [0.17.0](https://github.com/eSlider/go-onlyoffice/compare/v0.16.0...v0.17.0) (2026-08-31)
|
## [0.17.0](https://github.com/eSlider/go-onlyoffice/compare/v0.16.0...v0.17.0) (2026-08-31)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -678,6 +678,25 @@ 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)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Search (`oo search`)
|
||||||
|
|
||||||
|
Full-text search over the Documents index. The REST endpoint
|
||||||
|
`/api/2.0/files/@search/{query}` only searches file names in the database, so
|
||||||
|
`oo search` talks to the OnlyOffice **Elasticsearch** directly (index
|
||||||
|
`files_file`). Name search is default; `--content` also matches extracted
|
||||||
|
document text (`document.attachment.content`, Office formats only).
|
||||||
|
See [`docs/elasticsearch.md`](docs/elasticsearch.md) for the tunnel setup.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
oo search "Rechnung" # names only
|
||||||
|
oo search "Mahngebühr" --content # names + document text
|
||||||
|
oo search "Rechnung" --folder 649 --limit 50
|
||||||
|
oo search "Rechnung" --json # shorthand for -o json
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires `ONLYOFFICE_ES_URL` (plus optional `ONLYOFFICE_ES_INDEX`,
|
||||||
|
`ONLYOFFICE_TENANT`).
|
||||||
|
|
||||||
### Bulk tools (`cmd/`)
|
### Bulk tools (`cmd/`)
|
||||||
|
|
||||||
Small single-purpose binaries for bulk Documents work. All of them pace
|
Small single-purpose binaries for bulk Documents work. All of them pace
|
||||||
@@ -714,6 +733,7 @@ kontolink IN.xlsx oo-index.tsv OUT.xlsx [FILE_ID] [AMOUNTS_TSV]
|
|||||||
| `docs` | `tools`, `convert`, `optimize`, `ocr`, `hocr`, `as-md`, `put-md`, `put-txt`, `put-xlsx` |
|
| `docs` | `tools`, `convert`, `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`, `rename-file`, `rename-folder`, `download`, `fileops` |
|
||||||
|
| `search` | `QUERY` (`--content`, `--folder ID`, `--limit N`, `--json`) |
|
||||||
|
|
||||||
The CLI reads only `.env` from the current working directory (godotenv is a
|
The CLI reads only `.env` from the current working directory (godotenv is a
|
||||||
CLI-only concern — the library itself never loads dotfiles).
|
CLI-only concern — the library itself never loads dotfiles).
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ func davCmd() *cobra.Command {
|
|||||||
cmd.AddCommand(davMoveCmd())
|
cmd.AddCommand(davMoveCmd())
|
||||||
cmd.AddCommand(davCopyCmd())
|
cmd.AddCommand(davCopyCmd())
|
||||||
cmd.AddCommand(davMkdirCmd())
|
cmd.AddCommand(davMkdirCmd())
|
||||||
|
cmd.AddCommand(davRemoveCmd())
|
||||||
cmd.AddCommand(davRenameFileCmd())
|
cmd.AddCommand(davRenameFileCmd())
|
||||||
cmd.AddCommand(davRenameFolderCmd())
|
cmd.AddCommand(davRenameFolderCmd())
|
||||||
cmd.AddCommand(davDownloadCmd())
|
cmd.AddCommand(davDownloadCmd())
|
||||||
@@ -184,6 +185,32 @@ func davMkdirCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func davRemoveCmd() *cobra.Command {
|
||||||
|
var folderIDs []string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "rm [FILE_ID...]",
|
||||||
|
Aliases: []string{"delete"},
|
||||||
|
Short: "Permanently delete file(s) and/or folder(s) from Documents",
|
||||||
|
Args: cobra.ArbitraryArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) == 0 && len(folderIDs) == 0 {
|
||||||
|
return fmt.Errorf("dav rm: give at least one FILE_ID or --folders")
|
||||||
|
}
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.DeleteDavItems(cmd.Context(), folderIDs, args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(map[string]any{"deleted_files": args, "deleted_folders": folderIDs})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringSliceVar(&folderIDs, "folders", nil, "folder ids to delete")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
func davRenameFileCmd() *cobra.Command {
|
func davRenameFileCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "rename-file FILE_ID NEW_TITLE",
|
Use: "rename-file FILE_ID NEW_TITLE",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
// oo docs tools | convert | optimize | ocr | hocr | as-md | put-md | put-txt | put-xlsx
|
// oo docs tools | convert | 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 | rename-file | rename-folder | download | fileops
|
||||||
|
// oo search QUERY [--content] [--folder ID] [--limit N] [--json]
|
||||||
//
|
//
|
||||||
// CRM association rules: docs/crm-associations.md
|
// CRM association rules: docs/crm-associations.md
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(searchCmd())
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchCmd queries the OnlyOffice Elasticsearch index directly. The REST
|
||||||
|
// /api/2.0/files/@search endpoint only searches file names in the database;
|
||||||
|
// content search needs ES (see docs/elasticsearch.md).
|
||||||
|
func searchCmd() *cobra.Command {
|
||||||
|
var (
|
||||||
|
content bool
|
||||||
|
folder string
|
||||||
|
limit int
|
||||||
|
asJSON bool
|
||||||
|
)
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "search QUERY",
|
||||||
|
Short: "Full-text search over documents by name, optionally by content (Elasticsearch)",
|
||||||
|
Long: "Search the OnlyOffice Documents index.\n\n" +
|
||||||
|
"By default only file names are matched. With --content the query also\n" +
|
||||||
|
"matches extracted document text (document.attachment.content); this covers\n" +
|
||||||
|
"Office formats (docx/xlsx/pptx) and is slower.\n\n" +
|
||||||
|
"Requires ONLYOFFICE_ES_URL (and optionally ONLYOFFICE_ES_INDEX,\n" +
|
||||||
|
"ONLYOFFICE_TENANT). See docs/elasticsearch.md for the tunnel setup.",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if asJSON {
|
||||||
|
outputFormat = "json"
|
||||||
|
}
|
||||||
|
es, err := onlyoffice.NewESSearcher(onlyoffice.ESConfigFromEnv())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hits, err := es.Search(cmd.Context(), onlyoffice.SearchQuery{
|
||||||
|
Text: args[0],
|
||||||
|
InContent: content,
|
||||||
|
FolderID: folder,
|
||||||
|
Limit: limit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([]map[string]any, 0, len(hits))
|
||||||
|
for _, h := range hits {
|
||||||
|
rows = append(rows, map[string]any{
|
||||||
|
"id": h.ID,
|
||||||
|
"title": h.Title,
|
||||||
|
"folder": h.ParentID,
|
||||||
|
"score": h.Score,
|
||||||
|
"highlight": h.Highlight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if outputFormat == "json" {
|
||||||
|
printJSON(rows)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
printTable([]string{"id", "title", "folder", "score", "highlight"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&content, "content", false, "also match extracted document content")
|
||||||
|
cmd.Flags().StringVar(&folder, "folder", "", "limit to a Documents folder id")
|
||||||
|
cmd.Flags().IntVar(&limit, "limit", 20, "maximum number of results")
|
||||||
|
cmd.Flags().BoolVar(&asJSON, "json", false, "shorthand for --output json")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSearchCommandRegisteredWithFlags(t *testing.T) {
|
||||||
|
cmd, _, err := rootCmd.Find([]string{"search"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cmd.Name() != "search" {
|
||||||
|
t.Fatalf("search resolved to %q", cmd.Name())
|
||||||
|
}
|
||||||
|
for _, name := range []string{"content", "folder", "limit", "json"} {
|
||||||
|
if cmd.Flags().Lookup(name) == nil {
|
||||||
|
t.Errorf("search: missing --%s flag", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := cmd.Flags().Lookup("limit").DefValue; got != "20" {
|
||||||
|
t.Errorf("--limit default = %q, want 20", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchWithoutESURLIsClearError(t *testing.T) {
|
||||||
|
clearEnv(t, "ONLYOFFICE_ES_URL", "ONLYOFFICE_ES_INDEX", "ONLYOFFICE_TENANT")
|
||||||
|
errBuf := &bytes.Buffer{}
|
||||||
|
rootCmd.SetErr(errBuf)
|
||||||
|
rootCmd.SetOut(&bytes.Buffer{})
|
||||||
|
rootCmd.SetArgs([]string{"search", "Rechnung"})
|
||||||
|
t.Cleanup(func() {
|
||||||
|
rootCmd.SetArgs(nil)
|
||||||
|
rootCmd.SetOut(nil)
|
||||||
|
rootCmd.SetErr(nil)
|
||||||
|
})
|
||||||
|
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error without ONLYOFFICE_ES_URL")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "ONLYOFFICE_ES_URL") {
|
||||||
|
t.Fatalf("error %q missing ONLYOFFICE_ES_URL", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
+198
-14
@@ -18,7 +18,52 @@ import (
|
|||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
)
|
)
|
||||||
|
|
||||||
var amountRe = regexp.MustCompile(`(?i)(zu zahlender betrag|rechnungsbetrag)\s*[:\s]*([0-9][0-9.]*,[0-9]{2})`)
|
// amountPat is the amount capture shared by every amount regex.
|
||||||
|
const amountPat = `([0-9]+(?:[.,][0-9]+)*)`
|
||||||
|
|
||||||
|
// amountRE builds "<label> [optional (comment)] [: -] <number>".
|
||||||
|
func amountRE(label string) *regexp.Regexp {
|
||||||
|
return regexp.MustCompile(
|
||||||
|
`(?i)\b` + regexp.QuoteMeta(label) + `\b\s*(?:\([^)]*\))?\s*[:\-]?\s*` + amountPat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// amountRes lists the payable-amount patterns in strict priority order: the
|
||||||
|
// first pattern with a usable amount wins, and a lower-priority label can never
|
||||||
|
// override a higher-priority one ("zu zahlender betrag" > "rechnungsbetrag" >
|
||||||
|
// "rechnungsendbetrag" > "gesamtbetrag" > "gesamtsumme (inkl. steuern)").
|
||||||
|
//
|
||||||
|
// "gesamtbetrag" and "gesamtsumme" are not in the original set but are the real
|
||||||
|
// labels on Diashop invoices ("Gesamtsumme (inkl. Steuern)"). The inclusive
|
||||||
|
// variant is matched before a plain "gesamtsumme". Everything after those
|
||||||
|
// primary labels is the broader fallback set, consulted only when no primary
|
||||||
|
// label yields an amount. Within one pattern the last usable amount is taken,
|
||||||
|
// because totals usually come last.
|
||||||
|
var amountRes = []*regexp.Regexp{
|
||||||
|
amountRE("zu zahlender betrag"),
|
||||||
|
amountRE("rechnungsbetrag"),
|
||||||
|
amountRE("rechnungsendbetrag"),
|
||||||
|
amountRE("gesamtbetrag"),
|
||||||
|
regexp.MustCompile(`(?i)\bgesamtsumme\b\s*\(\s*inkl\.?\s*steuern\s*\)\s*[:\-]?\s*` + amountPat),
|
||||||
|
amountRE("gesamtsumme"),
|
||||||
|
amountRE("endbetrag"),
|
||||||
|
amountRE("zahlbetrag"),
|
||||||
|
amountRE("bruttobetrag"),
|
||||||
|
amountRE("betrag"),
|
||||||
|
amountRE("total"),
|
||||||
|
amountRE("summe"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// taxLineRe marks a line whose number is a tax rate/percentage: an explicit
|
||||||
|
// percent sign or a VAT/tax keyword. "Steuern" (plural, as in "inkl. Steuern")
|
||||||
|
// is handled separately so the inclusive total stays usable.
|
||||||
|
var taxLineRe = regexp.MustCompile(`(?i)%|\bMwSt\b|\bUSt\b|\bProzent\b`)
|
||||||
|
|
||||||
|
// steuerRe finds "Steuer"/"Umsatzsteuer" etc. RE2 has no lookahead, so the
|
||||||
|
// plural "Steuern" is excluded in isTaxLine.
|
||||||
|
var steuerRe = regexp.MustCompile(`(?i)steuer`)
|
||||||
|
|
||||||
|
// percentAfterRe detects a percent sign directly after a number (spaces ok).
|
||||||
|
var percentAfterRe = regexp.MustCompile(`^\s*%`)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
@@ -105,20 +150,159 @@ func pdfAmount(ctx context.Context, c *onlyoffice.Client, id string) (string, er
|
|||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
m := amountRe.FindStringSubmatch(buf.String())
|
return extractAmount(buf.String()), nil
|
||||||
if m == nil {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return parseDe(m[2]), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseDe turns "1.234,56" into 1234.56.
|
// extractAmount returns the normalised ("1234.56") payable amount found in
|
||||||
func parseDe(s string) string {
|
// text, or "" if no usable amount matches.
|
||||||
s = strings.ReplaceAll(s, ".", "")
|
//
|
||||||
s = strings.ReplaceAll(s, ",", ".")
|
// DKV invoices are special-cased first: they repeat a per-vehicle "TOTAL:" line
|
||||||
v, err := strconv.ParseFloat(s, 64)
|
// and carry the real total only in the "Gesamtsummenaufstellung" section.
|
||||||
if err != nil {
|
func extractAmount(text string) string {
|
||||||
return s
|
if v, ok := dkvGrandTotal(text); ok {
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
return strconv.FormatFloat(v, 'f', 2, 64)
|
for _, re := range amountRes {
|
||||||
|
if v, ok := lastUsableAmount(text, re); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// dkvGrandTotal extracts the total of a DKV "Gesamtsummenaufstellung" section.
|
||||||
|
//
|
||||||
|
// Rule: DKV invoices repeat a per-vehicle "TOTAL:" line, so the last TOTAL is
|
||||||
|
// not the invoice total. When a "Gesamtsummenaufstellung" section exists, its
|
||||||
|
// total wins over every "TOTAL:" line: the first amount after the "»" marker,
|
||||||
|
// or, if there is none, the last amount in the section. The section ends at the
|
||||||
|
// page break (form feed) or end of text.
|
||||||
|
func dkvGrandTotal(text string) (string, bool) {
|
||||||
|
idx := strings.Index(strings.ToLower(text), "gesamtsummenaufstellung")
|
||||||
|
if idx < 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
section := text[idx:]
|
||||||
|
if ff := strings.IndexByte(section, '\f'); ff >= 0 {
|
||||||
|
section = section[:ff]
|
||||||
|
}
|
||||||
|
if m := strings.Index(section, "»"); m >= 0 {
|
||||||
|
if v, ok := firstAmount(section[m:]); ok {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastAmount(section)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastUsableAmount returns the last amount matched by re that is not a tax rate
|
||||||
|
// or percentage. Within one label the last usable amount wins.
|
||||||
|
func lastUsableAmount(text string, re *regexp.Regexp) (string, bool) {
|
||||||
|
ms := re.FindAllStringSubmatchIndex(text, -1)
|
||||||
|
for i := len(ms) - 1; i >= 0; i-- {
|
||||||
|
m := ms[i]
|
||||||
|
if isTaxRate(text, m[2], m[3]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v, ok := normalizeAmount(text[m[2]:m[3]]); ok {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTaxRate reports whether the number at text[start:end] is a tax rate or a
|
||||||
|
// percentage instead of a payable amount. A candidate is rejected when the
|
||||||
|
// token right after the number is "%" or the number's line carries a percent
|
||||||
|
// sign or a tax keyword. Rejecting is deliberate: office matching treats a
|
||||||
|
// known-but-different amount as a hard disqualifier, so an empty result is
|
||||||
|
// safer than the VAT rate.
|
||||||
|
func isTaxRate(text string, start, end int) bool {
|
||||||
|
if percentAfterRe.MatchString(text[end:]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
lineStart := strings.LastIndexByte(text[:start], '\n') + 1
|
||||||
|
line := text[lineStart:]
|
||||||
|
if n := strings.IndexByte(text[end:], '\n'); n >= 0 {
|
||||||
|
line = text[lineStart : end+n]
|
||||||
|
}
|
||||||
|
return isTaxLine(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTaxLine reports whether a line looks like a tax rate rather than a payable
|
||||||
|
// amount. "Steuern" is treated as a qualifier ("inkl. Steuern"), not a rate.
|
||||||
|
func isTaxLine(line string) bool {
|
||||||
|
if taxLineRe.MatchString(line) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, loc := range steuerRe.FindAllStringIndex(line, -1) {
|
||||||
|
if loc[1] >= len(line) || (line[loc[1]] != 'n' && line[loc[1]] != 'N') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// numberRe finds bare numbers (with optional thousands/decimal separators).
|
||||||
|
var numberRe = regexp.MustCompile(`[0-9]+(?:[.,][0-9]+)*`)
|
||||||
|
|
||||||
|
func firstAmount(s string) (string, bool) {
|
||||||
|
for _, m := range numberRe.FindAllString(s, -1) {
|
||||||
|
if v, ok := normalizeAmount(m); ok {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastAmount(s string) (string, bool) {
|
||||||
|
ms := numberRe.FindAllString(s, -1)
|
||||||
|
for i := len(ms) - 1; i >= 0; i-- {
|
||||||
|
if v, ok := normalizeAmount(ms[i]); ok {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeAmount turns "1.234,56" (DE), "1,234.56" (EN) or "1234.56" into
|
||||||
|
// "1234.56". The rightmost separator is decimal only when followed by one or
|
||||||
|
// two digits; otherwise every separator is a thousands separator.
|
||||||
|
func normalizeAmount(s string) (string, bool) {
|
||||||
|
last := -1
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == '.' || s[i] == ',' {
|
||||||
|
last = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var dec byte
|
||||||
|
if last >= 0 {
|
||||||
|
digits := 0
|
||||||
|
for i := last + 1; i < len(s); i++ {
|
||||||
|
if s[i] < '0' || s[i] > '9' {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
digits++
|
||||||
|
}
|
||||||
|
if digits == 1 || digits == 2 {
|
||||||
|
dec = s[last]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
switch c := s[i]; {
|
||||||
|
case c >= '0' && c <= '9':
|
||||||
|
b.WriteByte(c)
|
||||||
|
case (c == '.' || c == ',') && c == dec:
|
||||||
|
b.WriteByte('.')
|
||||||
|
case c == '.' || c == ',':
|
||||||
|
// thousands separator
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseFloat(b.String(), 64)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strconv.FormatFloat(v, 'f', 2, 64), true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestExtractAmount(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, text, want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "rechnungsbetrag de format",
|
||||||
|
text: "Rechnungsbetrag: 1.234,56 €",
|
||||||
|
want: "1234.56",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rechnungsbetrag en thousands and dot",
|
||||||
|
text: "Rechnungsbetrag: 1,234.56",
|
||||||
|
want: "1234.56",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rechnungsbetrag plain dot",
|
||||||
|
text: "Rechnungsbetrag: 1234.56",
|
||||||
|
want: "1234.56",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rechnungsbetrag de comma only",
|
||||||
|
text: "Rechnungsbetrag: 1234,56",
|
||||||
|
want: "1234.56",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "currency suffix eur",
|
||||||
|
text: "Rechnungsbetrag: 1.234,56 EUR",
|
||||||
|
want: "1234.56",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zu zahlender betrag wins over rechnungsbetrag",
|
||||||
|
text: "Zu zahlender Betrag: 10,00\nRechnungsbetrag: 99,00",
|
||||||
|
want: "10.00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rechnungsbetrag wins over endbetrag",
|
||||||
|
text: "Endbetrag: 20,00\nRechnungsbetrag: 30,00",
|
||||||
|
want: "30.00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bruttobetrag wins over bare betrag",
|
||||||
|
text: "Bruttobetrag: 50,00\nBetrag: 10,00",
|
||||||
|
want: "50.00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gesamtbetrag wins over bare betrag",
|
||||||
|
text: "Gesamtbetrag: 80,00\nBetrag: 10,00",
|
||||||
|
want: "80.00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "last occurrence of same label wins",
|
||||||
|
text: "Rechnungsbetrag: 10,00\nRechnungsbetrag: 20,00",
|
||||||
|
want: "20.00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "endbetrag fallback",
|
||||||
|
text: "Endbetrag: 42,00",
|
||||||
|
want: "42.00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zahlbetrag fallback without colon",
|
||||||
|
text: "Zahlbetrag 7,50 €",
|
||||||
|
want: "7.50",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rechnungsendbetrag beats endbetrag",
|
||||||
|
text: "Rechnungsendbetrag: 12,00\nEndbetrag: 13,00",
|
||||||
|
want: "12.00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dkv style total line",
|
||||||
|
text: "Kundenbezogene Daten\n» TOTAL: 123,45 100,00 23,45 123,45\n",
|
||||||
|
want: "123.45",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dkv gesamtsummenaufstellung grand total after marker",
|
||||||
|
text: "» TOTAL: 111,11 100,00 11,11 111,11\n" +
|
||||||
|
"» TOTAL: 222,22 200,00 22,22 222,22\n" +
|
||||||
|
"Gesamtsummenaufstellung\n" +
|
||||||
|
"Netto 240,00\n" +
|
||||||
|
"MwSt 47,25\n" +
|
||||||
|
"» 287,25\n",
|
||||||
|
want: "287.25",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dkv gesamtsummenaufstellung total on next line",
|
||||||
|
text: "» TOTAL: 111,11\nGesamtsummenaufstellung\n»\n287,25\n",
|
||||||
|
want: "287.25",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tax rate with percent sign is not an amount",
|
||||||
|
text: "Betrag: 19,00 % MwSt",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mehrwertsteuer rate is not an amount",
|
||||||
|
text: "Gesamtsumme: 19,00% MwSt",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "steuer word on the number line rejects it",
|
||||||
|
text: "Betrag: 2,83 Steuer",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rejected primary falls back to a usable label",
|
||||||
|
text: "Gesamtsumme: 19,00 % MwSt\nEndbetrag: 42,00",
|
||||||
|
want: "42.00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "labeled zu zahlender betrag beats unlabeled larger number",
|
||||||
|
text: "unlabeled 999,99\nZu zahlender Betrag: 10,00",
|
||||||
|
want: "10.00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "labeled zu zahlender betrag beats lower label larger number",
|
||||||
|
text: "Endbetrag: 999,99\nZu zahlender Betrag: 10,00",
|
||||||
|
want: "10.00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "diashop style gesamtsumme with comment",
|
||||||
|
text: "Zwischensumme\n12,34 €\nZwischensumme\n12,34 €\nVersand & Bearbeitung\n4,95 €\nGesamtsumme (inkl. Steuern)\n17,29 €\n",
|
||||||
|
want: "17.29",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "diashop picks inclusive total last",
|
||||||
|
text: "Gesamtsumme (exkl. Steuern)\n12,34 €\nGesamtsumme (inkl. Steuern)\n17,29 €",
|
||||||
|
want: "17.29",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no label",
|
||||||
|
text: "some text without any amount label 12,34",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := extractAmount(tc.text); got != tc.want {
|
||||||
|
t.Fatalf("extractAmount()=%q want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeAmount(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"1.234,56", "1234.56", true},
|
||||||
|
{"1,234.56", "1234.56", true},
|
||||||
|
{"1234.56", "1234.56", true},
|
||||||
|
{"1234,56", "1234.56", true},
|
||||||
|
{"1.234.567,89", "1234567.89", true},
|
||||||
|
{"1,234,567.89", "1234567.89", true},
|
||||||
|
{"1.234", "1234.00", true},
|
||||||
|
{"12,5", "12.50", true},
|
||||||
|
{"12", "12.00", true},
|
||||||
|
{"", "0.00", false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
got, ok := normalizeAmount(tc.in)
|
||||||
|
if ok != tc.ok {
|
||||||
|
t.Fatalf("normalizeAmount(%q) ok=%v want %v", tc.in, ok, tc.ok)
|
||||||
|
}
|
||||||
|
if ok && got != tc.want {
|
||||||
|
t.Fatalf("normalizeAmount(%q)=%q want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
type: reference
|
||||||
|
status: current
|
||||||
|
related:
|
||||||
|
- README.md
|
||||||
|
- file_es.go
|
||||||
|
---
|
||||||
|
|
||||||
|
# Elasticsearch — полнотекстовый поиск OnlyOffice
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Полнотекстовый поиск OnlyOffice Workspace работает на **Elasticsearch**.
|
||||||
|
Клиент на сервере — NEST. Индекс — имя таблицы.
|
||||||
|
|
||||||
|
Для файлов индекс `files_file`:
|
||||||
|
|
||||||
|
| поле | тип | смысл |
|
||||||
|
|------|-----|-------|
|
||||||
|
| `id` | integer | id файла (тот же, что в REST/Documents) |
|
||||||
|
| `title` | text (`whitespacecustom`) | имя файла |
|
||||||
|
| `tenantId` | integer | тенант (портал) |
|
||||||
|
| `folders` | nested | список папок: `folderId` (строка), `id`, `tenantId` |
|
||||||
|
| `document.attachment.content` | text (`document`) | извлеченный текст (ingest-attachment) |
|
||||||
|
| `document.attachment.content_type` | text | MIME |
|
||||||
|
|
||||||
|
Важно:
|
||||||
|
- Живой сервер — **Elasticsearch 7.16.3**, кластер `elasticsearch`.
|
||||||
|
- REST `GET /api/2.0/files/@search/{query}` ищет **только по имени в БД**
|
||||||
|
(`fileDao.Search`), ES не задействует. Для поиска по содержимому нужен
|
||||||
|
прямой ES — это и делает `oo search`.
|
||||||
|
- `title` analyzer `whitespacecustom` режет по пробелам и lower-case. Полное
|
||||||
|
имя файла — один токен (`Rechnung-4711.pdf`), поэтому поиск по имени ищет
|
||||||
|
слово целиком, а не подстроку.
|
||||||
|
- `document.attachment.content` заполняется **только для Office-форматов**
|
||||||
|
(docx / xlsx / pptx). У PDF/txt, залитых через API, контент не извлекается.
|
||||||
|
- Индексация асинхронная (TeamLabSvc) — файл появляется в ES не мгновенно.
|
||||||
|
|
||||||
|
## Доступ
|
||||||
|
|
||||||
|
ES слушает `127.0.0.1:9200` **внутри** VM OnlyOffice. Снаружи порт закрыт,
|
||||||
|
SSH в VM открыт на хосте как `127.0.0.1:32` (контейнер `onlyoffice-v2`,
|
||||||
|
QEMU). Схема — SSH-туннель.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# из корня go-onlyoffice (ключ и хост — как в infra-доках)
|
||||||
|
ssh -f -N -o ControlMaster=no -o ControlPath=none \
|
||||||
|
-p 32 -i ~/.ssh/id_ed25519 \
|
||||||
|
-L 9200:127.0.0.1:9200 root@127.0.0.1
|
||||||
|
|
||||||
|
curl -s http://127.0.0.1:9200/ | head # tagline + version
|
||||||
|
curl -s 'http://127.0.0.1:9200/_cat/indices?h=index,docs.count'
|
||||||
|
```
|
||||||
|
|
||||||
|
`-o ControlMaster=no -o ControlPath=none` обязательны: иначе forward уходит
|
||||||
|
в persistent master-соединение из `~/.ssh/config` и порт остаётся занят.
|
||||||
|
|
||||||
|
Проверить, что туннель жив:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://127.0.0.1:9200/files_file/_count
|
||||||
|
```
|
||||||
|
|
||||||
|
## Переменные
|
||||||
|
|
||||||
|
| env | default | смысл |
|
||||||
|
|-----|---------|-------|
|
||||||
|
| `ONLYOFFICE_ES_URL` | — (обязателен) | `scheme://host:port` ES |
|
||||||
|
| `ONLYOFFICE_ES_INDEX` | `files_file` | индекс |
|
||||||
|
| `ONLYOFFICE_TENANT` | пусто (все) | фильтр `tenantId` |
|
||||||
|
|
||||||
|
Имена — в [`.env.example`](../.env.example). Секретов нет: ES без пароля.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ONLYOFFICE_ES_URL=http://127.0.0.1:9200 oo search "Rechnung"
|
||||||
|
ONLYOFFICE_ES_URL=http://127.0.0.1:9200 oo search "Mahngebühr" --content
|
||||||
|
oo search "Rechnung" --folder 649 --limit 50 --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Флаги: `--content` (искать и по тексту), `--folder ID` (папка
|
||||||
|
`folders.folderId`), `--limit N` (по умолчанию 20, максимум 200),
|
||||||
|
`--json` = `-o json`.
|
||||||
|
|
||||||
|
## Библиотека
|
||||||
|
|
||||||
|
`file_es.go` — `ESSearcher` (`Name() = "elasticsearch"`), прямой ES REST на
|
||||||
|
stdlib `net/http`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
es, _ := onlyoffice.NewESSearcher(onlyoffice.ESConfigFromEnv())
|
||||||
|
hits, _ := es.Search(ctx, onlyoffice.SearchQuery{
|
||||||
|
Text: "Rechnung", InContent: true, Limit: 20,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Запрос: `multi_match` по `title^2` (+ `document.attachment.content` при
|
||||||
|
`InContent`), фильтры `tenantId` и `folders.folderId`, `_source`
|
||||||
|
id/title/folders, `highlight` для фрагмента. Ответ → `[]SearchHit` (модель из
|
||||||
|
эпика #34; пока объявлена в `file_es.go`, переедет в `file_core.go` с F1 #35).
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# unit — чистые builders/парсеры, без сети
|
||||||
|
go test ./ -run ES
|
||||||
|
|
||||||
|
# integration — нужен ONLYOFFICE_ES_URL (+ креды REST для залива)
|
||||||
|
set -a; . .env; set +a
|
||||||
|
ONLYOFFICE_ES_URL=http://127.0.0.1:9200 ONLYOFFICE_TENANT=1 \
|
||||||
|
go test -tags=integration -run TestIntegrationESSearch -v .
|
||||||
|
```
|
||||||
|
|
||||||
|
Интеграционный тест заливает временный xlsx (в имени и в ячейке — уникальные
|
||||||
|
токены), ждёт индексации, проверяет поиск по имени и по содержимому, затем
|
||||||
|
удаляет проект.
|
||||||
|
|
||||||
|
## Грабли
|
||||||
|
|
||||||
|
- `locale`/версия ES: 7.16.3, `_search` совместим с REST 7.x.
|
||||||
|
- ES без auth и слушает только localhost — туннель обязателен.
|
||||||
|
- Фильтр `tenantId` сузит выдачу; без него видны документы всех тенантов.
|
||||||
|
- Поиск по содержимому PDF, залитых через API, не работает (нет
|
||||||
|
`attachment.content`) — только Office-форматы.
|
||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
// Canonical file model and the backend-agnostic store interface. REST
|
||||||
|
// (files.go), WebDAV (files_webdav.go) and future backends (PostgreSQL,
|
||||||
|
// Elasticsearch) implement FileStore/Searcher so callers stop depending on a
|
||||||
|
// concrete transport. This file holds only types and pure conversions — no IO.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kind distinguishes files from folders in the canonical model.
|
||||||
|
type Kind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
File Kind = iota
|
||||||
|
Folder
|
||||||
|
)
|
||||||
|
|
||||||
|
// String renders the kind for logs and table output.
|
||||||
|
func (k Kind) String() string {
|
||||||
|
switch k {
|
||||||
|
case File:
|
||||||
|
return "file"
|
||||||
|
case Folder:
|
||||||
|
return "folder"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider names for the FileStore adapters.
|
||||||
|
const (
|
||||||
|
ProviderREST = "rest"
|
||||||
|
ProviderDAV = "dav"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry is the backend-independent representation of a document or folder.
|
||||||
|
// Fields that a backend cannot supply stay at their zero value.
|
||||||
|
type Entry struct {
|
||||||
|
ID string
|
||||||
|
ParentID string
|
||||||
|
Title string
|
||||||
|
Kind Kind
|
||||||
|
Size int64
|
||||||
|
MIME string
|
||||||
|
Created time.Time
|
||||||
|
Modified time.Time
|
||||||
|
Version int
|
||||||
|
Provider string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileStore is the operation surface every file backend implements.
|
||||||
|
type FileStore interface {
|
||||||
|
Name() string
|
||||||
|
List(ctx context.Context, parentID string) ([]Entry, error)
|
||||||
|
Stat(ctx context.Context, id string) (Entry, error)
|
||||||
|
CreateFolder(ctx context.Context, parentID, title string) (Entry, error)
|
||||||
|
Upload(ctx context.Context, parentID, title string, r io.Reader) (Entry, error)
|
||||||
|
Download(ctx context.Context, id string, w io.Writer) (int64, error)
|
||||||
|
Move(ctx context.Context, ids []string, parentID string) error
|
||||||
|
Copy(ctx context.Context, ids []string, parentID string) error
|
||||||
|
Rename(ctx context.Context, id, title string) error
|
||||||
|
Delete(ctx context.Context, ids []string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchQuery narrows a Searcher request. InContent asks the backend to match
|
||||||
|
// document bodies, not just titles.
|
||||||
|
type SearchQuery struct {
|
||||||
|
Text string
|
||||||
|
InContent bool
|
||||||
|
FolderID string
|
||||||
|
Extensions []string
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchHit is one Searcher result: the matching entry plus backend-specific
|
||||||
|
// ranking metadata.
|
||||||
|
type SearchHit struct {
|
||||||
|
Entry
|
||||||
|
Score float64
|
||||||
|
Highlight string
|
||||||
|
Path []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Searcher is the optional content/name search surface. Only some backends
|
||||||
|
// (for example Elasticsearch) provide it.
|
||||||
|
type Searcher interface {
|
||||||
|
Search(ctx context.Context, q SearchQuery) ([]SearchHit, error)
|
||||||
|
Name() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileStore returns the adapter for a backend name: ProviderREST (default) or
|
||||||
|
// ProviderDAV. Unknown or empty names select the REST backend. The full facade
|
||||||
|
// (backend composition) is deliberately left to a later change.
|
||||||
|
func (c *Client) FileStore(backend string) FileStore {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(backend)) {
|
||||||
|
case ProviderDAV, "webdav":
|
||||||
|
return &davStore{c: c}
|
||||||
|
default:
|
||||||
|
return &restStore{c: c}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files returns the default (REST) file store.
|
||||||
|
func (c *Client) Files() FileStore { return c.FileStore(ProviderREST) }
|
||||||
|
|
||||||
|
// retryStoreOp runs one store operation under the shared deterministic
|
||||||
|
// transient-error policy (429/502/503/504).
|
||||||
|
func retryStoreOp(ctx context.Context, fn func() error) error {
|
||||||
|
return DoRetry(ctx, DefaultRetryPolicy(), fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileEntryToEntry converts a Files-module file row to the canonical model.
|
||||||
|
func FileEntryToEntry(f *FileEntry, provider string) Entry {
|
||||||
|
e := Entry{Kind: File, Provider: provider}
|
||||||
|
if f == nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if f.ID != nil {
|
||||||
|
e.ID = f.ID.String()
|
||||||
|
}
|
||||||
|
e.ParentID = FileFolderID(f)
|
||||||
|
if f.Title != nil {
|
||||||
|
e.Title = *f.Title
|
||||||
|
}
|
||||||
|
if f.ContentLength != nil {
|
||||||
|
e.Size = parseContentLength(*f.ContentLength)
|
||||||
|
}
|
||||||
|
exst := ""
|
||||||
|
if f.FileExst != nil {
|
||||||
|
exst = *f.FileExst
|
||||||
|
}
|
||||||
|
e.MIME = mimeForTitle(e.Title, exst)
|
||||||
|
if f.Updated != nil {
|
||||||
|
e.Modified = *f.Updated
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// DavFileToEntry converts a WebDAV file row to the canonical model.
|
||||||
|
func DavFileToEntry(f DavFile, provider string) Entry {
|
||||||
|
return Entry{
|
||||||
|
ID: f.ID,
|
||||||
|
Title: f.Title,
|
||||||
|
Kind: File,
|
||||||
|
Size: f.Size,
|
||||||
|
MIME: mimeForTitle(f.Title, ""),
|
||||||
|
Modified: f.ModTime(),
|
||||||
|
Provider: provider,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DavFolderToEntry converts a WebDAV folder row to the canonical model.
|
||||||
|
func DavFolderToEntry(f DavFolder, provider string) Entry {
|
||||||
|
return Entry{
|
||||||
|
ID: f.ID,
|
||||||
|
ParentID: f.ParentID,
|
||||||
|
Title: f.Title,
|
||||||
|
Kind: Folder,
|
||||||
|
Modified: f.ModTime(),
|
||||||
|
Provider: provider,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseContentLength reads the leading integer of an OnlyOffice contentLength
|
||||||
|
// string (the API sometimes appends a unit, e.g. "12345 b").
|
||||||
|
func parseContentLength(s string) int64 {
|
||||||
|
fields := strings.Fields(s)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(fields[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// mimeForTitle derives a MIME type from an explicit extension or the title.
|
||||||
|
func mimeForTitle(title, exst string) string {
|
||||||
|
ext := strings.TrimSpace(exst)
|
||||||
|
if ext == "" {
|
||||||
|
ext = filepath.Ext(title)
|
||||||
|
}
|
||||||
|
if ext == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(ext, ".") {
|
||||||
|
ext = "." + ext
|
||||||
|
}
|
||||||
|
return mime.TypeByExtension(strings.ToLower(ext))
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFileEntryToEntry(t *testing.T) {
|
||||||
|
id := json.Number("42")
|
||||||
|
title := "invoice.pdf"
|
||||||
|
exst := ".pdf"
|
||||||
|
size := "12345"
|
||||||
|
parent := json.Number("7")
|
||||||
|
updated := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||||
|
f := &FileEntry{
|
||||||
|
ID: &id,
|
||||||
|
Title: &title,
|
||||||
|
FileExst: &exst,
|
||||||
|
ContentLength: &size,
|
||||||
|
FolderID: &parent,
|
||||||
|
Updated: &updated,
|
||||||
|
}
|
||||||
|
|
||||||
|
e := FileEntryToEntry(f, ProviderREST)
|
||||||
|
if e.ID != "42" {
|
||||||
|
t.Errorf("ID = %q, want 42", e.ID)
|
||||||
|
}
|
||||||
|
if e.ParentID != "7" {
|
||||||
|
t.Errorf("ParentID = %q, want 7", e.ParentID)
|
||||||
|
}
|
||||||
|
if e.Title != title {
|
||||||
|
t.Errorf("Title = %q, want %q", e.Title, title)
|
||||||
|
}
|
||||||
|
if e.Kind != File {
|
||||||
|
t.Errorf("Kind = %v, want file", e.Kind)
|
||||||
|
}
|
||||||
|
if e.Size != 12345 {
|
||||||
|
t.Errorf("Size = %d, want 12345", e.Size)
|
||||||
|
}
|
||||||
|
if e.MIME != "application/pdf" {
|
||||||
|
t.Errorf("MIME = %q, want application/pdf", e.MIME)
|
||||||
|
}
|
||||||
|
if !e.Modified.Equal(updated) {
|
||||||
|
t.Errorf("Modified = %v, want %v", e.Modified, updated)
|
||||||
|
}
|
||||||
|
if e.Provider != ProviderREST {
|
||||||
|
t.Errorf("Provider = %q, want %q", e.Provider, ProviderREST)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileEntryToEntryNil(t *testing.T) {
|
||||||
|
e := FileEntryToEntry(nil, ProviderDAV)
|
||||||
|
if e.Kind != File {
|
||||||
|
t.Errorf("Kind = %v, want file", e.Kind)
|
||||||
|
}
|
||||||
|
if e.ID != "" || e.Title != "" {
|
||||||
|
t.Errorf("nil entry should be empty: %+v", e)
|
||||||
|
}
|
||||||
|
if e.Provider != ProviderDAV {
|
||||||
|
t.Errorf("Provider = %q, want %q", e.Provider, ProviderDAV)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileEntryToEntrySizeFormats(t *testing.T) {
|
||||||
|
cases := map[string]int64{
|
||||||
|
"12345": 12345,
|
||||||
|
"12345 b": 12345,
|
||||||
|
"0": 0,
|
||||||
|
"": 0,
|
||||||
|
"notanum": 0,
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
got := parseContentLength(in)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("parseContentLength(%q) = %d, want %d", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDavFileToEntry(t *testing.T) {
|
||||||
|
f := DavFile{
|
||||||
|
ID: "9",
|
||||||
|
Title: "note.txt",
|
||||||
|
Size: 10,
|
||||||
|
Updated: "2026-01-02T03:04:05.0000000+01:00",
|
||||||
|
}
|
||||||
|
e := DavFileToEntry(f, ProviderDAV)
|
||||||
|
if e.ID != "9" || e.Title != "note.txt" {
|
||||||
|
t.Errorf("identity mismatch: %+v", e)
|
||||||
|
}
|
||||||
|
if e.Kind != File {
|
||||||
|
t.Errorf("Kind = %v, want file", e.Kind)
|
||||||
|
}
|
||||||
|
if e.Size != 10 {
|
||||||
|
t.Errorf("Size = %d, want 10", e.Size)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(e.MIME, "text/plain") {
|
||||||
|
t.Errorf("MIME = %q, want text/plain*", e.MIME)
|
||||||
|
}
|
||||||
|
if e.Modified.IsZero() {
|
||||||
|
t.Error("Modified not parsed")
|
||||||
|
}
|
||||||
|
if e.Provider != ProviderDAV {
|
||||||
|
t.Errorf("Provider = %q, want %q", e.Provider, ProviderDAV)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDavFolderToEntry(t *testing.T) {
|
||||||
|
f := DavFolder{
|
||||||
|
ID: "5",
|
||||||
|
Title: "inbox",
|
||||||
|
ParentID: "1",
|
||||||
|
Updated: "2026-01-02T03:04:05.0000000+01:00",
|
||||||
|
}
|
||||||
|
e := DavFolderToEntry(f, ProviderDAV)
|
||||||
|
if e.ID != "5" || e.Title != "inbox" || e.ParentID != "1" {
|
||||||
|
t.Errorf("identity mismatch: %+v", e)
|
||||||
|
}
|
||||||
|
if e.Kind != Folder {
|
||||||
|
t.Errorf("Kind = %v, want folder", e.Kind)
|
||||||
|
}
|
||||||
|
if e.MIME != "" {
|
||||||
|
t.Errorf("folder MIME = %q, want empty", e.MIME)
|
||||||
|
}
|
||||||
|
if e.Modified.IsZero() {
|
||||||
|
t.Error("Modified not parsed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntriesFromFolderMap(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"files": []any{
|
||||||
|
map[string]any{"id": float64(42), "title": "a.pdf", "pureContentLength": float64(7)},
|
||||||
|
},
|
||||||
|
"folders": []any{
|
||||||
|
map[string]any{"id": float64(7), "title": "sub", "parentId": float64(1)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
entries, err := entriesFromFolderMap(m, ProviderREST)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("entriesFromFolderMap: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("got %d entries, want 2: %+v", len(entries), entries)
|
||||||
|
}
|
||||||
|
byID := map[string]Entry{}
|
||||||
|
for _, e := range entries {
|
||||||
|
byID[e.ID] = e
|
||||||
|
}
|
||||||
|
if got := byID["42"]; got.Kind != File || got.Size != 7 || got.Title != "a.pdf" {
|
||||||
|
t.Errorf("file entry = %+v", got)
|
||||||
|
}
|
||||||
|
if got := byID["7"]; got.Kind != Folder || got.ParentID != "1" || got.Title != "sub" {
|
||||||
|
t.Errorf("folder entry = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntriesFromFolderMapNil(t *testing.T) {
|
||||||
|
entries, err := entriesFromFolderMap(nil, ProviderREST)
|
||||||
|
if err != nil || entries != nil {
|
||||||
|
t.Fatalf("got %v, %v; want nil, nil", entries, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKindString(t *testing.T) {
|
||||||
|
if File.String() != "file" || Folder.String() != "folder" {
|
||||||
|
t.Errorf("kind strings: %q %q", File.String(), Folder.String())
|
||||||
|
}
|
||||||
|
if Kind(9).String() != "unknown" {
|
||||||
|
t.Errorf("unknown kind = %q", Kind(9).String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientFileStoreSelection(t *testing.T) {
|
||||||
|
c := NewClient(Credentials{})
|
||||||
|
if got := c.FileStore(ProviderDAV).Name(); got != ProviderDAV {
|
||||||
|
t.Errorf("FileStore(dav).Name() = %q", got)
|
||||||
|
}
|
||||||
|
if got := c.FileStore("webdav").Name(); got != ProviderDAV {
|
||||||
|
t.Errorf("FileStore(webdav).Name() = %q", got)
|
||||||
|
}
|
||||||
|
if got := c.FileStore(ProviderREST).Name(); got != ProviderREST {
|
||||||
|
t.Errorf("FileStore(rest).Name() = %q", got)
|
||||||
|
}
|
||||||
|
if got := c.FileStore("").Name(); got != ProviderREST {
|
||||||
|
t.Errorf("FileStore(\"\").Name() = %q", got)
|
||||||
|
}
|
||||||
|
if got := c.Files().Name(); got != ProviderREST {
|
||||||
|
t.Errorf("Files().Name() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
// davStore implements FileStore on top of the Documents/WebDAV methods in
|
||||||
|
// files_webdav.go. The Documents fileops calls need folder and file ids
|
||||||
|
// separated, so ids are classified through Stat before move/copy/rename/delete.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// davStore is a FileStore over the WebDAV-oriented Documents API.
|
||||||
|
type davStore struct{ c *Client }
|
||||||
|
|
||||||
|
// Name reports the backend name.
|
||||||
|
func (s *davStore) Name() string { return ProviderDAV }
|
||||||
|
|
||||||
|
// List returns the files and folders directly below parentID.
|
||||||
|
func (s *davStore) List(ctx context.Context, parentID string) ([]Entry, error) {
|
||||||
|
var out []Entry
|
||||||
|
err := retryStoreOp(ctx, func() error {
|
||||||
|
l, err := s.c.ListDavFolder(ctx, parentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entries := make([]Entry, 0, len(l.Folders)+len(l.Files))
|
||||||
|
for _, f := range l.Folders {
|
||||||
|
entries = append(entries, DavFolderToEntry(f, ProviderDAV))
|
||||||
|
}
|
||||||
|
for _, f := range l.Files {
|
||||||
|
entries = append(entries, DavFileToEntry(f, ProviderDAV))
|
||||||
|
}
|
||||||
|
out = entries
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat resolves a folder or file entry by id. A folder answers ListDavFolder
|
||||||
|
// with its own metadata in Current; otherwise the file metadata API is used.
|
||||||
|
func (s *davStore) Stat(ctx context.Context, id string) (Entry, error) {
|
||||||
|
return s.stat(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFolder creates a subfolder under parentID.
|
||||||
|
func (s *davStore) CreateFolder(ctx context.Context, parentID, title string) (Entry, error) {
|
||||||
|
var out Entry
|
||||||
|
err := retryStoreOp(ctx, func() error {
|
||||||
|
f, err := s.c.CreateDavFolder(ctx, parentID, title)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if f == nil {
|
||||||
|
return fmt.Errorf("onlyoffice: dav store: empty create-folder response")
|
||||||
|
}
|
||||||
|
out = DavFolderToEntry(*f, ProviderDAV)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload streams r into parentID as title. The reader is buffered once so a
|
||||||
|
// retry re-sends the same bytes instead of an exhausted stream.
|
||||||
|
func (s *davStore) Upload(ctx context.Context, parentID, title string, r io.Reader) (Entry, error) {
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
var out Entry
|
||||||
|
err = retryStoreOp(ctx, func() error {
|
||||||
|
f, err := s.c.UploadDavFile(ctx, parentID, title, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if f == nil {
|
||||||
|
return fmt.Errorf("onlyoffice: dav store: empty upload response")
|
||||||
|
}
|
||||||
|
out = DavFileToEntry(*f, ProviderDAV)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download streams the file bytes into w.
|
||||||
|
func (s *davStore) Download(ctx context.Context, id string, w io.Writer) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
err := retryStoreOp(ctx, func() error {
|
||||||
|
var e error
|
||||||
|
n, e = s.c.DownloadDavFile(ctx, id, w)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move moves ids into parentID, splitting folders from files.
|
||||||
|
func (s *davStore) Move(ctx context.Context, ids []string, parentID string) error {
|
||||||
|
folders, files, err := s.split(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(folders) == 0 && len(files) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return retryStoreOp(ctx, func() error {
|
||||||
|
return s.c.MoveDavItems(ctx, folders, files, parentID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy copies ids into parentID, splitting folders from files.
|
||||||
|
func (s *davStore) Copy(ctx context.Context, ids []string, parentID string) error {
|
||||||
|
folders, files, err := s.split(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(folders) == 0 && len(files) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return retryStoreOp(ctx, func() error {
|
||||||
|
return s.c.CopyDavItems(ctx, folders, files, parentID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename renames a folder or file.
|
||||||
|
func (s *davStore) Rename(ctx context.Context, id, title string) error {
|
||||||
|
e, err := s.stat(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return retryStoreOp(ctx, func() error {
|
||||||
|
if e.Kind == Folder {
|
||||||
|
return s.c.RenameDavFolder(ctx, id, title)
|
||||||
|
}
|
||||||
|
return s.c.RenameDavFile(ctx, id, title)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes ids, splitting folders from files.
|
||||||
|
func (s *davStore) Delete(ctx context.Context, ids []string) error {
|
||||||
|
folders, files, err := s.split(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(folders) == 0 && len(files) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return retryStoreOp(ctx, func() error {
|
||||||
|
return s.c.DeleteDavItems(ctx, folders, files)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// stat resolves a single id to a folder or file Entry.
|
||||||
|
func (s *davStore) stat(ctx context.Context, id string) (Entry, error) {
|
||||||
|
var out Entry
|
||||||
|
err := retryStoreOp(ctx, func() error {
|
||||||
|
if l, err := s.c.ListDavFolder(ctx, id); err == nil {
|
||||||
|
if l != nil && l.Current.ID != "" && l.Current.ID == id {
|
||||||
|
out = DavFolderToEntry(l.Current, ProviderDAV)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else if Transient(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := s.c.GetFile(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = FileEntryToEntry(f, ProviderDAV)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// split classifies ids into folder and file id lists.
|
||||||
|
func (s *davStore) split(ctx context.Context, ids []string) (folders, files []string, err error) {
|
||||||
|
for _, id := range ids {
|
||||||
|
e, err := s.stat(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if e.Kind == Folder {
|
||||||
|
folders = append(folders, id)
|
||||||
|
} else {
|
||||||
|
files = append(files, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return folders, files, nil
|
||||||
|
}
|
||||||
+285
@@ -0,0 +1,285 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
// Elasticsearch backend of the unified file client (epic #34, F3 #37).
|
||||||
|
//
|
||||||
|
// OnlyOffice full-text search runs on Elasticsearch (index `files_file`, NEST
|
||||||
|
// client on the server). The REST endpoint GET /api/2.0/files/@search/{query}
|
||||||
|
// only searches file names in the database, so content search needs a direct
|
||||||
|
// ES query. The live server is Elasticsearch 7.16.3; the request shape below
|
||||||
|
// is plain REST and stays stdlib-only, matching the repo's no-extra-deps rule.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The canonical model (Kind, Entry, SearchQuery, SearchHit, Searcher) lives in
|
||||||
|
// file_core.go (F1 #35).
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultESIndex = "files_file"
|
||||||
|
defaultESLimit = 20
|
||||||
|
maxESLimit = 200
|
||||||
|
maxESResponseSize = 8 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// ESConfig configures the direct Elasticsearch searcher.
|
||||||
|
type ESConfig struct {
|
||||||
|
URL string // scheme://host:port of the ES HTTP endpoint
|
||||||
|
Index string // index name, default files_file
|
||||||
|
Tenant string // tenantId filter, empty means all tenants
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESConfigFromEnv reads ONLYOFFICE_ES_URL, ONLYOFFICE_ES_INDEX (default
|
||||||
|
// files_file) and ONLYOFFICE_TENANT. The library never loads dotfiles — the
|
||||||
|
// CLI does that.
|
||||||
|
func ESConfigFromEnv() ESConfig {
|
||||||
|
return ESConfig{
|
||||||
|
URL: strings.TrimRight(strings.TrimSpace(os.Getenv("ONLYOFFICE_ES_URL")), "/"),
|
||||||
|
Index: firstNonEmpty(os.Getenv("ONLYOFFICE_ES_INDEX"), defaultESIndex),
|
||||||
|
Tenant: strings.TrimSpace(os.Getenv("ONLYOFFICE_TENANT")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESSearcher queries OnlyOffice's Elasticsearch index directly for file name
|
||||||
|
// and document content.
|
||||||
|
type ESSearcher struct {
|
||||||
|
cfg ESConfig
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewESSearcher returns a searcher for the OnlyOffice Elasticsearch index.
|
||||||
|
// The URL is required; an empty index falls back to files_file.
|
||||||
|
func NewESSearcher(cfg ESConfig) (*ESSearcher, error) {
|
||||||
|
if strings.TrimSpace(cfg.URL) == "" {
|
||||||
|
return nil, fmt.Errorf("onlyoffice: elasticsearch URL is empty (set ONLYOFFICE_ES_URL)")
|
||||||
|
}
|
||||||
|
cfg.URL = strings.TrimRight(cfg.URL, "/")
|
||||||
|
if cfg.Index == "" {
|
||||||
|
cfg.Index = defaultESIndex
|
||||||
|
}
|
||||||
|
return &ESSearcher{cfg: cfg, http: &http.Client{Timeout: 30 * time.Second}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name implements Searcher.
|
||||||
|
func (s *ESSearcher) Name() string { return "elasticsearch" }
|
||||||
|
|
||||||
|
// Search runs a multi_match over title (and, when q.InContent is set,
|
||||||
|
// document.attachment.content), filtered by tenant and optional folder.
|
||||||
|
func (s *ESSearcher) Search(ctx context.Context, q SearchQuery) ([]SearchHit, error) {
|
||||||
|
q.Text = strings.TrimSpace(q.Text)
|
||||||
|
if q.Text == "" {
|
||||||
|
return nil, fmt.Errorf("onlyoffice: empty search query")
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(esSearchRequest(q, s.cfg.Tenant))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("onlyoffice: build elasticsearch query: %w", err)
|
||||||
|
}
|
||||||
|
endpoint := s.cfg.URL + "/" + s.cfg.Index + "/_search"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := s.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("onlyoffice: elasticsearch search: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxESResponseSize))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("onlyoffice: elasticsearch search: %d %s", resp.StatusCode, truncate(string(raw), 400))
|
||||||
|
}
|
||||||
|
return parseESSearchResponse(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// esSearchRequest builds the ES query body. Pure, so it is unit-tested.
|
||||||
|
func esSearchRequest(q SearchQuery, tenant string) esRequest {
|
||||||
|
limit := q.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = defaultESLimit
|
||||||
|
}
|
||||||
|
if limit > maxESLimit {
|
||||||
|
limit = maxESLimit
|
||||||
|
}
|
||||||
|
fields := []string{"title^2"}
|
||||||
|
if q.InContent {
|
||||||
|
fields = append(fields, "document.attachment.content")
|
||||||
|
}
|
||||||
|
must := []esClause{{MultiMatch: &esMultiMatch{Query: q.Text, Fields: fields}}}
|
||||||
|
|
||||||
|
var filter []esClause
|
||||||
|
if t := strings.TrimSpace(tenant); t != "" {
|
||||||
|
filter = append(filter, esClause{Term: map[string]any{"tenantId": numericOrString(t)}})
|
||||||
|
}
|
||||||
|
if f := strings.TrimSpace(q.FolderID); f != "" {
|
||||||
|
filter = append(filter, esClause{Term: map[string]any{"folders.folderId": f}})
|
||||||
|
}
|
||||||
|
for _, ext := range normalizeExtensions(q.Extensions) {
|
||||||
|
filter = append(filter, esClause{Wildcard: map[string]any{"title": "*." + ext}})
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightFields := map[string]struct{}{"title": {}}
|
||||||
|
if q.InContent {
|
||||||
|
highlightFields["document.attachment.content"] = struct{}{}
|
||||||
|
}
|
||||||
|
return esRequest{
|
||||||
|
Size: limit,
|
||||||
|
Source: []string{"id", "title", "folders"},
|
||||||
|
Query: esQuery{Bool: esBool{Must: must, Filter: filter}},
|
||||||
|
Highlight: esHighlight{PreTags: []string{"<em>"}, PostTags: []string{"</em>"}, Fields: highlightFields},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeExtensions lowercases, trims leading dots and drops empties.
|
||||||
|
func normalizeExtensions(exts []string) []string {
|
||||||
|
out := make([]string, 0, len(exts))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, e := range exts {
|
||||||
|
e = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(e), ".")))
|
||||||
|
if e == "" || seen[e] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[e] = true
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// numericOrString keeps an integer-looking filter value numeric (tenantId is
|
||||||
|
// a long) and leaves anything else as a string (folderId is a text token).
|
||||||
|
func numericOrString(s string) any {
|
||||||
|
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// esRequest is the subset of the ES query DSL this client emits.
|
||||||
|
type esRequest struct {
|
||||||
|
Size int `json:"size"`
|
||||||
|
Source []string `json:"_source"`
|
||||||
|
Query esQuery `json:"query"`
|
||||||
|
Highlight esHighlight `json:"highlight"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type esQuery struct {
|
||||||
|
Bool esBool `json:"bool"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type esBool struct {
|
||||||
|
Must []esClause `json:"must,omitempty"`
|
||||||
|
Filter []esClause `json:"filter,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type esClause struct {
|
||||||
|
MultiMatch *esMultiMatch `json:"multi_match,omitempty"`
|
||||||
|
Term map[string]any `json:"term,omitempty"`
|
||||||
|
Wildcard map[string]any `json:"wildcard,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type esMultiMatch struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Fields []string `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type esHighlight struct {
|
||||||
|
PreTags []string `json:"pre_tags,omitempty"`
|
||||||
|
PostTags []string `json:"post_tags,omitempty"`
|
||||||
|
Fields map[string]struct{} `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// esResponse is the subset of an ES search response we consume.
|
||||||
|
type esResponse struct {
|
||||||
|
Took int `json:"took"`
|
||||||
|
Hits struct {
|
||||||
|
Total struct {
|
||||||
|
Value int `json:"value"`
|
||||||
|
Relation string `json:"relation"`
|
||||||
|
} `json:"total"`
|
||||||
|
Hits []esResponseHit `json:"hits"`
|
||||||
|
} `json:"hits"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type esResponseHit struct {
|
||||||
|
ID string `json:"_id"`
|
||||||
|
Score float64 `json:"_score"`
|
||||||
|
Source struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Folders []struct {
|
||||||
|
FolderID string `json:"folderId"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
} `json:"folders"`
|
||||||
|
} `json:"_source"`
|
||||||
|
Highlight map[string][]string `json:"highlight"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseESSearchResponse converts an ES search response into SearchHit values.
|
||||||
|
// Pure, so it is unit-tested.
|
||||||
|
func parseESSearchResponse(raw []byte) ([]SearchHit, error) {
|
||||||
|
var r esResponse
|
||||||
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
|
return nil, fmt.Errorf("onlyoffice: decode elasticsearch response: %w", err)
|
||||||
|
}
|
||||||
|
hits := make([]SearchHit, 0, len(r.Hits.Hits))
|
||||||
|
for _, h := range r.Hits.Hits {
|
||||||
|
id := strconv.Itoa(h.Source.ID)
|
||||||
|
if h.Source.ID == 0 {
|
||||||
|
id = h.ID
|
||||||
|
}
|
||||||
|
var parent string
|
||||||
|
path := make([]string, 0, len(h.Source.Folders))
|
||||||
|
for i, f := range h.Source.Folders {
|
||||||
|
path = append(path, f.FolderID)
|
||||||
|
if i == 0 {
|
||||||
|
parent = f.FolderID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hits = append(hits, SearchHit{
|
||||||
|
Entry: Entry{
|
||||||
|
ID: id,
|
||||||
|
ParentID: parent,
|
||||||
|
Title: h.Source.Title,
|
||||||
|
Kind: File,
|
||||||
|
Provider: "elasticsearch",
|
||||||
|
},
|
||||||
|
Score: h.Score,
|
||||||
|
Highlight: esHighlightText(h.Highlight),
|
||||||
|
Path: path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return hits, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var esHighlightTag = regexp.MustCompile(`</?em[^>]*>`)
|
||||||
|
|
||||||
|
// esHighlightText flattens a highlight map into one plain-text snippet,
|
||||||
|
// preferring the content fragment over the title.
|
||||||
|
func esHighlightText(hl map[string][]string) string {
|
||||||
|
for _, key := range []string{"document.attachment.content", "title"} {
|
||||||
|
frags := hl[key]
|
||||||
|
if len(frags) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clean := make([]string, 0, len(frags))
|
||||||
|
for _, f := range frags {
|
||||||
|
clean = append(clean, esHighlightTag.ReplaceAllString(f, ""))
|
||||||
|
}
|
||||||
|
return strings.Join(clean, " … ")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIntegrationESSearch uploads a throwaway workbook and verifies that the
|
||||||
|
// direct Elasticsearch search finds it by file name and by content.
|
||||||
|
//
|
||||||
|
// The content index (document.attachment.content) is only populated for Office
|
||||||
|
// formats (docx/xlsx/pptx), so the fixture is an xlsx whose cell carries a
|
||||||
|
// unique token. Requires ONLYOFFICE_ES_URL (a reachable ES endpoint — in the
|
||||||
|
// current setup a tunnel to the ES inside the OnlyOffice VM, see
|
||||||
|
// docs/elasticsearch.md) plus the regular REST credentials for the upload.
|
||||||
|
// Skips when either is missing.
|
||||||
|
func TestIntegrationESSearch(t *testing.T) {
|
||||||
|
esURL := strings.TrimSpace(os.Getenv("ONLYOFFICE_ES_URL"))
|
||||||
|
if esURL == "" {
|
||||||
|
t.Skip("ONLYOFFICE_ES_URL not set — skipping Elasticsearch integration test")
|
||||||
|
}
|
||||||
|
c := liveClient(t)
|
||||||
|
t.Cleanup(func() { cleanupTestProjects(t, c) })
|
||||||
|
|
||||||
|
stamp := time.Now().UTC().Format("20060102-150405")
|
||||||
|
nameToken := "goesname" + stamp
|
||||||
|
contentToken := "goescontent" + stamp
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
project, err := c.CreateProject(NewProjectRequest{
|
||||||
|
Title: testProjectPrefix + "es-" + stamp,
|
||||||
|
Description: "go-onlyoffice elasticsearch integration",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProject: %v", err)
|
||||||
|
}
|
||||||
|
if project.ID == nil {
|
||||||
|
t.Fatal("created project without id")
|
||||||
|
}
|
||||||
|
pid := strconv.Itoa(*project.ID)
|
||||||
|
|
||||||
|
title := nameToken + ".xlsx"
|
||||||
|
localPath := filepath.Join(t.TempDir(), title)
|
||||||
|
book := excelize.NewFile()
|
||||||
|
if err := book.SetCellValue("Sheet1", "A1", "OnlyOffice Elasticsearch content fixture "+contentToken); err != nil {
|
||||||
|
t.Fatalf("SetCellValue: %v", err)
|
||||||
|
}
|
||||||
|
if err := book.SaveAs(localPath); err != nil {
|
||||||
|
t.Fatalf("SaveAs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := c.UploadProjectFile(ctx, pid, localPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadProjectFile: %v", err)
|
||||||
|
}
|
||||||
|
fileID := strconv.Itoa(int(FileEntryNumericID(entry)))
|
||||||
|
if fileID == "0" {
|
||||||
|
t.Fatalf("upload returned no file id: %+v", entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
es, err := NewESSearcher(ESConfig{
|
||||||
|
URL: esURL,
|
||||||
|
Index: os.Getenv("ONLYOFFICE_ES_INDEX"),
|
||||||
|
Tenant: os.Getenv("ONLYOFFICE_TENANT"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewESSearcher: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indexing is asynchronous on the server; poll until the file shows up.
|
||||||
|
// The server's title analyzer splits on whitespace, so the name query is
|
||||||
|
// the full file name token (including extension), as a user would type it.
|
||||||
|
nameHit := waitForHit(t, ctx, es, SearchQuery{Text: title}, fileID)
|
||||||
|
if nameHit.Title != title {
|
||||||
|
t.Errorf("name hit title = %q, want %q", nameHit.Title, title)
|
||||||
|
}
|
||||||
|
contentHit := waitForHit(t, ctx, es, SearchQuery{Text: contentToken, InContent: true}, fileID)
|
||||||
|
if contentHit.Highlight == "" {
|
||||||
|
t.Error("content hit has no highlight fragment")
|
||||||
|
}
|
||||||
|
if !strings.Contains(contentHit.Title, nameToken) {
|
||||||
|
t.Errorf("content hit title = %q, want the uploaded workbook", contentHit.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The content token is absent from the title, so a name-only search must
|
||||||
|
// not return the file — this proves the content field is really queried.
|
||||||
|
if hits := searchQuiet(t, es, SearchQuery{Text: contentToken}); len(hits) != 0 {
|
||||||
|
t.Errorf("name-only search for content token returned %d hits, want 0", len(hits))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForHit polls ES until the file with fileID appears and returns that hit.
|
||||||
|
func waitForHit(t *testing.T, ctx context.Context, s *ESSearcher, q SearchQuery, fileID string) SearchHit {
|
||||||
|
t.Helper()
|
||||||
|
var lastErr error
|
||||||
|
for {
|
||||||
|
hits, err := s.Search(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
} else {
|
||||||
|
for _, h := range hits {
|
||||||
|
if h.ID == fileID {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatalf("search %q: file %s not indexed in time (last err: %v)", q.Text, fileID, lastErr)
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchQuiet(t *testing.T, s *ESSearcher, q SearchQuery) []SearchHit {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
hits, err := s.Search(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search(%q): %v", q.Text, err)
|
||||||
|
}
|
||||||
|
return hits
|
||||||
|
}
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestESSearchRequestNameOnly(t *testing.T) {
|
||||||
|
got := esSearchRequest(SearchQuery{Text: "Rechnung"}, "1")
|
||||||
|
if got.Size != defaultESLimit {
|
||||||
|
t.Errorf("size = %d, want %d", got.Size, defaultESLimit)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got.Source, []string{"id", "title", "folders"}) {
|
||||||
|
t.Errorf("_source = %v", got.Source)
|
||||||
|
}
|
||||||
|
if len(got.Query.Bool.Must) != 1 || got.Query.Bool.Must[0].MultiMatch == nil {
|
||||||
|
t.Fatalf("must = %+v, want one multi_match", got.Query.Bool.Must)
|
||||||
|
}
|
||||||
|
mm := got.Query.Bool.Must[0].MultiMatch
|
||||||
|
if mm.Query != "Rechnung" {
|
||||||
|
t.Errorf("query = %q", mm.Query)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(mm.Fields, []string{"title^2"}) {
|
||||||
|
t.Errorf("fields = %v, want title only", mm.Fields)
|
||||||
|
}
|
||||||
|
if _, ok := got.Highlight.Fields["document.attachment.content"]; ok {
|
||||||
|
t.Error("content highlight present without InContent")
|
||||||
|
}
|
||||||
|
if _, ok := got.Highlight.Fields["title"]; !ok {
|
||||||
|
t.Error("title highlight missing")
|
||||||
|
}
|
||||||
|
if len(got.Query.Bool.Filter) != 1 || got.Query.Bool.Filter[0].Term["tenantId"] != int64(1) {
|
||||||
|
t.Errorf("tenant filter = %+v, want numeric tenantId=1", got.Query.Bool.Filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESSearchRequestContentFields(t *testing.T) {
|
||||||
|
got := esSearchRequest(SearchQuery{Text: "Mahnung", InContent: true}, "")
|
||||||
|
mm := got.Query.Bool.Must[0].MultiMatch
|
||||||
|
want := []string{"title^2", "document.attachment.content"}
|
||||||
|
if !reflect.DeepEqual(mm.Fields, want) {
|
||||||
|
t.Errorf("fields = %v, want %v", mm.Fields, want)
|
||||||
|
}
|
||||||
|
if _, ok := got.Highlight.Fields["document.attachment.content"]; !ok {
|
||||||
|
t.Error("content highlight missing with InContent")
|
||||||
|
}
|
||||||
|
if len(got.Query.Bool.Filter) != 0 {
|
||||||
|
t.Errorf("filter = %+v, want none without tenant/folder", got.Query.Bool.Filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESSearchRequestFiltersAndLimit(t *testing.T) {
|
||||||
|
got := esSearchRequest(SearchQuery{
|
||||||
|
Text: "Storchen",
|
||||||
|
FolderID: "649",
|
||||||
|
Extensions: []string{".PDF", "pdf", "docx"},
|
||||||
|
Limit: 999,
|
||||||
|
}, "42")
|
||||||
|
if got.Size != maxESLimit {
|
||||||
|
t.Errorf("size = %d, want cap %d", got.Size, maxESLimit)
|
||||||
|
}
|
||||||
|
var tenant, folder, wildcards int
|
||||||
|
for _, f := range got.Query.Bool.Filter {
|
||||||
|
switch {
|
||||||
|
case f.Term != nil && f.Term["tenantId"] != nil:
|
||||||
|
tenant++
|
||||||
|
case f.Term != nil && f.Term["folders.folderId"] != nil:
|
||||||
|
folder++
|
||||||
|
if f.Term["folders.folderId"] != "649" {
|
||||||
|
t.Errorf("folder filter = %+v", f.Term)
|
||||||
|
}
|
||||||
|
case f.Wildcard != nil:
|
||||||
|
wildcards++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tenant != 1 || folder != 1 {
|
||||||
|
t.Errorf("term filters tenant=%d folder=%d, want 1 each", tenant, folder)
|
||||||
|
}
|
||||||
|
if wildcards != 2 {
|
||||||
|
t.Errorf("wildcard filters = %d, want deduped PDF+docx", wildcards)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESSearchRequestRejectsEmptyTextAtSearch(t *testing.T) {
|
||||||
|
s, err := NewESSearcher(ESConfig{URL: "http://localhost:9200"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewESSearcher: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.Search(t.Context(), SearchQuery{Text: " "}); err == nil {
|
||||||
|
t.Error("empty query: want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewESSearcherRequiresURL(t *testing.T) {
|
||||||
|
if _, err := NewESSearcher(ESConfig{}); err == nil {
|
||||||
|
t.Error("empty URL: want error")
|
||||||
|
}
|
||||||
|
s, err := NewESSearcher(ESConfig{URL: "http://es:9200/"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewESSearcher: %v", err)
|
||||||
|
}
|
||||||
|
if s.cfg.Index != defaultESIndex {
|
||||||
|
t.Errorf("index = %q, want %q", s.cfg.Index, defaultESIndex)
|
||||||
|
}
|
||||||
|
if s.cfg.URL != "http://es:9200" {
|
||||||
|
t.Errorf("url = %q, want trimmed", s.cfg.URL)
|
||||||
|
}
|
||||||
|
if s.Name() != "elasticsearch" {
|
||||||
|
t.Errorf("Name() = %q", s.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeExtensions(t *testing.T) {
|
||||||
|
got := normalizeExtensions([]string{" .PDF ", "pdf", "", "xlsx"})
|
||||||
|
want := []string{"pdf", "xlsx"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("normalizeExtensions = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseESSearchResponse(t *testing.T) {
|
||||||
|
raw := []byte(`{
|
||||||
|
"took": 12,
|
||||||
|
"hits": {
|
||||||
|
"total": {"value": 2, "relation": "eq"},
|
||||||
|
"hits": [
|
||||||
|
{
|
||||||
|
"_id": "2395",
|
||||||
|
"_score": 7.31,
|
||||||
|
"_source": {"id": 2395, "title": "Rechnung-4711.pdf",
|
||||||
|
"folders": [{"folderId": "438", "id": 0}, {"folderId": "11", "id": 0}]},
|
||||||
|
"highlight": {
|
||||||
|
"title": ["<em>Rechnung</em>-4711.pdf"],
|
||||||
|
"document.attachment.content": ["… Zahlung der <em>Rechnung</em> …"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "2318",
|
||||||
|
"_score": 6.02,
|
||||||
|
"_source": {"id": 2318, "title": "Mahnung.pdf", "folders": []},
|
||||||
|
"highlight": {"title": ["<em>Mahnung</em>.pdf"]}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
hits, err := parseESSearchResponse(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseESSearchResponse: %v", err)
|
||||||
|
}
|
||||||
|
if len(hits) != 2 {
|
||||||
|
t.Fatalf("hits = %d, want 2", len(hits))
|
||||||
|
}
|
||||||
|
h0 := hits[0]
|
||||||
|
if h0.ID != "2395" || h0.Title != "Rechnung-4711.pdf" || h0.Kind != File {
|
||||||
|
t.Errorf("hit0 entry = %+v", h0.Entry)
|
||||||
|
}
|
||||||
|
if h0.ParentID != "438" || !reflect.DeepEqual(h0.Path, []string{"438", "11"}) {
|
||||||
|
t.Errorf("hit0 path = %v parent = %q", h0.Path, h0.ParentID)
|
||||||
|
}
|
||||||
|
if h0.Score != 7.31 {
|
||||||
|
t.Errorf("hit0 score = %v", h0.Score)
|
||||||
|
}
|
||||||
|
if h0.Highlight != "… Zahlung der Rechnung …" {
|
||||||
|
t.Errorf("hit0 highlight = %q, want content fragment", h0.Highlight)
|
||||||
|
}
|
||||||
|
if hits[1].Highlight != "Mahnung.pdf" {
|
||||||
|
t.Errorf("hit1 highlight = %q, want title without tags", hits[1].Highlight)
|
||||||
|
}
|
||||||
|
if hits[1].ParentID != "" || len(hits[1].Path) != 0 {
|
||||||
|
t.Errorf("hit1 path = %v", hits[1].Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESSearchRequestJSONShape(t *testing.T) {
|
||||||
|
got := esSearchRequest(SearchQuery{Text: "Rechnung", InContent: true}, "1")
|
||||||
|
b, err := json.Marshal(got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
var back map[string]any
|
||||||
|
if err := json.Unmarshal(b, &back); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := back["query"].(map[string]any)["bool"]; !ok {
|
||||||
|
t.Errorf("query.bool missing: %s", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
+230
@@ -0,0 +1,230 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
// restStore implements FileStore on top of the REST Documents methods in
|
||||||
|
// files.go. It is a thin adapter: no endpoint logic lives here, and every call
|
||||||
|
// is wrapped in DoRetry.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// restStore is a FileStore over the REST Documents API.
|
||||||
|
type restStore struct{ c *Client }
|
||||||
|
|
||||||
|
// Name reports the backend name.
|
||||||
|
func (s *restStore) Name() string { return ProviderREST }
|
||||||
|
|
||||||
|
// List returns the files and folders directly below parentID.
|
||||||
|
func (s *restStore) List(ctx context.Context, parentID string) ([]Entry, error) {
|
||||||
|
var out []Entry
|
||||||
|
err := retryStoreOp(ctx, func() error {
|
||||||
|
raw, err := s.c.ListFolder(ctx, parentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entries, err := entriesFromFolderMap(raw, ProviderREST)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = entries
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat returns file metadata. The REST adapter resolves files only; folders
|
||||||
|
// are listed by their parent (use List).
|
||||||
|
func (s *restStore) Stat(ctx context.Context, id string) (Entry, error) {
|
||||||
|
var out Entry
|
||||||
|
err := retryStoreOp(ctx, func() error {
|
||||||
|
f, err := s.c.GetFile(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = FileEntryToEntry(f, ProviderREST)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFolder creates a subfolder under parentID.
|
||||||
|
func (s *restStore) CreateFolder(ctx context.Context, parentID, title string) (Entry, error) {
|
||||||
|
var out Entry
|
||||||
|
err := retryStoreOp(ctx, func() error {
|
||||||
|
m, err := s.c.CreateFolder(ctx, parentID, title)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e, err := folderEntryFromMap(m, parentID, ProviderREST)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if e.ParentID == "" {
|
||||||
|
e.ParentID = parentID
|
||||||
|
}
|
||||||
|
if e.Title == "" {
|
||||||
|
e.Title = title
|
||||||
|
}
|
||||||
|
out = e
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload streams r into parentID as title. UploadToFolder is path based, so
|
||||||
|
// the reader is spooled to a temporary file first (ponytail: OnlyOffice
|
||||||
|
// multipart upload buffers the whole body anyway).
|
||||||
|
func (s *restStore) Upload(ctx context.Context, parentID, title string, r io.Reader) (Entry, error) {
|
||||||
|
dir, err := os.MkdirTemp("", "oo-rest-upload-")
|
||||||
|
if err != nil {
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
|
local := filepath.Join(dir, SafeLocalFileName(title))
|
||||||
|
f, err := os.Create(local)
|
||||||
|
if err != nil {
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(f, r); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var out Entry
|
||||||
|
err = retryStoreOp(ctx, func() error {
|
||||||
|
fe, err := s.c.UploadToFolder(ctx, parentID, local)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = FileEntryToEntry(fe, ProviderREST)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download streams the file bytes into w.
|
||||||
|
func (s *restStore) Download(ctx context.Context, id string, w io.Writer) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
err := retryStoreOp(ctx, func() error {
|
||||||
|
var e error
|
||||||
|
n, e = s.c.DownloadFile(ctx, id, w)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move moves file ids into parentID. The REST MoveFiles endpoint handles files
|
||||||
|
// only; folder moves are not exposed by this adapter.
|
||||||
|
func (s *restStore) Move(ctx context.Context, ids []string, parentID string) error {
|
||||||
|
dest, err := strconv.Atoi(strings.TrimSpace(parentID))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("onlyoffice: rest store: move: non-numeric destination folder id %q", parentID)
|
||||||
|
}
|
||||||
|
fileIDs, err := numericIDs(ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return retryStoreOp(ctx, func() error {
|
||||||
|
_, err := s.c.MoveFiles(ctx, dest, fileIDs)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy copies file ids into parentID. files.go has no copy method, so the
|
||||||
|
// shared REST fileops copy endpoint (CopyDavItems) is used.
|
||||||
|
func (s *restStore) Copy(ctx context.Context, ids []string, parentID string) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return retryStoreOp(ctx, func() error {
|
||||||
|
return s.c.CopyDavItems(ctx, nil, ids, parentID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename sets a new title (including extension) for a file.
|
||||||
|
func (s *restStore) Rename(ctx context.Context, id, title string) error {
|
||||||
|
return retryStoreOp(ctx, func() error {
|
||||||
|
_, err := s.c.RenameFile(ctx, id, title)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete permanently deletes file ids.
|
||||||
|
func (s *restStore) Delete(ctx context.Context, ids []string) error {
|
||||||
|
fileIDs, err := numericIDs(ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(fileIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return retryStoreOp(ctx, func() error {
|
||||||
|
return s.c.DeleteFiles(ctx, fileIDs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// entriesFromFolderMap converts a ListFolder response map into canonical
|
||||||
|
// entries, reusing the DavFile/DavFolder decoders for robust size handling.
|
||||||
|
func entriesFromFolderMap(m map[string]any, provider string) ([]Entry, error) {
|
||||||
|
if m == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var listing DavListing
|
||||||
|
if err := json.Unmarshal(b, &listing); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]Entry, 0, len(listing.Folders)+len(listing.Files))
|
||||||
|
for _, f := range listing.Folders {
|
||||||
|
out = append(out, DavFolderToEntry(f, provider))
|
||||||
|
}
|
||||||
|
for _, f := range listing.Files {
|
||||||
|
out = append(out, DavFileToEntry(f, provider))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// folderEntryFromMap converts a CreateFolder response map into a folder Entry.
|
||||||
|
func folderEntryFromMap(m map[string]any, parentID, provider string) (Entry, error) {
|
||||||
|
e := Entry{Kind: Folder, Provider: provider, ParentID: parentID}
|
||||||
|
if m == nil {
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
var f DavFolder
|
||||||
|
if err := json.Unmarshal(b, &f); err != nil {
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
e = DavFolderToEntry(f, provider)
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// numericIDs parses Documents numeric ids from strings.
|
||||||
|
func numericIDs(ids []string) ([]int, error) {
|
||||||
|
out := make([]int, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(id))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("onlyoffice: rest store: non-numeric id %q", id)
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIntegrationFileStores runs the same operation set (create folder, upload,
|
||||||
|
// list, stat, download, move, copy, rename, delete) through the REST and DAV
|
||||||
|
// FileStore adapters against a throwaway project Documents folder. Destructive
|
||||||
|
// — only run against instances you own.
|
||||||
|
//
|
||||||
|
// The Documents fileops API is asynchronous: a move/copy/delete is accepted
|
||||||
|
// immediately and becomes visible a moment later, so effects are polled.
|
||||||
|
func TestIntegrationFileStores(t *testing.T) {
|
||||||
|
c := liveClient(t)
|
||||||
|
t.Cleanup(func() { cleanupTestProjects(t, c) })
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
suffix := time.Now().UTC().Format("20060102-150405")
|
||||||
|
project, err := c.CreateProject(NewProjectRequest{
|
||||||
|
Title: testProjectPrefix + "store-" + suffix,
|
||||||
|
Description: "go-onlyoffice file store integration",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProject: %v", err)
|
||||||
|
}
|
||||||
|
if project.ID == nil {
|
||||||
|
t.Fatal("created project without id")
|
||||||
|
}
|
||||||
|
root, err := c.projectFolderID(ctx, strconv.Itoa(*project.ID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("projectFolderID: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, backend := range []string{ProviderREST, ProviderDAV} {
|
||||||
|
t.Run(backend, func(t *testing.T) {
|
||||||
|
testFileStoreOps(t, ctx, c, c.FileStore(backend), root, suffix)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFileStoreOps(t *testing.T, ctx context.Context, c *Client, store FileStore, root, suffix string) {
|
||||||
|
t.Helper()
|
||||||
|
content := []byte("file store " + store.Name() + " " + suffix + "\n")
|
||||||
|
|
||||||
|
src, err := store.CreateFolder(ctx, root, "fs-src-"+suffix)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFolder src: %v", err)
|
||||||
|
}
|
||||||
|
if src.Kind != Folder || src.ID == "" {
|
||||||
|
t.Fatalf("created src folder: %+v", src)
|
||||||
|
}
|
||||||
|
dst, err := store.CreateFolder(ctx, root, "fs-dst-"+suffix)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFolder dst: %v", err)
|
||||||
|
}
|
||||||
|
if dst.Kind != Folder || dst.ID == "" {
|
||||||
|
t.Fatalf("created dst folder: %+v", dst)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := c.DeleteDavItems(ctx, []string{src.ID, dst.ID}, nil); err != nil {
|
||||||
|
t.Logf("cleanup folders: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
up, err := store.Upload(ctx, src.ID, "doc-"+suffix+".txt", bytes.NewReader(content))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Upload: %v", err)
|
||||||
|
}
|
||||||
|
if up.Kind != File || up.ID == "" {
|
||||||
|
t.Fatalf("uploaded entry: %+v", up)
|
||||||
|
}
|
||||||
|
if !waitEntry(ctx, store, src.ID, up.ID, 15*time.Second) {
|
||||||
|
t.Fatalf("uploaded %s not listed in src", up.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := store.Stat(ctx, up.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stat: %v", err)
|
||||||
|
}
|
||||||
|
if st.ID != up.ID || st.Kind != File {
|
||||||
|
t.Fatalf("stat = %+v", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
n, err := store.Download(ctx, up.ID, &buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Download: %v", err)
|
||||||
|
}
|
||||||
|
if n != int64(len(content)) || !bytes.Equal(buf.Bytes(), content) {
|
||||||
|
t.Fatalf("download mismatch: got %d bytes %q want %d", n, buf.String(), len(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
moveEventually(t, ctx, store, up.ID, dst.ID)
|
||||||
|
if !waitEntry(ctx, store, dst.ID, up.ID, 20*time.Second) {
|
||||||
|
t.Fatalf("moved file %s not in dst", up.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Copy(ctx, []string{up.ID}, src.ID); err != nil {
|
||||||
|
t.Fatalf("Copy: %v", err)
|
||||||
|
}
|
||||||
|
copied := waitOtherFile(ctx, store, src.ID, up.ID, 20*time.Second)
|
||||||
|
if copied == nil {
|
||||||
|
t.Fatalf("no copy found in src after Copy")
|
||||||
|
}
|
||||||
|
|
||||||
|
newTitle := "renamed-" + suffix + ".txt"
|
||||||
|
renameEventually(t, ctx, store, up.ID, newTitle)
|
||||||
|
|
||||||
|
if err := store.Delete(ctx, []string{up.ID, copied.ID}); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
if !waitNoEntry(ctx, store, dst.ID, up.ID, 20*time.Second) {
|
||||||
|
t.Fatalf("file %s still present in dst after delete", up.ID)
|
||||||
|
}
|
||||||
|
if !waitNoEntry(ctx, store, src.ID, copied.ID, 20*time.Second) {
|
||||||
|
t.Fatalf("copy %s still present in src after delete", copied.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveEventually issues Move and retries while the operation is not visible yet
|
||||||
|
// (the fileops API accepts asynchronously and occasionally rejects a move that
|
||||||
|
// raced the just-finished upload).
|
||||||
|
func moveEventually(t *testing.T, ctx context.Context, store FileStore, id, dstID string) {
|
||||||
|
t.Helper()
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < 5; attempt++ {
|
||||||
|
if lastErr = store.Move(ctx, []string{id}, dstID); lastErr == nil {
|
||||||
|
if waitEntry(ctx, store, dstID, id, 6*time.Second) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
t.Fatalf("Move %s -> %s: %v", id, dstID, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameEventually(t *testing.T, ctx context.Context, store FileStore, id, title string) {
|
||||||
|
t.Helper()
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < 5; attempt++ {
|
||||||
|
if lastErr = store.Rename(ctx, id, title); lastErr == nil {
|
||||||
|
if e, err := store.Stat(ctx, id); err == nil && e.Title == title {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
t.Fatalf("Rename %s -> %q: %v", id, title, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitEntry(ctx context.Context, store FileStore, parentID, id string, d time.Duration) bool {
|
||||||
|
deadline := time.Now().Add(d)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if list, err := store.List(ctx, parentID); err == nil && entryByID(list, id) != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitNoEntry(ctx context.Context, store FileStore, parentID, id string, d time.Duration) bool {
|
||||||
|
deadline := time.Now().Add(d)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if list, err := store.List(ctx, parentID); err == nil && entryByID(list, id) == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitOtherFile(ctx context.Context, store FileStore, parentID, id string, d time.Duration) *Entry {
|
||||||
|
deadline := time.Now().Add(d)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if list, err := store.List(ctx, parentID); err == nil {
|
||||||
|
if e := firstFileOtherThan(list, id); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func entryByID(entries []Entry, id string) *Entry {
|
||||||
|
for i := range entries {
|
||||||
|
if entries[i].ID == id {
|
||||||
|
return &entries[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstFileOtherThan(entries []Entry, id string) *Entry {
|
||||||
|
for i := range entries {
|
||||||
|
if entries[i].Kind == File && entries[i].ID != id {
|
||||||
|
return &entries[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -324,19 +324,22 @@ func (c *Client) MoveFiles(ctx context.Context, destFolderID int, fileIDs []int)
|
|||||||
"resolveType": "Skip",
|
"resolveType": "Skip",
|
||||||
"holdResult": true,
|
"holdResult": true,
|
||||||
}
|
}
|
||||||
out, err := c.putJSONObject(ctx, "/api/2.0/files/fileops/move.json", body)
|
// fileops/move answers an operations envelope (like MoveDavItems), not a
|
||||||
|
// single object, so parse the raw body before unwrapping and surface any
|
||||||
|
// per-operation error. Unwrapping first (putJSONObject) made fileopsError
|
||||||
|
// look for a "response" key that was already stripped.
|
||||||
|
raw, err := c.putJSON(ctx, "/api/2.0/files/fileops/move", body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out, err = c.putJSONObject(ctx, "/api/2.0/files/fileops/move", body)
|
raw, err = c.putJSON(ctx, "/api/2.0/files/fileops/move.json", body)
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if raw, merr := json.Marshal(out); merr == nil {
|
}
|
||||||
if ferr := fileopsError(raw); ferr != nil {
|
if ferr := fileopsError(raw); ferr != nil {
|
||||||
return nil, ferr
|
return nil, ferr
|
||||||
}
|
}
|
||||||
}
|
out, _ := unmarshalResponseObject(raw)
|
||||||
return out, err
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadToFolder uploads a local file into an arbitrary Documents folder id.
|
// UploadToFolder uploads a local file into an arbitrary Documents folder id.
|
||||||
|
|||||||
Reference in New Issue
Block a user