refactor(files): single file client facade + CLI/TUI migration (#38)
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go stable) (pull_request) Successful in 19s
Tests / Test (Go 1.25) (pull_request) Successful in 22s

- 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.
This commit is contained in:
2026-09-16 17:20:41 +00:00
parent 72c7cd5173
commit ba29738482
10 changed files with 655 additions and 84 deletions
+14 -6
View File
@@ -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
+3 -6
View File
@@ -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())
+55 -51
View File
@@ -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
+37 -6
View File
@@ -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 ""
+8 -5
View File
@@ -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,