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.
364 lines
9.5 KiB
Go
364 lines
9.5 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"time"
|
|
|
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func init() {
|
|
projectsCmd.AddCommand(projectFilesCmd())
|
|
}
|
|
|
|
func projectFilesCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "files",
|
|
Short: "Project Documents folder: list, upload, download, rename, delete",
|
|
}
|
|
cmd.AddCommand(prjFilesListCmd())
|
|
cmd.AddCommand(prjFilesUploadCmd())
|
|
cmd.AddCommand(prjFilesDownloadCmd())
|
|
cmd.AddCommand(prjFilesRenameCmd())
|
|
cmd.AddCommand(prjFilesDeleteCmd())
|
|
cmd.AddCommand(prjFilesDedupeCmd())
|
|
// Convenience aliases into oo docs (md↔docx / OCR pipeline).
|
|
cmd.AddCommand(aliasDocsAsMD())
|
|
cmd.AddCommand(aliasDocsPutMD())
|
|
cmd.AddCommand(aliasDocsPutTxt())
|
|
cmd.AddCommand(aliasDocsPutXlsx())
|
|
return cmd
|
|
}
|
|
|
|
func aliasDocsAsMD() *cobra.Command {
|
|
c := docsAsMDCmd()
|
|
c.Use = "as-md FILE_ID"
|
|
c.Short = "Alias of `oo docs as-md` — download OO file as Markdown (OCR if needed)"
|
|
return c
|
|
}
|
|
|
|
func aliasDocsPutMD() *cobra.Command {
|
|
c := docsPutMDCmd()
|
|
c.Use = "put-md PROJECT_ID MARKDOWN_PATH"
|
|
c.Short = "Alias of `oo docs put-md` — Markdown→DOCX upload into project"
|
|
return c
|
|
}
|
|
|
|
func aliasDocsPutTxt() *cobra.Command {
|
|
c := docsPutTxtCmd()
|
|
c.Use = "put-txt PROJECT_ID TEXT_PATH"
|
|
c.Short = "Alias of `oo docs put-txt` — plain text→DOCX upload into project"
|
|
return c
|
|
}
|
|
|
|
func aliasDocsPutXlsx() *cobra.Command {
|
|
c := docsPutXlsxCmd()
|
|
c.Use = "put-xlsx PROJECT_ID [LOCAL_XLSX]"
|
|
c.Short = "Alias of `oo docs put-xlsx` — generate/upload XLSX with formulas"
|
|
return c
|
|
}
|
|
|
|
func prjFilesListCmd() *cobra.Command {
|
|
var showFolders bool
|
|
cmd := &cobra.Command{
|
|
Use: "list PROJECT_ID",
|
|
Short: "List files (and optionally folders) attached to the project",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newOO(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pf, err := c.GetProjectFiles(cmd.Context(), args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if showFolders && len(pf.Folders) > 0 {
|
|
frows := make([]map[string]any, 0, len(pf.Folders))
|
|
for _, f := range pf.Folders {
|
|
if f == nil {
|
|
continue
|
|
}
|
|
frows = append(frows, map[string]any{
|
|
"id": folderIDStr(f),
|
|
"title": derefString(f.Title),
|
|
"filesCount": derefInt(f.FilesCount),
|
|
"foldersCount": derefInt(f.FoldersCount),
|
|
})
|
|
}
|
|
if outputFormat == "table" {
|
|
fmt.Println("folders:")
|
|
}
|
|
printTable([]string{"id", "title", "filesCount", "foldersCount"}, frows)
|
|
}
|
|
rows := fileEntryRows(pf.Files)
|
|
if outputFormat == "table" {
|
|
fmt.Println("files:")
|
|
}
|
|
printTable([]string{"id", "title", "fileExst", "contentLength", "updated"}, rows)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&showFolders, "folders", false, "also print project subfolders")
|
|
return cmd
|
|
}
|
|
|
|
func prjFilesUploadCmd() *cobra.Command {
|
|
var replace, allowDuplicate bool
|
|
cmd := &cobra.Command{
|
|
Use: "upload PROJECT_ID LOCAL_PATH [LOCAL_PATH...]",
|
|
Short: "Upload file(s) into the project's Documents folder (upsert by stem|ext)",
|
|
Long: `Default: replace an existing file with the same logical name (stem|ext), like cp overwrite.
|
|
Pass --no-replace to fail when the name is taken; --allow-duplicate to always create a new file id.`,
|
|
Args: cobra.MinimumNArgs(2),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newOO(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pid := args[0]
|
|
for _, p := range args[1:] {
|
|
var entry *onlyoffice.FileEntry
|
|
var deleted []int
|
|
switch {
|
|
case allowDuplicate:
|
|
entry, err = c.UploadProjectFile(cmd.Context(), pid, p)
|
|
case replace:
|
|
entry, deleted, err = c.UploadProjectFileReplacing(cmd.Context(), pid, p)
|
|
default:
|
|
entry, err = c.UploadProjectFileNoClobber(cmd.Context(), pid, p)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
obj := fileEntryToMap(entry)
|
|
if len(deleted) > 0 {
|
|
obj["replaced_file_ids"] = deleted
|
|
}
|
|
printObject(obj)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext in project folder (default)")
|
|
cmd.Flags().BoolVar(&allowDuplicate, "allow-duplicate", false, "always create a new file even when the name exists")
|
|
return cmd
|
|
}
|
|
|
|
func prjFilesDownloadCmd() *cobra.Command {
|
|
var to string
|
|
cmd := &cobra.Command{
|
|
Use: "download FILE_ID",
|
|
Short: "Download file bytes via viewUrl (default path: ./<title>)",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newOO(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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(e.Title)
|
|
}
|
|
out, err := os.Create(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
n, err := store.Download(ctx, args[0], out)
|
|
if err != nil {
|
|
_ = os.Remove(path)
|
|
return err
|
|
}
|
|
if outputFormat == "json" {
|
|
printObject(map[string]any{"path": path, "bytes": n})
|
|
return nil
|
|
}
|
|
fmt.Printf("downloaded: %s (%d bytes)\n", path, n)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&to, "to", "", "output path (default: ./<server title>)")
|
|
return cmd
|
|
}
|
|
|
|
func prjFilesRenameCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "rename FILE_ID NEW_TITLE",
|
|
Short: "Rename a file (include extension in NEW_TITLE)",
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newOO(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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
|
|
}
|
|
entry.Title = args[1]
|
|
printObject(entryToMap(entry))
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func prjFilesDeleteCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "delete FILE_ID [FILE_ID...]",
|
|
Aliases: []string{"rm"},
|
|
Short: "Permanently delete file(s) from Documents",
|
|
Args: cobra.MinimumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newOO(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ids := make([]int, 0, len(args))
|
|
for _, s := range args {
|
|
id, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
return fmt.Errorf("file id %q: %w", s, err)
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
if err := c.Files().Delete(cmd.Context(), args); err != nil {
|
|
return err
|
|
}
|
|
printObject(map[string]any{"deleted": ids})
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func prjFilesDedupeCmd() *cobra.Command {
|
|
var apply, cross bool
|
|
cmd := &cobra.Command{
|
|
Use: "dedupe PROJECT_ID",
|
|
Short: "Find (and optionally remove) duplicate files in project Documents folders",
|
|
Long: `Duplicates share the same logical name: stem|ext (OO title+fileExst).
|
|
|
|
Default: dry-run report. Pass --apply to delete older copies (keeps newest; --cross prefers non-trash folders).`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newOO(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
groups, deleted, err := c.DedupeProject(cmd.Context(), args[0], onlyoffice.DedupOptions{
|
|
CrossFolder: cross,
|
|
}, apply)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows := make([]map[string]any, 0, len(groups))
|
|
for _, g := range groups {
|
|
row := map[string]any{
|
|
"key": g.Key,
|
|
"folder_id": g.FolderID,
|
|
"folder_title": g.FolderTitle,
|
|
"keep_id": fileIDStr(g.Keep),
|
|
"keep_title": onlyoffice.FileEntryTitle(g.Keep),
|
|
"remove_count": len(g.Remove),
|
|
}
|
|
removeIDs := make([]string, 0, len(g.Remove))
|
|
for _, f := range g.Remove {
|
|
removeIDs = append(removeIDs, fileIDStr(f))
|
|
}
|
|
row["remove_ids"] = removeIDs
|
|
rows = append(rows, row)
|
|
}
|
|
out := map[string]any{
|
|
"project_id": args[0],
|
|
"dry_run": !apply,
|
|
"cross": cross,
|
|
"groups": len(groups),
|
|
"duplicates": rows,
|
|
}
|
|
if apply {
|
|
out["deleted_ids"] = deleted
|
|
}
|
|
printObject(out)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&apply, "apply", false, "delete duplicate files (default: report only)")
|
|
cmd.Flags().BoolVar(&cross, "cross", false, "also dedupe same stem|ext across folders (prefers non-_trash)")
|
|
return cmd
|
|
}
|
|
|
|
func fileEntryRows(files []*onlyoffice.FileEntry) []map[string]any {
|
|
rows := make([]map[string]any, 0, len(files))
|
|
for _, f := range files {
|
|
if f == nil {
|
|
continue
|
|
}
|
|
rows = append(rows, fileEntryToMap(f))
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func fileEntryToMap(f *onlyoffice.FileEntry) map[string]any {
|
|
m := map[string]any{
|
|
"id": fileIDStr(f),
|
|
"title": onlyoffice.FileEntryTitle(f),
|
|
"fileExst": derefString(f.FileExst),
|
|
"contentLength": derefString(f.ContentLength),
|
|
}
|
|
if f.Updated != nil {
|
|
m["updated"] = f.Updated.Format(time.RFC3339)
|
|
}
|
|
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 ""
|
|
}
|
|
return f.ID.String()
|
|
}
|
|
|
|
func folderIDStr(f *onlyoffice.FolderEntry) string {
|
|
if f == nil || f.ID == nil {
|
|
return ""
|
|
}
|
|
return f.ID.String()
|
|
}
|