chore(PROJ-60): Xapian-Legacy-Backend vollständig aus dem Code entfernt
Xapian war seit der Manticore-Migration (PROJ-30) nur noch ein build-tag-gatetes, in Produktion nie genutztes Legacy-Backend. internal/index/xapian.go, xapian_stub.go, xapian_wrapper.cpp/.h entfernt; index.New() unterstützt jetzt nur noch "manticore". Default-Fallbacks in Nebenwerkzeugen (archivmail-import/-export, cmd_export/cmd_reindex/...) von "xapian" auf "manticore" korrigiert, Xapian-spezifische Tests entfernt, Kommentare bereinigt. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
043520ecc3
commit
5f63bfe8d4
@@ -44,7 +44,7 @@ func main() {
|
|||||||
|
|
||||||
indexBackend := cfg.Index.Backend
|
indexBackend := cfg.Index.Backend
|
||||||
if indexBackend == "" {
|
if indexBackend == "" {
|
||||||
indexBackend = "xapian"
|
indexBackend = "manticore"
|
||||||
}
|
}
|
||||||
batchSize := cfg.Index.BatchSize
|
batchSize := cfg.Index.BatchSize
|
||||||
if batchSize <= 0 {
|
if batchSize <= 0 {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func main() {
|
|||||||
|
|
||||||
indexBackend := cfg.Index.Backend
|
indexBackend := cfg.Index.Backend
|
||||||
if indexBackend == "" {
|
if indexBackend == "" {
|
||||||
indexBackend = "xapian"
|
indexBackend = "manticore"
|
||||||
}
|
}
|
||||||
batchSize := cfg.Index.BatchSize
|
batchSize := cfg.Index.BatchSize
|
||||||
if batchSize <= 0 {
|
if batchSize <= 0 {
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ func runExport(args []string) {
|
|||||||
}
|
}
|
||||||
backend := cfg.Index.Backend
|
backend := cfg.Index.Backend
|
||||||
if backend == "" {
|
if backend == "" {
|
||||||
backend = "xapian"
|
backend = "manticore"
|
||||||
}
|
}
|
||||||
// The fulltext index is only needed when exporting via search filters.
|
// The fulltext index is only needed when exporting via search filters.
|
||||||
var idx index.Indexer
|
var idx index.Indexer
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func runImport(args []string) {
|
|||||||
}
|
}
|
||||||
backend := cfg.Index.Backend
|
backend := cfg.Index.Backend
|
||||||
if backend == "" {
|
if backend == "" {
|
||||||
backend = "xapian"
|
backend = "manticore"
|
||||||
}
|
}
|
||||||
|
|
||||||
var idxMgr index.TenantIndexer
|
var idxMgr index.TenantIndexer
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func runImportPiler(args []string) {
|
|||||||
}
|
}
|
||||||
backend := cfg.Index.Backend
|
backend := cfg.Index.Backend
|
||||||
if backend == "" {
|
if backend == "" {
|
||||||
backend = "xapian"
|
backend = "manticore"
|
||||||
}
|
}
|
||||||
var idxMgr index.TenantIndexer
|
var idxMgr index.TenantIndexer
|
||||||
if backend == "manticore" {
|
if backend == "manticore" {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func runReindex(args []string) {
|
|||||||
|
|
||||||
indexBackend := cfg.Index.Backend
|
indexBackend := cfg.Index.Backend
|
||||||
if indexBackend == "" {
|
if indexBackend == "" {
|
||||||
indexBackend = "xapian"
|
indexBackend = "manticore"
|
||||||
}
|
}
|
||||||
batchSize := cfg.Index.BatchSize
|
batchSize := cfg.Index.BatchSize
|
||||||
if batchSize <= 0 {
|
if batchSize <= 0 {
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ func newTestEnv(t *testing.T) *testEnv {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
idx, err := index.New(filepath.Join(dir, "index"), 100, "xapian")
|
idx, err := index.New(filepath.Join(dir, "index"), 100, "manticore")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Skip("xapian not available:", err)
|
t.Skip("manticore not available:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
|||||||
+16
-7
@@ -68,7 +68,7 @@ type Indexer interface {
|
|||||||
// updates of the OCR-extracted attachment text. Optional add-on to Indexer:
|
// updates of the OCR-extracted attachment text. Optional add-on to Indexer:
|
||||||
// callers should type-assert and degrade gracefully if not supported.
|
// callers should type-assert and degrade gracefully if not supported.
|
||||||
//
|
//
|
||||||
// PROJ-35: Manticore implements this; legacy Xapian does not.
|
// PROJ-35: implemented by the Manticore backend.
|
||||||
type AttachmentTextUpdater interface {
|
type AttachmentTextUpdater interface {
|
||||||
UpdateAttachmentText(mailID, text string) error
|
UpdateAttachmentText(mailID, text string) error
|
||||||
}
|
}
|
||||||
@@ -82,20 +82,29 @@ type AttachmentTextReader interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TenantIndexer manages per-tenant Indexer instances.
|
// TenantIndexer manages per-tenant Indexer instances.
|
||||||
// Implemented by ManticoreTenantManager (primary) and TenantIndexManager (legacy Xapian).
|
// Implemented by ManticoreTenantManager.
|
||||||
type TenantIndexer interface {
|
type TenantIndexer interface {
|
||||||
ForTenant(tenantID *int64) Indexer
|
ForTenant(tenantID *int64) Indexer
|
||||||
Global() Indexer
|
Global() Indexer
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an Indexer for the specified backend.
|
// DefaultManticoreDSN is the default Manticore connection string used when no
|
||||||
// Deprecated: use NewManticoreTenantManager instead.
|
// explicit DSN is configured.
|
||||||
|
const DefaultManticoreDSN = "manticore@tcp(127.0.0.1:9306)/?charset=utf8mb4"
|
||||||
|
|
||||||
|
// New creates a single (global) Indexer for the specified backend.
|
||||||
|
// Only "manticore" is supported. For per-tenant indexing prefer
|
||||||
|
// NewManticoreTenantManager directly.
|
||||||
func New(dir string, batchSize int, backend string) (Indexer, error) {
|
func New(dir string, batchSize int, backend string) (Indexer, error) {
|
||||||
switch backend {
|
switch backend {
|
||||||
case "xapian":
|
case "manticore":
|
||||||
return newXapian(dir)
|
m, err := NewManticoreTenantManager(DefaultManticoreDSN)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m.Global(), nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown index backend: %q (use manticore via NewManticoreTenantManager)", backend)
|
return nil, fmt.Errorf("unknown index backend: %q (only \"manticore\" is supported)", backend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,191 +2,13 @@ package index_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"archivmail/internal/index"
|
"archivmail/internal/index"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newXapianIndex creates a temporary Xapian index for testing.
|
|
||||||
func newXapianIndex(t *testing.T) index.Indexer {
|
|
||||||
t.Helper()
|
|
||||||
idx, err := index.New(t.TempDir(), 100, "xapian")
|
|
||||||
if err != nil {
|
|
||||||
t.Skip("xapian not available:", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { idx.Close() })
|
|
||||||
return idx
|
|
||||||
}
|
|
||||||
|
|
||||||
func seedDocs(t *testing.T, idx index.Indexer) {
|
|
||||||
t.Helper()
|
|
||||||
docs := []index.MailDocument{
|
|
||||||
{
|
|
||||||
ID: "aaa111",
|
|
||||||
From: "alice@example.com",
|
|
||||||
To: "bob@example.com",
|
|
||||||
Subject: "Invoice Q1-2026",
|
|
||||||
Body: "Please find attached the invoice for January.",
|
|
||||||
Date: time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC),
|
|
||||||
Size: 1024,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "bbb222",
|
|
||||||
From: "bob@example.com",
|
|
||||||
To: "alice@example.com charlie@example.com",
|
|
||||||
Subject: "Meeting Agenda",
|
|
||||||
Body: "Agenda for the quarterly review meeting.",
|
|
||||||
Date: time.Date(2026, 2, 1, 9, 0, 0, 0, time.UTC),
|
|
||||||
Size: 512,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "ccc333",
|
|
||||||
From: "charlie@example.com",
|
|
||||||
To: "alice@example.com",
|
|
||||||
Subject: "Offer with attachment",
|
|
||||||
Body: "Please review the attached offer document.",
|
|
||||||
AttachNames: "offer.pdf",
|
|
||||||
HasAttachment: true,
|
|
||||||
Date: time.Date(2026, 3, 1, 14, 0, 0, 0, time.UTC),
|
|
||||||
Size: 8192,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, d := range docs {
|
|
||||||
if err := idx.IndexSync(d); err != nil {
|
|
||||||
t.Fatalf("IndexSync %s: %v", d.ID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIndexAndSearchFulltext(t *testing.T) {
|
|
||||||
idx := newXapianIndex(t)
|
|
||||||
seedDocs(t, idx)
|
|
||||||
|
|
||||||
result, err := idx.Search(index.SearchRequest{Query: "invoice", PageSize: 10})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Search: %v", err)
|
|
||||||
}
|
|
||||||
if result.Total == 0 {
|
|
||||||
t.Error("expected at least 1 hit for 'invoice'")
|
|
||||||
}
|
|
||||||
if result.Hits[0].ID != "aaa111" {
|
|
||||||
t.Errorf("top hit = %q, want aaa111", result.Hits[0].ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSearchMatchAll(t *testing.T) {
|
|
||||||
idx := newXapianIndex(t)
|
|
||||||
seedDocs(t, idx)
|
|
||||||
|
|
||||||
result, err := idx.Search(index.SearchRequest{PageSize: 25})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Search all: %v", err)
|
|
||||||
}
|
|
||||||
if result.Total != 3 {
|
|
||||||
t.Errorf("expected 3 total hits, got %d", result.Total)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSearchFromFilter(t *testing.T) {
|
|
||||||
idx := newXapianIndex(t)
|
|
||||||
seedDocs(t, idx)
|
|
||||||
|
|
||||||
result, err := idx.Search(index.SearchRequest{
|
|
||||||
From: "alice@example.com",
|
|
||||||
PageSize: 25,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Search from: %v", err)
|
|
||||||
}
|
|
||||||
if result.Total != 1 {
|
|
||||||
t.Errorf("expected 1 hit from alice, got %d", result.Total)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSearchDateRange(t *testing.T) {
|
|
||||||
idx := newXapianIndex(t)
|
|
||||||
seedDocs(t, idx)
|
|
||||||
|
|
||||||
from := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
||||||
to := time.Date(2026, 2, 1, 23, 59, 59, 0, time.UTC)
|
|
||||||
result, err := idx.Search(index.SearchRequest{
|
|
||||||
DateFrom: &from,
|
|
||||||
DateTo: &to,
|
|
||||||
PageSize: 25,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Search date range: %v", err)
|
|
||||||
}
|
|
||||||
if result.Total != 2 {
|
|
||||||
t.Errorf("expected 2 hits in Jan-Feb 2026, got %d", result.Total)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSearchOwnEmail(t *testing.T) {
|
|
||||||
idx := newXapianIndex(t)
|
|
||||||
seedDocs(t, idx)
|
|
||||||
|
|
||||||
// charlie@example.com sent 1 mail and received 1 mail = should see 2
|
|
||||||
result, err := idx.Search(index.SearchRequest{
|
|
||||||
OwnEmail: "charlie@example.com",
|
|
||||||
PageSize: 25,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Search OwnEmail: %v", err)
|
|
||||||
}
|
|
||||||
if result.Total < 1 {
|
|
||||||
t.Errorf("charlie should see at least 1 mail, got %d", result.Total)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSearchPagination(t *testing.T) {
|
|
||||||
idx := newXapianIndex(t)
|
|
||||||
seedDocs(t, idx)
|
|
||||||
|
|
||||||
page0, _ := idx.Search(index.SearchRequest{PageSize: 2, Page: 0})
|
|
||||||
page1, _ := idx.Search(index.SearchRequest{PageSize: 2, Page: 1})
|
|
||||||
|
|
||||||
if len(page0.Hits) != 2 {
|
|
||||||
t.Errorf("page 0: expected 2 hits, got %d", len(page0.Hits))
|
|
||||||
}
|
|
||||||
if len(page1.Hits) != 1 {
|
|
||||||
t.Errorf("page 1: expected 1 hit, got %d", len(page1.Hits))
|
|
||||||
}
|
|
||||||
// No overlap
|
|
||||||
if page0.Hits[0].ID == page1.Hits[0].ID {
|
|
||||||
t.Error("pagination returned duplicate results")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDelete(t *testing.T) {
|
|
||||||
idx := newXapianIndex(t)
|
|
||||||
seedDocs(t, idx)
|
|
||||||
|
|
||||||
if err := idx.Delete("aaa111"); err != nil {
|
|
||||||
t.Fatalf("Delete: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, _ := idx.Search(index.SearchRequest{Query: "invoice", PageSize: 10})
|
|
||||||
for _, h := range result.Hits {
|
|
||||||
if h.ID == "aaa111" {
|
|
||||||
t.Error("deleted document still in results")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnknownBackend(t *testing.T) {
|
func TestUnknownBackend(t *testing.T) {
|
||||||
_, err := index.New(t.TempDir(), 10, "elasticsearch")
|
_, err := index.New(t.TempDir(), 10, "elasticsearch")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for unknown backend")
|
t.Error("expected error for unknown backend")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestXapianNotCompiledError(t *testing.T) {
|
|
||||||
_, err := index.New(t.TempDir(), 10, "xapian")
|
|
||||||
// Without -tags xapian this must return a helpful error
|
|
||||||
if err == nil {
|
|
||||||
t.Log("xapian compiled in — skipping stub error test")
|
|
||||||
} else {
|
|
||||||
t.Logf("xapian stub error (expected): %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TenantIndexManager manages a pool of Xapian indexes, one per tenant.
|
// TenantIndexManager manages a pool of single-Indexer instances, one per tenant,
|
||||||
|
// for filesystem-backed backends created via New().
|
||||||
// Tenant 0 / nil maps to the global index used by superadmin and as a fallback.
|
// Tenant 0 / nil maps to the global index used by superadmin and as a fallback.
|
||||||
type TenantIndexManager struct {
|
type TenantIndexManager struct {
|
||||||
basePath string
|
basePath string
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// IndexWorker processes MailDocument indexing requests asynchronously via a
|
// IndexWorker processes MailDocument indexing requests asynchronously via a
|
||||||
// buffered channel. It serialises writes to the underlying Indexer (important
|
// buffered channel. It serialises writes to the underlying Indexer so that
|
||||||
// for Xapian which only allows one writer at a time).
|
// backends requiring a single concurrent writer remain safe.
|
||||||
type IndexWorker struct {
|
type IndexWorker struct {
|
||||||
idx Indexer
|
idx Indexer
|
||||||
queue chan MailDocument
|
queue chan MailDocument
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
//go:build xapian
|
|
||||||
|
|
||||||
package index
|
|
||||||
|
|
||||||
/*
|
|
||||||
#cgo pkg-config: xapian-core
|
|
||||||
#cgo LDFLAGS: -lstdc++
|
|
||||||
#include "xapian_wrapper.h"
|
|
||||||
#include <stdlib.h>
|
|
||||||
*/
|
|
||||||
import "C"
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"unsafe"
|
|
||||||
)
|
|
||||||
|
|
||||||
type xapianIndex struct {
|
|
||||||
db *C.XapianDB
|
|
||||||
}
|
|
||||||
|
|
||||||
func newXapian(dir string) (Indexer, error) {
|
|
||||||
cdir := C.CString(dir)
|
|
||||||
defer C.free(unsafe.Pointer(cdir))
|
|
||||||
var cerr *C.char
|
|
||||||
db := C.xapian_open(cdir, 1, &cerr)
|
|
||||||
if db == nil {
|
|
||||||
msg := C.GoString(cerr)
|
|
||||||
C.xapian_free_string(cerr)
|
|
||||||
return nil, fmt.Errorf("xapian open: %s", msg)
|
|
||||||
}
|
|
||||||
return &xapianIndex{db: db}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *xapianIndex) IndexSync(doc MailDocument) error {
|
|
||||||
cid := C.CString(doc.ID)
|
|
||||||
defer C.free(unsafe.Pointer(cid))
|
|
||||||
cfrom := C.CString(doc.From)
|
|
||||||
defer C.free(unsafe.Pointer(cfrom))
|
|
||||||
cto := C.CString(doc.To)
|
|
||||||
defer C.free(unsafe.Pointer(cto))
|
|
||||||
csubj := C.CString(doc.Subject)
|
|
||||||
defer C.free(unsafe.Pointer(csubj))
|
|
||||||
cbody := C.CString(doc.Body)
|
|
||||||
defer C.free(unsafe.Pointer(cbody))
|
|
||||||
hasAttach := C.int(0)
|
|
||||||
if doc.HasAttachment {
|
|
||||||
hasAttach = C.int(1)
|
|
||||||
}
|
|
||||||
var cerr *C.char
|
|
||||||
rc := C.xapian_index(x.db, cid, cfrom, cto, csubj, cbody, C.longlong(doc.Date.Unix()), hasAttach, &cerr)
|
|
||||||
if rc != 0 {
|
|
||||||
msg := C.GoString(cerr)
|
|
||||||
C.xapian_free_string(cerr)
|
|
||||||
return fmt.Errorf("xapian index: %s", msg)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *xapianIndex) Delete(id string) error {
|
|
||||||
cid := C.CString(id)
|
|
||||||
defer C.free(unsafe.Pointer(cid))
|
|
||||||
var cerr *C.char
|
|
||||||
rc := C.xapian_delete(x.db, cid, &cerr)
|
|
||||||
if rc != 0 {
|
|
||||||
msg := C.GoString(cerr)
|
|
||||||
C.xapian_free_string(cerr)
|
|
||||||
return fmt.Errorf("xapian delete: %s", msg)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *xapianIndex) Search(req SearchRequest) (*SearchResult, error) {
|
|
||||||
cquery := C.CString(req.Query)
|
|
||||||
defer C.free(unsafe.Pointer(cquery))
|
|
||||||
cfrom := C.CString(req.From)
|
|
||||||
defer C.free(unsafe.Pointer(cfrom))
|
|
||||||
cown := C.CString(req.OwnEmail)
|
|
||||||
defer C.free(unsafe.Pointer(cown))
|
|
||||||
cto := C.CString(req.To)
|
|
||||||
defer C.free(unsafe.Pointer(cto))
|
|
||||||
|
|
||||||
var dateFrom, dateTo C.longlong
|
|
||||||
if req.DateFrom != nil {
|
|
||||||
dateFrom = C.longlong(req.DateFrom.Unix())
|
|
||||||
}
|
|
||||||
if req.DateTo != nil {
|
|
||||||
dateTo = C.longlong(req.DateTo.Unix())
|
|
||||||
}
|
|
||||||
page := req.Page
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
offset := C.int((page - 1) * req.PageSize)
|
|
||||||
limit := C.int(req.PageSize)
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 25
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort mode: 0=relevance, 1=date_desc (default), 2=date_asc
|
|
||||||
sortMode := C.int(1)
|
|
||||||
switch req.Sort {
|
|
||||||
case "relevance":
|
|
||||||
sortMode = C.int(0)
|
|
||||||
case "date_asc":
|
|
||||||
sortMode = C.int(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attachment filter: 0=all, 1=only with, -1=only without
|
|
||||||
attachFilter := C.int(0)
|
|
||||||
if req.HasAttachment != nil {
|
|
||||||
if *req.HasAttachment {
|
|
||||||
attachFilter = C.int(1)
|
|
||||||
} else {
|
|
||||||
attachFilter = C.int(-1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var cerr *C.char
|
|
||||||
cresult := C.xapian_search(x.db, cquery, cfrom, cown, cto, dateFrom, dateTo, offset, limit, sortMode, attachFilter, &cerr)
|
|
||||||
if cresult == nil {
|
|
||||||
msg := C.GoString(cerr)
|
|
||||||
C.xapian_free_string(cerr)
|
|
||||||
return nil, fmt.Errorf("xapian search: %s", msg)
|
|
||||||
}
|
|
||||||
defer C.xapian_free_string(cresult)
|
|
||||||
jsonStr := C.GoString(cresult)
|
|
||||||
|
|
||||||
var raw struct {
|
|
||||||
Total int `json:"total"`
|
|
||||||
Hits []struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Score float64 `json:"score"`
|
|
||||||
} `json:"hits"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
|
|
||||||
return nil, fmt.Errorf("xapian parse result: %w", err)
|
|
||||||
}
|
|
||||||
hits := make([]Hit, len(raw.Hits))
|
|
||||||
for i, h := range raw.Hits {
|
|
||||||
hits[i] = Hit{ID: h.ID, Score: h.Score}
|
|
||||||
}
|
|
||||||
return &SearchResult{Total: raw.Total, Hits: hits}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *xapianIndex) Close() error {
|
|
||||||
C.xapian_close(x.db)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
//go:build !xapian
|
|
||||||
|
|
||||||
package index
|
|
||||||
|
|
||||||
import "errors"
|
|
||||||
|
|
||||||
func newXapian(dir string) (Indexer, error) {
|
|
||||||
return nil, errors.New("xapian: not compiled in — rebuild with: go build -tags xapian")
|
|
||||||
}
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
#include "xapian_wrapper.h"
|
|
||||||
#include <xapian.h>
|
|
||||||
#include <cstring>
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <string>
|
|
||||||
#include <sstream>
|
|
||||||
#include <stdexcept>
|
|
||||||
|
|
||||||
struct XapianDB {
|
|
||||||
Xapian::WritableDatabase* wdb;
|
|
||||||
Xapian::Database* rdb;
|
|
||||||
bool writable;
|
|
||||||
};
|
|
||||||
|
|
||||||
static char* dup_error(const std::string& msg) {
|
|
||||||
char* s = (char*)malloc(msg.size() + 1);
|
|
||||||
if (s) memcpy(s, msg.c_str(), msg.size() + 1);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C" {
|
|
||||||
|
|
||||||
XapianDB* xapian_open(const char* path, int writable, char** err) {
|
|
||||||
try {
|
|
||||||
XapianDB* db = new XapianDB{nullptr, nullptr, (bool)writable};
|
|
||||||
if (writable) {
|
|
||||||
db->wdb = new Xapian::WritableDatabase(path, Xapian::DB_CREATE_OR_OPEN);
|
|
||||||
} else {
|
|
||||||
db->rdb = new Xapian::Database(path);
|
|
||||||
}
|
|
||||||
return db;
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
if (err) *err = dup_error(e.what());
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void xapian_close(XapianDB* db) {
|
|
||||||
if (!db) return;
|
|
||||||
if (db->wdb) { db->wdb->close(); delete db->wdb; }
|
|
||||||
if (db->rdb) { db->rdb->close(); delete db->rdb; }
|
|
||||||
delete db;
|
|
||||||
}
|
|
||||||
|
|
||||||
int xapian_index(XapianDB* db, const char* id, const char* from,
|
|
||||||
const char* to, const char* subject, const char* body,
|
|
||||||
long long timestamp, int has_attachment, char** err) {
|
|
||||||
try {
|
|
||||||
Xapian::Document doc;
|
|
||||||
Xapian::TermGenerator gen;
|
|
||||||
gen.set_document(doc);
|
|
||||||
gen.set_stemmer(Xapian::Stem("en"));
|
|
||||||
|
|
||||||
// Prefix-indexed fields for filtering
|
|
||||||
gen.index_text(from, 1, "XF");
|
|
||||||
gen.index_text(to, 1, "XT");
|
|
||||||
gen.index_text(subject, 1, "XS");
|
|
||||||
|
|
||||||
// Free-text indexed fields
|
|
||||||
gen.index_text(subject);
|
|
||||||
gen.increase_termpos();
|
|
||||||
gen.index_text(body);
|
|
||||||
gen.increase_termpos();
|
|
||||||
gen.index_text(from);
|
|
||||||
gen.increase_termpos();
|
|
||||||
gen.index_text(to);
|
|
||||||
|
|
||||||
// Boolean term for attachment filter
|
|
||||||
if (has_attachment) {
|
|
||||||
doc.add_boolean_term("XHA");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store timestamp for date range queries (value slot 0)
|
|
||||||
doc.add_value(0, Xapian::sortable_serialise((double)timestamp));
|
|
||||||
|
|
||||||
// Store ID as document data
|
|
||||||
doc.set_data(id);
|
|
||||||
doc.add_boolean_term(std::string("Q") + id);
|
|
||||||
|
|
||||||
db->wdb->replace_document(std::string("Q") + id, doc);
|
|
||||||
db->wdb->commit();
|
|
||||||
return 0;
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
if (err) *err = dup_error(e.what());
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int xapian_delete(XapianDB* db, const char* id, char** err) {
|
|
||||||
try {
|
|
||||||
db->wdb->delete_document(std::string("Q") + id);
|
|
||||||
db->wdb->commit();
|
|
||||||
return 0;
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
if (err) *err = dup_error(e.what());
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
char* xapian_search(XapianDB* db, const char* query_str,
|
|
||||||
const char* from_filter, const char* own_email,
|
|
||||||
const char* to_filter,
|
|
||||||
long long date_from, long long date_to,
|
|
||||||
int offset, int limit,
|
|
||||||
int sort_mode, int has_attachment,
|
|
||||||
char** err) {
|
|
||||||
try {
|
|
||||||
Xapian::Database& xdb = db->wdb ? (Xapian::Database&)*db->wdb : *db->rdb;
|
|
||||||
Xapian::Enquire enquire(xdb);
|
|
||||||
|
|
||||||
Xapian::Query main_query;
|
|
||||||
|
|
||||||
// Full-text query
|
|
||||||
if (query_str && query_str[0] != '\0') {
|
|
||||||
Xapian::QueryParser qp;
|
|
||||||
qp.set_database(xdb);
|
|
||||||
qp.set_stemmer(Xapian::Stem("en"));
|
|
||||||
qp.set_stemming_strategy(Xapian::QueryParser::STEM_SOME);
|
|
||||||
qp.add_prefix("from", "XF");
|
|
||||||
qp.add_prefix("to", "XT");
|
|
||||||
qp.add_prefix("subject", "XS");
|
|
||||||
main_query = qp.parse_query(query_str,
|
|
||||||
Xapian::QueryParser::FLAG_DEFAULT |
|
|
||||||
Xapian::QueryParser::FLAG_PARTIAL);
|
|
||||||
} else {
|
|
||||||
main_query = Xapian::Query::MatchAll;
|
|
||||||
}
|
|
||||||
|
|
||||||
// From filter
|
|
||||||
if (from_filter && from_filter[0] != '\0') {
|
|
||||||
Xapian::QueryParser qp;
|
|
||||||
qp.set_database(xdb);
|
|
||||||
Xapian::Query fq = qp.parse_query(from_filter,
|
|
||||||
Xapian::QueryParser::FLAG_DEFAULT, "XF");
|
|
||||||
main_query = Xapian::Query(Xapian::Query::OP_AND, main_query, fq);
|
|
||||||
}
|
|
||||||
|
|
||||||
// OwnEmail filter: (from=own OR to=own)
|
|
||||||
if (own_email && own_email[0] != '\0') {
|
|
||||||
Xapian::QueryParser qp;
|
|
||||||
qp.set_database(xdb);
|
|
||||||
Xapian::Query fq = qp.parse_query(own_email,
|
|
||||||
Xapian::QueryParser::FLAG_DEFAULT, "XF");
|
|
||||||
Xapian::Query tq = qp.parse_query(own_email,
|
|
||||||
Xapian::QueryParser::FLAG_DEFAULT, "XT");
|
|
||||||
Xapian::Query owq(Xapian::Query::OP_OR, fq, tq);
|
|
||||||
main_query = Xapian::Query(Xapian::Query::OP_AND, main_query, owq);
|
|
||||||
}
|
|
||||||
|
|
||||||
// To filter
|
|
||||||
if (to_filter && to_filter[0] != '\0') {
|
|
||||||
Xapian::QueryParser qp;
|
|
||||||
qp.set_database(xdb);
|
|
||||||
Xapian::Query tq = qp.parse_query(to_filter,
|
|
||||||
Xapian::QueryParser::FLAG_DEFAULT, "XT");
|
|
||||||
main_query = Xapian::Query(Xapian::Query::OP_AND, main_query, tq);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Date range
|
|
||||||
if (date_from > 0 || date_to > 0) {
|
|
||||||
double lo = date_from > 0 ? (double)date_from : 0.0;
|
|
||||||
double hi = date_to > 0 ? (double)date_to : 1e18;
|
|
||||||
Xapian::Query drq(Xapian::Query::OP_VALUE_RANGE, 0,
|
|
||||||
Xapian::sortable_serialise(lo),
|
|
||||||
Xapian::sortable_serialise(hi));
|
|
||||||
main_query = Xapian::Query(Xapian::Query::OP_AND, main_query, drq);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attachment filter
|
|
||||||
if (has_attachment == 1) {
|
|
||||||
Xapian::Query aq("XHA");
|
|
||||||
main_query = Xapian::Query(Xapian::Query::OP_AND, main_query, aq);
|
|
||||||
} else if (has_attachment == -1) {
|
|
||||||
Xapian::Query aq("XHA");
|
|
||||||
main_query = Xapian::Query(Xapian::Query::OP_AND_NOT, main_query, aq);
|
|
||||||
}
|
|
||||||
|
|
||||||
enquire.set_query(main_query);
|
|
||||||
|
|
||||||
// Sort mode: 0=relevance, 1=date_desc, 2=date_asc
|
|
||||||
if (sort_mode == 2) {
|
|
||||||
enquire.set_sort_by_value(0, false); // date ascending
|
|
||||||
} else if (sort_mode == 0 && query_str && query_str[0] != '\0') {
|
|
||||||
// relevance: default BM25 ranking (no explicit sort)
|
|
||||||
} else {
|
|
||||||
enquire.set_sort_by_value(0, true); // date descending (default)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get total count
|
|
||||||
Xapian::MSet all = enquire.get_mset(0, xdb.get_doccount());
|
|
||||||
int total = (int)all.get_matches_estimated();
|
|
||||||
|
|
||||||
// Get page
|
|
||||||
Xapian::MSet mset = enquire.get_mset(offset, limit);
|
|
||||||
|
|
||||||
std::ostringstream json;
|
|
||||||
json << "{\"total\":" << total << ",\"hits\":[";
|
|
||||||
bool first = true;
|
|
||||||
for (auto it = mset.begin(); it != mset.end(); ++it) {
|
|
||||||
if (!first) json << ",";
|
|
||||||
first = false;
|
|
||||||
std::string id = it.get_document().get_data();
|
|
||||||
double score = it.get_weight();
|
|
||||||
json << "{\"id\":\"" << id << "\",\"score\":" << score << "}";
|
|
||||||
}
|
|
||||||
json << "]}";
|
|
||||||
|
|
||||||
std::string result = json.str();
|
|
||||||
char* out = (char*)malloc(result.size() + 1);
|
|
||||||
memcpy(out, result.c_str(), result.size() + 1);
|
|
||||||
return out;
|
|
||||||
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
if (err) *err = dup_error(e.what());
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void xapian_free_string(char* s) {
|
|
||||||
free(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // extern "C"
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#ifndef XAPIAN_WRAPPER_H
|
|
||||||
#define XAPIAN_WRAPPER_H
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
typedef struct XapianDB XapianDB;
|
|
||||||
|
|
||||||
XapianDB* xapian_open(const char* path, int writable, char** err);
|
|
||||||
void xapian_close(XapianDB* db);
|
|
||||||
|
|
||||||
/* has_attachment: 0=no attachment, 1=has attachment */
|
|
||||||
int xapian_index(XapianDB* db, const char* id, const char* from,
|
|
||||||
const char* to, const char* subject, const char* body,
|
|
||||||
long long timestamp, int has_attachment, char** err);
|
|
||||||
|
|
||||||
int xapian_delete(XapianDB* db, const char* id, char** err);
|
|
||||||
|
|
||||||
/* Returns JSON string: {"total":N,"hits":[{"id":"...","score":0.9},...]}
|
|
||||||
Returns NULL on error, sets *err. Caller must free with xapian_free_string.
|
|
||||||
sort_mode: 0=relevance, 1=date_desc, 2=date_asc
|
|
||||||
has_attachment: 0=all, 1=only with attachment, -1=only without */
|
|
||||||
char* xapian_search(XapianDB* db, const char* query,
|
|
||||||
const char* from_filter, const char* own_email,
|
|
||||||
const char* to_filter,
|
|
||||||
long long date_from, long long date_to,
|
|
||||||
int offset, int limit,
|
|
||||||
int sort_mode, int has_attachment,
|
|
||||||
char** err);
|
|
||||||
|
|
||||||
void xapian_free_string(char* s);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
#endif
|
|
||||||
Reference in New Issue
Block a user