Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33b5c1ea97 |
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.18.0"
|
".": "0.17.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,23 +6,6 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ 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())
|
||||||
@@ -185,32 +184,6 @@ 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",
|
||||||
|
|||||||
+13
-197
@@ -18,52 +18,7 @@ import (
|
|||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
)
|
)
|
||||||
|
|
||||||
// amountPat is the amount capture shared by every amount regex.
|
var amountRe = regexp.MustCompile(`(?i)(zu zahlender betrag|rechnungsbetrag)\s*[:\s]*([0-9][0-9.]*,[0-9]{2})`)
|
||||||
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 {
|
||||||
@@ -150,159 +105,20 @@ 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
|
||||||
}
|
}
|
||||||
return extractAmount(buf.String()), nil
|
m := amountRe.FindStringSubmatch(buf.String())
|
||||||
|
if m == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return parseDe(m[2]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractAmount returns the normalised ("1234.56") payable amount found in
|
// parseDe turns "1.234,56" into 1234.56.
|
||||||
// text, or "" if no usable amount matches.
|
func parseDe(s string) string {
|
||||||
//
|
s = strings.ReplaceAll(s, ".", "")
|
||||||
// DKV invoices are special-cased first: they repeat a per-vehicle "TOTAL:" line
|
s = strings.ReplaceAll(s, ",", ".")
|
||||||
// and carry the real total only in the "Gesamtsummenaufstellung" section.
|
v, err := strconv.ParseFloat(s, 64)
|
||||||
func extractAmount(text string) string {
|
|
||||||
if v, ok := dkvGrandTotal(text); ok {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
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 {
|
if err != nil {
|
||||||
return "", false
|
return s
|
||||||
}
|
}
|
||||||
return strconv.FormatFloat(v, 'f', 2, 64), true
|
return strconv.FormatFloat(v, 'f', 2, 64)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-199
@@ -1,199 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
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
@@ -1,189 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
+46
-2
@@ -22,8 +22,52 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The canonical model (Kind, Entry, SearchQuery, SearchHit, Searcher) lives in
|
// Canonical file/search model (epic #34, F1 #35). Declared here because F1 is
|
||||||
// file_core.go (F1 #35).
|
// not merged yet; move to file_core.go and drop these when it lands. Keep the
|
||||||
|
// shape identical to the contract in #35.
|
||||||
|
type (
|
||||||
|
// Kind distinguishes a file from a folder.
|
||||||
|
Kind int
|
||||||
|
// Entry is a canonical file/folder record.
|
||||||
|
Entry struct {
|
||||||
|
ID string
|
||||||
|
ParentID string
|
||||||
|
Title string
|
||||||
|
Kind Kind
|
||||||
|
Size int64
|
||||||
|
MIME string
|
||||||
|
Created time.Time
|
||||||
|
Modified time.Time
|
||||||
|
Version int
|
||||||
|
Provider string
|
||||||
|
}
|
||||||
|
// SearchQuery is a backend-agnostic search request.
|
||||||
|
SearchQuery struct {
|
||||||
|
Text string
|
||||||
|
InContent bool
|
||||||
|
FolderID string
|
||||||
|
Extensions []string
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
// SearchHit is a search result entry plus its relevance data.
|
||||||
|
SearchHit struct {
|
||||||
|
Entry
|
||||||
|
Score float64
|
||||||
|
Highlight string
|
||||||
|
Path []string
|
||||||
|
}
|
||||||
|
// Searcher searches a document store by name and optionally content.
|
||||||
|
Searcher interface {
|
||||||
|
Search(ctx context.Context, q SearchQuery) ([]SearchHit, error)
|
||||||
|
Name() string
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kind values (epic #34).
|
||||||
|
const (
|
||||||
|
File Kind = iota
|
||||||
|
Folder
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultESIndex = "files_file"
|
defaultESIndex = "files_file"
|
||||||
|
|||||||
-230
@@ -1,230 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
//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,22 +324,19 @@ func (c *Client) MoveFiles(ctx context.Context, destFolderID int, fileIDs []int)
|
|||||||
"resolveType": "Skip",
|
"resolveType": "Skip",
|
||||||
"holdResult": true,
|
"holdResult": true,
|
||||||
}
|
}
|
||||||
// fileops/move answers an operations envelope (like MoveDavItems), not a
|
out, err := c.putJSONObject(ctx, "/api/2.0/files/fileops/move.json", body)
|
||||||
// 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 {
|
||||||
raw, err = c.putJSON(ctx, "/api/2.0/files/fileops/move.json", body)
|
out, err = c.putJSONObject(ctx, "/api/2.0/files/fileops/move", 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, nil
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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