chore: move business tooling out of the public library; tidy filestore naming
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 1m19s
Tests / Test (Go 1.25) (pull_request) Successful in 1m24s
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 1m19s
Tests / Test (Go 1.25) (pull_request) Successful in 1m24s
Keep the public tree project-generic. Business/one-off tools, deployment and business docs move to the private oo-workspace repo. Moved to oo-workspace: - cmd/ooscan, cmd/pdfamount, cmd/kontoblatt, cmd/kontolink - internal/xlspipe (cutover-portugal workbook) -> oow workbook build (drops the --template/--title flags from oo docs put-xlsx) - deploy/docker-compose.rclone-webdav.yml + docs/rclone-webdav.md - docs/crm-associations.md Removed GitHub-era leftovers: - .github/workflows/release-please.yml, release-please-config.json, .release-please-manifest.json (tags are created on Gitea per SemVer) Naming: the FileStore subsystem is now filestore_*.go (was file_*.go) to match files_*.go (project Documents). Docs/AGENTS/README updated.
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
package onlyoffice
|
||||
|
||||
// Canonical file model and the backend-agnostic store interface. REST
|
||||
// (files.go), WebDAV (files_webdav.go) and future backends (PostgreSQL,
|
||||
// Elasticsearch) implement FileStore/Searcher so callers stop depending on a
|
||||
// concrete transport. This file holds only types and pure conversions — no IO.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Kind distinguishes files from folders in the canonical model.
|
||||
type Kind int
|
||||
|
||||
const (
|
||||
File Kind = iota
|
||||
Folder
|
||||
)
|
||||
|
||||
// String renders the kind for logs and table output.
|
||||
func (k Kind) String() string {
|
||||
switch k {
|
||||
case File:
|
||||
return "file"
|
||||
case Folder:
|
||||
return "folder"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Provider names for the FileStore adapters.
|
||||
const (
|
||||
ProviderREST = "rest"
|
||||
ProviderDAV = "dav"
|
||||
)
|
||||
|
||||
// Entry is the backend-independent representation of a document or folder.
|
||||
// Fields that a backend cannot supply stay at their zero value.
|
||||
type Entry struct {
|
||||
ID string
|
||||
ParentID string
|
||||
Title string
|
||||
Kind Kind
|
||||
Size int64
|
||||
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.
|
||||
type FileStore interface {
|
||||
Name() string
|
||||
List(ctx context.Context, parentID string) ([]Entry, error)
|
||||
Stat(ctx context.Context, id string) (Entry, error)
|
||||
CreateFolder(ctx context.Context, parentID, title string) (Entry, error)
|
||||
Upload(ctx context.Context, parentID, title string, r io.Reader) (Entry, error)
|
||||
Download(ctx context.Context, id string, w io.Writer) (int64, error)
|
||||
Move(ctx context.Context, ids []string, parentID string) error
|
||||
Copy(ctx context.Context, ids []string, parentID string) error
|
||||
Rename(ctx context.Context, id, title string) error
|
||||
Delete(ctx context.Context, ids []string) error
|
||||
}
|
||||
|
||||
// SearchQuery narrows a Searcher request. InContent asks the backend to match
|
||||
// document bodies, not just titles. Substring switches title matching from the
|
||||
// analyzer's whole-token match to a case-insensitive "*term*" wildcard and ANDs
|
||||
// every whitespace-separated term (e.g. "rechnung 2025").
|
||||
type SearchQuery struct {
|
||||
Text string
|
||||
InContent bool
|
||||
FolderID string
|
||||
Extensions []string
|
||||
Limit int
|
||||
Substring bool
|
||||
}
|
||||
|
||||
// SearchHit is one Searcher result: the matching entry plus backend-specific
|
||||
// ranking metadata.
|
||||
type SearchHit struct {
|
||||
Entry
|
||||
Score float64
|
||||
Highlight string
|
||||
Path []string
|
||||
}
|
||||
|
||||
// Searcher is the optional content/name search surface. Only some backends
|
||||
// (for example Elasticsearch) provide it.
|
||||
type Searcher interface {
|
||||
Search(ctx context.Context, q SearchQuery) ([]SearchHit, error)
|
||||
Name() string
|
||||
}
|
||||
|
||||
// FileStore returns the adapter for a backend name: ProviderREST (default),
|
||||
// ProviderDAV (alias "webdav") or the read-only SQL store (ProviderPG,
|
||||
// ProviderMySQL and the aliases "pg"/"sql"). The SQL store is opened from the
|
||||
// environment (ONLYOFFICE_DSN / ONLYOFFICE_PG_*); when it cannot be opened the
|
||||
// returned store surfaces that error on every operation instead of returning
|
||||
// nil. Use SQLFileStore when the open error itself is needed. Unknown or empty
|
||||
// names select the REST backend. The composed facade (backend
|
||||
// selection/fallback) lives on FileClient in filestore_facade.go.
|
||||
func (c *Client) FileStore(backend string) FileStore {
|
||||
switch strings.ToLower(strings.TrimSpace(backend)) {
|
||||
case ProviderDAV, "webdav":
|
||||
return &davStore{c: c}
|
||||
case ProviderPG, ProviderMySQL, "pg", "sql":
|
||||
s, err := c.SQLFileStore()
|
||||
if err != nil {
|
||||
return &errStore{name: strings.ToLower(strings.TrimSpace(backend)), err: err}
|
||||
}
|
||||
return s
|
||||
default:
|
||||
return &restStore{c: c}
|
||||
}
|
||||
}
|
||||
|
||||
// errStore is the FileStore placeholder returned when a backend cannot be
|
||||
// opened (for example SQL without a DSN). Every operation returns the recorded
|
||||
// error instead of panicking on a nil interface.
|
||||
type errStore struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *errStore) Name() string { return s.name }
|
||||
|
||||
func (s *errStore) List(context.Context, string) ([]Entry, error) { return nil, s.err }
|
||||
|
||||
func (s *errStore) Stat(context.Context, string) (Entry, error) { return Entry{}, s.err }
|
||||
|
||||
func (s *errStore) CreateFolder(context.Context, string, string) (Entry, error) {
|
||||
return Entry{}, s.err
|
||||
}
|
||||
|
||||
func (s *errStore) Upload(context.Context, string, string, io.Reader) (Entry, error) {
|
||||
return Entry{}, s.err
|
||||
}
|
||||
|
||||
func (s *errStore) Download(context.Context, string, io.Writer) (int64, error) {
|
||||
return 0, s.err
|
||||
}
|
||||
|
||||
func (s *errStore) Move(context.Context, []string, string) error { return s.err }
|
||||
|
||||
func (s *errStore) Copy(context.Context, []string, string) error { return s.err }
|
||||
|
||||
func (s *errStore) Rename(context.Context, string, string) error { return s.err }
|
||||
|
||||
func (s *errStore) Delete(context.Context, []string) error { return s.err }
|
||||
|
||||
// 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).
|
||||
func retryStoreOp(ctx context.Context, fn func() error) error {
|
||||
return DoRetry(ctx, DefaultRetryPolicy(), fn)
|
||||
}
|
||||
|
||||
// FileEntryToEntry converts a Files-module file row to the canonical model.
|
||||
func FileEntryToEntry(f *FileEntry, provider string) Entry {
|
||||
e := Entry{Kind: File, Provider: provider}
|
||||
if f == nil {
|
||||
return e
|
||||
}
|
||||
if f.ID != nil {
|
||||
e.ID = f.ID.String()
|
||||
}
|
||||
e.ParentID = FileFolderID(f)
|
||||
if f.Title != nil {
|
||||
e.Title = *f.Title
|
||||
}
|
||||
if f.ContentLength != nil {
|
||||
e.Size = parseContentLength(*f.ContentLength)
|
||||
}
|
||||
exst := ""
|
||||
if f.FileExst != nil {
|
||||
exst = *f.FileExst
|
||||
}
|
||||
e.MIME = mimeForTitle(e.Title, exst)
|
||||
if f.Updated != nil {
|
||||
e.Modified = *f.Updated
|
||||
e.Updated = f.Updated.Format(time.RFC3339)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// DavFileToEntry converts a WebDAV file row to the canonical model.
|
||||
func DavFileToEntry(f DavFile, provider string) Entry {
|
||||
return Entry{
|
||||
ID: f.ID,
|
||||
Title: f.Title,
|
||||
Kind: File,
|
||||
Size: f.Size,
|
||||
MIME: mimeForTitle(f.Title, ""),
|
||||
Modified: f.ModTime(),
|
||||
Updated: f.Updated,
|
||||
Provider: provider,
|
||||
}
|
||||
}
|
||||
|
||||
// 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(),
|
||||
Updated: f.Updated,
|
||||
Provider: provider,
|
||||
FilesCount: f.FilesCount,
|
||||
FoldersCount: f.FoldersCount,
|
||||
}
|
||||
}
|
||||
|
||||
// parseContentLength reads the leading integer of an OnlyOffice contentLength
|
||||
// string (the API sometimes appends a unit, e.g. "12345 b").
|
||||
func parseContentLength(s string) int64 {
|
||||
fields := strings.Fields(s)
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.ParseInt(fields[0], 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// mimeForTitle derives a MIME type from an explicit extension or the title.
|
||||
func mimeForTitle(title, exst string) string {
|
||||
ext := strings.TrimSpace(exst)
|
||||
if ext == "" {
|
||||
ext = filepath.Ext(title)
|
||||
}
|
||||
if ext == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(ext, ".") {
|
||||
ext = "." + ext
|
||||
}
|
||||
return mime.TypeByExtension(strings.ToLower(ext))
|
||||
}
|
||||
Reference in New Issue
Block a user