From ba29738482c71d18fc527b319fc7a2a86f5ed078 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Wed, 16 Sep 2026 17:20:41 +0000 Subject: [PATCH] refactor(files): single file client facade + CLI/TUI migration (#38) - FileClient composes FileStore/Searcher backends; Read()/Write()/Search() select REST/DAV/PG/ES with transient read fallback. Client.Files() returns it and *FileClient implements FileStore, so existing callers keep working. - Entry gains backend-native Updated + folder FilesCount/FoldersCount so dav ls output round-trips. - cmd/oo dav/projects files and cmd/office/fetch download/preview/delete go through FileStore; oo search goes through the facade. - Mark transport methods that FileStore now abstracts as deprecated. --- cmd/office/fetch/list.go | 20 ++- cmd/office/fetch/preview.go | 9 +- cmd/oo/dav.go | 106 +++++++-------- cmd/oo/projects_files.go | 43 +++++- cmd/oo/search.go | 13 +- file_core.go | 35 +++-- file_facade.go | 251 ++++++++++++++++++++++++++++++++++++ file_facade_test.go | 230 +++++++++++++++++++++++++++++++++ files.go | 14 ++ files_webdav.go | 18 +++ 10 files changed, 655 insertions(+), 84 deletions(-) create mode 100644 file_facade.go create mode 100644 file_facade_test.go diff --git a/cmd/office/fetch/list.go b/cmd/office/fetch/list.go index e21ddc7..afe0c24 100644 --- a/cmd/office/fetch/list.go +++ b/cmd/office/fetch/list.go @@ -17,6 +17,18 @@ const MailListPageSize = 25 // Loader fetches list items for a menu subject using the OnlyOffice client. type Loader struct { 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). @@ -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 case model.KindFile: - id, err := strconv.Atoi(item.ID) - if err != nil { - return "", err - } - if err := l.Client.DeleteFiles(ctx, []int{id}); err != nil { + if err := l.fileStore().Delete(ctx, []string{item.ID}); err != nil { return "", err } 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 } 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 fmt.Sprintf("Downloaded to %s", destPath), nil diff --git a/cmd/office/fetch/preview.go b/cmd/office/fetch/preview.go index 0a868fa..a898ef9 100644 --- a/cmd/office/fetch/preview.go +++ b/cmd/office/fetch/preview.go @@ -5,7 +5,6 @@ import ( "context" "fmt" - onlyoffice "github.com/eslider/go-onlyoffice" "github.com/eslider/go-onlyoffice/cmd/office/model" "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") } name := item.Title - if meta, err := l.Client.GetFile(ctx, item.ID); err == nil && meta != nil { - if t := onlyoffice.FileEntryTitle(meta); t != "" { - name = t - } + if e, err := l.fileStore().Stat(ctx, item.ID); err == nil && e.Title != "" { + name = e.Title } 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 preview.FileBytesToMarkdown(name, buf.Bytes()) diff --git a/cmd/oo/dav.go b/cmd/oo/dav.go index 56272f4..d51cd70 100644 --- a/cmd/oo/dav.go +++ b/cmd/oo/dav.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "time" onlyoffice "github.com/eslider/go-onlyoffice" "github.com/spf13/cobra" @@ -12,8 +13,8 @@ func init() { rootCmd.AddCommand(davCmd()) } -// davCmd exposes the Documents module through the same Dav calls that back -// oo-webdav (ListDavFolder / MoveDavItems / CopyDavItems / DownloadDavFile). +// davCmd exposes the Documents module through the backend-agnostic FileStore +// (DAV backend). The underlying Dav calls are the oo-webdav proven path: // MoveDavItems sends resolveType=Skip + holdResult=true, which the legacy // fileops/move call without those params silently ignores (200 without move). func davCmd() *cobra.Command { @@ -61,59 +62,46 @@ func davLsCmd() *cobra.Command { printTable([]string{"id", "title", "filesCount", "foldersCount"}, rows) return nil } - l, err := c.ListDavFolder(ctx, args[0]) + entries, err := c.FileStore(onlyoffice.ProviderDAV).List(ctx, args[0]) if err != nil { return err } - if outputFormat == "json" { - folders := make([]map[string]any, 0, len(l.Folders)) - for _, f := range l.Folders { - folders = append(folders, map[string]any{ - "id": f.ID, - "title": f.Title, - "filesCount": f.FilesCount, - "foldersCount": f.FoldersCount, - }) + folders := make([]onlyoffice.Entry, 0, len(entries)) + files := make([]onlyoffice.Entry, 0, len(entries)) + for _, e := range entries { + if e.Kind == onlyoffice.Folder { + folders = append(folders, e) + } else { + files = append(files, e) } - 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}) - return nil } - 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{ - "id": f.ID, - "title": f.Title, - "filesCount": f.FilesCount, - "foldersCount": f.FoldersCount, - }) - } - if outputFormat == "table" { - fmt.Println("folders:") - } - printTable([]string{"id", "title", "filesCount", "foldersCount"}, frows) + frows := make([]map[string]any, 0, len(folders)) + for _, f := range folders { + frows = append(frows, map[string]any{ + "id": f.ID, + "title": f.Title, + "filesCount": f.FilesCount, + "foldersCount": f.FoldersCount, + }) } - rows := make([]map[string]any, 0, len(l.Files)) - for _, f := range l.Files { + rows := make([]map[string]any, 0, len(files)) + for _, f := range files { rows = append(rows, map[string]any{ "id": f.ID, "title": f.Title, "size": f.Size, - "updated": f.Updated, + "updated": entryUpdated(f), }) } - if outputFormat == "table" { - fmt.Println("files:") + if outputFormat == "json" { + 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) return nil }, @@ -121,6 +109,18 @@ func davLsCmd() *cobra.Command { 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 { var folderIDs []string cmd := &cobra.Command{ @@ -132,7 +132,8 @@ func davMoveCmd() *cobra.Command { if err != nil { 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 } 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 { 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 } 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 { 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 { return err } @@ -200,7 +202,8 @@ func davRemoveCmd() *cobra.Command { if err != nil { 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 } printObject(map[string]any{"deleted_files": args, "deleted_folders": folderIDs}) @@ -221,7 +224,7 @@ func davRenameFileCmd() *cobra.Command { if err != nil { 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 } printObject(map[string]any{"id": args[0], "title": args[1]}) @@ -240,7 +243,7 @@ func davRenameFolderCmd() *cobra.Command { if err != nil { 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 } printObject(map[string]any{"id": args[0], "title": args[1]}) @@ -261,20 +264,21 @@ func davDownloadCmd() *cobra.Command { return err } 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 { return err } path := to if path == "" { - path = onlyoffice.SafeLocalFileName(onlyoffice.FileEntryTitle(f)) + path = onlyoffice.SafeLocalFileName(e.Title) } out, err := os.Create(path) if err != nil { return err } defer out.Close() - n, err := c.DownloadDavFile(ctx, args[0], out) + n, err := store.Download(ctx, args[0], out) if err != nil { _ = os.Remove(path) return err diff --git a/cmd/oo/projects_files.go b/cmd/oo/projects_files.go index 0469d98..fb41f57 100644 --- a/cmd/oo/projects_files.go +++ b/cmd/oo/projects_files.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "path/filepath" "strconv" "time" @@ -159,20 +160,22 @@ func prjFilesDownloadCmd() *cobra.Command { if err != nil { 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 { return err } path := to if path == "" { - path = onlyoffice.SafeLocalFileName(onlyoffice.FileEntryTitle(f)) + path = onlyoffice.SafeLocalFileName(e.Title) } out, err := os.Create(path) if err != nil { return err } defer out.Close() - n, err := c.DownloadFile(cmd.Context(), args[0], out) + n, err := store.Download(ctx, args[0], out) if err != nil { _ = os.Remove(path) return err @@ -199,11 +202,16 @@ func prjFilesRenameCmd() *cobra.Command { if err != nil { 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 { return err } - printObject(fileEntryToMap(entry)) + entry.Title = args[1] + printObject(entryToMap(entry)) return nil }, } @@ -228,7 +236,7 @@ func prjFilesDeleteCmd() *cobra.Command { } 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 } printObject(map[string]any{"deleted": ids}) @@ -317,6 +325,29 @@ func fileEntryToMap(f *onlyoffice.FileEntry) map[string]any { 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 { if f == nil || f.ID == nil { return "" diff --git a/cmd/oo/search.go b/cmd/oo/search.go index 2ceab0e..5da8309 100644 --- a/cmd/oo/search.go +++ b/cmd/oo/search.go @@ -2,6 +2,7 @@ package main import ( onlyoffice "github.com/eslider/go-onlyoffice" + "github.com/eslider/go-onlyoffice/cmd/internal/bootstrap" "github.com/spf13/cobra" ) @@ -9,9 +10,9 @@ func init() { rootCmd.AddCommand(searchCmd()) } -// searchCmd queries the OnlyOffice Elasticsearch index directly. The REST -// /api/2.0/files/@search endpoint only searches file names in the database; -// content search needs ES (see docs/elasticsearch.md). +// searchCmd queries the OnlyOffice document index through the file facade. The +// REST /api/2.0/files/@search endpoint only searches file names in the database; +// content search needs Elasticsearch (see docs/elasticsearch.md). func searchCmd() *cobra.Command { var ( content bool @@ -33,11 +34,13 @@ func searchCmd() *cobra.Command { if asJSON { outputFormat = "json" } - es, err := onlyoffice.NewESSearcher(onlyoffice.ESConfigFromEnv()) + bootstrap.LoadEnv() + c := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials()) + searcher, err := c.Files().Search() if err != nil { return err } - hits, err := es.Search(cmd.Context(), onlyoffice.SearchQuery{ + hits, err := searcher.Search(cmd.Context(), onlyoffice.SearchQuery{ Text: args[0], InContent: content, FolderID: folder, diff --git a/file_core.go b/file_core.go index b9766ca..3461be2 100644 --- a/file_core.go +++ b/file_core.go @@ -52,8 +52,17 @@ type Entry struct { MIME string Created 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 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. @@ -97,8 +106,8 @@ type Searcher interface { } // FileStore returns the adapter for a backend name: ProviderREST (default) or -// ProviderDAV. Unknown or empty names select the REST backend. The full facade -// (backend composition) is deliberately left to a later change. +// ProviderDAV. Unknown or empty names select the REST backend. The composed +// facade (backend selection/fallback) lives on FileClient in file_facade.go. func (c *Client) FileStore(backend string) FileStore { switch strings.ToLower(strings.TrimSpace(backend)) { case ProviderDAV, "webdav": @@ -108,8 +117,9 @@ func (c *Client) FileStore(backend string) FileStore { } } -// Files returns the default (REST) file store. -func (c *Client) Files() FileStore { return c.FileStore(ProviderREST) } +// Files returns the composed file facade. The returned *FileClient implements +// 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 // transient-error policy (429/502/503/504). @@ -140,6 +150,7 @@ func FileEntryToEntry(f *FileEntry, provider string) Entry { e.MIME = mimeForTitle(e.Title, exst) if f.Updated != nil { e.Modified = *f.Updated + e.Updated = f.Updated.Format(time.RFC3339) } return e } @@ -153,6 +164,7 @@ func DavFileToEntry(f DavFile, provider string) Entry { Size: f.Size, MIME: mimeForTitle(f.Title, ""), Modified: f.ModTime(), + Updated: f.Updated, Provider: provider, } } @@ -160,12 +172,15 @@ func DavFileToEntry(f DavFile, provider string) Entry { // DavFolderToEntry converts a WebDAV folder row to the canonical model. func DavFolderToEntry(f DavFolder, provider string) Entry { return Entry{ - ID: f.ID, - ParentID: f.ParentID, - Title: f.Title, - Kind: Folder, - Modified: f.ModTime(), - Provider: provider, + ID: f.ID, + ParentID: f.ParentID, + Title: f.Title, + Kind: Folder, + Modified: f.ModTime(), + Updated: f.Updated, + Provider: provider, + FilesCount: f.FilesCount, + FoldersCount: f.FoldersCount, } } diff --git a/file_facade.go b/file_facade.go new file mode 100644 index 0000000..bbc8533 --- /dev/null +++ b/file_facade.go @@ -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 +} diff --git a/file_facade_test.go b/file_facade_test.go new file mode 100644 index 0000000..087f0c5 --- /dev/null +++ b/file_facade_test.go @@ -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()) + } +} diff --git a/files.go b/files.go index b3f38bc..690e510 100644 --- a/files.go +++ b/files.go @@ -247,6 +247,8 @@ func (c *Client) UploadProjectFileReplacing(ctx context.Context, projectID, loca } // 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) { if fileID == "" { 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. +// +// Deprecated: use FileStore.Rename via Client.Files()/Client.FileStore. func (c *Client) RenameFile(ctx context.Context, fileID, newTitle string) (*FileEntry, error) { if fileID == "" || newTitle == "" { 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). // Uses per-file DELETE (DeleteDavItems); fileops/delete returns 200 on some // 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 { if len(fileIDs) == 0 { 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 // (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) { if folderID == "" { 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). +// +// Deprecated: use FileStore.Move via Client.Files()/Client.FileStore. func (c *Client) MoveFiles(ctx context.Context, destFolderID int, fileIDs []int) (map[string]any, error) { if destFolderID == 0 || len(fileIDs) == 0 { 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. +// +// Deprecated: use FileStore.Upload via Client.Files()/Client.FileStore. func (c *Client) UploadToFolder(ctx context.Context, folderID, localPath string) (*FileEntry, error) { if folderID == "" || localPath == "" { 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 // AWS S3 consumer, the bytes are fetched from the local MinIO store instead // (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) { f, err := c.GetFile(ctx, fileID) if err != nil { diff --git a/files_webdav.go b/files_webdav.go index 536071a..197c394 100644 --- a/files_webdav.go +++ b/files_webdav.go @@ -52,6 +52,8 @@ type DavListing struct { // ListDavFolder returns the contents of a folder by id, which may be a // 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) { raw, err := c.getJSON(ctx, "/api/2.0/files/"+url.PathEscape(id)) if err != nil { @@ -111,6 +113,8 @@ func (c *Client) ListDavSections(ctx context.Context) ([]DavFolder, error) { } // 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) { raw, err := c.postJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(parentID), map[string]string{"title": title}) @@ -130,6 +134,8 @@ func (c *Client) CreateDavFolder(ctx context.Context, parentID, title string) (* } // RenameDavFolder renames a folder. +// +// Deprecated: use FileStore.Rename via Client.Files()/Client.FileStore. func (c *Client) RenameDavFolder(ctx context.Context, id, title string) error { _, err := c.putJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(id), 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). +// +// Deprecated: use FileStore.Rename via Client.Files()/Client.FileStore. func (c *Client) RenameDavFile(ctx context.Context, id, title string) error { _, err := c.putJSON(ctx, "/api/2.0/files/file/"+url.PathEscape(id), 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 // nothing moves (e.g. missing permission), so the response is parsed and the // 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 { raw, err := c.putJSON(ctx, "/api/2.0/files/fileops/move", map[string]any{ "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. // 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 { raw, err := c.putJSON(ctx, "/api/2.0/files/fileops/copy", map[string]any{ "folderIds": nums(folderIDs), @@ -226,6 +238,8 @@ func fileopsError(raw json.RawMessage) error { } // 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 { body := map[string]any{"DeleteAfter": true, "Immediately": true} 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. +// +// Deprecated: use FileStore.Upload via Client.Files()/Client.FileStore. 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) 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 // 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) { return c.DownloadFile(ctx, id, w) }