Initial QSfera import
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/tags"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
)
|
||||
|
||||
// Basic is the simplest Extractor implementation.
|
||||
type Basic struct {
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
// NewBasicExtractor creates a new Basic instance.
|
||||
func NewBasicExtractor(logger log.Logger) (*Basic, error) {
|
||||
return &Basic{logger: logger}, nil
|
||||
}
|
||||
|
||||
// Extract literally just rearranges the inputs and processes them into a Document.
|
||||
func (b Basic) Extract(_ context.Context, ri *storageProvider.ResourceInfo) (Document, error) {
|
||||
doc := Document{
|
||||
Name: ri.Name,
|
||||
Size: ri.Size,
|
||||
MimeType: ri.MimeType,
|
||||
}
|
||||
|
||||
if m := ri.ArbitraryMetadata.GetMetadata(); m != nil {
|
||||
if t, ok := m["tags"]; ok {
|
||||
doc.Tags = tags.New(t).AsSlice()
|
||||
}
|
||||
}
|
||||
|
||||
if m := ri.Opaque.GetMap(); m != nil && m["favorites"] != nil {
|
||||
favEntry := m["favorites"]
|
||||
|
||||
switch favEntry.Decoder {
|
||||
case "json":
|
||||
favorites := []string{}
|
||||
err := json.Unmarshal(favEntry.Value, &favorites)
|
||||
if err != nil {
|
||||
b.logger.Error().Err(err).Msg("failed to unmarshal favorites")
|
||||
break
|
||||
}
|
||||
|
||||
doc.Favorites = favorites
|
||||
default:
|
||||
b.logger.Error().Msgf("unsupported decoder for favorites: %s", favEntry.Decoder)
|
||||
}
|
||||
}
|
||||
|
||||
if ri.Mtime != nil {
|
||||
doc.Mtime = utils.TSToTime(ri.Mtime).UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package content_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
cs3Types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/search/pkg/content"
|
||||
)
|
||||
|
||||
var _ = Describe("Basic", func() {
|
||||
var (
|
||||
basic content.Extractor
|
||||
logger = log.NewLogger()
|
||||
ctx = context.TODO()
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
basic, _ = content.NewBasicExtractor(logger)
|
||||
})
|
||||
|
||||
Describe("extract", func() {
|
||||
It("basic fields", func() {
|
||||
ri := &storageProvider.ResourceInfo{
|
||||
Name: "bar.pdf",
|
||||
Path: "./foo/bar.pdf",
|
||||
Size: 1024,
|
||||
MimeType: "application/pdf",
|
||||
}
|
||||
|
||||
doc, err := basic.Extract(ctx, ri)
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(doc).ToNot(BeNil())
|
||||
Expect(doc.Name).To(Equal(ri.Name))
|
||||
Expect(doc.Size).To(Equal(ri.Size))
|
||||
Expect(doc.MimeType).To(Equal(ri.MimeType))
|
||||
})
|
||||
|
||||
It("adds tags", func() {
|
||||
for _, data := range []struct {
|
||||
tags string
|
||||
expect []string
|
||||
}{
|
||||
{tags: "", expect: []string{}},
|
||||
{tags: ",,,", expect: []string{}},
|
||||
{tags: ",foo,,", expect: []string{"foo"}},
|
||||
{tags: ",foo,,bar,", expect: []string{"foo", "bar"}},
|
||||
} {
|
||||
ri := &storageProvider.ResourceInfo{
|
||||
ArbitraryMetadata: &storageProvider.ArbitraryMetadata{
|
||||
Metadata: map[string]string{
|
||||
"tags": data.tags,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
doc, err := basic.Extract(ctx, ri)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(doc).ToNot(BeNil())
|
||||
Expect(doc.Tags).To(Equal(data.expect))
|
||||
}
|
||||
})
|
||||
|
||||
It("RFC3339 mtime", func() {
|
||||
for _, data := range []struct {
|
||||
second uint64
|
||||
expect string
|
||||
}{
|
||||
{second: 4000, expect: "1970-01-01T01:06:40Z"},
|
||||
{second: 3000, expect: "1970-01-01T00:50:00Z"},
|
||||
{expect: ""},
|
||||
} {
|
||||
ri := &storageProvider.ResourceInfo{}
|
||||
|
||||
if data.second != 0 {
|
||||
ri.Mtime = &cs3Types.Timestamp{Seconds: data.second}
|
||||
}
|
||||
|
||||
doc, err := basic.Extract(ctx, ri)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(doc).ToNot(BeNil())
|
||||
Expect(doc.Mtime).To(Equal(data.expect))
|
||||
}
|
||||
})
|
||||
|
||||
It("extracts favorites", func() {
|
||||
favorites := []string{"foo", "bar"}
|
||||
favBytes, _ := json.Marshal(favorites)
|
||||
|
||||
ri := &storageProvider.ResourceInfo{
|
||||
Opaque: &cs3Types.Opaque{
|
||||
Map: map[string]*cs3Types.OpaqueEntry{
|
||||
"favorites": {
|
||||
Decoder: "json",
|
||||
Value: favBytes,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
doc, err := basic.Extract(ctx, ri)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(doc).ToNot(BeNil())
|
||||
Expect(doc.Favorites).To(Equal(favorites))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/bbalet/stopwords"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
)
|
||||
|
||||
func init() {
|
||||
stopwords.OverwriteWordSegmenter(`[^ ]+`)
|
||||
}
|
||||
|
||||
// Document wraps all resource meta fields,
|
||||
// it is used as a content extraction result.
|
||||
type Document struct {
|
||||
Title string
|
||||
Name string
|
||||
Content string
|
||||
Size uint64
|
||||
Mtime string
|
||||
MimeType string
|
||||
Tags []string
|
||||
Favorites []string
|
||||
Audio *libregraph.Audio `json:"audio,omitempty"`
|
||||
Image *libregraph.Image `json:"image,omitempty"`
|
||||
Location *libregraph.GeoCoordinates `json:"location,omitempty"`
|
||||
Photo *libregraph.Photo `json:"photo,omitempty"`
|
||||
}
|
||||
|
||||
func CleanString(content, langCode string) string {
|
||||
return strings.TrimSpace(stopwords.CleanString(content, langCode, true))
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package content_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestContent(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Content Suite")
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package content_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/content"
|
||||
)
|
||||
|
||||
func TestCleanContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
given string
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
given: "find can keeper should keeper will",
|
||||
expect: "keeper keeper",
|
||||
},
|
||||
{
|
||||
given: "user1 shares the file to Mary",
|
||||
expect: "user1 shares file mary",
|
||||
},
|
||||
{
|
||||
given: "content contains https://localhost/remote.php/dav/files/admin/Photos/San%20Francisco.jpg and stop word",
|
||||
expect: "content contains https://localhost/remote.php/dav/files/admin/photos/san%20francisco.jpg stop word",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.given, func(t *testing.T) {
|
||||
Equal(t, tc.expect, content.CleanString(tc.given, "en"))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
)
|
||||
|
||||
type cs3 struct {
|
||||
httpClient http.Client
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
func newCS3Retriever(gatewaySelector pool.Selectable[gateway.GatewayAPIClient], logger log.Logger, insecure bool) cs3 {
|
||||
return cs3{
|
||||
httpClient: http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure}, //nolint:gosec
|
||||
},
|
||||
},
|
||||
gatewaySelector: gatewaySelector,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve downloads the file from a cs3 service
|
||||
// The caller MUST make sure to close the returned ReadCloser
|
||||
func (s cs3) Retrieve(ctx context.Context, rID *provider.ResourceId) (io.ReadCloser, error) {
|
||||
at, ok := contextGet(ctx, revactx.TokenHeader)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("context without %s", revactx.TokenHeader)
|
||||
}
|
||||
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("could not get reva gatewayClient")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := gatewayClient.InitiateFileDownload(ctx, &provider.InitiateFileDownloadRequest{Ref: &provider.Reference{ResourceId: rID, Path: "."}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Status.Code != rpc.Code_CODE_OK {
|
||||
return nil, fmt.Errorf("could not load resoure: %s", res.Status.Message)
|
||||
}
|
||||
|
||||
var ep, tt string
|
||||
for _, p := range res.Protocols {
|
||||
if p.Protocol == "spaces" {
|
||||
ep, tt = p.DownloadEndpoint, p.Token
|
||||
break
|
||||
}
|
||||
}
|
||||
if (ep == "" || tt == "") && len(res.Protocols) > 0 {
|
||||
ep, tt = res.Protocols[0].DownloadEndpoint, res.Protocols[0].Token
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, ep, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set(revactx.TokenHeader, at)
|
||||
req.Header.Set("X-Reva-Transfer", tt)
|
||||
|
||||
cres, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cres.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("could not download resource. Request returned with statuscode %d ", cres.StatusCode)
|
||||
}
|
||||
|
||||
return cres.Body, nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
)
|
||||
|
||||
// Extractor is responsible to extract content and meta information from documents.
|
||||
type Extractor interface {
|
||||
Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, error)
|
||||
}
|
||||
|
||||
func getFirstValue(m map[string][]string, key string) (string, error) {
|
||||
if m == nil {
|
||||
return "", errors.New("undefined map")
|
||||
}
|
||||
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unknown key: %v", key)
|
||||
}
|
||||
|
||||
if len(m) == 0 {
|
||||
return "", fmt.Errorf("no values for: %v", key)
|
||||
}
|
||||
|
||||
return v[0], nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Code generated by mockery; DO NOT EDIT.
|
||||
// github.com/vektra/mockery
|
||||
// template: testify
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/qsfera/server/services/search/pkg/content"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// NewExtractor creates a new instance of Extractor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewExtractor(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *Extractor {
|
||||
mock := &Extractor{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
// Extractor is an autogenerated mock type for the Extractor type
|
||||
type Extractor struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type Extractor_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *Extractor) EXPECT() *Extractor_Expecter {
|
||||
return &Extractor_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Extract provides a mock function for the type Extractor
|
||||
func (_mock *Extractor) Extract(ctx context.Context, ri *providerv1beta1.ResourceInfo) (content.Document, error) {
|
||||
ret := _mock.Called(ctx, ri)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Extract")
|
||||
}
|
||||
|
||||
var r0 content.Document
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceInfo) (content.Document, error)); ok {
|
||||
return returnFunc(ctx, ri)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceInfo) content.Document); ok {
|
||||
r0 = returnFunc(ctx, ri)
|
||||
} else {
|
||||
r0 = ret.Get(0).(content.Document)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, *providerv1beta1.ResourceInfo) error); ok {
|
||||
r1 = returnFunc(ctx, ri)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Extractor_Extract_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Extract'
|
||||
type Extractor_Extract_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Extract is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - ri *providerv1beta1.ResourceInfo
|
||||
func (_e *Extractor_Expecter) Extract(ctx interface{}, ri interface{}) *Extractor_Extract_Call {
|
||||
return &Extractor_Extract_Call{Call: _e.mock.On("Extract", ctx, ri)}
|
||||
}
|
||||
|
||||
func (_c *Extractor_Extract_Call) Run(run func(ctx context.Context, ri *providerv1beta1.ResourceInfo)) *Extractor_Extract_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 *providerv1beta1.ResourceInfo
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(*providerv1beta1.ResourceInfo)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Extractor_Extract_Call) Return(document content.Document, err error) *Extractor_Extract_Call {
|
||||
_c.Call.Return(document, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Extractor_Extract_Call) RunAndReturn(run func(ctx context.Context, ri *providerv1beta1.ResourceInfo) (content.Document, error)) *Extractor_Extract_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Code generated by mockery; DO NOT EDIT.
|
||||
// github.com/vektra/mockery
|
||||
// template: testify
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// NewRetriever creates a new instance of Retriever. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewRetriever(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *Retriever {
|
||||
mock := &Retriever{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
// Retriever is an autogenerated mock type for the Retriever type
|
||||
type Retriever struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type Retriever_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *Retriever) EXPECT() *Retriever_Expecter {
|
||||
return &Retriever_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Retrieve provides a mock function for the type Retriever
|
||||
func (_mock *Retriever) Retrieve(ctx context.Context, rID *providerv1beta1.ResourceId) (io.ReadCloser, error) {
|
||||
ret := _mock.Called(ctx, rID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Retrieve")
|
||||
}
|
||||
|
||||
var r0 io.ReadCloser
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceId) (io.ReadCloser, error)); ok {
|
||||
return returnFunc(ctx, rID)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceId) io.ReadCloser); ok {
|
||||
r0 = returnFunc(ctx, rID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(io.ReadCloser)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, *providerv1beta1.ResourceId) error); ok {
|
||||
r1 = returnFunc(ctx, rID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Retriever_Retrieve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Retrieve'
|
||||
type Retriever_Retrieve_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Retrieve is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - rID *providerv1beta1.ResourceId
|
||||
func (_e *Retriever_Expecter) Retrieve(ctx interface{}, rID interface{}) *Retriever_Retrieve_Call {
|
||||
return &Retriever_Retrieve_Call{Call: _e.mock.On("Retrieve", ctx, rID)}
|
||||
}
|
||||
|
||||
func (_c *Retriever_Retrieve_Call) Run(run func(ctx context.Context, rID *providerv1beta1.ResourceId)) *Retriever_Retrieve_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 *providerv1beta1.ResourceId
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(*providerv1beta1.ResourceId)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Retriever_Retrieve_Call) Return(readCloser io.ReadCloser, err error) *Retriever_Retrieve_Call {
|
||||
_c.Call.Return(readCloser, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Retriever_Retrieve_Call) RunAndReturn(run func(ctx context.Context, rID *providerv1beta1.ResourceId) (io.ReadCloser, error)) *Retriever_Retrieve_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
// Retriever is the interface that wraps the basic Retrieve method. 🐕
|
||||
// It requests and then returns a resource from the underlying storage.
|
||||
type Retriever interface {
|
||||
Retrieve(ctx context.Context, rID *provider.ResourceId) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
func contextGet(ctx context.Context, k string) (string, bool) {
|
||||
md, ok := metadata.FromOutgoingContext(ctx)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
token, ok := md[k]
|
||||
if len(token) == 0 || !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return token[0], ok
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/google/go-tika/tika"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/search/pkg/config"
|
||||
)
|
||||
|
||||
// Tika is used to extract content from a resource,
|
||||
// it uses apache tika to retrieve all the data.
|
||||
type Tika struct {
|
||||
*Basic
|
||||
Retriever
|
||||
tika *tika.Client
|
||||
ContentExtractionSizeLimit uint64
|
||||
CleanStopWords bool
|
||||
}
|
||||
|
||||
// NewTikaExtractor creates a new Tika instance.
|
||||
func NewTikaExtractor(gatewaySelector pool.Selectable[gateway.GatewayAPIClient], logger log.Logger, cfg *config.Config) (*Tika, error) {
|
||||
basic, err := NewBasicExtractor(logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tk := tika.NewClient(nil, cfg.Extractor.Tika.TikaURL)
|
||||
tkv, err := tk.Version(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.Info().Msgf("Tika version: %s", tkv)
|
||||
|
||||
return &Tika{
|
||||
Basic: basic,
|
||||
Retriever: newCS3Retriever(gatewaySelector, logger, cfg.Extractor.CS3AllowInsecure),
|
||||
tika: tika.NewClient(nil, cfg.Extractor.Tika.TikaURL),
|
||||
ContentExtractionSizeLimit: cfg.ContentExtractionSizeLimit,
|
||||
CleanStopWords: cfg.Extractor.Tika.CleanStopWords,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Extract loads a resource from its underlying storage, passes it to tika and processes the result into a Document.
|
||||
func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, error) {
|
||||
doc, err := t.Basic.Extract(ctx, ri)
|
||||
if err != nil {
|
||||
return doc, err
|
||||
}
|
||||
|
||||
if ri.Size == 0 {
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
if ri.Size > t.ContentExtractionSizeLimit {
|
||||
t.logger.Info().Interface("ResourceID", ri.Id).Str("Name", ri.Name).Msg("file exceeds content extraction size limit. skipping.")
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
if ri.Type != provider.ResourceType_RESOURCE_TYPE_FILE {
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
data, err := t.Retrieve(ctx, ri.Id)
|
||||
if err != nil {
|
||||
return doc, err
|
||||
}
|
||||
defer data.Close()
|
||||
|
||||
metas, err := t.tika.MetaRecursive(ctx, data)
|
||||
if err != nil {
|
||||
return doc, err
|
||||
}
|
||||
|
||||
for _, meta := range metas {
|
||||
if title, err := getFirstValue(meta, "title"); err == nil {
|
||||
doc.Title = strings.TrimSpace(fmt.Sprintf("%s %s", doc.Title, title))
|
||||
}
|
||||
|
||||
if content, err := getFirstValue(meta, "X-TIKA:content"); err == nil {
|
||||
doc.Content = strings.TrimSpace(fmt.Sprintf("%s %s", doc.Content, content))
|
||||
}
|
||||
|
||||
doc.Location = t.getLocation(meta)
|
||||
doc.Image = t.getImage(meta)
|
||||
doc.Photo = t.getPhoto(meta)
|
||||
|
||||
if contentType, err := getFirstValue(meta, "Content-Type"); err == nil && strings.HasPrefix(contentType, "audio/") {
|
||||
doc.Audio = t.getAudio(meta)
|
||||
}
|
||||
}
|
||||
|
||||
if langCode, _ := t.tika.LanguageString(ctx, doc.Content); langCode != "" && t.CleanStopWords {
|
||||
doc.Content = CleanString(doc.Content, langCode)
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func (t Tika) getImage(meta map[string][]string) *libregraph.Image {
|
||||
var image *libregraph.Image
|
||||
initImage := func() {
|
||||
if image == nil {
|
||||
image = libregraph.NewImage()
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "tiff:ImageWidth"); err == nil {
|
||||
if i, err := strconv.ParseInt(v, 0, 32); err == nil {
|
||||
initImage()
|
||||
image.SetWidth(int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "tiff:ImageLength"); err == nil {
|
||||
if i, err := strconv.ParseInt(v, 0, 32); err == nil {
|
||||
initImage()
|
||||
image.SetHeight(int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
func (t Tika) getLocation(meta map[string][]string) *libregraph.GeoCoordinates {
|
||||
var location *libregraph.GeoCoordinates
|
||||
initLocation := func() {
|
||||
if location == nil {
|
||||
location = libregraph.NewGeoCoordinates()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: location.Altitute: transform the following data to … feet above sea level.
|
||||
// "GPS:GPS Altitude": []string{"227.4 metres"},
|
||||
// "GPS:GPS Altitude Ref": []string{"Sea level"},
|
||||
|
||||
if v, err := getFirstValue(meta, "geo:lat"); err == nil {
|
||||
if i, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
initLocation()
|
||||
location.SetLatitude(i)
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "geo:long"); err == nil {
|
||||
if i, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
initLocation()
|
||||
location.SetLongitude(i)
|
||||
}
|
||||
}
|
||||
|
||||
return location
|
||||
}
|
||||
|
||||
func (t Tika) getPhoto(meta map[string][]string) *libregraph.Photo {
|
||||
var photo *libregraph.Photo
|
||||
initPhoto := func() {
|
||||
if photo == nil {
|
||||
photo = libregraph.NewPhoto()
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "tiff:Make"); err == nil {
|
||||
initPhoto()
|
||||
photo.SetCameraMake(v)
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "tiff:Model"); err == nil {
|
||||
initPhoto()
|
||||
photo.SetCameraModel(v)
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "exif:FNumber"); err == nil {
|
||||
if i, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
initPhoto()
|
||||
photo.SetFNumber(i)
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "exif:FocalLength"); err == nil {
|
||||
if i, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
initPhoto()
|
||||
photo.SetFocalLength(i)
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "Base ISO"); err == nil {
|
||||
if i, err := strconv.ParseInt(v, 0, 32); err == nil {
|
||||
initPhoto()
|
||||
photo.SetIso(int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "tiff:Orientation"); err == nil {
|
||||
if i, err := strconv.ParseInt(v, 0, 32); err == nil {
|
||||
initPhoto()
|
||||
photo.SetOrientation(int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "exif:DateTimeOriginal"); err == nil {
|
||||
layout := "2006-01-02T15:04:05"
|
||||
if t, err := time.Parse(layout, v); err == nil {
|
||||
initPhoto()
|
||||
photo.SetTakenDateTime(t)
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "exif:ExposureTime"); err == nil {
|
||||
if i, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
initPhoto()
|
||||
photo.SetExposureNumerator(1)
|
||||
photo.SetExposureDenominator(math.Round(1 / i))
|
||||
}
|
||||
}
|
||||
|
||||
return photo
|
||||
}
|
||||
|
||||
func (t Tika) getAudio(meta map[string][]string) *libregraph.Audio {
|
||||
var audio *libregraph.Audio
|
||||
initAudio := func() {
|
||||
if audio == nil {
|
||||
audio = libregraph.NewAudio()
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "xmpDM:album"); err == nil {
|
||||
initAudio()
|
||||
audio.SetAlbum(v)
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "xmpDM:albumArtist"); err == nil {
|
||||
initAudio()
|
||||
audio.SetAlbumArtist(v)
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "xmpDM:artist"); err == nil {
|
||||
initAudio()
|
||||
audio.SetArtist(v)
|
||||
}
|
||||
|
||||
// TODO: audio.Bitrate: not provided by tika
|
||||
// TODO: audio.Composers: not provided by tika
|
||||
// TODO: audio.Copyright: not provided by tika for audio files?
|
||||
|
||||
if v, err := getFirstValue(meta, "xmpDM:discNumber"); err == nil {
|
||||
if i, err := strconv.ParseInt(v, 10, 32); err == nil {
|
||||
initAudio()
|
||||
audio.SetDisc(int32(i))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TODO: audio.DiscCount: not provided by tika
|
||||
|
||||
if v, err := getFirstValue(meta, "xmpDM:duration"); err == nil {
|
||||
// Tika emits fractional seconds.
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
initAudio()
|
||||
audio.SetDuration(int64(math.Round(f * 1000)))
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "xmpDM:genre"); err == nil {
|
||||
initAudio()
|
||||
audio.SetGenre(v)
|
||||
}
|
||||
|
||||
// TODO: audio.HasDrm: not provided by tika
|
||||
// TODO: audio.IsVariableBitrate: not provided by tika
|
||||
|
||||
if v, err := getFirstValue(meta, "dc:title"); err == nil {
|
||||
initAudio()
|
||||
audio.SetTitle(v)
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "xmpDM:trackNumber"); err == nil {
|
||||
if i, err := strconv.ParseInt(v, 10, 32); err == nil {
|
||||
initAudio()
|
||||
audio.SetTrack(int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: audio.TrackCount: not provided by tika
|
||||
|
||||
if v, err := getFirstValue(meta, "xmpDM:releaseDate"); err == nil {
|
||||
if i, err := strconv.ParseInt(v, 10, 32); err == nil {
|
||||
initAudio()
|
||||
audio.SetYear(int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
return audio
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package content_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
conf "github.com/qsfera/server/services/search/pkg/config/defaults"
|
||||
"github.com/qsfera/server/services/search/pkg/content"
|
||||
contentMocks "github.com/qsfera/server/services/search/pkg/content/mocks"
|
||||
)
|
||||
|
||||
var _ = Describe("Tika", func() {
|
||||
Describe("extract", func() {
|
||||
var (
|
||||
body string
|
||||
fullResponse string
|
||||
language string
|
||||
version string
|
||||
srv *httptest.Server
|
||||
tika *content.Tika
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
body = ""
|
||||
language = ""
|
||||
version = ""
|
||||
fullResponse = ""
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
out := ""
|
||||
switch req.URL.Path {
|
||||
case "/version":
|
||||
out = version
|
||||
case "/language/string":
|
||||
out = language
|
||||
case "/rmeta/text":
|
||||
if fullResponse != "" {
|
||||
out = fullResponse
|
||||
} else {
|
||||
out = fmt.Sprintf(`[{"X-TIKA:content":"%s"}]`, body)
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte(out))
|
||||
}))
|
||||
|
||||
cfg := conf.DefaultConfig()
|
||||
cfg.Extractor.Tika.TikaURL = srv.URL
|
||||
cfg.Extractor.Tika.CleanStopWords = true
|
||||
|
||||
var err error
|
||||
tika, err = content.NewTikaExtractor(nil, log.NewLogger(), cfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(tika).ToNot(BeNil())
|
||||
|
||||
retriever := &contentMocks.Retriever{}
|
||||
retriever.On("Retrieve", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader(body)), nil)
|
||||
|
||||
tika.Retriever = retriever
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
srv.Close()
|
||||
})
|
||||
|
||||
It("skips non file resources", func() {
|
||||
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(doc.Content).To(Equal(""))
|
||||
})
|
||||
|
||||
It("adds content", func() {
|
||||
body = "any body"
|
||||
|
||||
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Size: 1,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(doc.Content).To(Equal(body))
|
||||
})
|
||||
|
||||
It("adds audio content", func() {
|
||||
fullResponse = `[
|
||||
{
|
||||
"xmpDM:genre": "Some Genre",
|
||||
"xmpDM:album": "Some Album",
|
||||
"xmpDM:trackNumber": "7",
|
||||
"xmpDM:discNumber": "4",
|
||||
"xmpDM:releaseDate": "2004",
|
||||
"xmpDM:artist": "Some Artist",
|
||||
"xmpDM:albumArtist": "Some AlbumArtist",
|
||||
"xmpDM:audioCompressor": "MP3",
|
||||
"xmpDM:audioChannelType": "Stereo",
|
||||
"version": "MPEG 3 Layer III Version 1",
|
||||
"xmpDM:logComment": "some comment",
|
||||
"xmpDM:audioSampleRate": "44100",
|
||||
"channels": "2",
|
||||
"dc:title": "Some Title",
|
||||
"xmpDM:duration": "225.5",
|
||||
"Content-Type": "audio/mpeg",
|
||||
"samplerate": "44100"
|
||||
}
|
||||
]`
|
||||
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Size: 1,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
audio := doc.Audio
|
||||
Expect(audio).ToNot(BeNil())
|
||||
|
||||
Expect(audio.Album).To(Equal(libregraph.PtrString("Some Album")))
|
||||
Expect(audio.AlbumArtist).To(Equal(libregraph.PtrString("Some AlbumArtist")))
|
||||
Expect(audio.Artist).To(Equal(libregraph.PtrString("Some Artist")))
|
||||
// Expect(audio.Bitrate).To(Equal(libregraph.PtrInt64(192)))
|
||||
// Expect(audio.Composers).To(Equal(libregraph.PtrString("Some Composers")))
|
||||
// Expect(audio.Copyright).To(Equal(libregraph.PtrString("Some Copyright")))
|
||||
Expect(audio.Disc).To(Equal(libregraph.PtrInt32(4)))
|
||||
// Expect(audio.DiscCount).To(Equal(libregraph.PtrInt32(5)))
|
||||
Expect(audio.Duration).To(Equal(libregraph.PtrInt64(225500)))
|
||||
Expect(audio.Genre).To(Equal(libregraph.PtrString("Some Genre")))
|
||||
// Expect(audio.HasDrm).To(Equal(libregraph.PtrBool(false)))
|
||||
// Expect(audio.IsVariableBitrate).To(Equal(libregraph.PtrBool(true)))
|
||||
Expect(audio.Title).To(Equal(libregraph.PtrString("Some Title")))
|
||||
Expect(audio.Track).To(Equal(libregraph.PtrInt32(7)))
|
||||
// Expect(audio.TrackCount).To(Equal(libregraph.PtrInt32(9)))
|
||||
Expect(audio.Year).To(Equal(libregraph.PtrInt32(2004)))
|
||||
|
||||
})
|
||||
|
||||
It("adds location content", func() {
|
||||
fullResponse = `[
|
||||
{
|
||||
"geo:lat": "49.48675890884328",
|
||||
"geo:long": "11.103870357204285"
|
||||
}
|
||||
]`
|
||||
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Size: 1,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
location := doc.Location
|
||||
Expect(location).ToNot(BeNil())
|
||||
|
||||
// TODO: Altitude is not supported right now
|
||||
Expect(location.Altitude).To(BeNil())
|
||||
Expect(location.Latitude).To(Equal(libregraph.PtrFloat64(49.48675890884328)))
|
||||
Expect(location.Longitude).To(Equal(libregraph.PtrFloat64(11.103870357204285)))
|
||||
})
|
||||
|
||||
It("adds image content", func() {
|
||||
fullResponse = `[
|
||||
{
|
||||
"tiff:ImageWidth": "100",
|
||||
"tiff:ImageLength": "100"
|
||||
}
|
||||
]`
|
||||
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Size: 1,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
image := doc.Image
|
||||
Expect(image).ToNot(BeNil())
|
||||
|
||||
Expect(image.Width).To(Equal(libregraph.PtrInt32(100)))
|
||||
Expect(image.Height).To(Equal(libregraph.PtrInt32(100)))
|
||||
})
|
||||
|
||||
It("adds photo content", func() {
|
||||
fullResponse = `[
|
||||
{
|
||||
"tiff:Make": "Canon",
|
||||
"tiff:Model": "Canon EOS 5D",
|
||||
"exif:ExposureTime": "0.001",
|
||||
"exif:FNumber": "1.8",
|
||||
"exif:FocalLength": "50",
|
||||
"Base ISO": "100",
|
||||
"tiff:Orientation": "1",
|
||||
"exif:DateTimeOriginal": "2018-01-01T12:34:56"
|
||||
}
|
||||
]`
|
||||
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Size: 1,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
photo := doc.Photo
|
||||
Expect(photo).ToNot(BeNil())
|
||||
|
||||
Expect(photo.CameraMake).To(Equal(libregraph.PtrString("Canon")))
|
||||
Expect(photo.CameraModel).To(Equal(libregraph.PtrString("Canon EOS 5D")))
|
||||
Expect(photo.ExposureNumerator).To(Equal(libregraph.PtrFloat64(1)))
|
||||
Expect(photo.ExposureDenominator).To(Equal(libregraph.PtrFloat64(1000)))
|
||||
Expect(photo.FNumber).To(Equal(libregraph.PtrFloat64(1.8)))
|
||||
Expect(photo.FocalLength).To(Equal(libregraph.PtrFloat64(50)))
|
||||
Expect(photo.Iso).To(Equal(libregraph.PtrInt32(100)))
|
||||
Expect(photo.Orientation).To(Equal(libregraph.PtrInt32(1)))
|
||||
Expect(photo.TakenDateTime).To(Equal(libregraph.PtrTime(time.Date(2018, 1, 1, 12, 34, 56, 0, time.UTC))))
|
||||
})
|
||||
|
||||
It("removes stop words", func() {
|
||||
body = "body to test stop words!!! against almost everyone"
|
||||
language = "en"
|
||||
|
||||
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Size: 1,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(doc.Content).To(Equal("body test stop words!!!"))
|
||||
})
|
||||
|
||||
It("keeps stop words", func() {
|
||||
body = "body to test stop words!!! against almost everyone"
|
||||
language = "en"
|
||||
|
||||
tika.CleanStopWords = false
|
||||
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Size: 1,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(doc.Content).To(Equal(body))
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user