Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
@@ -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"))
})
}
}
+87
View File
@@ -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
}
+304
View File
@@ -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))
})
})
})