refactor(files): единый файловый фасад + миграция CLI/TUI (#38) #43

Merged
eSlider merged 1 commits from feat/file-facade#38 into main 2026-09-16 18:27:21 +01:00
10 changed files with 655 additions and 84 deletions
Showing only changes of commit ba29738482 - Show all commits
+14 -6
View File
@@ -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
+3 -6
View File
@@ -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
View File
@@ -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
+37 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+230
View File
@@ -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())
}
}
+14
View File
@@ -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 {
+18
View File
@@ -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)
} }