refactor(files): единый файловый фасад + миграция CLI/TUI (#38) #43
@@ -17,6 +17,18 @@ const MailListPageSize = 25
|
|||||||
// Loader fetches list items for a menu subject using the OnlyOffice client.
|
// Loader fetches list items for a menu subject using the OnlyOffice client.
|
||||||
type Loader struct {
|
type Loader struct {
|
||||||
Client *onlyoffice.Client
|
Client *onlyoffice.Client
|
||||||
|
|
||||||
|
// Files is the backend-agnostic file store used for file download, preview
|
||||||
|
// and delete. When nil it falls back to Client.FileStore(ProviderREST).
|
||||||
|
Files onlyoffice.FileStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileStore returns the configured file store, defaulting to REST.
|
||||||
|
func (l *Loader) fileStore() onlyoffice.FileStore {
|
||||||
|
if l.Files != nil {
|
||||||
|
return l.Files
|
||||||
|
}
|
||||||
|
return l.Client.FileStore(onlyoffice.ProviderREST)
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns items for the given list spec (nav leaf).
|
// List returns items for the given list spec (nav leaf).
|
||||||
@@ -171,11 +183,7 @@ func (l *Loader) executeDelete(ctx context.Context, item model.Item) (string, er
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("Deleted message %s", item.Title), nil
|
return fmt.Sprintf("Deleted message %s", item.Title), nil
|
||||||
case model.KindFile:
|
case model.KindFile:
|
||||||
id, err := strconv.Atoi(item.ID)
|
if err := l.fileStore().Delete(ctx, []string{item.ID}); err != nil {
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := l.Client.DeleteFiles(ctx, []int{id}); err != nil {
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("Deleted file %s", item.Title), nil
|
return fmt.Sprintf("Deleted file %s", item.Title), nil
|
||||||
@@ -199,7 +207,7 @@ func (l *Loader) executeDownload(ctx context.Context, item model.Item, destPath
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
if _, err := l.Client.DownloadFile(ctx, item.ID, f); err != nil {
|
if _, err := l.fileStore().Download(ctx, item.ID, f); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("Downloaded to %s", destPath), nil
|
return fmt.Sprintf("Downloaded to %s", destPath), nil
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
|
||||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
"github.com/eslider/go-onlyoffice/cmd/office/preview"
|
"github.com/eslider/go-onlyoffice/cmd/office/preview"
|
||||||
)
|
)
|
||||||
@@ -30,13 +29,11 @@ func (l *Loader) filePreviewMarkdown(ctx context.Context, item model.Item) (stri
|
|||||||
return "", fmt.Errorf("file id missing")
|
return "", fmt.Errorf("file id missing")
|
||||||
}
|
}
|
||||||
name := item.Title
|
name := item.Title
|
||||||
if meta, err := l.Client.GetFile(ctx, item.ID); err == nil && meta != nil {
|
if e, err := l.fileStore().Stat(ctx, item.ID); err == nil && e.Title != "" {
|
||||||
if t := onlyoffice.FileEntryTitle(meta); t != "" {
|
name = e.Title
|
||||||
name = t
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if _, err := l.Client.DownloadFile(ctx, item.ID, &buf); err != nil {
|
if _, err := l.fileStore().Download(ctx, item.ID, &buf); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return preview.FileBytesToMarkdown(name, buf.Bytes())
|
return preview.FileBytesToMarkdown(name, buf.Bytes())
|
||||||
|
|||||||
+49
-45
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -12,8 +13,8 @@ func init() {
|
|||||||
rootCmd.AddCommand(davCmd())
|
rootCmd.AddCommand(davCmd())
|
||||||
}
|
}
|
||||||
|
|
||||||
// davCmd exposes the Documents module through the same Dav calls that back
|
// davCmd exposes the Documents module through the backend-agnostic FileStore
|
||||||
// oo-webdav (ListDavFolder / MoveDavItems / CopyDavItems / DownloadDavFile).
|
// (DAV backend). The underlying Dav calls are the oo-webdav proven path:
|
||||||
// MoveDavItems sends resolveType=Skip + holdResult=true, which the legacy
|
// MoveDavItems sends resolveType=Skip + holdResult=true, which the legacy
|
||||||
// fileops/move call without those params silently ignores (200 without move).
|
// fileops/move call without those params silently ignores (200 without move).
|
||||||
func davCmd() *cobra.Command {
|
func davCmd() *cobra.Command {
|
||||||
@@ -61,35 +62,21 @@ func davLsCmd() *cobra.Command {
|
|||||||
printTable([]string{"id", "title", "filesCount", "foldersCount"}, rows)
|
printTable([]string{"id", "title", "filesCount", "foldersCount"}, rows)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
l, err := c.ListDavFolder(ctx, args[0])
|
entries, err := c.FileStore(onlyoffice.ProviderDAV).List(ctx, args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if outputFormat == "json" {
|
folders := make([]onlyoffice.Entry, 0, len(entries))
|
||||||
folders := make([]map[string]any, 0, len(l.Folders))
|
files := make([]onlyoffice.Entry, 0, len(entries))
|
||||||
for _, f := range l.Folders {
|
for _, e := range entries {
|
||||||
folders = append(folders, map[string]any{
|
if e.Kind == onlyoffice.Folder {
|
||||||
"id": f.ID,
|
folders = append(folders, e)
|
||||||
"title": f.Title,
|
} else {
|
||||||
"filesCount": f.FilesCount,
|
files = append(files, e)
|
||||||
"foldersCount": f.FoldersCount,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
files := make([]map[string]any, 0, len(l.Files))
|
|
||||||
for _, f := range l.Files {
|
|
||||||
files = append(files, map[string]any{
|
|
||||||
"id": f.ID,
|
|
||||||
"title": f.Title,
|
|
||||||
"size": f.Size,
|
|
||||||
"updated": f.Updated,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
printObject(map[string]any{"folders": folders, "files": files})
|
frows := make([]map[string]any, 0, len(folders))
|
||||||
return nil
|
for _, f := range folders {
|
||||||
}
|
|
||||||
if len(l.Folders) > 0 {
|
|
||||||
frows := make([]map[string]any, 0, len(l.Folders))
|
|
||||||
for _, f := range l.Folders {
|
|
||||||
frows = append(frows, map[string]any{
|
frows = append(frows, map[string]any{
|
||||||
"id": f.ID,
|
"id": f.ID,
|
||||||
"title": f.Title,
|
"title": f.Title,
|
||||||
@@ -97,23 +84,24 @@ func davLsCmd() *cobra.Command {
|
|||||||
"foldersCount": f.FoldersCount,
|
"foldersCount": f.FoldersCount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if outputFormat == "table" {
|
rows := make([]map[string]any, 0, len(files))
|
||||||
fmt.Println("folders:")
|
for _, f := range files {
|
||||||
}
|
|
||||||
printTable([]string{"id", "title", "filesCount", "foldersCount"}, frows)
|
|
||||||
}
|
|
||||||
rows := make([]map[string]any, 0, len(l.Files))
|
|
||||||
for _, f := range l.Files {
|
|
||||||
rows = append(rows, map[string]any{
|
rows = append(rows, map[string]any{
|
||||||
"id": f.ID,
|
"id": f.ID,
|
||||||
"title": f.Title,
|
"title": f.Title,
|
||||||
"size": f.Size,
|
"size": f.Size,
|
||||||
"updated": f.Updated,
|
"updated": entryUpdated(f),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if outputFormat == "table" {
|
if outputFormat == "json" {
|
||||||
fmt.Println("files:")
|
printObject(map[string]any{"folders": frows, "files": rows})
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
if len(frows) > 0 {
|
||||||
|
fmt.Println("folders:")
|
||||||
|
printTable([]string{"id", "title", "filesCount", "foldersCount"}, frows)
|
||||||
|
}
|
||||||
|
fmt.Println("files:")
|
||||||
printTable([]string{"id", "title", "size", "updated"}, rows)
|
printTable([]string{"id", "title", "size", "updated"}, rows)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -121,6 +109,18 @@ func davLsCmd() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// entryUpdated prefers the backend-native timestamp string so table/JSON output
|
||||||
|
// round-trips what the API returned.
|
||||||
|
func entryUpdated(e onlyoffice.Entry) string {
|
||||||
|
if e.Updated != "" {
|
||||||
|
return e.Updated
|
||||||
|
}
|
||||||
|
if e.Modified.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.Modified.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
func davMoveCmd() *cobra.Command {
|
func davMoveCmd() *cobra.Command {
|
||||||
var folderIDs []string
|
var folderIDs []string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
@@ -132,7 +132,8 @@ func davMoveCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := c.MoveDavItems(cmd.Context(), folderIDs, args[1:], args[0]); err != nil {
|
ids := append(append([]string{}, folderIDs...), args[1:]...)
|
||||||
|
if err := c.FileStore(onlyoffice.ProviderDAV).Move(cmd.Context(), ids, args[0]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
printObject(map[string]any{"moved_files": args[1:], "moved_folders": folderIDs, "dest": args[0]})
|
printObject(map[string]any{"moved_files": args[1:], "moved_folders": folderIDs, "dest": args[0]})
|
||||||
@@ -154,7 +155,8 @@ func davCopyCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := c.CopyDavItems(cmd.Context(), folderIDs, args[1:], args[0]); err != nil {
|
ids := append(append([]string{}, folderIDs...), args[1:]...)
|
||||||
|
if err := c.FileStore(onlyoffice.ProviderDAV).Copy(cmd.Context(), ids, args[0]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
printObject(map[string]any{"copied_files": args[1:], "copied_folders": folderIDs, "dest": args[0]})
|
printObject(map[string]any{"copied_files": args[1:], "copied_folders": folderIDs, "dest": args[0]})
|
||||||
@@ -175,7 +177,7 @@ func davMkdirCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
f, err := c.CreateDavFolder(cmd.Context(), args[0], args[1])
|
f, err := c.FileStore(onlyoffice.ProviderDAV).CreateFolder(cmd.Context(), args[0], args[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -200,7 +202,8 @@ func davRemoveCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := c.DeleteDavItems(cmd.Context(), folderIDs, args); err != nil {
|
ids := append(append([]string{}, args...), folderIDs...)
|
||||||
|
if err := c.FileStore(onlyoffice.ProviderDAV).Delete(cmd.Context(), ids); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
printObject(map[string]any{"deleted_files": args, "deleted_folders": folderIDs})
|
printObject(map[string]any{"deleted_files": args, "deleted_folders": folderIDs})
|
||||||
@@ -221,7 +224,7 @@ func davRenameFileCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := c.RenameDavFile(cmd.Context(), args[0], args[1]); err != nil {
|
if err := c.FileStore(onlyoffice.ProviderDAV).Rename(cmd.Context(), args[0], args[1]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
printObject(map[string]any{"id": args[0], "title": args[1]})
|
printObject(map[string]any{"id": args[0], "title": args[1]})
|
||||||
@@ -240,7 +243,7 @@ func davRenameFolderCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := c.RenameDavFolder(cmd.Context(), args[0], args[1]); err != nil {
|
if err := c.FileStore(onlyoffice.ProviderDAV).Rename(cmd.Context(), args[0], args[1]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
printObject(map[string]any{"id": args[0], "title": args[1]})
|
printObject(map[string]any{"id": args[0], "title": args[1]})
|
||||||
@@ -261,20 +264,21 @@ func davDownloadCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ctx := cmd.Context()
|
ctx := cmd.Context()
|
||||||
f, err := c.GetFile(ctx, args[0])
|
store := c.FileStore(onlyoffice.ProviderDAV)
|
||||||
|
e, err := store.Stat(ctx, args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
path := to
|
path := to
|
||||||
if path == "" {
|
if path == "" {
|
||||||
path = onlyoffice.SafeLocalFileName(onlyoffice.FileEntryTitle(f))
|
path = onlyoffice.SafeLocalFileName(e.Title)
|
||||||
}
|
}
|
||||||
out, err := os.Create(path)
|
out, err := os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
n, err := c.DownloadDavFile(ctx, args[0], out)
|
n, err := store.Download(ctx, args[0], out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = os.Remove(path)
|
_ = os.Remove(path)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -159,20 +160,22 @@ func prjFilesDownloadCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
f, err := c.GetFile(cmd.Context(), args[0])
|
ctx := cmd.Context()
|
||||||
|
store := c.Files()
|
||||||
|
e, err := store.Stat(ctx, args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
path := to
|
path := to
|
||||||
if path == "" {
|
if path == "" {
|
||||||
path = onlyoffice.SafeLocalFileName(onlyoffice.FileEntryTitle(f))
|
path = onlyoffice.SafeLocalFileName(e.Title)
|
||||||
}
|
}
|
||||||
out, err := os.Create(path)
|
out, err := os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
n, err := c.DownloadFile(cmd.Context(), args[0], out)
|
n, err := store.Download(ctx, args[0], out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = os.Remove(path)
|
_ = os.Remove(path)
|
||||||
return err
|
return err
|
||||||
@@ -199,11 +202,16 @@ func prjFilesRenameCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
entry, err := c.RenameFile(cmd.Context(), args[0], args[1])
|
store := c.Files()
|
||||||
|
if err := store.Rename(cmd.Context(), args[0], args[1]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entry, err := store.Stat(cmd.Context(), args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
printObject(fileEntryToMap(entry))
|
entry.Title = args[1]
|
||||||
|
printObject(entryToMap(entry))
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -228,7 +236,7 @@ func prjFilesDeleteCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
ids = append(ids, id)
|
ids = append(ids, id)
|
||||||
}
|
}
|
||||||
if err := c.DeleteFiles(cmd.Context(), ids); err != nil {
|
if err := c.Files().Delete(cmd.Context(), args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
printObject(map[string]any{"deleted": ids})
|
printObject(map[string]any{"deleted": ids})
|
||||||
@@ -317,6 +325,29 @@ func fileEntryToMap(f *onlyoffice.FileEntry) map[string]any {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// entryToMap renders a canonical Entry with the same keys as fileEntryToMap.
|
||||||
|
func entryToMap(e onlyoffice.Entry) map[string]any {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": e.ID,
|
||||||
|
"title": e.Title,
|
||||||
|
"fileExst": filepath.Ext(e.Title),
|
||||||
|
"contentLength": contentLengthString(e.Size),
|
||||||
|
}
|
||||||
|
if e.Updated != "" {
|
||||||
|
m["updated"] = e.Updated
|
||||||
|
} else if !e.Modified.IsZero() {
|
||||||
|
m["updated"] = e.Modified.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentLengthString(n int64) string {
|
||||||
|
if n <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.FormatInt(n, 10)
|
||||||
|
}
|
||||||
|
|
||||||
func fileIDStr(f *onlyoffice.FileEntry) string {
|
func fileIDStr(f *onlyoffice.FileEntry) string {
|
||||||
if f == nil || f.ID == nil {
|
if f == nil || f.ID == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
+8
-5
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -9,9 +10,9 @@ func init() {
|
|||||||
rootCmd.AddCommand(searchCmd())
|
rootCmd.AddCommand(searchCmd())
|
||||||
}
|
}
|
||||||
|
|
||||||
// searchCmd queries the OnlyOffice Elasticsearch index directly. The REST
|
// searchCmd queries the OnlyOffice document index through the file facade. The
|
||||||
// /api/2.0/files/@search endpoint only searches file names in the database;
|
// REST /api/2.0/files/@search endpoint only searches file names in the database;
|
||||||
// content search needs ES (see docs/elasticsearch.md).
|
// content search needs Elasticsearch (see docs/elasticsearch.md).
|
||||||
func searchCmd() *cobra.Command {
|
func searchCmd() *cobra.Command {
|
||||||
var (
|
var (
|
||||||
content bool
|
content bool
|
||||||
@@ -33,11 +34,13 @@ func searchCmd() *cobra.Command {
|
|||||||
if asJSON {
|
if asJSON {
|
||||||
outputFormat = "json"
|
outputFormat = "json"
|
||||||
}
|
}
|
||||||
es, err := onlyoffice.NewESSearcher(onlyoffice.ESConfigFromEnv())
|
bootstrap.LoadEnv()
|
||||||
|
c := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials())
|
||||||
|
searcher, err := c.Files().Search()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
hits, err := es.Search(cmd.Context(), onlyoffice.SearchQuery{
|
hits, err := searcher.Search(cmd.Context(), onlyoffice.SearchQuery{
|
||||||
Text: args[0],
|
Text: args[0],
|
||||||
InContent: content,
|
InContent: content,
|
||||||
FolderID: folder,
|
FolderID: folder,
|
||||||
|
|||||||
+19
-4
@@ -52,8 +52,17 @@ type Entry struct {
|
|||||||
MIME string
|
MIME string
|
||||||
Created time.Time
|
Created time.Time
|
||||||
Modified time.Time
|
Modified time.Time
|
||||||
|
// Updated is the backend-native timestamp string, when the backend exposes
|
||||||
|
// one. It lets list output round-trip the API value; Modified is the
|
||||||
|
// parsed form for logic.
|
||||||
|
Updated string
|
||||||
Version int
|
Version int
|
||||||
Provider string
|
Provider string
|
||||||
|
|
||||||
|
// Folder-only counters. Zero for files and for backends that do not
|
||||||
|
// report them.
|
||||||
|
FilesCount int
|
||||||
|
FoldersCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileStore is the operation surface every file backend implements.
|
// FileStore is the operation surface every file backend implements.
|
||||||
@@ -97,8 +106,8 @@ type Searcher interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FileStore returns the adapter for a backend name: ProviderREST (default) or
|
// FileStore returns the adapter for a backend name: ProviderREST (default) or
|
||||||
// ProviderDAV. Unknown or empty names select the REST backend. The full facade
|
// ProviderDAV. Unknown or empty names select the REST backend. The composed
|
||||||
// (backend composition) is deliberately left to a later change.
|
// facade (backend selection/fallback) lives on FileClient in file_facade.go.
|
||||||
func (c *Client) FileStore(backend string) FileStore {
|
func (c *Client) FileStore(backend string) FileStore {
|
||||||
switch strings.ToLower(strings.TrimSpace(backend)) {
|
switch strings.ToLower(strings.TrimSpace(backend)) {
|
||||||
case ProviderDAV, "webdav":
|
case ProviderDAV, "webdav":
|
||||||
@@ -108,8 +117,9 @@ func (c *Client) FileStore(backend string) FileStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files returns the default (REST) file store.
|
// Files returns the composed file facade. The returned *FileClient implements
|
||||||
func (c *Client) Files() FileStore { return c.FileStore(ProviderREST) }
|
// FileStore, so callers that used Files() as the plain REST store keep working.
|
||||||
|
func (c *Client) Files() *FileClient { return c.newFileClient() }
|
||||||
|
|
||||||
// retryStoreOp runs one store operation under the shared deterministic
|
// retryStoreOp runs one store operation under the shared deterministic
|
||||||
// transient-error policy (429/502/503/504).
|
// transient-error policy (429/502/503/504).
|
||||||
@@ -140,6 +150,7 @@ func FileEntryToEntry(f *FileEntry, provider string) Entry {
|
|||||||
e.MIME = mimeForTitle(e.Title, exst)
|
e.MIME = mimeForTitle(e.Title, exst)
|
||||||
if f.Updated != nil {
|
if f.Updated != nil {
|
||||||
e.Modified = *f.Updated
|
e.Modified = *f.Updated
|
||||||
|
e.Updated = f.Updated.Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
@@ -153,6 +164,7 @@ func DavFileToEntry(f DavFile, provider string) Entry {
|
|||||||
Size: f.Size,
|
Size: f.Size,
|
||||||
MIME: mimeForTitle(f.Title, ""),
|
MIME: mimeForTitle(f.Title, ""),
|
||||||
Modified: f.ModTime(),
|
Modified: f.ModTime(),
|
||||||
|
Updated: f.Updated,
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,7 +177,10 @@ func DavFolderToEntry(f DavFolder, provider string) Entry {
|
|||||||
Title: f.Title,
|
Title: f.Title,
|
||||||
Kind: Folder,
|
Kind: Folder,
|
||||||
Modified: f.ModTime(),
|
Modified: f.ModTime(),
|
||||||
|
Updated: f.Updated,
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
|
FilesCount: f.FilesCount,
|
||||||
|
FoldersCount: f.FoldersCount,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+251
@@ -0,0 +1,251 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
// Single file client (epic #34, F4 #38). FileClient composes the registered
|
||||||
|
// FileStore and Searcher backends and picks one per operation: REST/DAV for
|
||||||
|
// writes, PostgreSQL (when registered) for fast reads, Elasticsearch for name
|
||||||
|
// and content search. Client.Files returns the facade; it also implements
|
||||||
|
// FileStore, so existing callers keep compiling.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Provider names for the composed backends. ProviderPG is reserved for the
|
||||||
|
// read-only PostgreSQL store (F2 #36); it is not registered until it exists.
|
||||||
|
const (
|
||||||
|
ProviderPG = "postgres"
|
||||||
|
ProviderES = "elasticsearch"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errNoReadBackend = errors.New("onlyoffice: no file backend registered for reads")
|
||||||
|
errNoWriteBackend = errors.New("onlyoffice: no file backend registered for writes")
|
||||||
|
errNoSearcher = errors.New("onlyoffice: no search backend registered (set ONLYOFFICE_ES_URL)")
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileClient is the single entry point for file operations. It holds the
|
||||||
|
// registered backends and the order in which each operation tries them.
|
||||||
|
type FileClient struct {
|
||||||
|
stores map[string]FileStore
|
||||||
|
searchers map[string]Searcher
|
||||||
|
|
||||||
|
readOrder []string
|
||||||
|
writeOrder []string
|
||||||
|
searchOrder []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// newFileClient builds the facade over the built-in REST and DAV stores. The
|
||||||
|
// Elasticsearch searcher is registered when ONLYOFFICE_ES_URL is set; the
|
||||||
|
// missing-credential case is left to Search so read-only commands still work.
|
||||||
|
func (c *Client) newFileClient() *FileClient {
|
||||||
|
f := &FileClient{
|
||||||
|
stores: map[string]FileStore{
|
||||||
|
ProviderREST: &restStore{c: c},
|
||||||
|
ProviderDAV: &davStore{c: c},
|
||||||
|
},
|
||||||
|
searchers: map[string]Searcher{},
|
||||||
|
readOrder: []string{ProviderPG, ProviderREST, ProviderDAV},
|
||||||
|
writeOrder: []string{ProviderREST, ProviderDAV},
|
||||||
|
searchOrder: []string{ProviderES},
|
||||||
|
}
|
||||||
|
if cfg := ESConfigFromEnv(); cfg.URL != "" {
|
||||||
|
if es, err := NewESSearcher(cfg); err == nil {
|
||||||
|
f.searchers[ProviderES] = es
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterStore adds or replaces a named backend (for example the PostgreSQL
|
||||||
|
// read store). The name is matched case-insensitively.
|
||||||
|
func (f *FileClient) RegisterStore(name string, s FileStore) {
|
||||||
|
if f == nil || s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name = normalizeProvider(name)
|
||||||
|
if name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if f.stores == nil {
|
||||||
|
f.stores = map[string]FileStore{}
|
||||||
|
}
|
||||||
|
f.stores[name] = s
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterSearcher adds or replaces a named search backend.
|
||||||
|
func (f *FileClient) RegisterSearcher(name string, s Searcher) {
|
||||||
|
if f == nil || s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name = normalizeProvider(name)
|
||||||
|
if name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if f.searchers == nil {
|
||||||
|
f.searchers = map[string]Searcher{}
|
||||||
|
}
|
||||||
|
f.searchers[name] = s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read returns the preferred backend for reads: PostgreSQL when registered,
|
||||||
|
// then REST, then WebDAV.
|
||||||
|
func (f *FileClient) Read() FileStore { return f.firstStore(f.readOrder) }
|
||||||
|
|
||||||
|
// Write returns the preferred backend for writes: REST, then WebDAV.
|
||||||
|
func (f *FileClient) Write() FileStore { return f.firstStore(f.writeOrder) }
|
||||||
|
|
||||||
|
// Search returns the preferred name/content searcher (Elasticsearch), or an
|
||||||
|
// error when no search backend is configured.
|
||||||
|
func (f *FileClient) Search() (Searcher, error) {
|
||||||
|
if f == nil {
|
||||||
|
return nil, errNoSearcher
|
||||||
|
}
|
||||||
|
for _, name := range f.searchOrder {
|
||||||
|
if s := f.searchers[normalizeProvider(name)]; s != nil {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errNoSearcher
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstStore returns the first registered store in the order.
|
||||||
|
func (f *FileClient) firstStore(order []string) FileStore {
|
||||||
|
if f == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, name := range order {
|
||||||
|
if s := f.stores[normalizeProvider(name)]; s != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderedStores returns the registered stores in the order.
|
||||||
|
func (f *FileClient) orderedStores(order []string) []FileStore {
|
||||||
|
if f == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]FileStore, 0, len(order))
|
||||||
|
for _, name := range order {
|
||||||
|
if s := f.stores[normalizeProvider(name)]; s != nil {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeProvider(name string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name implements FileStore and reports the preferred read backend.
|
||||||
|
func (f *FileClient) Name() string {
|
||||||
|
if s := f.Read(); s != nil {
|
||||||
|
return s.Name()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// List reads from the preferred backend, falling back to the next read backend
|
||||||
|
// only on a transient error (429/502/503/504).
|
||||||
|
func (f *FileClient) List(ctx context.Context, parentID string) ([]Entry, error) {
|
||||||
|
return fallbackRead(ctx, f.orderedStores(f.readOrder), func(s FileStore) ([]Entry, error) {
|
||||||
|
return s.List(ctx, parentID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat reads from the preferred backend, with the same transient fallback.
|
||||||
|
func (f *FileClient) Stat(ctx context.Context, id string) (Entry, error) {
|
||||||
|
return fallbackRead(ctx, f.orderedStores(f.readOrder), func(s FileStore) (Entry, error) {
|
||||||
|
return s.Stat(ctx, id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download streams file bytes. It does not fall back: a failed attempt may have
|
||||||
|
// already written partial bytes into w, so a second backend would append.
|
||||||
|
func (f *FileClient) Download(ctx context.Context, id string, w io.Writer) (int64, error) {
|
||||||
|
s := f.Read()
|
||||||
|
if s == nil {
|
||||||
|
return 0, errNoReadBackend
|
||||||
|
}
|
||||||
|
return s.Download(ctx, id, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFolder writes to the preferred write backend.
|
||||||
|
func (f *FileClient) CreateFolder(ctx context.Context, parentID, title string) (Entry, error) {
|
||||||
|
s := f.Write()
|
||||||
|
if s == nil {
|
||||||
|
return Entry{}, errNoWriteBackend
|
||||||
|
}
|
||||||
|
return s.CreateFolder(ctx, parentID, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload writes to the preferred write backend.
|
||||||
|
func (f *FileClient) Upload(ctx context.Context, parentID, title string, r io.Reader) (Entry, error) {
|
||||||
|
s := f.Write()
|
||||||
|
if s == nil {
|
||||||
|
return Entry{}, errNoWriteBackend
|
||||||
|
}
|
||||||
|
return s.Upload(ctx, parentID, title, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move writes to the preferred write backend.
|
||||||
|
func (f *FileClient) Move(ctx context.Context, ids []string, parentID string) error {
|
||||||
|
s := f.Write()
|
||||||
|
if s == nil {
|
||||||
|
return errNoWriteBackend
|
||||||
|
}
|
||||||
|
return s.Move(ctx, ids, parentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy writes to the preferred write backend.
|
||||||
|
func (f *FileClient) Copy(ctx context.Context, ids []string, parentID string) error {
|
||||||
|
s := f.Write()
|
||||||
|
if s == nil {
|
||||||
|
return errNoWriteBackend
|
||||||
|
}
|
||||||
|
return s.Copy(ctx, ids, parentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename writes to the preferred write backend.
|
||||||
|
func (f *FileClient) Rename(ctx context.Context, id, title string) error {
|
||||||
|
s := f.Write()
|
||||||
|
if s == nil {
|
||||||
|
return errNoWriteBackend
|
||||||
|
}
|
||||||
|
return s.Rename(ctx, id, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete writes to the preferred write backend.
|
||||||
|
func (f *FileClient) Delete(ctx context.Context, ids []string) error {
|
||||||
|
s := f.Write()
|
||||||
|
if s == nil {
|
||||||
|
return errNoWriteBackend
|
||||||
|
}
|
||||||
|
return s.Delete(ctx, ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallbackRead runs op against each store in order, moving on only when the
|
||||||
|
// error is transient. Non-transient errors (not found, forbidden) are final.
|
||||||
|
func fallbackRead[T any](ctx context.Context, stores []FileStore, op func(FileStore) (T, error)) (T, error) {
|
||||||
|
var zero T
|
||||||
|
if len(stores) == 0 {
|
||||||
|
return zero, errNoReadBackend
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
for i, s := range stores {
|
||||||
|
var v T
|
||||||
|
v, err = op(s)
|
||||||
|
if err == nil {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
if i == len(stores)-1 || !Transient(err) {
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeStore is a FileStore test double; it records which backend served a call
|
||||||
|
// and returns a canned result or error.
|
||||||
|
type fakeStore struct {
|
||||||
|
name string
|
||||||
|
entries []Entry
|
||||||
|
err error
|
||||||
|
calls *[]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) record(op string) {
|
||||||
|
if f.calls != nil {
|
||||||
|
*f.calls = append(*f.calls, op+":"+f.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) Name() string { return f.name }
|
||||||
|
|
||||||
|
func (f *fakeStore) List(_ context.Context, _ string) ([]Entry, error) {
|
||||||
|
f.record("list")
|
||||||
|
if f.err != nil {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
return f.entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) Stat(_ context.Context, id string) (Entry, error) {
|
||||||
|
f.record("stat")
|
||||||
|
if f.err != nil {
|
||||||
|
return Entry{}, f.err
|
||||||
|
}
|
||||||
|
return Entry{ID: id, Title: "t-" + f.name, Provider: f.name}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateFolder(_ context.Context, _, title string) (Entry, error) {
|
||||||
|
f.record("mkdir")
|
||||||
|
if f.err != nil {
|
||||||
|
return Entry{}, f.err
|
||||||
|
}
|
||||||
|
return Entry{ID: "new", Title: title, Provider: f.name}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) Upload(_ context.Context, _, title string, _ io.Reader) (Entry, error) {
|
||||||
|
f.record("upload")
|
||||||
|
return Entry{ID: "up", Title: title, Provider: f.name}, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) Download(_ context.Context, _ string, _ io.Writer) (int64, error) {
|
||||||
|
f.record("download")
|
||||||
|
return 0, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) Move(_ context.Context, _ []string, _ string) error {
|
||||||
|
f.record("move")
|
||||||
|
return f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) Copy(_ context.Context, _ []string, _ string) error {
|
||||||
|
f.record("copy")
|
||||||
|
return f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) Rename(_ context.Context, _, _ string) error {
|
||||||
|
f.record("rename")
|
||||||
|
return f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) Delete(_ context.Context, _ []string) error {
|
||||||
|
f.record("delete")
|
||||||
|
return f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeSearcher struct{ name string }
|
||||||
|
|
||||||
|
func (s *fakeSearcher) Name() string { return s.name }
|
||||||
|
|
||||||
|
func (s *fakeSearcher) Search(_ context.Context, _ SearchQuery) ([]SearchHit, error) {
|
||||||
|
return []SearchHit{{Entry: Entry{Title: s.name}}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFacadeTestClient(stores map[string]FileStore, read, write []string) *FileClient {
|
||||||
|
return &FileClient{
|
||||||
|
stores: stores,
|
||||||
|
searchers: map[string]Searcher{},
|
||||||
|
readOrder: read,
|
||||||
|
writeOrder: write,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileClientIsFileStore guarantees the facade can stand in for the
|
||||||
|
// interface anywhere a plain FileStore is expected.
|
||||||
|
func TestFileClientIsFileStore(t *testing.T) {
|
||||||
|
var _ FileStore = (*FileClient)(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientFilesPrefersRESTForReadsAndWrites(t *testing.T) {
|
||||||
|
c := NewClient(Credentials{})
|
||||||
|
f := c.Files()
|
||||||
|
if got := f.Read().Name(); got != ProviderREST {
|
||||||
|
t.Errorf("Read().Name() = %q, want %q", got, ProviderREST)
|
||||||
|
}
|
||||||
|
if got := f.Write().Name(); got != ProviderREST {
|
||||||
|
t.Errorf("Write().Name() = %q, want %q", got, ProviderREST)
|
||||||
|
}
|
||||||
|
if got := f.Name(); got != ProviderREST {
|
||||||
|
t.Errorf("Name() = %q, want %q", got, ProviderREST)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileClientPostgresTakesReadPriority(t *testing.T) {
|
||||||
|
pg := &fakeStore{name: ProviderPG}
|
||||||
|
f := newFacadeTestClient(
|
||||||
|
map[string]FileStore{ProviderREST: &fakeStore{name: ProviderREST}, ProviderPG: pg},
|
||||||
|
[]string{ProviderPG, ProviderREST},
|
||||||
|
[]string{ProviderREST},
|
||||||
|
)
|
||||||
|
if got := f.Read().Name(); got != ProviderPG {
|
||||||
|
t.Errorf("Read().Name() = %q, want %q", got, ProviderPG)
|
||||||
|
}
|
||||||
|
if got := f.Write().Name(); got != ProviderREST {
|
||||||
|
t.Errorf("Write().Name() = %q, want %q (PG is read-only)", got, ProviderREST)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileClientRegisterStoreNormalizesName(t *testing.T) {
|
||||||
|
pg := &fakeStore{name: "pg"}
|
||||||
|
f := newFacadeTestClient(map[string]FileStore{}, []string{ProviderPG}, nil)
|
||||||
|
f.RegisterStore(" POSTGRES ", pg)
|
||||||
|
if got := f.Read(); got != pg {
|
||||||
|
t.Fatalf("Read() = %v, want registered postgres store", got)
|
||||||
|
}
|
||||||
|
f.RegisterStore("", pg)
|
||||||
|
f.RegisterStore("pg", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileClientReadFallsBackOnlyOnTransient(t *testing.T) {
|
||||||
|
var calls []string
|
||||||
|
primary := &fakeStore{name: "primary", err: fmt.Errorf("onlyoffice: list: 503 unavailable"), calls: &calls}
|
||||||
|
secondary := &fakeStore{name: "secondary", entries: []Entry{{ID: "1"}}, calls: &calls}
|
||||||
|
f := newFacadeTestClient(
|
||||||
|
map[string]FileStore{"primary": primary, "secondary": secondary},
|
||||||
|
[]string{"primary", "secondary"},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
got, err := f.List(context.Background(), "root")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].ID != "1" {
|
||||||
|
t.Fatalf("List() = %+v, want secondary entry", got)
|
||||||
|
}
|
||||||
|
want := []string{"list:primary", "list:secondary"}
|
||||||
|
if fmt.Sprint(calls) != fmt.Sprint(want) {
|
||||||
|
t.Fatalf("call order = %v, want %v", calls, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileClientReadStopsOnPermanentError(t *testing.T) {
|
||||||
|
var calls []string
|
||||||
|
primary := &fakeStore{name: "primary", err: errors.New("onlyoffice: not found"), calls: &calls}
|
||||||
|
secondary := &fakeStore{name: "secondary", entries: []Entry{{ID: "1"}}, calls: &calls}
|
||||||
|
f := newFacadeTestClient(
|
||||||
|
map[string]FileStore{"primary": primary, "secondary": secondary},
|
||||||
|
[]string{"primary", "secondary"},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if _, err := f.List(context.Background(), "root"); err == nil {
|
||||||
|
t.Fatal("expected permanent error to be returned")
|
||||||
|
}
|
||||||
|
if len(calls) != 1 || calls[0] != "list:primary" {
|
||||||
|
t.Fatalf("secondary backend must not run on a permanent error: %v", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileClientWriteUsesWriteBackend(t *testing.T) {
|
||||||
|
var calls []string
|
||||||
|
rest := &fakeStore{name: ProviderREST, calls: &calls}
|
||||||
|
dav := &fakeStore{name: ProviderDAV, calls: &calls}
|
||||||
|
f := newFacadeTestClient(
|
||||||
|
map[string]FileStore{ProviderREST: rest, ProviderDAV: dav},
|
||||||
|
[]string{ProviderREST},
|
||||||
|
[]string{ProviderREST, ProviderDAV},
|
||||||
|
)
|
||||||
|
if _, err := f.CreateFolder(context.Background(), "p", "t"); err != nil {
|
||||||
|
t.Fatalf("CreateFolder: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := f.Upload(context.Background(), "p", "t", nil); err != nil {
|
||||||
|
t.Fatalf("Upload: %v", err)
|
||||||
|
}
|
||||||
|
if len(calls) != 2 || calls[0] != "mkdir:rest" || calls[1] != "upload:rest" {
|
||||||
|
t.Fatalf("write calls = %v, want REST", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileClientWriteWithoutBackend(t *testing.T) {
|
||||||
|
f := newFacadeTestClient(map[string]FileStore{}, nil, nil)
|
||||||
|
if err := f.Delete(context.Background(), []string{"1"}); !errors.Is(err, errNoWriteBackend) {
|
||||||
|
t.Fatalf("Delete err = %v, want errNoWriteBackend", err)
|
||||||
|
}
|
||||||
|
if _, err := f.List(context.Background(), "root"); !errors.Is(err, errNoReadBackend) {
|
||||||
|
t.Fatalf("List err = %v, want errNoReadBackend", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileClientSearchSelection(t *testing.T) {
|
||||||
|
f := &FileClient{searchers: map[string]Searcher{}, searchOrder: []string{ProviderES}}
|
||||||
|
_, err := f.Search()
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "ONLYOFFICE_ES_URL") {
|
||||||
|
t.Fatalf("Search without backend = %v, want ONLYOFFICE_ES_URL hint", err)
|
||||||
|
}
|
||||||
|
es := &fakeSearcher{name: "fake-es"}
|
||||||
|
f.RegisterSearcher(ProviderES, es)
|
||||||
|
got, err := f.Search()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name() != "fake-es" {
|
||||||
|
t.Fatalf("searcher = %q, want fake-es", got.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -247,6 +247,8 @@ func (c *Client) UploadProjectFileReplacing(ctx context.Context, projectID, loca
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetFile returns file metadata including viewUrl for download.
|
// GetFile returns file metadata including viewUrl for download.
|
||||||
|
//
|
||||||
|
// Deprecated: use FileStore.Stat via Client.Files()/Client.FileStore.
|
||||||
func (c *Client) GetFile(ctx context.Context, fileID string) (*FileEntry, error) {
|
func (c *Client) GetFile(ctx context.Context, fileID string) (*FileEntry, error) {
|
||||||
if fileID == "" {
|
if fileID == "" {
|
||||||
return nil, fmt.Errorf("file id is required")
|
return nil, fmt.Errorf("file id is required")
|
||||||
@@ -260,6 +262,8 @@ func (c *Client) GetFile(ctx context.Context, fileID string) (*FileEntry, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RenameFile sets a new title (including extension) for the file.
|
// RenameFile sets a new title (including extension) for the file.
|
||||||
|
//
|
||||||
|
// Deprecated: use FileStore.Rename via Client.Files()/Client.FileStore.
|
||||||
func (c *Client) RenameFile(ctx context.Context, fileID, newTitle string) (*FileEntry, error) {
|
func (c *Client) RenameFile(ctx context.Context, fileID, newTitle string) (*FileEntry, error) {
|
||||||
if fileID == "" || newTitle == "" {
|
if fileID == "" || newTitle == "" {
|
||||||
return nil, fmt.Errorf("file id and new title are required")
|
return nil, fmt.Errorf("file id and new title are required")
|
||||||
@@ -275,6 +279,8 @@ func (c *Client) RenameFile(ctx context.Context, fileID, newTitle string) (*File
|
|||||||
// DeleteFiles permanently deletes files by numeric id (Documents module).
|
// DeleteFiles permanently deletes files by numeric id (Documents module).
|
||||||
// Uses per-file DELETE (DeleteDavItems); fileops/delete returns 200 on some
|
// Uses per-file DELETE (DeleteDavItems); fileops/delete returns 200 on some
|
||||||
// portals (e.g. produktor.io) without removing the file.
|
// portals (e.g. produktor.io) without removing the file.
|
||||||
|
//
|
||||||
|
// Deprecated: use FileStore.Delete via Client.Files()/Client.FileStore.
|
||||||
func (c *Client) DeleteFiles(ctx context.Context, fileIDs []int) error {
|
func (c *Client) DeleteFiles(ctx context.Context, fileIDs []int) error {
|
||||||
if len(fileIDs) == 0 {
|
if len(fileIDs) == 0 {
|
||||||
return fmt.Errorf("no file ids to delete")
|
return fmt.Errorf("no file ids to delete")
|
||||||
@@ -288,6 +294,8 @@ func (c *Client) DeleteFiles(ctx context.Context, fileIDs []int) error {
|
|||||||
|
|
||||||
// ListFolder returns the Documents module listing for a folder id
|
// ListFolder returns the Documents module listing for a folder id
|
||||||
// (GET /api/2.0/files/{folderId}).
|
// (GET /api/2.0/files/{folderId}).
|
||||||
|
//
|
||||||
|
// Deprecated: use FileStore.List via Client.Files()/Client.FileStore.
|
||||||
func (c *Client) ListFolder(ctx context.Context, folderID string) (map[string]any, error) {
|
func (c *Client) ListFolder(ctx context.Context, folderID string) (map[string]any, error) {
|
||||||
if folderID == "" {
|
if folderID == "" {
|
||||||
return nil, fmt.Errorf("folder id is required")
|
return nil, fmt.Errorf("folder id is required")
|
||||||
@@ -313,6 +321,8 @@ func (c *Client) CreateFolder(ctx context.Context, parentFolderID, title string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MoveFiles moves file ids into destFolderID (Documents fileops/move).
|
// MoveFiles moves file ids into destFolderID (Documents fileops/move).
|
||||||
|
//
|
||||||
|
// Deprecated: use FileStore.Move via Client.Files()/Client.FileStore.
|
||||||
func (c *Client) MoveFiles(ctx context.Context, destFolderID int, fileIDs []int) (map[string]any, error) {
|
func (c *Client) MoveFiles(ctx context.Context, destFolderID int, fileIDs []int) (map[string]any, error) {
|
||||||
if destFolderID == 0 || len(fileIDs) == 0 {
|
if destFolderID == 0 || len(fileIDs) == 0 {
|
||||||
return nil, fmt.Errorf("dest folder and file ids are required")
|
return nil, fmt.Errorf("dest folder and file ids are required")
|
||||||
@@ -343,6 +353,8 @@ func (c *Client) MoveFiles(ctx context.Context, destFolderID int, fileIDs []int)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UploadToFolder uploads a local file into an arbitrary Documents folder id.
|
// UploadToFolder uploads a local file into an arbitrary Documents folder id.
|
||||||
|
//
|
||||||
|
// Deprecated: use FileStore.Upload via Client.Files()/Client.FileStore.
|
||||||
func (c *Client) UploadToFolder(ctx context.Context, folderID, localPath string) (*FileEntry, error) {
|
func (c *Client) UploadToFolder(ctx context.Context, folderID, localPath string) (*FileEntry, error) {
|
||||||
if folderID == "" || localPath == "" {
|
if folderID == "" || localPath == "" {
|
||||||
return nil, fmt.Errorf("folder id and local path are required")
|
return nil, fmt.Errorf("folder id and local path are required")
|
||||||
@@ -400,6 +412,8 @@ func FileFolderID(f *FileEntry) string {
|
|||||||
// as API calls. Writes into dst. When the portal serves the file from its stale
|
// as API calls. Writes into dst. When the portal serves the file from its stale
|
||||||
// AWS S3 consumer, the bytes are fetched from the local MinIO store instead
|
// AWS S3 consumer, the bytes are fetched from the local MinIO store instead
|
||||||
// (see storage_fallback.go).
|
// (see storage_fallback.go).
|
||||||
|
//
|
||||||
|
// Deprecated: use FileStore.Download via Client.Files()/Client.FileStore.
|
||||||
func (c *Client) DownloadFile(ctx context.Context, fileID string, dst io.Writer) (int64, error) {
|
func (c *Client) DownloadFile(ctx context.Context, fileID string, dst io.Writer) (int64, error) {
|
||||||
f, err := c.GetFile(ctx, fileID)
|
f, err := c.GetFile(ctx, fileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ type DavListing struct {
|
|||||||
|
|
||||||
// ListDavFolder returns the contents of a folder by id, which may be a
|
// ListDavFolder returns the contents of a folder by id, which may be a
|
||||||
// symbolic root such as "@my". For "@root" use ListDavSections.
|
// symbolic root such as "@my". For "@root" use ListDavSections.
|
||||||
|
//
|
||||||
|
// Deprecated: use FileStore.List via Client.Files()/Client.FileStore.
|
||||||
func (c *Client) ListDavFolder(ctx context.Context, id string) (*DavListing, error) {
|
func (c *Client) ListDavFolder(ctx context.Context, id string) (*DavListing, error) {
|
||||||
raw, err := c.getJSON(ctx, "/api/2.0/files/"+url.PathEscape(id))
|
raw, err := c.getJSON(ctx, "/api/2.0/files/"+url.PathEscape(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -111,6 +113,8 @@ func (c *Client) ListDavSections(ctx context.Context) ([]DavFolder, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateDavFolder creates a folder titled title inside parentID.
|
// CreateDavFolder creates a folder titled title inside parentID.
|
||||||
|
//
|
||||||
|
// Deprecated: use FileStore.CreateFolder via Client.Files()/Client.FileStore.
|
||||||
func (c *Client) CreateDavFolder(ctx context.Context, parentID, title string) (*DavFolder, error) {
|
func (c *Client) CreateDavFolder(ctx context.Context, parentID, title string) (*DavFolder, error) {
|
||||||
raw, err := c.postJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(parentID),
|
raw, err := c.postJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(parentID),
|
||||||
map[string]string{"title": title})
|
map[string]string{"title": title})
|
||||||
@@ -130,6 +134,8 @@ func (c *Client) CreateDavFolder(ctx context.Context, parentID, title string) (*
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RenameDavFolder renames a folder.
|
// RenameDavFolder renames a folder.
|
||||||
|
//
|
||||||
|
// Deprecated: use FileStore.Rename via Client.Files()/Client.FileStore.
|
||||||
func (c *Client) RenameDavFolder(ctx context.Context, id, title string) error {
|
func (c *Client) RenameDavFolder(ctx context.Context, id, title string) error {
|
||||||
_, err := c.putJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(id),
|
_, err := c.putJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(id),
|
||||||
map[string]string{"title": title})
|
map[string]string{"title": title})
|
||||||
@@ -137,6 +143,8 @@ func (c *Client) RenameDavFolder(ctx context.Context, id, title string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RenameDavFile renames a file (title includes the extension).
|
// RenameDavFile renames a file (title includes the extension).
|
||||||
|
//
|
||||||
|
// Deprecated: use FileStore.Rename via Client.Files()/Client.FileStore.
|
||||||
func (c *Client) RenameDavFile(ctx context.Context, id, title string) error {
|
func (c *Client) RenameDavFile(ctx context.Context, id, title string) error {
|
||||||
_, err := c.putJSON(ctx, "/api/2.0/files/file/"+url.PathEscape(id),
|
_, err := c.putJSON(ctx, "/api/2.0/files/file/"+url.PathEscape(id),
|
||||||
map[string]string{"title": title})
|
map[string]string{"title": title})
|
||||||
@@ -147,6 +155,8 @@ func (c *Client) RenameDavFile(ctx context.Context, id, title string) error {
|
|||||||
// The fileops API answers 200 with per-operation error strings even when
|
// The fileops API answers 200 with per-operation error strings even when
|
||||||
// nothing moves (e.g. missing permission), so the response is parsed and the
|
// nothing moves (e.g. missing permission), so the response is parsed and the
|
||||||
// first operation error is returned instead of a silent nil.
|
// first operation error is returned instead of a silent nil.
|
||||||
|
//
|
||||||
|
// Deprecated: use FileStore.Move via Client.Files()/Client.FileStore.
|
||||||
func (c *Client) MoveDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error {
|
func (c *Client) MoveDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error {
|
||||||
raw, err := c.putJSON(ctx, "/api/2.0/files/fileops/move", map[string]any{
|
raw, err := c.putJSON(ctx, "/api/2.0/files/fileops/move", map[string]any{
|
||||||
"folderIds": nums(folderIDs),
|
"folderIds": nums(folderIDs),
|
||||||
@@ -163,6 +173,8 @@ func (c *Client) MoveDavItems(ctx context.Context, folderIDs, fileIDs []string,
|
|||||||
|
|
||||||
// CopyDavItems copies the given folders and/or files into destFolderID.
|
// CopyDavItems copies the given folders and/or files into destFolderID.
|
||||||
// Per-operation errors are surfaced like in MoveDavItems.
|
// Per-operation errors are surfaced like in MoveDavItems.
|
||||||
|
//
|
||||||
|
// Deprecated: use FileStore.Copy via Client.Files()/Client.FileStore.
|
||||||
func (c *Client) CopyDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error {
|
func (c *Client) CopyDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error {
|
||||||
raw, err := c.putJSON(ctx, "/api/2.0/files/fileops/copy", map[string]any{
|
raw, err := c.putJSON(ctx, "/api/2.0/files/fileops/copy", map[string]any{
|
||||||
"folderIds": nums(folderIDs),
|
"folderIds": nums(folderIDs),
|
||||||
@@ -226,6 +238,8 @@ func fileopsError(raw json.RawMessage) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeleteDavItems deletes the given folders and/or files.
|
// DeleteDavItems deletes the given folders and/or files.
|
||||||
|
//
|
||||||
|
// Deprecated: use FileStore.Delete via Client.Files()/Client.FileStore.
|
||||||
func (c *Client) DeleteDavItems(ctx context.Context, folderIDs, fileIDs []string) error {
|
func (c *Client) DeleteDavItems(ctx context.Context, folderIDs, fileIDs []string) error {
|
||||||
body := map[string]any{"DeleteAfter": true, "Immediately": true}
|
body := map[string]any{"DeleteAfter": true, "Immediately": true}
|
||||||
for _, id := range folderIDs {
|
for _, id := range folderIDs {
|
||||||
@@ -242,6 +256,8 @@ func (c *Client) DeleteDavItems(ctx context.Context, folderIDs, fileIDs []string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UploadDavFile uploads src (fileName) into folderID, streaming from src.
|
// UploadDavFile uploads src (fileName) into folderID, streaming from src.
|
||||||
|
//
|
||||||
|
// Deprecated: use FileStore.Upload via Client.Files()/Client.FileStore.
|
||||||
func (c *Client) UploadDavFile(ctx context.Context, folderID, fileName string, src io.Reader) (*DavFile, error) {
|
func (c *Client) UploadDavFile(ctx context.Context, folderID, fileName string, src io.Reader) (*DavFile, error) {
|
||||||
raw, err := c.uploadReader(ctx, "/api/2.0/files/"+url.PathEscape(folderID)+"/upload", "file", fileName, src)
|
raw, err := c.uploadReader(ctx, "/api/2.0/files/"+url.PathEscape(folderID)+"/upload", "file", fileName, src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -261,6 +277,8 @@ func (c *Client) UploadDavFile(ctx context.Context, folderID, fileName string, s
|
|||||||
|
|
||||||
// DownloadDavFile streams the file identified by id to w, returning bytes
|
// DownloadDavFile streams the file identified by id to w, returning bytes
|
||||||
// copied. It shares the MinIO stale-S3 fallback with DownloadFile.
|
// copied. It shares the MinIO stale-S3 fallback with DownloadFile.
|
||||||
|
//
|
||||||
|
// Deprecated: use FileStore.Download via Client.Files()/Client.FileStore.
|
||||||
func (c *Client) DownloadDavFile(ctx context.Context, id string, w io.Writer) (int64, error) {
|
func (c *Client) DownloadDavFile(ctx context.Context, id string, w io.Writer) (int64, error) {
|
||||||
return c.DownloadFile(ctx, id, w)
|
return c.DownloadFile(ctx, id, w)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user