Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c3a0b8b6e |
+1
-15
@@ -41,21 +41,7 @@ ONLYOFFICE_PROJECT_ID=33
|
|||||||
# ONLYOFFICE_ES_URL=http://127.0.0.1:9200
|
# ONLYOFFICE_ES_URL=http://127.0.0.1:9200
|
||||||
# ONLYOFFICE_ES_INDEX=files_file
|
# ONLYOFFICE_ES_INDEX=files_file
|
||||||
# ONLYOFFICE_TENANT=
|
# ONLYOFFICE_TENANT=
|
||||||
|
#
|
||||||
# Read-only SQL file store over the Community Server database (see
|
|
||||||
# docs/community-server-db.md). The live portal runs MySQL; ONLYOFFICE_DSN is
|
|
||||||
# `user:pass@tcp(host:port)/onlyoffice?parseTime=true`, or a `postgres://` URL.
|
|
||||||
# ONLYOFFICE_DSN=
|
|
||||||
# ONLYOFFICE_PG_DRIVER= # postgres | mysql (auto-detected from DSN)
|
|
||||||
# ONLYOFFICE_PG_TENANT=1 # falls back to ONLYOFFICE_TENANT
|
|
||||||
# Alternatively build a PostgreSQL DSN from parts:
|
|
||||||
# ONLYOFFICE_PG_HOST=
|
|
||||||
# ONLYOFFICE_PG_PORT=5432
|
|
||||||
# ONLYOFFICE_PG_USER=
|
|
||||||
# ONLYOFFICE_PG_PASSWORD=
|
|
||||||
# ONLYOFFICE_PG_DBNAME=onlyoffice
|
|
||||||
# ONLYOFFICE_PG_SSLMODE=disable
|
|
||||||
|
|
||||||
# oo index / oo search --backend own — own full-text index for PDF/scans,
|
# oo index / oo search --backend own — own full-text index for PDF/scans,
|
||||||
# filled by `oo index` from internal/docpipe (pdftotext + OCR). Defaults to
|
# filled by `oo index` from internal/docpipe (pdftotext + OCR). Defaults to
|
||||||
# oo_docs_text. Uses the same ONLYOFFICE_ES_URL.
|
# oo_docs_text. Uses the same ONLYOFFICE_ES_URL.
|
||||||
|
|||||||
@@ -701,11 +701,9 @@ Requires `ONLYOFFICE_ES_URL` (plus optional `ONLYOFFICE_ES_INDEX`,
|
|||||||
|
|
||||||
The OnlyOffice index covers Office formats only, so PDFs (`S1019`-style invoice
|
The OnlyOffice index covers Office formats only, so PDFs (`S1019`-style invoice
|
||||||
numbers) are not searchable by content. `oo index` extracts PDF text with
|
numbers) are not searchable by content. `oo index` extracts PDF text with
|
||||||
`internal/docpipe` (pdftotext, OCR for scans) — including the text of embedded
|
`internal/docpipe` (pdftotext, OCR for scans) into a separate index
|
||||||
PDF attachments (`pdfdetach`: `<doc>.md`, `.xml`, covers the original/scan and
|
(`ONLYOFFICE_ES_TEXT_INDEX`, default `oo_docs_text`); the OnlyOffice server and
|
||||||
ZUGFeRD e-invoice XML) — into a separate index (`ONLYOFFICE_ES_TEXT_INDEX`,
|
its index are **not** modified. Then search it with `--backend own`.
|
||||||
default `oo_docs_text`); the OnlyOffice server and its index are **not**
|
|
||||||
modified. Then search it with `--backend own`.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
oo index folder 634 --recursive --exts pdf # populate (idempotent upsert)
|
oo index folder 634 --recursive --exts pdf # populate (idempotent upsert)
|
||||||
|
|||||||
@@ -17,18 +17,6 @@ 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).
|
||||||
@@ -183,7 +171,11 @@ 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:
|
||||||
if err := l.fileStore().Delete(ctx, []string{item.ID}); err != nil {
|
id, err := strconv.Atoi(item.ID)
|
||||||
|
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
|
||||||
@@ -207,7 +199,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.fileStore().Download(ctx, item.ID, f); err != nil {
|
if _, err := l.Client.DownloadFile(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
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -29,11 +30,13 @@ 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 e, err := l.fileStore().Stat(ctx, item.ID); err == nil && e.Title != "" {
|
if meta, err := l.Client.GetFile(ctx, item.ID); err == nil && meta != nil {
|
||||||
name = e.Title
|
if t := onlyoffice.FileEntryTitle(meta); t != "" {
|
||||||
|
name = t
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if _, err := l.fileStore().Download(ctx, item.ID, &buf); err != nil {
|
if _, err := l.Client.DownloadFile(ctx, item.ID, &buf); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return preview.FileBytesToMarkdown(name, buf.Bytes())
|
return preview.FileBytesToMarkdown(name, buf.Bytes())
|
||||||
|
|||||||
+45
-49
@@ -3,7 +3,6 @@ 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"
|
||||||
@@ -13,8 +12,8 @@ func init() {
|
|||||||
rootCmd.AddCommand(davCmd())
|
rootCmd.AddCommand(davCmd())
|
||||||
}
|
}
|
||||||
|
|
||||||
// davCmd exposes the Documents module through the backend-agnostic FileStore
|
// davCmd exposes the Documents module through the same Dav calls that back
|
||||||
// (DAV backend). The underlying Dav calls are the oo-webdav proven path:
|
// oo-webdav (ListDavFolder / MoveDavItems / CopyDavItems / DownloadDavFile).
|
||||||
// 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 {
|
||||||
@@ -62,21 +61,35 @@ func davLsCmd() *cobra.Command {
|
|||||||
printTable([]string{"id", "title", "filesCount", "foldersCount"}, rows)
|
printTable([]string{"id", "title", "filesCount", "foldersCount"}, rows)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
entries, err := c.FileStore(onlyoffice.ProviderDAV).List(ctx, args[0])
|
l, err := c.ListDavFolder(ctx, args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
folders := make([]onlyoffice.Entry, 0, len(entries))
|
if outputFormat == "json" {
|
||||||
files := make([]onlyoffice.Entry, 0, len(entries))
|
folders := make([]map[string]any, 0, len(l.Folders))
|
||||||
for _, e := range entries {
|
for _, f := range l.Folders {
|
||||||
if e.Kind == onlyoffice.Folder {
|
folders = append(folders, map[string]any{
|
||||||
folders = append(folders, e)
|
"id": f.ID,
|
||||||
} else {
|
"title": f.Title,
|
||||||
files = append(files, e)
|
"filesCount": f.FilesCount,
|
||||||
|
"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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
frows := make([]map[string]any, 0, len(folders))
|
printObject(map[string]any{"folders": folders, "files": files})
|
||||||
for _, f := range folders {
|
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{
|
frows = append(frows, map[string]any{
|
||||||
"id": f.ID,
|
"id": f.ID,
|
||||||
"title": f.Title,
|
"title": f.Title,
|
||||||
@@ -84,24 +97,23 @@ func davLsCmd() *cobra.Command {
|
|||||||
"foldersCount": f.FoldersCount,
|
"foldersCount": f.FoldersCount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
rows := make([]map[string]any, 0, len(files))
|
if outputFormat == "table" {
|
||||||
for _, f := range files {
|
fmt.Println("folders:")
|
||||||
|
}
|
||||||
|
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": entryUpdated(f),
|
"updated": f.Updated,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if outputFormat == "json" {
|
if outputFormat == "table" {
|
||||||
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:")
|
fmt.Println("files:")
|
||||||
|
}
|
||||||
printTable([]string{"id", "title", "size", "updated"}, rows)
|
printTable([]string{"id", "title", "size", "updated"}, rows)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -109,18 +121,6 @@ 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,8 +132,7 @@ func davMoveCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ids := append(append([]string{}, folderIDs...), args[1:]...)
|
if err := c.MoveDavItems(cmd.Context(), folderIDs, args[1:], args[0]); err != nil {
|
||||||
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]})
|
||||||
@@ -155,8 +154,7 @@ func davCopyCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ids := append(append([]string{}, folderIDs...), args[1:]...)
|
if err := c.CopyDavItems(cmd.Context(), folderIDs, args[1:], args[0]); err != nil {
|
||||||
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]})
|
||||||
@@ -177,7 +175,7 @@ func davMkdirCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
f, err := c.FileStore(onlyoffice.ProviderDAV).CreateFolder(cmd.Context(), args[0], args[1])
|
f, err := c.CreateDavFolder(cmd.Context(), args[0], args[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -202,8 +200,7 @@ func davRemoveCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ids := append(append([]string{}, args...), folderIDs...)
|
if err := c.DeleteDavItems(cmd.Context(), folderIDs, args); err != nil {
|
||||||
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})
|
||||||
@@ -224,7 +221,7 @@ func davRenameFileCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := c.FileStore(onlyoffice.ProviderDAV).Rename(cmd.Context(), args[0], args[1]); err != nil {
|
if err := c.RenameDavFile(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]})
|
||||||
@@ -243,7 +240,7 @@ func davRenameFolderCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := c.FileStore(onlyoffice.ProviderDAV).Rename(cmd.Context(), args[0], args[1]); err != nil {
|
if err := c.RenameDavFolder(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]})
|
||||||
@@ -264,21 +261,20 @@ func davDownloadCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ctx := cmd.Context()
|
ctx := cmd.Context()
|
||||||
store := c.FileStore(onlyoffice.ProviderDAV)
|
f, err := c.GetFile(ctx, args[0])
|
||||||
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(e.Title)
|
path = onlyoffice.SafeLocalFileName(onlyoffice.FileEntryTitle(f))
|
||||||
}
|
}
|
||||||
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 := store.Download(ctx, args[0], out)
|
n, err := c.DownloadDavFile(ctx, args[0], out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = os.Remove(path)
|
_ = os.Remove(path)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -160,22 +159,20 @@ func prjFilesDownloadCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ctx := cmd.Context()
|
f, err := c.GetFile(cmd.Context(), args[0])
|
||||||
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(e.Title)
|
path = onlyoffice.SafeLocalFileName(onlyoffice.FileEntryTitle(f))
|
||||||
}
|
}
|
||||||
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 := store.Download(ctx, args[0], out)
|
n, err := c.DownloadFile(cmd.Context(), args[0], out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = os.Remove(path)
|
_ = os.Remove(path)
|
||||||
return err
|
return err
|
||||||
@@ -202,16 +199,11 @@ func prjFilesRenameCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
store := c.Files()
|
entry, err := c.RenameFile(cmd.Context(), args[0], args[1])
|
||||||
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
|
||||||
}
|
}
|
||||||
entry.Title = args[1]
|
printObject(fileEntryToMap(entry))
|
||||||
printObject(entryToMap(entry))
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -236,7 +228,7 @@ func prjFilesDeleteCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
ids = append(ids, id)
|
ids = append(ids, id)
|
||||||
}
|
}
|
||||||
if err := c.Files().Delete(cmd.Context(), args); err != nil {
|
if err := c.DeleteFiles(cmd.Context(), ids); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
printObject(map[string]any{"deleted": ids})
|
printObject(map[string]any{"deleted": ids})
|
||||||
@@ -325,29 +317,6 @@ 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 ""
|
||||||
|
|||||||
+4
-7
@@ -5,7 +5,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,9 +12,9 @@ func init() {
|
|||||||
rootCmd.AddCommand(searchCmd())
|
rootCmd.AddCommand(searchCmd())
|
||||||
}
|
}
|
||||||
|
|
||||||
// searchCmd queries the OnlyOffice document index through the file facade. The
|
// searchCmd queries the OnlyOffice Elasticsearch index directly. The REST
|
||||||
// REST /api/2.0/files/@search endpoint only searches file names in the database;
|
// /api/2.0/files/@search endpoint only searches file names in the database;
|
||||||
// content search needs Elasticsearch (see docs/elasticsearch.md).
|
// content search needs ES (see docs/elasticsearch.md).
|
||||||
func searchCmd() *cobra.Command {
|
func searchCmd() *cobra.Command {
|
||||||
var (
|
var (
|
||||||
content bool
|
content bool
|
||||||
@@ -41,15 +40,13 @@ func searchCmd() *cobra.Command {
|
|||||||
if asJSON {
|
if asJSON {
|
||||||
outputFormat = "json"
|
outputFormat = "json"
|
||||||
}
|
}
|
||||||
bootstrap.LoadEnv()
|
|
||||||
c := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials())
|
|
||||||
var (
|
var (
|
||||||
searcher onlyoffice.Searcher
|
searcher onlyoffice.Searcher
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
switch strings.ToLower(strings.TrimSpace(backend)) {
|
switch strings.ToLower(strings.TrimSpace(backend)) {
|
||||||
case "", "oo", "elasticsearch":
|
case "", "oo", "elasticsearch":
|
||||||
searcher, err = c.Files().Search()
|
searcher, err = onlyoffice.NewESSearcher(onlyoffice.ESConfigFromEnv())
|
||||||
case "own", "es-text":
|
case "own", "es-text":
|
||||||
searcher, err = onlyoffice.NewESTextIndex(onlyoffice.ESTextConfigFromEnv())
|
searcher, err = onlyoffice.NewESTextIndex(onlyoffice.ESTextConfigFromEnv())
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
---
|
|
||||||
type: reference
|
|
||||||
status: current
|
|
||||||
related:
|
|
||||||
- README.md
|
|
||||||
- file_pg.go
|
|
||||||
- docs/elasticsearch.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# Community Server DB — прямой SQL-доступ (read-only)
|
|
||||||
|
|
||||||
## Что это
|
|
||||||
|
|
||||||
Бэкенд `pgStore` (`file_pg.go`) читает файлы и папки **напрямую из БД
|
|
||||||
Community Server**, без HTTP-слоя. Реализует `FileStore` (`List`/`Stat`/
|
|
||||||
`Download`) и `Searcher` по имени. Запись запрещена: все write-методы
|
|
||||||
возвращают `ErrReadOnly`.
|
|
||||||
|
|
||||||
## Что за БД (research, live)
|
|
||||||
|
|
||||||
Проверено на VM `onlyoffice-v2` (SSH `127.0.0.1:32`):
|
|
||||||
|
|
||||||
- Community Server работает на **MySQL 8.0**, не на PostgreSQL.
|
|
||||||
- Хост: `127.0.0.1:3306` внутри VM, база `onlyoffice`.
|
|
||||||
- Конфиг: `/etc/onlyoffice/communityserver/appsettings.production.json`,
|
|
||||||
`providerName: MySql.Data.MySqlClient`.
|
|
||||||
- Таблицы: `files_file`, `files_folder`, `files_folder_tree`,
|
|
||||||
`files_security`, тенанты — `tenants_tenants` (не `tenants`).
|
|
||||||
- PostgreSQL 16 в той же VM — **наш** контур (`edw_docs`, роли `edw`/`edw_ro`,
|
|
||||||
office-assistant), к OnlyOffice отношения не имеет. `files_file` в PG нет.
|
|
||||||
- Портал хранит файлы в **S3/MinIO** (DiscStorage только для мелочи).
|
|
||||||
Бакет `office`, объект — по ключу (см. ниже).
|
|
||||||
|
|
||||||
Вывод: бэкенд назван по issue «PostgreSQL», но живой источник — MySQL.
|
|
||||||
`database/sql` + драйвер по DSN: `mysql` для MySQL, `pgx` для PostgreSQL.
|
|
||||||
`Name()` возвращает фактический движок (`mysql` или `postgres`).
|
|
||||||
|
|
||||||
## Схема
|
|
||||||
|
|
||||||
`files_file` — одна строка **на версию** (PK `tenant_id, id, version`):
|
|
||||||
|
|
||||||
| поле | смысл |
|
|
||||||
|------|-------|
|
|
||||||
| `id` | id файла (тот же, что в REST/ES) |
|
|
||||||
| `version` | номер версии этой строки |
|
|
||||||
| `version_group` | номер версии |
|
|
||||||
| `current_version` | `1` = текущая версия, `0` = старая |
|
|
||||||
| `folder_id` | id родительской папки |
|
|
||||||
| `title` | имя файла с расширением |
|
|
||||||
| `content_length` | размер в байтах |
|
|
||||||
| `create_on`, `modified_on` | даты (UTC, без зоны) |
|
|
||||||
| `tenant_id` | тенант (портал) |
|
|
||||||
|
|
||||||
`files_folder`: `id`, `parent_id`, `title`, `create_on`, `modified_on`,
|
|
||||||
`tenant_id`. `files_folder_tree`: `folder_id`, `parent_id`, `level` — готовое
|
|
||||||
дерево, пока не используется.
|
|
||||||
|
|
||||||
Текущую строку файла берём по `current_version = 1`.
|
|
||||||
|
|
||||||
## Доступ (SSH-туннель)
|
|
||||||
|
|
||||||
MySQL слушает только `127.0.0.1:3306` внутри VM. Снаружи — SSH-туннель
|
|
||||||
(SSH в VM открыт как `127.0.0.1:32`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh -f -N -o ControlMaster=no -o ControlPath=none \
|
|
||||||
-p 32 -i ~/.ssh/id_ed25519 \
|
|
||||||
-L 13306:127.0.0.1:3306 root@127.0.0.1
|
|
||||||
|
|
||||||
# MySQL DSN затем:
|
|
||||||
# root:<pw>@tcp(127.0.0.1:13306)/onlyoffice?parseTime=true
|
|
||||||
```
|
|
||||||
|
|
||||||
`-o ControlMaster=no -o ControlPath=none` обязательны: иначе forward уходит в
|
|
||||||
persistent master из `~/.ssh/config`.
|
|
||||||
|
|
||||||
## Переменные
|
|
||||||
|
|
||||||
| env | default | смысл |
|
|
||||||
|-----|---------|-------|
|
|
||||||
| `ONLYOFFICE_DSN` | — | DSN драйвера (MySQL `...@tcp(...)/...` или `postgres://...`) |
|
|
||||||
| `ONLYOFFICE_PG_DRIVER` | авто | `postgres` или `mysql`; иначе по форме DSN |
|
|
||||||
| `ONLYOFFICE_PG_TENANT` | `ONLYOFFICE_TENANT` | фильтр `tenant_id` (пусто = все) |
|
|
||||||
| `ONLYOFFICE_PG_HOST/PORT/USER/PASSWORD/DBNAME/SSLMODE` | — | собрать PG DSN, если `ONLYOFFICE_DSN` пуст |
|
|
||||||
|
|
||||||
Имена — в [`.env.example`](../.env.example). Секретов нет.
|
|
||||||
|
|
||||||
## Download (MinIO)
|
|
||||||
|
|
||||||
`Download` не ходит в REST. Ключ объекта собирается из строки `files_file`:
|
|
||||||
|
|
||||||
```
|
|
||||||
00/00/<tenant>/files/folder_<shard>/file_<id>/v<version>/content.<ext>
|
|
||||||
shard = (id/1000 + 1) * 1000
|
|
||||||
```
|
|
||||||
|
|
||||||
`shard` — не `folder_id`, а следующая тысяча над `id` (файл 3727 →
|
|
||||||
`folder_4000`). Проверено live по бакету `office`.
|
|
||||||
|
|
||||||
Стриминг переиспользует `downloadMinioObject` из `storage_fallback.go`
|
|
||||||
(та же подпись SigV4 и `MINIO_*`), без дублирования.
|
|
||||||
|
|
||||||
## Тесты
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./... # unit: rebind, csObjectKey, маппинг
|
|
||||||
go test -race ./...
|
|
||||||
|
|
||||||
# integration (нужен DSN; skip без него)
|
|
||||||
ONLYOFFICE_DSN='root:<pw>@tcp(127.0.0.1:13306)/onlyoffice?parseTime=true' \
|
|
||||||
ONLYOFFICE_PG_TENANT=1 \
|
|
||||||
ONLYOFFICE_PG_TEST_FILE_ID=22484 \
|
|
||||||
ONLYOFFICE_PG_TEST_FOLDER_ID=649 \
|
|
||||||
MINIO_ACCESS_KEY=... MINIO_SECRET_KEY=... \
|
|
||||||
go test -tags=integration -run TestIntegrationPGStore -v ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Integration сверяет `Stat`/`List`/`Download` с REST (`c.Files()`) и проверяет,
|
|
||||||
что write-методы дают `ErrReadOnly`.
|
|
||||||
|
|
||||||
## Грабли
|
|
||||||
|
|
||||||
- MySQL хранит `datetime` без зоны; `parseTime=true` (ставится автоматически)
|
|
||||||
читает их как UTC. REST отдаёт `+02:00` — сравнивать моменты, не строки.
|
|
||||||
- `GetFile` (REST) не отдаёт `contentLength` — размер сверять с `Stat` SQL.
|
|
||||||
- Один файл = много строк `files_file` (по версиям). Без `current_version = 1`
|
|
||||||
получите дубликаты.
|
|
||||||
- `folder_id` не входит в ключ MinIO; ключ считает `shard` от `id`.
|
|
||||||
- Searcher SQL ищет только по имени (`LIKE`). Контент — Elasticsearch
|
|
||||||
([elasticsearch.md](elasticsearch.md)).
|
|
||||||
+2
-26
@@ -174,22 +174,6 @@ OnlyOffice PDF лежит только по имени.
|
|||||||
- CLI: `oo index folder|files` наполняет индекс; `oo search --backend own`
|
- CLI: `oo index folder|files` наполняет индекс; `oo search --backend own`
|
||||||
ищет по нему.
|
ищет по нему.
|
||||||
|
|
||||||
### Встроенные вложения PDF
|
|
||||||
|
|
||||||
Оцифрованные PDF несут вложения (`<doc>.md` — текст/таблицы скана,
|
|
||||||
`<doc>.yaml`/`.json` — метаданные, `.xml` — EN 16931 CII eRechnung,
|
|
||||||
`factur-x.xml` у ZUGFeRD; см. `office-assistant/docs/reference/document-metadata.md`).
|
|
||||||
`TextIndexer` обходит их: `pdfdetach -list` перечисляет, `-save` сохраняет,
|
|
||||||
каждое вложение проходит штатный `docpipe.ToMarkdown` (PDF/картинки → OCR,
|
|
||||||
`.md`/`.txt` — как есть). Форматы, которые docpipe не конвертирует
|
|
||||||
(`.xml`/`.html` — снимаются теги; `.json`/`.csv` — как текст), извлекаются
|
|
||||||
текстом; нечитаемые — пропускаются.
|
|
||||||
|
|
||||||
Текст склеивается: тело, затем по секции на вложение с маркером
|
|
||||||
`[attachment: <имя>]` (функция `docpipe.JoinWithAttachments`). Индекс — тот же
|
|
||||||
`file_id`, upsert идемпотентен. Нет вложений или pdfdetach/формат нечитаем —
|
|
||||||
индексируется тело (без падения).
|
|
||||||
|
|
||||||
Поля `oo_docs_text`:
|
Поля `oo_docs_text`:
|
||||||
|
|
||||||
| поле | тип | смысл |
|
| поле | тип | смысл |
|
||||||
@@ -233,12 +217,8 @@ ONLYOFFICE_ES_URL=http://127.0.0.1:9200 \
|
|||||||
```
|
```
|
||||||
|
|
||||||
Интеграционный тест создаёт временный индекс, наполняет, ищет по контенту,
|
Интеграционный тест создаёт временный индекс, наполняет, ищет по контенту,
|
||||||
проверяет фильтры и удаление, затем удаляет индекс;
|
проверяет фильтры и удаление, затем удаляет индекс. Unit-тесты используют
|
||||||
`TestIntegrationESTextIndexPDFAttachment` индексирует
|
fake-store/fake-extractor и не требуют pdftotext/OCR.
|
||||||
`testdata/pdf-with-attachment.pdf` реальным конвейером (pdfdetach + pdftotext)
|
|
||||||
и ищет токен, лежащий только во вложении. Unit-тесты используют
|
|
||||||
fake-store/fake-extractor и не требуют pdftotext/OCR (парсер списка, склейка
|
|
||||||
`JoinWithAttachments`, снятие тегов `xmlToText` — чистые).
|
|
||||||
|
|
||||||
## Грабли
|
## Грабли
|
||||||
|
|
||||||
@@ -249,7 +229,3 @@ fake-store/fake-extractor и не требуют pdftotext/OCR (парсер с
|
|||||||
- `folder` фильтруется как id папки, а не как путь.
|
- `folder` фильтруется как id папки, а не как путь.
|
||||||
- Дубликаты (напр. `S1055.pdf` и `2026-08-20-S1055-…`) дадут несколько строк —
|
- Дубликаты (напр. `S1055.pdf` и `2026-08-20-S1055-…`) дадут несколько строк —
|
||||||
это ожидаемо, дедуп — на стороне потребителя.
|
это ожидаемо, дедуп — на стороне потребителя.
|
||||||
- Вложения: нужен `pdfdetach` (poppler); если его нет — индексируется только
|
|
||||||
тело. Вложенный PDF/картинка с плохим текстовым слоем проходит OCR, это
|
|
||||||
медленно. `.json`-метаданные (CuraSoft) индексируются как текст и могут
|
|
||||||
добавить шумовых токенов.
|
|
||||||
|
|||||||
+4
-19
@@ -52,17 +52,8 @@ 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.
|
||||||
@@ -106,8 +97,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 composed
|
// ProviderDAV. Unknown or empty names select the REST backend. The full facade
|
||||||
// facade (backend selection/fallback) lives on FileClient in file_facade.go.
|
// (backend composition) is deliberately left to a later change.
|
||||||
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":
|
||||||
@@ -117,9 +108,8 @@ func (c *Client) FileStore(backend string) FileStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files returns the composed file facade. The returned *FileClient implements
|
// Files returns the default (REST) file store.
|
||||||
// FileStore, so callers that used Files() as the plain REST store keep working.
|
func (c *Client) Files() FileStore { return c.FileStore(ProviderREST) }
|
||||||
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).
|
||||||
@@ -150,7 +140,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -164,7 +153,6 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,10 +165,7 @@ 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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/eslider/go-onlyoffice/internal/docpipe"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestIntegrationESTextIndex verifies the own full-text index end to end
|
// TestIntegrationESTextIndex verifies the own full-text index end to end
|
||||||
@@ -92,67 +90,3 @@ func TestIntegrationESTextIndex(t *testing.T) {
|
|||||||
t.Errorf("after delete search returned %d hits, want 0", len(hits))
|
t.Errorf("after delete search returned %d hits, want 0", len(hits))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestIntegrationESTextIndexPDFAttachment indexes testdata/pdf-with-attachment.pdf
|
|
||||||
// through the real pipeline (TextIndexer + docpipe: pdfdetach + pdftotext) and
|
|
||||||
// verifies that text living only in the embedded attachment is searchable.
|
|
||||||
//
|
|
||||||
// Requires ONLYOFFICE_ES_URL plus poppler (pdfdetach/pdftotext). No OnlyOffice
|
|
||||||
// credentials are needed: a fixture FileStore serves the PDF bytes.
|
|
||||||
func TestIntegrationESTextIndexPDFAttachment(t *testing.T) {
|
|
||||||
esURL := strings.TrimSpace(os.Getenv("ONLYOFFICE_ES_URL"))
|
|
||||||
if esURL == "" {
|
|
||||||
t.Skip("ONLYOFFICE_ES_URL not set — skipping Elasticsearch integration test")
|
|
||||||
}
|
|
||||||
if docpipe.LookPath().PDFDetach == "" {
|
|
||||||
t.Skip("pdfdetach not on PATH — skipping PDF attachment integration test")
|
|
||||||
}
|
|
||||||
pdf, err := os.ReadFile("testdata/pdf-with-attachment.pdf")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read fixture: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stamp := time.Now().UTC().Format("20060102150405")
|
|
||||||
idx, err := NewESTextIndex(ESTextConfig{URL: esURL, Index: "oo_docs_text_it_att_" + stamp})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewESTextIndex: %v", err)
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
t.Cleanup(func() {
|
|
||||||
cleanupCtx, done := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer done()
|
|
||||||
_, _, _ = idx.do(cleanupCtx, http.MethodDelete, "/"+idx.Index(), nil, "")
|
|
||||||
})
|
|
||||||
if err := idx.Ensure(ctx); err != nil {
|
|
||||||
t.Fatalf("Ensure: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
store := &textFakeStore{files: map[string][]byte{"9001": pdf}}
|
|
||||||
ix := NewTextIndexer(store, idx)
|
|
||||||
res, err := ix.IndexEntries(ctx, []Entry{{ID: "9001", Title: "scan.pdf", ParentID: "777", Kind: File}}, IndexOptions{MinChars: 1})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("IndexEntries: %v", err)
|
|
||||||
}
|
|
||||||
if res.Indexed != 1 || res.Failed != 0 {
|
|
||||||
t.Fatalf("result = %+v, want one indexed doc", res)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Token appears only inside the embedded goo-note.txt attachment.
|
|
||||||
hits, err := idx.Search(ctx, SearchQuery{Text: "gooattachmenttoken"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Search attachment token: %v", err)
|
|
||||||
}
|
|
||||||
if len(hits) != 1 || hits[0].ID != "9001" {
|
|
||||||
t.Fatalf("attachment-token hits = %+v, want doc 9001", hits)
|
|
||||||
}
|
|
||||||
if !strings.Contains(hits[0].Highlight, "gooattachmenttoken") {
|
|
||||||
t.Errorf("highlight = %q, want attachment token", hits[0].Highlight)
|
|
||||||
}
|
|
||||||
// Body text is indexed as before.
|
|
||||||
if hits, err := idx.Search(ctx, SearchQuery{Text: "goobodytoken"}); err != nil {
|
|
||||||
t.Fatalf("Search body token: %v", err)
|
|
||||||
} else if len(hits) != 1 {
|
|
||||||
t.Errorf("body-token hits = %d, want 1", len(hits))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+28
-28
@@ -135,12 +135,12 @@ func TestESTextConfigFromEnvIndexDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTextIndexerIndexEntries(t *testing.T) {
|
func TestTextIndexerIndexEntries(t *testing.T) {
|
||||||
store := &textFakeStore{
|
store := &fakeStore{
|
||||||
files: map[string][]byte{"1": []byte("PDFBYTES")},
|
files: map[string][]byte{"1": []byte("PDFBYTES")},
|
||||||
}
|
}
|
||||||
idx := &textFakeIndex{}
|
idx := &fakeIndex{}
|
||||||
ix := NewTextIndexer(store, idx)
|
ix := NewTextIndexer(store, idx)
|
||||||
ix.Extractor = textFakeExtractor{prefix: "TEXT "}
|
ix.Extractor = fakeExtractor{prefix: "TEXT "}
|
||||||
|
|
||||||
res, err := ix.IndexEntries(context.Background(), []Entry{
|
res, err := ix.IndexEntries(context.Background(), []Entry{
|
||||||
{ID: "1", Title: "Rechnung.PDF", ParentID: "649", Kind: File},
|
{ID: "1", Title: "Rechnung.PDF", ParentID: "649", Kind: File},
|
||||||
@@ -164,10 +164,10 @@ func TestTextIndexerIndexEntries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTextIndexerRecordsExtractionFailure(t *testing.T) {
|
func TestTextIndexerRecordsExtractionFailure(t *testing.T) {
|
||||||
store := &textFakeStore{files: map[string][]byte{"1": []byte("x")}}
|
store := &fakeStore{files: map[string][]byte{"1": []byte("x")}}
|
||||||
idx := &textFakeIndex{}
|
idx := &fakeIndex{}
|
||||||
ix := NewTextIndexer(store, idx)
|
ix := NewTextIndexer(store, idx)
|
||||||
ix.Extractor = textFailingExtractor{}
|
ix.Extractor = failingExtractor{}
|
||||||
|
|
||||||
res, err := ix.IndexEntries(context.Background(), []Entry{{ID: "1", Title: "a.pdf", Kind: File}}, IndexOptions{})
|
res, err := ix.IndexEntries(context.Background(), []Entry{{ID: "1", Title: "a.pdf", Kind: File}}, IndexOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -179,7 +179,7 @@ func TestTextIndexerRecordsExtractionFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTextIndexerPlanFolder(t *testing.T) {
|
func TestTextIndexerPlanFolder(t *testing.T) {
|
||||||
store := &textFakeStore{dirs: map[string][]Entry{
|
store := &fakeStore{dirs: map[string][]Entry{
|
||||||
"root": {
|
"root": {
|
||||||
{ID: "10", Title: "a.pdf", Kind: File},
|
{ID: "10", Title: "a.pdf", Kind: File},
|
||||||
{ID: "11", Title: "sub", Kind: Folder},
|
{ID: "11", Title: "sub", Kind: Folder},
|
||||||
@@ -189,7 +189,7 @@ func TestTextIndexerPlanFolder(t *testing.T) {
|
|||||||
{ID: "13", Title: "c.xlsx", Kind: File},
|
{ID: "13", Title: "c.xlsx", Kind: File},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
ix := NewTextIndexer(store, &textFakeIndex{})
|
ix := NewTextIndexer(store, &fakeIndex{})
|
||||||
|
|
||||||
flat, err := ix.PlanFolder(context.Background(), "root", IndexOptions{})
|
flat, err := ix.PlanFolder(context.Background(), "root", IndexOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -209,26 +209,26 @@ func TestTextIndexerPlanFolder(t *testing.T) {
|
|||||||
|
|
||||||
// --- fakes -----------------------------------------------------------------
|
// --- fakes -----------------------------------------------------------------
|
||||||
|
|
||||||
type textFakeStore struct {
|
type fakeStore struct {
|
||||||
dirs map[string][]Entry
|
dirs map[string][]Entry
|
||||||
files map[string][]byte
|
files map[string][]byte
|
||||||
stat map[string]Entry
|
stat map[string]Entry
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *textFakeStore) Name() string { return "fake" }
|
func (f *fakeStore) Name() string { return "fake" }
|
||||||
|
|
||||||
func (f *textFakeStore) List(_ context.Context, parentID string) ([]Entry, error) {
|
func (f *fakeStore) List(_ context.Context, parentID string) ([]Entry, error) {
|
||||||
return f.dirs[parentID], nil
|
return f.dirs[parentID], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *textFakeStore) Stat(_ context.Context, id string) (Entry, error) {
|
func (f *fakeStore) Stat(_ context.Context, id string) (Entry, error) {
|
||||||
if e, ok := f.stat[id]; ok {
|
if e, ok := f.stat[id]; ok {
|
||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
return Entry{}, fmt.Errorf("not found: %s", id)
|
return Entry{}, fmt.Errorf("not found: %s", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *textFakeStore) Download(_ context.Context, id string, w io.Writer) (int64, error) {
|
func (f *fakeStore) Download(_ context.Context, id string, w io.Writer) (int64, error) {
|
||||||
b, ok := f.files[id]
|
b, ok := f.files[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, fmt.Errorf("no bytes for %s", id)
|
return 0, fmt.Errorf("no bytes for %s", id)
|
||||||
@@ -237,30 +237,30 @@ func (f *textFakeStore) Download(_ context.Context, id string, w io.Writer) (int
|
|||||||
return int64(n), err
|
return int64(n), err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *textFakeStore) CreateFolder(context.Context, string, string) (Entry, error) {
|
func (f *fakeStore) CreateFolder(context.Context, string, string) (Entry, error) {
|
||||||
return Entry{}, nil
|
return Entry{}, nil
|
||||||
}
|
}
|
||||||
func (f *textFakeStore) Upload(context.Context, string, string, io.Reader) (Entry, error) {
|
func (f *fakeStore) Upload(context.Context, string, string, io.Reader) (Entry, error) {
|
||||||
return Entry{}, nil
|
return Entry{}, nil
|
||||||
}
|
}
|
||||||
func (f *textFakeStore) Move(context.Context, []string, string) error { return nil }
|
func (f *fakeStore) Move(context.Context, []string, string) error { return nil }
|
||||||
func (f *textFakeStore) Copy(context.Context, []string, string) error { return nil }
|
func (f *fakeStore) Copy(context.Context, []string, string) error { return nil }
|
||||||
func (f *textFakeStore) Rename(context.Context, string, string) error { return nil }
|
func (f *fakeStore) Rename(context.Context, string, string) error { return nil }
|
||||||
func (f *textFakeStore) Delete(context.Context, []string) error { return nil }
|
func (f *fakeStore) Delete(context.Context, []string) error { return nil }
|
||||||
|
|
||||||
type textFakeIndex struct{ docs []TextDoc }
|
type fakeIndex struct{ docs []TextDoc }
|
||||||
|
|
||||||
func (f *textFakeIndex) Put(_ context.Context, docs []TextDoc) error {
|
func (f *fakeIndex) Put(_ context.Context, docs []TextDoc) error {
|
||||||
f.docs = append(f.docs, docs...)
|
f.docs = append(f.docs, docs...)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (f *textFakeIndex) Delete(context.Context, []string) error { return nil }
|
func (f *fakeIndex) Delete(context.Context, []string) error { return nil }
|
||||||
func (f *textFakeIndex) Search(context.Context, SearchQuery) ([]SearchHit, error) { return nil, nil }
|
func (f *fakeIndex) Search(context.Context, SearchQuery) ([]SearchHit, error) { return nil, nil }
|
||||||
func (f *textFakeIndex) Name() string { return "fake" }
|
func (f *fakeIndex) Name() string { return "fake" }
|
||||||
|
|
||||||
type textFakeExtractor struct{ prefix string }
|
type fakeExtractor struct{ prefix string }
|
||||||
|
|
||||||
func (f textFakeExtractor) Extract(path, _, _ string, _ int) (string, error) {
|
func (f fakeExtractor) Extract(path, _, _ string, _ int) (string, error) {
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -268,8 +268,8 @@ func (f textFakeExtractor) Extract(path, _, _ string, _ int) (string, error) {
|
|||||||
return f.prefix + string(b), nil
|
return f.prefix + string(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type textFailingExtractor struct{}
|
type failingExtractor struct{}
|
||||||
|
|
||||||
func (textFailingExtractor) Extract(string, string, string, int) (string, error) {
|
func (failingExtractor) Extract(string, string, string, int) (string, error) {
|
||||||
return "", fmt.Errorf("boom")
|
return "", fmt.Errorf("boom")
|
||||||
}
|
}
|
||||||
|
|||||||
-249
@@ -1,249 +0,0 @@
|
|||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ProviderES is the composed Elasticsearch searcher. The SQL store owns
|
|
||||||
// ProviderPG/ProviderMySQL (file_pg.go); the facade references ProviderPG in
|
|
||||||
// readOrder.
|
|
||||||
const 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
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-516
@@ -1,516 +0,0 @@
|
|||||||
package onlyoffice
|
|
||||||
|
|
||||||
// Read-only SQL backend of the unified file client (epic #34, F2 #36).
|
|
||||||
//
|
|
||||||
// The goal is to read files and folders straight from the Community Server
|
|
||||||
// database, without the REST layer. Research on the live portal (VM
|
|
||||||
// `onlyoffice-v2`) showed the server runs on **MySQL 8.0** (`files_file`,
|
|
||||||
// `files_folder`, `files_folder_tree`, tenant `tenants_tenants`), not
|
|
||||||
// PostgreSQL — see docs/community-server-db.md. The store below therefore
|
|
||||||
// speaks `database/sql` and selects its driver from the DSN, so it works
|
|
||||||
// against the live MySQL today and against PostgreSQL if the portal is ever
|
|
||||||
// migrated. Every query is a SELECT; the write methods of FileStore return
|
|
||||||
// ErrReadOnly.
|
|
||||||
//
|
|
||||||
// Downloads follow the portal's S3/MinIO object layout through the shared
|
|
||||||
// MinIO helper in storage_fallback.go — no HTTP file endpoint is used.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-sql-driver/mysql"
|
|
||||||
_ "github.com/jackc/pgx/v5/stdlib"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Provider names for the SQL backend. ProviderPG is the value Name reports for
|
|
||||||
// a PostgreSQL connection and ProviderMySQL for MySQL.
|
|
||||||
const (
|
|
||||||
ProviderPG = "postgres"
|
|
||||||
ProviderMySQL = "mysql"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrReadOnly is returned by every FileStore write method of the SQL backend.
|
|
||||||
var ErrReadOnly = errors.New("onlyoffice: sql file store is read-only")
|
|
||||||
|
|
||||||
const (
|
|
||||||
pgConnectTimeout = 10 * time.Second
|
|
||||||
pgSearchLimit = 50
|
|
||||||
pgSearchMaxLimit = 500
|
|
||||||
)
|
|
||||||
|
|
||||||
// PGConfig configures the read-only SQL store. DSN is a driver DSN:
|
|
||||||
// `user:pass@tcp(host:port)/onlyoffice?parseTime=true` for MySQL or a
|
|
||||||
// `postgres://` / libpq keyword string for PostgreSQL. Driver, when set,
|
|
||||||
// forces the engine ("postgres" or "mysql"); otherwise it is detected from the
|
|
||||||
// DSN. Tenant filters rows (empty means all tenants).
|
|
||||||
type PGConfig struct {
|
|
||||||
DSN string
|
|
||||||
Driver string
|
|
||||||
Tenant string
|
|
||||||
}
|
|
||||||
|
|
||||||
// PGConfigFromEnv reads ONLYOFFICE_DSN (or the ONLYOFFICE_PG_* parts),
|
|
||||||
// ONLYOFFICE_PG_DRIVER and the tenant from ONLYOFFICE_PG_TENANT /
|
|
||||||
// ONLYOFFICE_TENANT. The library never loads dotfiles — the CLI does that.
|
|
||||||
func PGConfigFromEnv() PGConfig {
|
|
||||||
dsn := strings.TrimSpace(os.Getenv("ONLYOFFICE_DSN"))
|
|
||||||
if dsn == "" {
|
|
||||||
dsn = pgDSNFromParts()
|
|
||||||
}
|
|
||||||
return PGConfig{
|
|
||||||
DSN: dsn,
|
|
||||||
Driver: strings.TrimSpace(os.Getenv("ONLYOFFICE_PG_DRIVER")),
|
|
||||||
Tenant: firstNonEmpty(os.Getenv("ONLYOFFICE_PG_TENANT"), os.Getenv("ONLYOFFICE_TENANT")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pgDSNFromParts builds a libpq keyword DSN from ONLYOFFICE_PG_* variables.
|
|
||||||
// It returns "" unless a host is set, which keeps the MySQL path (ONLYOFFICE_DSN)
|
|
||||||
// the default.
|
|
||||||
func pgDSNFromParts() string {
|
|
||||||
host := strings.TrimSpace(os.Getenv("ONLYOFFICE_PG_HOST"))
|
|
||||||
if host == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
port := firstNonEmpty(os.Getenv("ONLYOFFICE_PG_PORT"), "5432")
|
|
||||||
dbname := firstNonEmpty(os.Getenv("ONLYOFFICE_PG_DBNAME"), "onlyoffice")
|
|
||||||
sslmode := firstNonEmpty(os.Getenv("ONLYOFFICE_PG_SSLMODE"), "disable")
|
|
||||||
return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
|
|
||||||
host, port, os.Getenv("ONLYOFFICE_PG_USER"), os.Getenv("ONLYOFFICE_PG_PASSWORD"), dbname, sslmode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// pgStore is a read-only FileStore/Searcher over the Community Server database.
|
|
||||||
type pgStore struct {
|
|
||||||
db *sql.DB
|
|
||||||
driver string
|
|
||||||
tenantID int64
|
|
||||||
hasTenant bool
|
|
||||||
http *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
_ FileStore = (*pgStore)(nil)
|
|
||||||
_ Searcher = (*pgStore)(nil)
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewPGStore opens the database and verifies connectivity. It never writes.
|
|
||||||
func NewPGStore(cfg PGConfig) (*pgStore, error) {
|
|
||||||
dsn := strings.TrimSpace(cfg.DSN)
|
|
||||||
if dsn == "" {
|
|
||||||
return nil, fmt.Errorf("onlyoffice: sql file store: empty DSN (set ONLYOFFICE_DSN)")
|
|
||||||
}
|
|
||||||
driver := pgDriver(dsn, cfg.Driver)
|
|
||||||
dsn, err := normalizeSQLDSN(driver, dsn)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
db, err := sql.Open(sqlDriverName(driver), dsn)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("onlyoffice: sql file store: open %s: %w", driver, err)
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), pgConnectTimeout)
|
|
||||||
defer cancel()
|
|
||||||
if err := db.PingContext(ctx); err != nil {
|
|
||||||
db.Close()
|
|
||||||
return nil, fmt.Errorf("onlyoffice: sql file store: ping %s: %w", driver, err)
|
|
||||||
}
|
|
||||||
s := &pgStore{db: db, driver: driver, http: &http.Client{}}
|
|
||||||
if t := strings.TrimSpace(cfg.Tenant); t != "" {
|
|
||||||
n, err := strconv.ParseInt(t, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
db.Close()
|
|
||||||
return nil, fmt.Errorf("onlyoffice: sql file store: non-numeric tenant %q", t)
|
|
||||||
}
|
|
||||||
s.tenantID, s.hasTenant = n, true
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close releases the database handle.
|
|
||||||
func (s *pgStore) Close() error { return s.db.Close() }
|
|
||||||
|
|
||||||
// Name implements FileStore and Searcher.
|
|
||||||
func (s *pgStore) Name() string { return s.driver }
|
|
||||||
|
|
||||||
// pgDriver resolves the engine: the explicit value wins, otherwise the DSN
|
|
||||||
// shape decides. A leading postgres:// scheme or a libpq keyword DSN (which
|
|
||||||
// always carries '=') selects PostgreSQL; anything else is MySQL.
|
|
||||||
func pgDriver(dsn, explicit string) string {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(explicit)) {
|
|
||||||
case ProviderPG, "pg", "postgresql", "pgx":
|
|
||||||
return ProviderPG
|
|
||||||
case ProviderMySQL, "mariadb":
|
|
||||||
return ProviderMySQL
|
|
||||||
}
|
|
||||||
l := strings.ToLower(strings.TrimSpace(dsn))
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(l, "postgres://"), strings.HasPrefix(l, "postgresql://"):
|
|
||||||
return ProviderPG
|
|
||||||
case strings.HasPrefix(l, "mysql://"), strings.Contains(l, "@tcp("), strings.Contains(l, "@unix("):
|
|
||||||
return ProviderMySQL
|
|
||||||
case strings.Contains(l, "="):
|
|
||||||
return ProviderPG
|
|
||||||
default:
|
|
||||||
return ProviderMySQL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sqlDriverName maps the engine to its registered database/sql driver.
|
|
||||||
func sqlDriverName(driver string) string {
|
|
||||||
if driver == ProviderPG {
|
|
||||||
return "pgx"
|
|
||||||
}
|
|
||||||
return "mysql"
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeSQLDSN converts a mysql:// URL to the go-sql-driver form and forces
|
|
||||||
// parseTime so datetime columns scan into time.Time. PostgreSQL DSNs pass
|
|
||||||
// through untouched.
|
|
||||||
func normalizeSQLDSN(driver, dsn string) (string, error) {
|
|
||||||
if driver != ProviderMySQL {
|
|
||||||
return dsn, nil
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(strings.ToLower(dsn), "mysql://") {
|
|
||||||
converted, err := mysqlDSNFromURL(dsn)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
dsn = converted
|
|
||||||
}
|
|
||||||
cfg, err := mysql.ParseDSN(dsn)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("onlyoffice: sql file store: parse mysql DSN: %w", err)
|
|
||||||
}
|
|
||||||
cfg.ParseTime = true
|
|
||||||
return cfg.FormatDSN(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// mysqlDSNFromURL turns mysql://user:pass@host:port/db into the driver DSN.
|
|
||||||
func mysqlDSNFromURL(raw string) (string, error) {
|
|
||||||
u, err := url.Parse(raw)
|
|
||||||
if err != nil || u.Host == "" {
|
|
||||||
return "", fmt.Errorf("onlyoffice: sql file store: bad mysql URL %q", raw)
|
|
||||||
}
|
|
||||||
user := ""
|
|
||||||
if u.User != nil {
|
|
||||||
user = u.User.Username()
|
|
||||||
if p, ok := u.User.Password(); ok {
|
|
||||||
user += ":" + p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
q := u.Query()
|
|
||||||
q.Set("parseTime", "true")
|
|
||||||
return fmt.Sprintf("%s@tcp(%s)/%s?%s", user, u.Host, strings.TrimPrefix(u.Path, "/"), q.Encode()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// rebind rewrites '?' placeholders to PostgreSQL's $1..$n. MySQL keeps them.
|
|
||||||
func rebind(query, driver string) string {
|
|
||||||
if driver != ProviderPG {
|
|
||||||
return query
|
|
||||||
}
|
|
||||||
var b strings.Builder
|
|
||||||
b.Grow(len(query) + 8)
|
|
||||||
n := 0
|
|
||||||
for _, r := range query {
|
|
||||||
if r == '?' {
|
|
||||||
n++
|
|
||||||
b.WriteByte('$')
|
|
||||||
b.WriteString(strconv.Itoa(n))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
b.WriteRune(r)
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns the folders and files directly below parentID, folders first.
|
|
||||||
func (s *pgStore) List(ctx context.Context, parentID string) ([]Entry, error) {
|
|
||||||
pid, err := parseEntryID(parentID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
folders, err := s.queryFolders(ctx, "parent_id = ?", pid)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
files, err := s.queryFiles(ctx, "folder_id = ? AND current_version = 1", pid)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make([]Entry, 0, len(folders)+len(files))
|
|
||||||
for _, f := range folders {
|
|
||||||
out = append(out, folderRowToEntry(f, s.Name()))
|
|
||||||
}
|
|
||||||
for _, f := range files {
|
|
||||||
out = append(out, fileRowToEntry(f, s.Name()))
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stat resolves a folder or file id to an Entry. Folders win when both id
|
|
||||||
// spaces overlap (they never do on a real portal, but the lookup is cheap).
|
|
||||||
func (s *pgStore) Stat(ctx context.Context, id string) (Entry, error) {
|
|
||||||
n, err := parseEntryID(id)
|
|
||||||
if err != nil {
|
|
||||||
return Entry{}, err
|
|
||||||
}
|
|
||||||
folders, err := s.queryFolders(ctx, "id = ?", n)
|
|
||||||
if err != nil {
|
|
||||||
return Entry{}, err
|
|
||||||
}
|
|
||||||
if len(folders) > 0 {
|
|
||||||
return folderRowToEntry(folders[0], s.Name()), nil
|
|
||||||
}
|
|
||||||
files, err := s.queryFiles(ctx, "id = ? AND current_version = 1", n)
|
|
||||||
if err != nil {
|
|
||||||
return Entry{}, err
|
|
||||||
}
|
|
||||||
if len(files) == 0 {
|
|
||||||
return Entry{}, fmt.Errorf("onlyoffice: sql file store: id %s not found", id)
|
|
||||||
}
|
|
||||||
return fileRowToEntry(files[0], s.Name()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download streams the file's current version from the portal's S3/MinIO store.
|
|
||||||
// The object key is reconstructed from the file id and version; the parent
|
|
||||||
// folder id is not part of the key.
|
|
||||||
func (s *pgStore) Download(ctx context.Context, id string, w io.Writer) (int64, error) {
|
|
||||||
n, err := parseEntryID(id)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
files, err := s.queryFiles(ctx, "id = ? AND current_version = 1", n)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if len(files) == 0 {
|
|
||||||
return 0, fmt.Errorf("onlyoffice: sql file store: file %s not found", id)
|
|
||||||
}
|
|
||||||
f := files[0]
|
|
||||||
key := csObjectKey(s.tenantID, f.id, f.version, filepath.Ext(f.title))
|
|
||||||
return downloadMinioObject(ctx, s.http, key, w)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateFolder is unavailable: the SQL backend is read-only.
|
|
||||||
func (s *pgStore) CreateFolder(context.Context, string, string) (Entry, error) {
|
|
||||||
return Entry{}, ErrReadOnly
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload is unavailable: the SQL backend is read-only.
|
|
||||||
func (s *pgStore) Upload(context.Context, string, string, io.Reader) (Entry, error) {
|
|
||||||
return Entry{}, ErrReadOnly
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move is unavailable: the SQL backend is read-only.
|
|
||||||
func (s *pgStore) Move(context.Context, []string, string) error { return ErrReadOnly }
|
|
||||||
|
|
||||||
// Copy is unavailable: the SQL backend is read-only.
|
|
||||||
func (s *pgStore) Copy(context.Context, []string, string) error { return ErrReadOnly }
|
|
||||||
|
|
||||||
// Rename is unavailable: the SQL backend is read-only.
|
|
||||||
func (s *pgStore) Rename(context.Context, string, string) error { return ErrReadOnly }
|
|
||||||
|
|
||||||
// Delete is unavailable: the SQL backend is read-only.
|
|
||||||
func (s *pgStore) Delete(context.Context, []string) error { return ErrReadOnly }
|
|
||||||
|
|
||||||
// Search matches file titles by substring. Content search lives in the
|
|
||||||
// Elasticsearch backend; q.InContent is ignored here.
|
|
||||||
func (s *pgStore) Search(ctx context.Context, q SearchQuery) ([]SearchHit, error) {
|
|
||||||
text := strings.TrimSpace(q.Text)
|
|
||||||
if text == "" {
|
|
||||||
return nil, fmt.Errorf("onlyoffice: empty search query")
|
|
||||||
}
|
|
||||||
limit := q.Limit
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = pgSearchLimit
|
|
||||||
}
|
|
||||||
if limit > pgSearchMaxLimit {
|
|
||||||
limit = pgSearchMaxLimit
|
|
||||||
}
|
|
||||||
|
|
||||||
where := "title LIKE ? AND current_version = 1"
|
|
||||||
args := []any{"%" + text + "%"}
|
|
||||||
if s.hasTenant {
|
|
||||||
where += " AND tenant_id = ?"
|
|
||||||
args = append(args, s.tenantID)
|
|
||||||
}
|
|
||||||
if fid := strings.TrimSpace(q.FolderID); fid != "" {
|
|
||||||
n, err := parseEntryID(fid)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
where += " AND folder_id = ?"
|
|
||||||
args = append(args, n)
|
|
||||||
}
|
|
||||||
for _, ext := range normalizeExtensions(q.Extensions) {
|
|
||||||
where += " AND LOWER(title) LIKE ?"
|
|
||||||
args = append(args, "%."+ext)
|
|
||||||
}
|
|
||||||
query := rebind(`SELECT id, folder_id, title, content_length, version, create_on, modified_on
|
|
||||||
FROM files_file WHERE `+where+` ORDER BY modified_on DESC, id DESC LIMIT ?`, s.driver)
|
|
||||||
args = append(args, limit)
|
|
||||||
|
|
||||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("onlyoffice: sql search: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var hits []SearchHit
|
|
||||||
for rows.Next() {
|
|
||||||
f, err := scanFileRow(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
hits = append(hits, SearchHit{Entry: fileRowToEntry(f, s.Name())})
|
|
||||||
}
|
|
||||||
return hits, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// queryFolders runs a folder SELECT with the tenant filter applied.
|
|
||||||
func (s *pgStore) queryFolders(ctx context.Context, where string, arg any) ([]pgFolderRow, error) {
|
|
||||||
args := []any{arg}
|
|
||||||
if s.hasTenant {
|
|
||||||
where += " AND tenant_id = ?"
|
|
||||||
args = append(args, s.tenantID)
|
|
||||||
}
|
|
||||||
query := rebind(`SELECT id, parent_id, title, create_on, modified_on
|
|
||||||
FROM files_folder WHERE `+where+` ORDER BY title, id`, s.driver)
|
|
||||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("onlyoffice: sql list folders: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []pgFolderRow
|
|
||||||
for rows.Next() {
|
|
||||||
var r pgFolderRow
|
|
||||||
if err := rows.Scan(&r.id, &r.parentID, &r.title, &r.created, &r.modified); err != nil {
|
|
||||||
return nil, fmt.Errorf("onlyoffice: sql folder row: %w", err)
|
|
||||||
}
|
|
||||||
out = append(out, r)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// queryFiles runs a file SELECT for the current version with the tenant filter.
|
|
||||||
func (s *pgStore) queryFiles(ctx context.Context, where string, arg any) ([]pgFileRow, error) {
|
|
||||||
args := []any{arg}
|
|
||||||
if s.hasTenant {
|
|
||||||
where += " AND tenant_id = ?"
|
|
||||||
args = append(args, s.tenantID)
|
|
||||||
}
|
|
||||||
query := rebind(`SELECT id, folder_id, title, content_length, version, create_on, modified_on
|
|
||||||
FROM files_file WHERE `+where+` ORDER BY title, id`, s.driver)
|
|
||||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("onlyoffice: sql list files: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []pgFileRow
|
|
||||||
for rows.Next() {
|
|
||||||
f, err := scanFileRow(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, f)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// pgFileRow is one current files_file row.
|
|
||||||
type pgFileRow struct {
|
|
||||||
id int64
|
|
||||||
folderID int64
|
|
||||||
title string
|
|
||||||
size int64
|
|
||||||
version int
|
|
||||||
created time.Time
|
|
||||||
modified time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// pgFolderRow is one files_folder row.
|
|
||||||
type pgFolderRow struct {
|
|
||||||
id int64
|
|
||||||
parentID int64
|
|
||||||
title string
|
|
||||||
created time.Time
|
|
||||||
modified time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// scanFileRow reads the canonical file column order.
|
|
||||||
func scanFileRow(rows *sql.Rows) (pgFileRow, error) {
|
|
||||||
var f pgFileRow
|
|
||||||
if err := rows.Scan(&f.id, &f.folderID, &f.title, &f.size, &f.version, &f.created, &f.modified); err != nil {
|
|
||||||
return f, fmt.Errorf("onlyoffice: sql file row: %w", err)
|
|
||||||
}
|
|
||||||
return f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fileRowToEntry maps a files_file row to the canonical model.
|
|
||||||
func fileRowToEntry(f pgFileRow, provider string) Entry {
|
|
||||||
return Entry{
|
|
||||||
ID: strconv.FormatInt(f.id, 10),
|
|
||||||
ParentID: strconv.FormatInt(f.folderID, 10),
|
|
||||||
Title: f.title,
|
|
||||||
Kind: File,
|
|
||||||
Size: f.size,
|
|
||||||
MIME: mimeForTitle(f.title, ""),
|
|
||||||
Created: f.created.UTC(),
|
|
||||||
Modified: f.modified.UTC(),
|
|
||||||
Version: f.version,
|
|
||||||
Provider: provider,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// folderRowToEntry maps a files_folder row to the canonical model.
|
|
||||||
func folderRowToEntry(f pgFolderRow, provider string) Entry {
|
|
||||||
return Entry{
|
|
||||||
ID: strconv.FormatInt(f.id, 10),
|
|
||||||
ParentID: strconv.FormatInt(f.parentID, 10),
|
|
||||||
Title: f.title,
|
|
||||||
Kind: Folder,
|
|
||||||
Created: f.created.UTC(),
|
|
||||||
Modified: f.modified.UTC(),
|
|
||||||
Provider: provider,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// csObjectKey reconstructs the object key the portal's S3 consumer uses:
|
|
||||||
//
|
|
||||||
// 00/00/<tenant>/files/folder_<shard>/file_<id>/v<version>/content.<ext>
|
|
||||||
//
|
|
||||||
// The shard is the next thousand above the file id (file 3727 -> folder_4000),
|
|
||||||
// NOT the parent folder id — verified live against the MinIO bucket.
|
|
||||||
func csObjectKey(tenant int64, fileID int64, version int, ext string) string {
|
|
||||||
shard := (fileID/1000 + 1) * 1000
|
|
||||||
ext = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".")
|
|
||||||
if ext == "" {
|
|
||||||
ext = "bin"
|
|
||||||
}
|
|
||||||
if version < 1 {
|
|
||||||
version = 1
|
|
||||||
}
|
|
||||||
if tenant <= 0 {
|
|
||||||
tenant = 1
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("00/00/%02d/files/folder_%d/file_%d/v%d/content.%s", tenant, shard, fileID, version, ext)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseEntryID parses a numeric OnlyOffice id or returns a store error.
|
|
||||||
func parseEntryID(id string) (int64, error) {
|
|
||||||
n, err := strconv.ParseInt(strings.TrimSpace(id), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("onlyoffice: sql file store: non-numeric id %q", id)
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
//go:build integration
|
|
||||||
|
|
||||||
package onlyoffice
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestIntegrationPGStore exercises the read-only SQL backend against the live
|
|
||||||
// Community Server database and cross-checks list/stat/download with the REST
|
|
||||||
// FileStore. It needs ONLYOFFICE_DSN plus the usual ONLYOFFICE_URL/USER/PASS;
|
|
||||||
// ONLYOFFICE_PG_TEST_FILE_ID / ONLYOFFICE_PG_TEST_FOLDER_ID pick a real file
|
|
||||||
// (a file reachable over REST too). Download streams from MinIO, so it also
|
|
||||||
// needs MINIO_ACCESS_KEY/MINIO_SECRET_KEY.
|
|
||||||
//
|
|
||||||
// The live Community Server runs on MySQL; PostgreSQL is supported by the same
|
|
||||||
// code path when the DSN says so.
|
|
||||||
func TestIntegrationPGStore(t *testing.T) {
|
|
||||||
cfg := PGConfigFromEnv()
|
|
||||||
if strings.TrimSpace(cfg.DSN) == "" {
|
|
||||||
t.Skip("ONLYOFFICE_DSN not set — skipping SQL store integration test")
|
|
||||||
}
|
|
||||||
store, err := NewPGStore(cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewPGStore: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = store.Close() })
|
|
||||||
t.Logf("sql store backend: %s", store.Name())
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if err := testPGStoreReadOnly(ctx, store); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fileID := strings.TrimSpace(os.Getenv("ONLYOFFICE_PG_TEST_FILE_ID"))
|
|
||||||
folderID := strings.TrimSpace(os.Getenv("ONLYOFFICE_PG_TEST_FOLDER_ID"))
|
|
||||||
if fileID == "" || folderID == "" {
|
|
||||||
t.Skip("ONLYOFFICE_PG_TEST_FILE_ID / ONLYOFFICE_PG_TEST_FOLDER_ID not set — skipping live comparison")
|
|
||||||
}
|
|
||||||
|
|
||||||
c := liveClient(t)
|
|
||||||
rest := c.Files()
|
|
||||||
|
|
||||||
dbFile, err := store.Stat(ctx, fileID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("sql Stat(%s): %v", fileID, err)
|
|
||||||
}
|
|
||||||
restFile, err := rest.Stat(ctx, fileID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("rest Stat(%s): %v", fileID, err)
|
|
||||||
}
|
|
||||||
if dbFile.Kind != File {
|
|
||||||
t.Errorf("sql kind = %v, want file", dbFile.Kind)
|
|
||||||
}
|
|
||||||
if dbFile.ID != restFile.ID || dbFile.Title != restFile.Title || dbFile.ParentID != restFile.ParentID {
|
|
||||||
t.Errorf("stat mismatch sql=%+v rest=%+v", dbFile, restFile)
|
|
||||||
}
|
|
||||||
// GetFile omits contentLength, so size is only comparable when REST has it.
|
|
||||||
if restFile.Size > 0 && dbFile.Size != restFile.Size {
|
|
||||||
t.Errorf("size sql=%d rest=%d", dbFile.Size, restFile.Size)
|
|
||||||
}
|
|
||||||
if d := dbFile.Modified.Sub(restFile.Modified); d > 2*time.Minute || d < -2*time.Minute {
|
|
||||||
t.Errorf("modified sql=%v rest=%v", dbFile.Modified, restFile.Modified)
|
|
||||||
}
|
|
||||||
|
|
||||||
list, err := store.List(ctx, folderID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("sql List(%s): %v", folderID, err)
|
|
||||||
}
|
|
||||||
if entryByID(list, fileID) == nil {
|
|
||||||
t.Errorf("file %s not in sql List(%s)", fileID, folderID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every file the REST layer can see in the folder must be in the SQL list
|
|
||||||
// (the SQL store sees more, so only assert this direction).
|
|
||||||
restList, err := rest.List(ctx, folderID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("rest List(%s): %v", folderID, err)
|
|
||||||
}
|
|
||||||
dbIDs := make(map[string]bool, len(list))
|
|
||||||
for _, e := range list {
|
|
||||||
dbIDs[e.ID] = true
|
|
||||||
}
|
|
||||||
for _, e := range restList {
|
|
||||||
if e.Kind == File && !dbIDs[e.ID] {
|
|
||||||
t.Errorf("rest file %s (%q) missing from sql list", e.ID, e.Title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
n, err := store.Download(ctx, fileID, &buf)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("sql Download(%s): %v", fileID, err)
|
|
||||||
}
|
|
||||||
if n == 0 || n != dbFile.Size {
|
|
||||||
t.Errorf("sql Download = %d bytes, stat says %d", n, dbFile.Size)
|
|
||||||
}
|
|
||||||
if os.Getenv("MINIO_ACCESS_KEY") != "" && os.Getenv("MINIO_SECRET_KEY") != "" {
|
|
||||||
var restBuf bytes.Buffer
|
|
||||||
rn, err := rest.Download(ctx, fileID, &restBuf)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("rest Download(%s): %v", fileID, err)
|
|
||||||
}
|
|
||||||
if rn != n || !bytes.Equal(restBuf.Bytes(), buf.Bytes()) {
|
|
||||||
t.Errorf("download mismatch sql=%d rest=%d bytes", n, rn)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
t.Log("MINIO_ACCESS_KEY/MINIO_SECRET_KEY not set — REST download cross-check skipped")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// testPGStoreReadOnly asserts that every write method returns ErrReadOnly.
|
|
||||||
func testPGStoreReadOnly(ctx context.Context, s *pgStore) error {
|
|
||||||
if _, err := s.CreateFolder(ctx, "1", "x"); !errors.Is(err, ErrReadOnly) {
|
|
||||||
return errors.New("CreateFolder did not return ErrReadOnly")
|
|
||||||
}
|
|
||||||
if _, err := s.Upload(ctx, "1", "x", strings.NewReader("x")); !errors.Is(err, ErrReadOnly) {
|
|
||||||
return errors.New("Upload did not return ErrReadOnly")
|
|
||||||
}
|
|
||||||
if err := s.Move(ctx, []string{"1"}, "2"); !errors.Is(err, ErrReadOnly) {
|
|
||||||
return errors.New("Move did not return ErrReadOnly")
|
|
||||||
}
|
|
||||||
if err := s.Copy(ctx, []string{"1"}, "2"); !errors.Is(err, ErrReadOnly) {
|
|
||||||
return errors.New("Copy did not return ErrReadOnly")
|
|
||||||
}
|
|
||||||
if err := s.Rename(ctx, "1", "x"); !errors.Is(err, ErrReadOnly) {
|
|
||||||
return errors.New("Rename did not return ErrReadOnly")
|
|
||||||
}
|
|
||||||
if err := s.Delete(ctx, []string{"1"}); !errors.Is(err, ErrReadOnly) {
|
|
||||||
return errors.New("Delete did not return ErrReadOnly")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
-165
@@ -1,165 +0,0 @@
|
|||||||
package onlyoffice
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRebind(t *testing.T) {
|
|
||||||
mysqlQuery := "SELECT id FROM files_file WHERE folder_id = ? AND title = ? LIMIT ?"
|
|
||||||
if got := rebind(mysqlQuery, ProviderMySQL); got != mysqlQuery {
|
|
||||||
t.Errorf("mysql query changed: %q", got)
|
|
||||||
}
|
|
||||||
want := "SELECT id FROM files_file WHERE folder_id = $1 AND title = $2 LIMIT $3"
|
|
||||||
if got := rebind(mysqlQuery, ProviderPG); got != want {
|
|
||||||
t.Errorf("rebind = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCSPObjectKey(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
tenant int64
|
|
||||||
fileID int64
|
|
||||||
version int
|
|
||||||
ext string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{1, 2, 1, ".docx", "00/00/01/files/folder_1000/file_2/v1/content.docx"},
|
|
||||||
{1, 999, 1, ".pdf", "00/00/01/files/folder_1000/file_999/v1/content.pdf"},
|
|
||||||
{1, 1000, 1, ".xlsx", "00/00/01/files/folder_2000/file_1000/v1/content.xlsx"},
|
|
||||||
{1, 3727, 1, ".pdf", "00/00/01/files/folder_4000/file_3727/v1/content.pdf"},
|
|
||||||
{1, 22484, 1, ".PDF", "00/00/01/files/folder_23000/file_22484/v1/content.pdf"},
|
|
||||||
{1, 4, 6, "xlsx", "00/00/01/files/folder_1000/file_4/v6/content.xlsx"},
|
|
||||||
{0, 7, 0, "", "00/00/01/files/folder_1000/file_7/v1/content.bin"},
|
|
||||||
{2, 11, 3, ".doc", "00/00/02/files/folder_1000/file_11/v3/content.doc"},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
if got := csObjectKey(tc.tenant, tc.fileID, tc.version, tc.ext); got != tc.want {
|
|
||||||
t.Errorf("csObjectKey(%d,%d,%d,%q) = %q, want %q", tc.tenant, tc.fileID, tc.version, tc.ext, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPGDriverDetection(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
dsn, explicit, want string
|
|
||||||
}{
|
|
||||||
{"postgres://u:p@h:5432/onlyoffice", "", ProviderPG},
|
|
||||||
{"postgresql://u:p@h/db", "", ProviderPG},
|
|
||||||
{"host=h user=u password=p dbname=onlyoffice sslmode=disable", "", ProviderPG},
|
|
||||||
{"root:secret@tcp(127.0.0.1:3306)/onlyoffice?parseTime=true", "", ProviderMySQL},
|
|
||||||
{"mysql://root:secret@127.0.0.1:3306/onlyoffice", "", ProviderMySQL},
|
|
||||||
{"root:secret@tcp(h:3306)/db", "postgres", ProviderPG},
|
|
||||||
{"postgres://u:p@h/db", "mysql", ProviderMySQL},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
if got := pgDriver(tc.dsn, tc.explicit); got != tc.want {
|
|
||||||
t.Errorf("pgDriver(%q, %q) = %q, want %q", tc.dsn, tc.explicit, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeSQLDSNMySQL(t *testing.T) {
|
|
||||||
got, err := normalizeSQLDSN(ProviderMySQL, "mysql://root:secret@127.0.0.1:3306/onlyoffice")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("normalizeSQLDSN: %v", err)
|
|
||||||
}
|
|
||||||
want := "root:secret@tcp(127.0.0.1:3306)/onlyoffice?parseTime=true"
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("normalize = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A driver DSN keeps parseTime and gains it when missing.
|
|
||||||
got, err = normalizeSQLDSN(ProviderMySQL, "root:secret@tcp(127.0.0.1:3306)/onlyoffice")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("normalizeSQLDSN: %v", err)
|
|
||||||
}
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("normalize = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileRowToEntry(t *testing.T) {
|
|
||||||
created := time.Date(2026, 9, 12, 18, 0, 37, 0, time.UTC)
|
|
||||||
modified := time.Date(2026, 9, 13, 13, 50, 36, 0, time.UTC)
|
|
||||||
e := fileRowToEntry(pgFileRow{
|
|
||||||
id: 22484, folderID: 649, title: "Rechnung.pdf",
|
|
||||||
size: 123433, version: 2, created: created, modified: modified,
|
|
||||||
}, ProviderMySQL)
|
|
||||||
if e.ID != "22484" || e.ParentID != "649" {
|
|
||||||
t.Errorf("ids = %q/%q", e.ID, e.ParentID)
|
|
||||||
}
|
|
||||||
if e.Title != "Rechnung.pdf" || e.Kind != File {
|
|
||||||
t.Errorf("title/kind = %q/%v", e.Title, e.Kind)
|
|
||||||
}
|
|
||||||
if e.Size != 123433 || e.Version != 2 {
|
|
||||||
t.Errorf("size/version = %d/%d", e.Size, e.Version)
|
|
||||||
}
|
|
||||||
if e.MIME != "application/pdf" {
|
|
||||||
t.Errorf("mime = %q", e.MIME)
|
|
||||||
}
|
|
||||||
if !e.Created.Equal(created) || !e.Modified.Equal(modified) {
|
|
||||||
t.Errorf("times = %v/%v", e.Created, e.Modified)
|
|
||||||
}
|
|
||||||
if e.Provider != ProviderMySQL {
|
|
||||||
t.Errorf("provider = %q", e.Provider)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFolderRowToEntry(t *testing.T) {
|
|
||||||
modified := time.Date(2026, 8, 1, 10, 30, 0, 0, time.UTC)
|
|
||||||
e := folderRowToEntry(pgFolderRow{id: 649, parentID: 647, title: "2025", modified: modified}, ProviderMySQL)
|
|
||||||
if e.ID != "649" || e.ParentID != "647" || e.Title != "2025" {
|
|
||||||
t.Errorf("folder = %+v", e)
|
|
||||||
}
|
|
||||||
if e.Kind != Folder {
|
|
||||||
t.Errorf("kind = %v, want folder", e.Kind)
|
|
||||||
}
|
|
||||||
if e.Size != 0 || e.MIME != "" {
|
|
||||||
t.Errorf("folder size/mime = %d/%q", e.Size, e.MIME)
|
|
||||||
}
|
|
||||||
if !e.Modified.Equal(modified) {
|
|
||||||
t.Errorf("modified = %v", e.Modified)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPGStoreWriteMethodsReadOnly(t *testing.T) {
|
|
||||||
s := &pgStore{driver: ProviderPG}
|
|
||||||
ctx := context.Background()
|
|
||||||
if _, err := s.CreateFolder(ctx, "1", "x"); !errors.Is(err, ErrReadOnly) {
|
|
||||||
t.Errorf("CreateFolder err = %v", err)
|
|
||||||
}
|
|
||||||
if _, err := s.Upload(ctx, "1", "x", nil); !errors.Is(err, ErrReadOnly) {
|
|
||||||
t.Errorf("Upload err = %v", err)
|
|
||||||
}
|
|
||||||
if err := s.Move(ctx, nil, "1"); !errors.Is(err, ErrReadOnly) {
|
|
||||||
t.Errorf("Move err = %v", err)
|
|
||||||
}
|
|
||||||
if err := s.Copy(ctx, nil, "1"); !errors.Is(err, ErrReadOnly) {
|
|
||||||
t.Errorf("Copy err = %v", err)
|
|
||||||
}
|
|
||||||
if err := s.Rename(ctx, "1", "x"); !errors.Is(err, ErrReadOnly) {
|
|
||||||
t.Errorf("Rename err = %v", err)
|
|
||||||
}
|
|
||||||
if err := s.Delete(ctx, nil); !errors.Is(err, ErrReadOnly) {
|
|
||||||
t.Errorf("Delete err = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPGStoreName(t *testing.T) {
|
|
||||||
if got := (&pgStore{driver: ProviderPG}).Name(); got != ProviderPG {
|
|
||||||
t.Errorf("Name = %q, want %q", got, ProviderPG)
|
|
||||||
}
|
|
||||||
if got := (&pgStore{driver: ProviderMySQL}).Name(); got != ProviderMySQL {
|
|
||||||
t.Errorf("Name = %q, want %q", got, ProviderMySQL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPGStoreStatRejectsNonNumeric(t *testing.T) {
|
|
||||||
s := &pgStore{driver: ProviderPG}
|
|
||||||
if _, err := s.Stat(context.Background(), "not-a-number"); err == nil {
|
|
||||||
t.Error("Stat accepted a non-numeric id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+6
-8
@@ -3,10 +3,9 @@ package onlyoffice
|
|||||||
// Text extraction pipeline for the own full-text index (epic #34, F6 #42).
|
// Text extraction pipeline for the own full-text index (epic #34, F6 #42).
|
||||||
//
|
//
|
||||||
// TextIndexer downloads stored documents, extracts text through docpipe
|
// TextIndexer downloads stored documents, extracts text through docpipe
|
||||||
// (pdftotext; OCR for scans) and writes the result to a TextIndex. For PDFs it
|
// (pdftotext; OCR for scans) and writes the result to a TextIndex. It is the
|
||||||
// also indexes the text of embedded attachments (pdfdetach), so a scan filed
|
// write side of ESTextIndex and never touches the OnlyOffice server's own ES
|
||||||
// as an attachment is searchable too. It is the write side of ESTextIndex and
|
// index.
|
||||||
// never touches the OnlyOffice server's own ES index.
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -39,14 +38,13 @@ type TextExtractor interface {
|
|||||||
type docpipeExtractor struct{ tools docpipe.Tools }
|
type docpipeExtractor struct{ tools docpipe.Tools }
|
||||||
|
|
||||||
// Extract renders the file as Markdown, OCRing PDFs/images with a weak text
|
// Extract renders the file as Markdown, OCRing PDFs/images with a weak text
|
||||||
// layer first and appending the text of embedded PDF attachments
|
// layer first (docpipe.ToMarkdown).
|
||||||
// (docpipe.ToMarkdownWithAttachments).
|
|
||||||
func (d docpipeExtractor) Extract(path, workDir, lang string, minChars int) (string, error) {
|
func (d docpipeExtractor) Extract(path, workDir, lang string, minChars int) (string, error) {
|
||||||
text, err := d.tools.ToMarkdownWithAttachments(path, workDir, lang, minChars)
|
res, err := d.tools.ToMarkdown(path, workDir, lang, minChars)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(text), nil
|
return strings.TrimSpace(res.Markdown), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IndexOptions controls a TextIndexer run.
|
// IndexOptions controls a TextIndexer run.
|
||||||
|
|||||||
@@ -247,8 +247,6 @@ 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")
|
||||||
@@ -262,8 +260,6 @@ 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")
|
||||||
@@ -279,8 +275,6 @@ 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")
|
||||||
@@ -294,8 +288,6 @@ 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")
|
||||||
@@ -321,8 +313,6 @@ 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")
|
||||||
@@ -353,8 +343,6 @@ 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")
|
||||||
@@ -412,8 +400,6 @@ 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 {
|
||||||
|
|||||||
@@ -52,8 +52,6 @@ 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 {
|
||||||
@@ -113,8 +111,6 @@ 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})
|
||||||
@@ -134,8 +130,6 @@ 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})
|
||||||
@@ -143,8 +137,6 @@ 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})
|
||||||
@@ -155,8 +147,6 @@ 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),
|
||||||
@@ -173,8 +163,6 @@ 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),
|
||||||
@@ -238,8 +226,6 @@ 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 {
|
||||||
@@ -256,8 +242,6 @@ 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 {
|
||||||
@@ -277,8 +261,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ require (
|
|||||||
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3
|
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3
|
||||||
github.com/eslider/go-hocr v0.2.2-0.20260827163626-8ff01582b002
|
github.com/eslider/go-hocr v0.2.2-0.20260827163626-8ff01582b002
|
||||||
github.com/eslider/go-xls/v2 v2.1.0
|
github.com/eslider/go-xls/v2 v2.1.0
|
||||||
github.com/go-sql-driver/mysql v1.10.1
|
|
||||||
github.com/google/go-querystring v1.2.0
|
github.com/google/go-querystring v1.2.0
|
||||||
github.com/jackc/pgx/v5 v5.11.0
|
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/mattn/go-runewidth v0.0.15
|
github.com/mattn/go-runewidth v0.0.15
|
||||||
github.com/muesli/termenv v0.16.0
|
github.com/muesli/termenv v0.16.0
|
||||||
@@ -26,7 +24,6 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.2.0 // indirect
|
|
||||||
github.com/JohannesKaufmann/dom v0.3.1 // indirect
|
github.com/JohannesKaufmann/dom v0.3.1 // indirect
|
||||||
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
@@ -39,10 +36,6 @@ require (
|
|||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
|
||||||
github.com/kr/text v0.2.0 // indirect
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
@@ -55,7 +48,6 @@ require (
|
|||||||
github.com/richardlehane/mscfb v1.0.7 // indirect
|
github.com/richardlehane/mscfb v1.0.7 // indirect
|
||||||
github.com/richardlehane/msoleps v1.0.6 // indirect
|
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||||
github.com/xuri/efp v0.0.1 // indirect
|
github.com/xuri/efp v0.0.1 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
|
||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
|
||||||
github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0=
|
github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0=
|
||||||
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
|
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
|
||||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
|
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
|
||||||
@@ -37,8 +35,6 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20240715153702-9ba8adf781c4/go.mod
|
|||||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
|
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
|
||||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
@@ -51,8 +47,6 @@ github.com/eslider/go-hocr v0.2.2-0.20260827163626-8ff01582b002 h1:LOFxQG4mxvlH7
|
|||||||
github.com/eslider/go-hocr v0.2.2-0.20260827163626-8ff01582b002/go.mod h1:fIgfH/E1j3rU8du4X4+7mxTD0GPtPQibTzytgitdJWU=
|
github.com/eslider/go-hocr v0.2.2-0.20260827163626-8ff01582b002/go.mod h1:fIgfH/E1j3rU8du4X4+7mxTD0GPtPQibTzytgitdJWU=
|
||||||
github.com/eslider/go-xls/v2 v2.1.0 h1:HszWKqYQbXxACmAXXWdMsfNl1NDBfGVBnJUPtyUHQ7A=
|
github.com/eslider/go-xls/v2 v2.1.0 h1:HszWKqYQbXxACmAXXWdMsfNl1NDBfGVBnJUPtyUHQ7A=
|
||||||
github.com/eslider/go-xls/v2 v2.1.0/go.mod h1:xgxO6JrfuBr9jGUB+0z5l/yDmFFZ5diGk0ATGihxlMU=
|
github.com/eslider/go-xls/v2 v2.1.0/go.mod h1:xgxO6JrfuBr9jGUB+0z5l/yDmFFZ5diGk0ATGihxlMU=
|
||||||
github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
|
|
||||||
github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
||||||
@@ -69,20 +63,8 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
|
|||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
|
||||||
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
|
||||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
@@ -116,8 +98,6 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
|
|||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
|
||||||
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc=
|
github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc=
|
||||||
github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
|
github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
|
||||||
@@ -127,9 +107,6 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
|||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||||
@@ -165,10 +142,8 @@ golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
|||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
// - pandoc — md↔docx
|
// - pandoc — md↔docx
|
||||||
// - ocrmypdf — OCR into a searchable PDF
|
// - ocrmypdf — OCR into a searchable PDF
|
||||||
// - pdftotext — extract text layer
|
// - pdftotext — extract text layer
|
||||||
// - pdfdetach — list/save embedded PDF attachments
|
|
||||||
// - tesseract — OCR single images when ocrmypdf is unsuitable
|
// - tesseract — OCR single images when ocrmypdf is unsuitable
|
||||||
// - ghostscript (gs) — PDF rewrite/optimize via PostScript (pdfwrite)
|
// - ghostscript (gs) — PDF rewrite/optimize via PostScript (pdfwrite)
|
||||||
package docpipe
|
package docpipe
|
||||||
@@ -27,7 +26,6 @@ type Tools struct {
|
|||||||
Pandoc string
|
Pandoc string
|
||||||
OCRMyPDF string
|
OCRMyPDF string
|
||||||
PDFToText string
|
PDFToText string
|
||||||
PDFDetach string
|
|
||||||
Tesseract string
|
Tesseract string
|
||||||
Ghostscript string
|
Ghostscript string
|
||||||
}
|
}
|
||||||
@@ -46,7 +44,6 @@ func LookPath() Tools {
|
|||||||
Pandoc: find("pandoc"),
|
Pandoc: find("pandoc"),
|
||||||
OCRMyPDF: find("ocrmypdf"),
|
OCRMyPDF: find("ocrmypdf"),
|
||||||
PDFToText: find("pdftotext"),
|
PDFToText: find("pdftotext"),
|
||||||
PDFDetach: find("pdfdetach"),
|
|
||||||
Tesseract: find("tesseract"),
|
Tesseract: find("tesseract"),
|
||||||
Ghostscript: find("gs", "ghostscript"),
|
Ghostscript: find("gs", "ghostscript"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,231 +0,0 @@
|
|||||||
package docpipe
|
|
||||||
|
|
||||||
// Embedded PDF attachments (F6 #42). Digitised invoices often carry the
|
|
||||||
// original scan as a PDF attachment; the searchable body may hold only a
|
|
||||||
// summary. pdfdetach (poppler) lists/saves them; each saved attachment is run
|
|
||||||
// through the normal docpipe extraction (pdftotext/OCR).
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/xml"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PDFAttachment is one embedded file in a PDF.
|
|
||||||
type PDFAttachment struct {
|
|
||||||
Index int // 1-based number as `pdfdetach -list` reports it
|
|
||||||
Name string // embedded file name
|
|
||||||
}
|
|
||||||
|
|
||||||
// AttachmentText is the extracted text of one embedded attachment.
|
|
||||||
type AttachmentText struct {
|
|
||||||
Name string
|
|
||||||
Text string
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseAttachmentList parses `pdfdetach -list` output. The first line is a
|
|
||||||
// count ("N embedded files"); every following line is "<index>: <name>".
|
|
||||||
// Pure, so it is unit-tested.
|
|
||||||
func parseAttachmentList(out string) []PDFAttachment {
|
|
||||||
var atts []PDFAttachment
|
|
||||||
for _, line := range strings.Split(out, "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
colon := strings.Index(line, ":")
|
|
||||||
if colon <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
n, err := strconv.Atoi(strings.TrimSpace(line[:colon]))
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := strings.TrimSpace(line[colon+1:])
|
|
||||||
if name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
atts = append(atts, PDFAttachment{Index: n, Name: name})
|
|
||||||
}
|
|
||||||
return atts
|
|
||||||
}
|
|
||||||
|
|
||||||
// safeAttachmentName strips directories and leading dots so a hostile
|
|
||||||
// attachment name cannot escape the extraction directory.
|
|
||||||
func safeAttachmentName(name string) string {
|
|
||||||
name = strings.ReplaceAll(strings.TrimSpace(name), "\\", "/")
|
|
||||||
name = filepath.Base(name)
|
|
||||||
name = strings.TrimLeft(name, ".")
|
|
||||||
if name == "" || name == "." || name == "/" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
// JoinWithAttachments appends attachment text to the document body, each
|
|
||||||
// section preceded by an "[attachment: <name>]" marker so a search hit shows
|
|
||||||
// its source. Empty attachments are skipped. Pure, so it is unit-tested.
|
|
||||||
func JoinWithAttachments(body string, atts []AttachmentText) string {
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString(strings.TrimRight(body, "\n"))
|
|
||||||
for _, a := range atts {
|
|
||||||
text := strings.TrimSpace(a.Text)
|
|
||||||
if text == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
b.WriteString("\n\n[attachment: ")
|
|
||||||
b.WriteString(a.Name)
|
|
||||||
b.WriteString("]\n\n")
|
|
||||||
b.WriteString(text)
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListAttachments returns the embedded files of a PDF. A PDF without
|
|
||||||
// attachments yields an empty slice and no error.
|
|
||||||
func (t Tools) ListAttachments(pdfPath string) ([]PDFAttachment, error) {
|
|
||||||
if t.PDFDetach == "" {
|
|
||||||
return nil, fmt.Errorf("pdfdetach not found on PATH")
|
|
||||||
}
|
|
||||||
cmd := exec.Command(t.PDFDetach, "-list", pdfPath)
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
out, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("pdfdetach -list %s: %w (%s)", filepath.Base(pdfPath), err, strings.TrimSpace(stderr.String()))
|
|
||||||
}
|
|
||||||
return parseAttachmentList(string(out)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveAttachment writes the n-th embedded file (1-based) to outPath.
|
|
||||||
func (t Tools) SaveAttachment(pdfPath string, index int, outPath string) error {
|
|
||||||
if t.PDFDetach == "" {
|
|
||||||
return fmt.Errorf("pdfdetach not found on PATH")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(outPath) == "" {
|
|
||||||
return fmt.Errorf("output path required")
|
|
||||||
}
|
|
||||||
if err := EnsureDir(outPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
cmd := exec.Command(t.PDFDetach, "-save", strconv.Itoa(index), "-o", outPath, pdfPath)
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("pdfdetach -save %d: %w (%s)", index, err, strings.TrimSpace(stderr.String()))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToMarkdownWithAttachments extracts the file as ToMarkdown does, then — for
|
|
||||||
// PDFs — appends the text of every embedded attachment under an
|
|
||||||
// "[attachment: <name>]" marker. Attachment failures are non-fatal: the body
|
|
||||||
// is returned unchanged.
|
|
||||||
func (t Tools) ToMarkdownWithAttachments(path, workDir, lang string, minChars int) (string, error) {
|
|
||||||
res, err := t.ToMarkdown(path, workDir, lang, minChars)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if Ext(path) != ".pdf" {
|
|
||||||
return res.Markdown, nil
|
|
||||||
}
|
|
||||||
atts, err := t.attachmentTexts(path, workDir, lang, minChars)
|
|
||||||
if err != nil {
|
|
||||||
return res.Markdown, nil
|
|
||||||
}
|
|
||||||
return JoinWithAttachments(res.Markdown, atts), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// attachmentTexts saves and extracts every embedded attachment, skipping the
|
|
||||||
// ones that cannot be read. It returns an error only when the attachment list
|
|
||||||
// itself cannot be obtained.
|
|
||||||
func (t Tools) attachmentTexts(pdfPath, workDir, lang string, minChars int) ([]AttachmentText, error) {
|
|
||||||
list, err := t.ListAttachments(pdfPath)
|
|
||||||
if err != nil || len(list) == 0 {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if workDir == "" {
|
|
||||||
workDir = os.TempDir()
|
|
||||||
}
|
|
||||||
dir := filepath.Join(workDir, "att-"+trimExt(filepath.Base(pdfPath)))
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(dir)
|
|
||||||
|
|
||||||
out := make([]AttachmentText, 0, len(list))
|
|
||||||
for _, a := range list {
|
|
||||||
name := safeAttachmentName(a.Name)
|
|
||||||
if name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
saved := filepath.Join(dir, fmt.Sprintf("%d-%s", a.Index, name))
|
|
||||||
if err := t.SaveAttachment(pdfPath, a.Index, saved); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
text, err := t.attachmentMarkdown(saved, dir, lang, minChars)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, AttachmentText{Name: a.Name, Text: text})
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// attachmentMarkdown extracts a saved attachment with the regular pipeline.
|
|
||||||
// Structured attachments that docpipe does not convert (e-invoice XML,
|
|
||||||
// CuraSoft JSON, CSV/HTML) fall back to their text content, so the embedded
|
|
||||||
// original is still searchable. Other unreadable formats return an error and
|
|
||||||
// the caller skips them.
|
|
||||||
func (t Tools) attachmentMarkdown(path, workDir, lang string, minChars int) (string, error) {
|
|
||||||
if res, err := t.ToMarkdown(path, workDir, lang, minChars); err == nil {
|
|
||||||
return res.Markdown, nil
|
|
||||||
}
|
|
||||||
switch Ext(path) {
|
|
||||||
case ".xml", ".html", ".htm":
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return xmlToText(raw), nil
|
|
||||||
case ".json", ".csv":
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(raw), nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("unsupported attachment type %q", Ext(path))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// xmlToText returns the character data of an XML/HTML document: element text
|
|
||||||
// values with decoded entities, one per line. Used for invoice XML (EN 16931
|
|
||||||
// CII / ZUGFeRD) and HTML attachments. Pure, so it is unit-tested.
|
|
||||||
func xmlToText(raw []byte) string {
|
|
||||||
dec := xml.NewDecoder(bytes.NewReader(raw))
|
|
||||||
dec.Strict = false
|
|
||||||
var b strings.Builder
|
|
||||||
for {
|
|
||||||
tok, err := dec.Token()
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
cd, ok := tok.(xml.CharData)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
s := strings.TrimSpace(string(cd))
|
|
||||||
if s == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
b.WriteString(s)
|
|
||||||
b.WriteByte('\n')
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
package docpipe
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseAttachmentList(t *testing.T) {
|
|
||||||
out := "2 embedded files\n1: original.pdf\n2: scan_001.png\n"
|
|
||||||
got := parseAttachmentList(out)
|
|
||||||
want := []PDFAttachment{{Index: 1, Name: "original.pdf"}, {Index: 2, Name: "scan_001.png"}}
|
|
||||||
if len(got) != len(want) {
|
|
||||||
t.Fatalf("got %+v, want %+v", got, want)
|
|
||||||
}
|
|
||||||
for i := range want {
|
|
||||||
if got[i] != want[i] {
|
|
||||||
t.Errorf("att[%d] = %+v, want %+v", i, got[i], want[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseAttachmentListEmptyAndMalformed(t *testing.T) {
|
|
||||||
for _, in := range []string{"", "0 embedded files\n", "garbage\n\n \n"} {
|
|
||||||
if got := parseAttachmentList(in); len(got) != 0 {
|
|
||||||
t.Errorf("parseAttachmentList(%q) = %+v, want empty", in, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSafeAttachmentName(t *testing.T) {
|
|
||||||
cases := map[string]string{
|
|
||||||
"note.txt": "note.txt",
|
|
||||||
"../../evil.pdf": "evil.pdf",
|
|
||||||
`..\..\evil.pdf`: "evil.pdf",
|
|
||||||
"/abs/scan_001.pdf": "scan_001.pdf",
|
|
||||||
".hidden": "hidden",
|
|
||||||
" spaced name.txt ": "spaced name.txt",
|
|
||||||
"..": "",
|
|
||||||
"": "",
|
|
||||||
}
|
|
||||||
for in, want := range cases {
|
|
||||||
if got := safeAttachmentName(in); got != want {
|
|
||||||
t.Errorf("safeAttachmentName(%q) = %q, want %q", in, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJoinWithAttachments(t *testing.T) {
|
|
||||||
body := "# scan.pdf\n\nbody token\n"
|
|
||||||
atts := []AttachmentText{
|
|
||||||
{Name: "original.pdf", Text: " original token "},
|
|
||||||
{Name: "empty.txt", Text: " "},
|
|
||||||
}
|
|
||||||
got := JoinWithAttachments(body, atts)
|
|
||||||
if !strings.Contains(got, "body token") {
|
|
||||||
t.Errorf("body text lost: %q", got)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, "[attachment: original.pdf]") {
|
|
||||||
t.Errorf("marker missing: %q", got)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, "original token") {
|
|
||||||
t.Errorf("attachment text missing: %q", got)
|
|
||||||
}
|
|
||||||
if strings.Contains(got, "empty.txt") {
|
|
||||||
t.Errorf("empty attachment must be skipped: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJoinWithAttachmentsNoAttachments(t *testing.T) {
|
|
||||||
got := JoinWithAttachments("# a.pdf\n\ntext\n\n", nil)
|
|
||||||
if got != "# a.pdf\n\ntext" {
|
|
||||||
t.Errorf("got %q, want trimmed body only", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListAttachmentsWithoutTool(t *testing.T) {
|
|
||||||
if _, err := (Tools{}).ListAttachments("x.pdf"); err == nil || !strings.Contains(err.Error(), "pdfdetach") {
|
|
||||||
t.Fatalf("want pdfdetach error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestToMarkdownWithAttachmentsFixture exercises the real pdfdetach + pdftotext
|
|
||||||
// pipeline on testdata/pdf-with-attachment.pdf (body token + embedded
|
|
||||||
// goo-note.txt). Skips when poppler is not installed.
|
|
||||||
func TestToMarkdownWithAttachmentsFixture(t *testing.T) {
|
|
||||||
tools := LookPath()
|
|
||||||
if tools.PDFDetach == "" || tools.PDFToText == "" {
|
|
||||||
t.Skip("pdfdetach/pdftotext not on PATH — skipping attachment extraction test")
|
|
||||||
}
|
|
||||||
fixture := filepath.Join("..", "..", "testdata", "pdf-with-attachment.pdf")
|
|
||||||
got, err := tools.ToMarkdownWithAttachments(fixture, t.TempDir(), "eng", 1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ToMarkdownWithAttachments: %v", err)
|
|
||||||
}
|
|
||||||
for _, want := range []string{"goobodytoken", "[attachment: goo-note.txt]", "gooattachmenttoken"} {
|
|
||||||
if !strings.Contains(got, want) {
|
|
||||||
t.Errorf("result missing %q:\n%s", want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestXMLToText(t *testing.T) {
|
|
||||||
raw := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<rsm:CrossIndustryInvoice><rsm:ExchangedDocument>
|
|
||||||
<ram:ID>S1063</ram:ID></rsm:ExchangedDocument>
|
|
||||||
<ram:Name>Edelweiss & Co</ram:Name><ram:GrandTotalAmount>42.00</ram:GrandTotalAmount>
|
|
||||||
</rsm:CrossIndustryInvoice>`)
|
|
||||||
got := xmlToText(raw)
|
|
||||||
for _, want := range []string{"S1063", "Edelweiss & Co", "42.00"} {
|
|
||||||
if !strings.Contains(got, want) {
|
|
||||||
t.Errorf("xmlToText missing %q:\n%s", want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.ContainsAny(got, "<>") {
|
|
||||||
t.Errorf("xmlToText left markup: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAttachmentMarkdownFallback verifies structured attachments that docpipe
|
|
||||||
// cannot convert are still reduced to searchable text, and unknown binary
|
|
||||||
// formats error (so the caller skips them).
|
|
||||||
func TestAttachmentMarkdownFallback(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
xmlPath := filepath.Join(dir, "factur-x.xml")
|
|
||||||
if err := os.WriteFile(xmlPath, []byte(`<Invoice><Number>S1063</Number></Invoice>`), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got, err := (Tools{}).attachmentMarkdown(xmlPath, dir, "", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("attachmentMarkdown(xml): %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, "S1063") {
|
|
||||||
t.Errorf("xml attachment text = %q, want S1063", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
binPath := filepath.Join(dir, "data.bin")
|
|
||||||
if err := os.WriteFile(binPath, []byte{0, 1, 2, 3}, 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := (Tools{}).attachmentMarkdown(binPath, dir, "", 0); err == nil {
|
|
||||||
t.Error("unsupported attachment: want error, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestToMarkdownWithAttachmentsPlainPDF ensures a PDF without attachments
|
|
||||||
// returns just the body (pdfdetach prints "0 embedded files").
|
|
||||||
func TestToMarkdownWithAttachmentsPlainPDF(t *testing.T) {
|
|
||||||
tools := LookPath()
|
|
||||||
if tools.PDFDetach == "" || tools.PDFToText == "" {
|
|
||||||
t.Skip("pdfdetach/pdftotext not on PATH")
|
|
||||||
}
|
|
||||||
// The fixture itself is a PDF with one attachment; strip it by extracting
|
|
||||||
// the body only through ToMarkdown and compare JoinWithAttachments(nil).
|
|
||||||
res, err := tools.ToMarkdown(filepath.Join("..", "..", "testdata", "pdf-with-attachment.pdf"), t.TempDir(), "eng", 1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ToMarkdown: %v", err)
|
|
||||||
}
|
|
||||||
if strings.Contains(res.Markdown, "gooattachmenttoken") {
|
|
||||||
t.Fatalf("body must not contain attachment text: %q", res.Markdown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-8
@@ -157,13 +157,6 @@ func (c *Client) downloadFileEntry(ctx context.Context, f *FileEntry, dst io.Wri
|
|||||||
|
|
||||||
// downloadFromMinio streams objectKey from the configured MinIO bucket.
|
// downloadFromMinio streams objectKey from the configured MinIO bucket.
|
||||||
func (c *Client) downloadFromMinio(ctx context.Context, objectKey string, dst io.Writer) (int64, error) {
|
func (c *Client) downloadFromMinio(ctx context.Context, objectKey string, dst io.Writer) (int64, error) {
|
||||||
return downloadMinioObject(ctx, c.client, objectKey, dst)
|
|
||||||
}
|
|
||||||
|
|
||||||
// downloadMinioObject streams objectKey from the configured MinIO bucket using
|
|
||||||
// hc. It is the shared implementation behind (*Client).downloadFromMinio and
|
|
||||||
// the read-only SQL store (file_pg.go), which has no *Client.
|
|
||||||
func downloadMinioObject(ctx context.Context, hc *http.Client, objectKey string, dst io.Writer) (int64, error) {
|
|
||||||
if objectKey == "" {
|
if objectKey == "" {
|
||||||
return 0, fmt.Errorf("onlyoffice: minio fallback: empty object key")
|
return 0, fmt.Errorf("onlyoffice: minio fallback: empty object key")
|
||||||
}
|
}
|
||||||
@@ -184,7 +177,7 @@ func downloadMinioObject(ctx context.Context, hc *http.Client, objectKey string,
|
|||||||
if err := signMinioRequest(ctx, cfg, req); err != nil {
|
if err := signMinioRequest(ctx, cfg, req); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
resp, err := hc.Do(req)
|
resp, err := c.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("onlyoffice: minio fallback: %w", err)
|
return 0, fmt.Errorf("onlyoffice: minio fallback: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
-114
@@ -1,114 +0,0 @@
|
|||||||
%PDF-1.7
|
|
||||||
%Çì�¢
|
|
||||||
%%Invocation: gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite ? ? ?
|
|
||||||
5 0 obj
|
|
||||||
<</Length 6 0 R/Filter /FlateDecode>>
|
|
||||||
stream
|
|
||||||
xœ-ŠA
|
|
||||||
ƒ0÷ÿoÙnìO7q]èZ*ÿ�IˆmJ½½M
|
|
||||||
³f7
|
|
||||||
\¨6‘žfRÿŠ*ñºõª…8:g}‡f†Dºø”ÞiØsúØ ÝôÝ;炱(Ùn.ly]ìUFz
|
|
||||||
½~Aë øendstream
|
|
||||||
endobj
|
|
||||||
6 0 obj
|
|
||||||
110
|
|
||||||
endobj
|
|
||||||
4 0 obj
|
|
||||||
<</Type/Page/MediaBox [0 0 612 792]
|
|
||||||
/Rotate 0/Parent 3 0 R
|
|
||||||
/Resources<</ProcSet[/PDF /Text]
|
|
||||||
/Font 8 0 R
|
|
||||||
>>
|
|
||||||
/Contents 5 0 R
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
3 0 obj
|
|
||||||
<< /Type /Pages /Kids [
|
|
||||||
4 0 R
|
|
||||||
] /Count 1
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
1 0 obj
|
|
||||||
<</Type /Catalog /Pages 3 0 R
|
|
||||||
/Metadata 9 0 R
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
8 0 obj
|
|
||||||
<</R7
|
|
||||||
7 0 R>>
|
|
||||||
endobj
|
|
||||||
7 0 obj
|
|
||||||
<</BaseFont/Helvetica/Type/Font
|
|
||||||
/Subtype/Type1>>
|
|
||||||
endobj
|
|
||||||
9 0 obj
|
|
||||||
<</Type/Metadata
|
|
||||||
/Subtype/XML/Length 1173>>stream
|
|
||||||
<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
|
|
||||||
<?adobe-xap-filters esc="CRLF"?>
|
|
||||||
<x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='XMP toolkit 2.9.1-13, framework 1.6'>
|
|
||||||
<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:iX='http://ns.adobe.com/iX/1.0/'>
|
|
||||||
<rdf:Description rdf:about="" xmlns:pdf='http://ns.adobe.com/pdf/1.3/' pdf:Producer='GPL Ghostscript 10.02.1'/>
|
|
||||||
<rdf:Description rdf:about="" xmlns:xmp='http://ns.adobe.com/xap/1.0/'><xmp:ModifyDate>2026-09-16T17:29:12Z</xmp:ModifyDate>
|
|
||||||
<xmp:CreateDate>2026-09-16T17:29:12Z</xmp:CreateDate>
|
|
||||||
<xmp:CreatorTool>UnknownApplication</xmp:CreatorTool></rdf:Description>
|
|
||||||
<rdf:Description rdf:about="" xmlns:xapMM='http://ns.adobe.com/xap/1.0/mm/' xapMM:DocumentID='uuid:ae8d7575-ea10-11fc-0000-3a98742e0b71'/>
|
|
||||||
<rdf:Description rdf:about="" xmlns:dc='http://purl.org/dc/elements/1.1/' dc:format='application/pdf'><dc:title><rdf:Alt><rdf:li xml:lang='x-default'>Untitled</rdf:li></rdf:Alt></dc:title></rdf:Description>
|
|
||||||
</rdf:RDF>
|
|
||||||
</x:xmpmeta>
|
|
||||||
|
|
||||||
|
|
||||||
<?xpacket end='w'?>
|
|
||||||
endstream
|
|
||||||
endobj
|
|
||||||
2 0 obj
|
|
||||||
<</Producer(GPL Ghostscript 10.02.1)
|
|
||||||
/CreationDate(D:20260916172912Z00'00')
|
|
||||||
/ModDate(D:20260916172912Z00'00')>>endobj
|
|
||||||
xref
|
|
||||||
0 10
|
|
||||||
0000000000 65535 f
|
|
||||||
0000000476 00000 n
|
|
||||||
0000001882 00000 n
|
|
||||||
0000000417 00000 n
|
|
||||||
0000000276 00000 n
|
|
||||||
0000000077 00000 n
|
|
||||||
0000000257 00000 n
|
|
||||||
0000000569 00000 n
|
|
||||||
0000000540 00000 n
|
|
||||||
0000000633 00000 n
|
|
||||||
trailer
|
|
||||||
<< /Size 10 /Root 1 0 R /Info 2 0 R
|
|
||||||
/ID [<5A562476FBF11852133AA873452909F9><5A562476FBF11852133AA873452909F9>]
|
|
||||||
>>
|
|
||||||
startxref
|
|
||||||
2008
|
|
||||||
%%EOF
|
|
||||||
1 0 obj
|
|
||||||
<</Type /Catalog /Pages 3 0 R /Metadata 9 0 R /Names <</EmbeddedFiles 12 0 R >> >>
|
|
||||||
endobj
|
|
||||||
10 0 obj
|
|
||||||
<</Length 44 /Params <</Size 44 >> >> stream
|
|
||||||
gooattachmenttoken embedded attachment text
|
|
||||||
|
|
||||||
endstream
|
|
||||||
|
|
||||||
endobj
|
|
||||||
11 0 obj
|
|
||||||
<</Type /Filespec /UF <feff0067006f006f002d006e006f00740065002e007400780074> /EF <</F 10 0 R >> >>
|
|
||||||
endobj
|
|
||||||
12 0 obj
|
|
||||||
<</Names [<feff0067006f006f002d006e006f00740065002e007400780074> 11 0 R ] >>
|
|
||||||
endobj
|
|
||||||
xref
|
|
||||||
0 2
|
|
||||||
0000000002 65535 f
|
|
||||||
0000002361 00000 n
|
|
||||||
10 3
|
|
||||||
0000002463 00000 n
|
|
||||||
0000002586 00000 n
|
|
||||||
0000002705 00000 n
|
|
||||||
trailer
|
|
||||||
<</Size 13 /ID [(ZV$vûñR:¨sE\) ù) (P…õrˆ‚ Åð¡Ï) ] /Root 1 0 R /Prev 2008 /Info 2 0 R >>
|
|
||||||
startxref
|
|
||||||
2802
|
|
||||||
Reference in New Issue
Block a user