Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
18
cloud/maplepress-backend/pkg/search/config.go
Normal file
18
cloud/maplepress-backend/pkg/search/config.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// File Path: monorepo/cloud/maplepress-backend/pkg/search/config.go
|
||||
package search
|
||||
|
||||
// Config holds Meilisearch configuration
|
||||
type Config struct {
|
||||
Host string
|
||||
APIKey string
|
||||
IndexPrefix string // e.g., "maplepress_" or "site_"
|
||||
}
|
||||
|
||||
// NewConfig creates a new Meilisearch configuration
|
||||
func NewConfig(host, apiKey, indexPrefix string) *Config {
|
||||
return &Config{
|
||||
Host: host,
|
||||
APIKey: apiKey,
|
||||
IndexPrefix: indexPrefix,
|
||||
}
|
||||
}
|
||||
216
cloud/maplepress-backend/pkg/search/index.go
Normal file
216
cloud/maplepress-backend/pkg/search/index.go
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
// File Path: monorepo/cloud/maplepress-backend/pkg/search/index.go
|
||||
package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
)
|
||||
|
||||
// PageDocument represents a document in the search index
|
||||
type PageDocument struct {
|
||||
ID string `json:"id"` // page_id
|
||||
SiteID string `json:"site_id"` // for filtering (though each site has its own index)
|
||||
TenantID string `json:"tenant_id"` // for additional isolation
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"` // HTML stripped
|
||||
Excerpt string `json:"excerpt"`
|
||||
URL string `json:"url"`
|
||||
Status string `json:"status"` // publish, draft, trash
|
||||
PostType string `json:"post_type"` // page, post
|
||||
Author string `json:"author"`
|
||||
PublishedAt int64 `json:"published_at"` // Unix timestamp for sorting
|
||||
ModifiedAt int64 `json:"modified_at"` // Unix timestamp for sorting
|
||||
}
|
||||
|
||||
// CreateIndex creates a new index for a site
|
||||
func (c *Client) CreateIndex(siteID string) error {
|
||||
indexName := c.GetIndexName(siteID)
|
||||
|
||||
// Create index with site_id as primary key
|
||||
_, err := c.client.CreateIndex(&meilisearch.IndexConfig{
|
||||
Uid: indexName,
|
||||
PrimaryKey: "id", // page_id is the primary key
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create index %s: %w", indexName, err)
|
||||
}
|
||||
|
||||
// Configure index settings
|
||||
return c.ConfigureIndex(siteID)
|
||||
}
|
||||
|
||||
// ConfigureIndex configures the index settings
|
||||
func (c *Client) ConfigureIndex(siteID string) error {
|
||||
indexName := c.GetIndexName(siteID)
|
||||
index := c.client.Index(indexName)
|
||||
|
||||
// Set searchable attributes (in order of priority)
|
||||
searchableAttributes := []string{
|
||||
"title",
|
||||
"excerpt",
|
||||
"content",
|
||||
}
|
||||
|
||||
_, err := index.UpdateSearchableAttributes(&searchableAttributes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set searchable attributes: %w", err)
|
||||
}
|
||||
|
||||
// Set filterable attributes
|
||||
filterableAttributes := []interface{}{
|
||||
"status",
|
||||
"post_type",
|
||||
"author",
|
||||
"published_at",
|
||||
}
|
||||
|
||||
_, err = index.UpdateFilterableAttributes(&filterableAttributes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set filterable attributes: %w", err)
|
||||
}
|
||||
|
||||
// Set ranking rules
|
||||
rankingRules := []string{
|
||||
"words",
|
||||
"typo",
|
||||
"proximity",
|
||||
"attribute",
|
||||
"sort",
|
||||
"exactness",
|
||||
}
|
||||
|
||||
_, err = index.UpdateRankingRules(&rankingRules)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set ranking rules: %w", err)
|
||||
}
|
||||
|
||||
// Set displayed attributes (don't return full content in search results)
|
||||
displayedAttributes := []string{
|
||||
"id",
|
||||
"title",
|
||||
"excerpt",
|
||||
"url",
|
||||
"status",
|
||||
"post_type",
|
||||
"author",
|
||||
"published_at",
|
||||
"modified_at",
|
||||
}
|
||||
|
||||
_, err = index.UpdateDisplayedAttributes(&displayedAttributes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set displayed attributes: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IndexExists checks if an index exists
|
||||
func (c *Client) IndexExists(siteID string) (bool, error) {
|
||||
indexName := c.GetIndexName(siteID)
|
||||
|
||||
_, err := c.client.GetIndex(indexName)
|
||||
if err != nil {
|
||||
// Check if error is "index not found" (status code 404)
|
||||
if meiliErr, ok := err.(*meilisearch.Error); ok {
|
||||
if meiliErr.StatusCode == 404 {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return false, fmt.Errorf("failed to check index existence: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// DeleteIndex deletes an index for a site
|
||||
func (c *Client) DeleteIndex(siteID string) error {
|
||||
indexName := c.GetIndexName(siteID)
|
||||
|
||||
_, err := c.client.DeleteIndex(indexName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete index %s: %w", indexName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddDocuments adds or updates documents in the index
|
||||
func (c *Client) AddDocuments(siteID string, documents []PageDocument) (*meilisearch.TaskInfo, error) {
|
||||
indexName := c.GetIndexName(siteID)
|
||||
index := c.client.Index(indexName)
|
||||
|
||||
taskInfo, err := index.AddDocuments(documents, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to add documents to index %s: %w", indexName, err)
|
||||
}
|
||||
|
||||
return taskInfo, nil
|
||||
}
|
||||
|
||||
// UpdateDocuments updates documents in the index
|
||||
func (c *Client) UpdateDocuments(siteID string, documents []PageDocument) (*meilisearch.TaskInfo, error) {
|
||||
indexName := c.GetIndexName(siteID)
|
||||
index := c.client.Index(indexName)
|
||||
|
||||
taskInfo, err := index.UpdateDocuments(documents, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update documents in index %s: %w", indexName, err)
|
||||
}
|
||||
|
||||
return taskInfo, nil
|
||||
}
|
||||
|
||||
// DeleteDocument deletes a single document from the index
|
||||
func (c *Client) DeleteDocument(siteID string, documentID string) (*meilisearch.TaskInfo, error) {
|
||||
indexName := c.GetIndexName(siteID)
|
||||
index := c.client.Index(indexName)
|
||||
|
||||
taskInfo, err := index.DeleteDocument(documentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to delete document %s from index %s: %w", documentID, indexName, err)
|
||||
}
|
||||
|
||||
return taskInfo, nil
|
||||
}
|
||||
|
||||
// DeleteDocuments deletes multiple documents from the index
|
||||
func (c *Client) DeleteDocuments(siteID string, documentIDs []string) (*meilisearch.TaskInfo, error) {
|
||||
indexName := c.GetIndexName(siteID)
|
||||
index := c.client.Index(indexName)
|
||||
|
||||
taskInfo, err := index.DeleteDocuments(documentIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to delete documents from index %s: %w", indexName, err)
|
||||
}
|
||||
|
||||
return taskInfo, nil
|
||||
}
|
||||
|
||||
// DeleteAllDocuments deletes all documents from the index
|
||||
func (c *Client) DeleteAllDocuments(siteID string) (*meilisearch.TaskInfo, error) {
|
||||
indexName := c.GetIndexName(siteID)
|
||||
index := c.client.Index(indexName)
|
||||
|
||||
taskInfo, err := index.DeleteAllDocuments()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to delete all documents from index %s: %w", indexName, err)
|
||||
}
|
||||
|
||||
return taskInfo, nil
|
||||
}
|
||||
|
||||
// GetStats returns statistics about an index
|
||||
func (c *Client) GetStats(siteID string) (*meilisearch.StatsIndex, error) {
|
||||
indexName := c.GetIndexName(siteID)
|
||||
index := c.client.Index(indexName)
|
||||
|
||||
stats, err := index.GetStats()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get stats for index %s: %w", indexName, err)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
47
cloud/maplepress-backend/pkg/search/meilisearch.go
Normal file
47
cloud/maplepress-backend/pkg/search/meilisearch.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// File Path: monorepo/cloud/maplepress-backend/pkg/search/meilisearch.go
|
||||
package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Client wraps the Meilisearch client
|
||||
type Client struct {
|
||||
client meilisearch.ServiceManager
|
||||
config *Config
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewClient creates a new Meilisearch client
|
||||
func NewClient(config *Config, logger *zap.Logger) (*Client, error) {
|
||||
if config.Host == "" {
|
||||
return nil, fmt.Errorf("meilisearch host is required")
|
||||
}
|
||||
|
||||
client := meilisearch.New(config.Host, meilisearch.WithAPIKey(config.APIKey))
|
||||
|
||||
return &Client{
|
||||
client: client,
|
||||
config: config,
|
||||
logger: logger.Named("meilisearch"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetIndexName returns the full index name for a site
|
||||
func (c *Client) GetIndexName(siteID string) string {
|
||||
return c.config.IndexPrefix + siteID
|
||||
}
|
||||
|
||||
// Health checks if Meilisearch is healthy
|
||||
func (c *Client) Health() error {
|
||||
_, err := c.client.Health()
|
||||
return err
|
||||
}
|
||||
|
||||
// GetClient returns the underlying Meilisearch client (for advanced operations)
|
||||
func (c *Client) GetClient() meilisearch.ServiceManager {
|
||||
return c.client
|
||||
}
|
||||
22
cloud/maplepress-backend/pkg/search/provider.go
Normal file
22
cloud/maplepress-backend/pkg/search/provider.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ProvideClient provides a Meilisearch client
|
||||
func ProvideClient(cfg *config.Config, logger *zap.Logger) (*Client, error) {
|
||||
searchConfig := NewConfig(
|
||||
cfg.Meilisearch.Host,
|
||||
cfg.Meilisearch.APIKey,
|
||||
cfg.Meilisearch.IndexPrefix,
|
||||
)
|
||||
|
||||
client, err := NewClient(searchConfig, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
155
cloud/maplepress-backend/pkg/search/search.go
Normal file
155
cloud/maplepress-backend/pkg/search/search.go
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
// File Path: monorepo/cloud/maplepress-backend/pkg/search/search.go
|
||||
package search
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// SearchRequest represents a search request
|
||||
type SearchRequest struct {
|
||||
Query string
|
||||
Limit int64
|
||||
Offset int64
|
||||
Filter string // e.g., "status = publish"
|
||||
}
|
||||
|
||||
// SearchResult represents a search result
|
||||
type SearchResult struct {
|
||||
Hits []map[string]interface{} `json:"hits"`
|
||||
Query string `json:"query"`
|
||||
ProcessingTimeMs int64 `json:"processing_time_ms"`
|
||||
TotalHits int64 `json:"total_hits"`
|
||||
Limit int64 `json:"limit"`
|
||||
Offset int64 `json:"offset"`
|
||||
}
|
||||
|
||||
// Search performs a search query on the index
|
||||
func (c *Client) Search(siteID string, req SearchRequest) (*SearchResult, error) {
|
||||
indexName := c.GetIndexName(siteID)
|
||||
|
||||
c.logger.Info("initiating search",
|
||||
zap.String("site_id", siteID),
|
||||
zap.String("index_name", indexName),
|
||||
zap.String("query", req.Query),
|
||||
zap.Int64("limit", req.Limit),
|
||||
zap.Int64("offset", req.Offset),
|
||||
zap.String("filter", req.Filter))
|
||||
|
||||
// Build search request manually to avoid hybrid field
|
||||
searchBody := map[string]interface{}{
|
||||
"q": req.Query,
|
||||
"limit": req.Limit,
|
||||
"offset": req.Offset,
|
||||
"attributesToHighlight": []string{"title", "excerpt", "content"},
|
||||
}
|
||||
|
||||
// Add filter if provided
|
||||
if req.Filter != "" {
|
||||
searchBody["filter"] = req.Filter
|
||||
}
|
||||
|
||||
// Marshal to JSON
|
||||
jsonData, err := json.Marshal(searchBody)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to marshal search request", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to marshal search request: %w", err)
|
||||
}
|
||||
|
||||
// Uncomment for debugging: shows exact JSON payload sent to Meilisearch
|
||||
// c.logger.Debug("search request payload", zap.String("json", string(jsonData)))
|
||||
|
||||
// Build search URL
|
||||
searchURL := fmt.Sprintf("%s/indexes/%s/search", c.config.Host, indexName)
|
||||
// Uncomment for debugging: shows the Meilisearch API endpoint
|
||||
// c.logger.Debug("search URL", zap.String("url", searchURL))
|
||||
|
||||
// Create HTTP request
|
||||
httpReq, err := http.NewRequest("POST", searchURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
c.logger.Error("failed to create HTTP request", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to create search request: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.config.APIKey))
|
||||
|
||||
// Execute request
|
||||
httpClient := &http.Client{}
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to execute HTTP request", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to execute search request: %w", err)
|
||||
}
|
||||
if resp == nil {
|
||||
c.logger.Error("received nil response from search API")
|
||||
return nil, fmt.Errorf("received nil response from search API")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
c.logger.Info("received search response",
|
||||
zap.Int("status_code", resp.StatusCode),
|
||||
zap.String("status", resp.Status))
|
||||
|
||||
// Read response body for logging
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to read response body", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// Uncomment for debugging: shows full Meilisearch response
|
||||
// c.logger.Debug("search response body", zap.String("body", string(bodyBytes)))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.logger.Error("search request failed",
|
||||
zap.Int("status_code", resp.StatusCode),
|
||||
zap.String("response_body", string(bodyBytes)))
|
||||
return nil, fmt.Errorf("search request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var searchResp struct {
|
||||
Hits []map[string]interface{} `json:"hits"`
|
||||
Query string `json:"query"`
|
||||
ProcessingTimeMs int64 `json:"processingTimeMs"`
|
||||
EstimatedTotalHits int `json:"estimatedTotalHits"`
|
||||
Limit int64 `json:"limit"`
|
||||
Offset int64 `json:"offset"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bodyBytes, &searchResp); err != nil {
|
||||
c.logger.Error("failed to decode search response", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to decode search response: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("search completed successfully",
|
||||
zap.Int("hits_count", len(searchResp.Hits)),
|
||||
zap.Int("total_hits", searchResp.EstimatedTotalHits),
|
||||
zap.Int64("processing_time_ms", searchResp.ProcessingTimeMs))
|
||||
|
||||
// Convert response
|
||||
result := &SearchResult{
|
||||
Hits: searchResp.Hits,
|
||||
Query: searchResp.Query,
|
||||
ProcessingTimeMs: searchResp.ProcessingTimeMs,
|
||||
TotalHits: int64(searchResp.EstimatedTotalHits),
|
||||
Limit: req.Limit,
|
||||
Offset: req.Offset,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SearchWithHighlights performs a search with custom highlighting
|
||||
// Note: Currently uses same implementation as Search()
|
||||
func (c *Client) SearchWithHighlights(siteID string, req SearchRequest, highlightTags []string) (*SearchResult, error) {
|
||||
// For now, just use the regular Search method
|
||||
// TODO: Implement custom highlight tags if needed
|
||||
return c.Search(siteID, req)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue