From ce70bbca92720640aa897642574b636b6f8a1f75 Mon Sep 17 00:00:00 2001 From: Sergio Vera Date: Tue, 9 Jun 2026 15:57:48 +0200 Subject: [PATCH 01/10] Added search by author --- internal/datasource/wikidata/gowikidata.go | 36 ++- internal/datasource/wikidata/httpclient.go | 112 ++++++++ .../datasource/wikidata/httpclient_test.go | 96 +++++++ internal/index/author_search.go | 142 ++++++++++ internal/index/author_search_test.go | 137 ++++++++++ internal/index/authors_enrich.go | 134 ++++++++++ internal/index/authors_enrich_test.go | 203 ++++++++++++++ internal/index/bleve.go | 48 +++- internal/index/bleve_read.go | 40 ++- internal/index/progress.go | 8 + .../webserver/controller/author/controller.go | 1 + internal/webserver/controller/author/image.go | 2 +- .../webserver/controller/author/search.go | 159 +++++++++++ .../webserver/controller/author/summary.go | 32 +-- .../webserver/controller/author/update.go | 2 +- .../webserver/controller/home/controller.go | 1 + internal/webserver/controller/home/index.go | 17 +- internal/webserver/embedded/css/display.css | 45 +++- .../embedded/js/author-search-filters.js | 247 ++++++++++++++++++ .../webserver/embedded/js/home-search-tabs.js | 47 ++++ .../embedded/js/keyboard-shortcuts.js | 2 + .../webserver/embedded/translations/de.yml | 27 ++ .../webserver/embedded/translations/es.yml | 24 ++ .../webserver/embedded/translations/fr.yml | 24 ++ .../webserver/embedded/translations/ru.yml | 24 ++ .../embedded/views/author/search.html | 24 ++ internal/webserver/embedded/views/layout.html | 2 +- .../views/partials/author-search-filters.html | 77 ++++++ .../views/partials/authors-list-content.html | 13 + .../partials/authors-list-fragments.html | 2 + .../views/partials/authors-list-header.html | 15 ++ .../views/partials/authors-list-item.html | 28 ++ .../embedded/views/partials/main.html | 5 + .../embedded/views/partials/navbar.html | 31 ++- .../embedded/views/partials/searchbox.html | 86 ++++-- internal/webserver/middleware.go | 1 + internal/webserver/routes.go | 1 + internal/webserver/webserver.go | 5 + main.go | 6 + 39 files changed, 1811 insertions(+), 95 deletions(-) create mode 100644 internal/datasource/wikidata/httpclient.go create mode 100644 internal/datasource/wikidata/httpclient_test.go create mode 100644 internal/index/author_search.go create mode 100644 internal/index/author_search_test.go create mode 100644 internal/index/authors_enrich.go create mode 100644 internal/index/authors_enrich_test.go create mode 100644 internal/webserver/controller/author/search.go create mode 100644 internal/webserver/embedded/js/author-search-filters.js create mode 100644 internal/webserver/embedded/js/home-search-tabs.js create mode 100644 internal/webserver/embedded/views/author/search.html create mode 100644 internal/webserver/embedded/views/partials/author-search-filters.html create mode 100644 internal/webserver/embedded/views/partials/authors-list-content.html create mode 100644 internal/webserver/embedded/views/partials/authors-list-fragments.html create mode 100644 internal/webserver/embedded/views/partials/authors-list-header.html create mode 100644 internal/webserver/embedded/views/partials/authors-list-item.html diff --git a/internal/datasource/wikidata/gowikidata.go b/internal/datasource/wikidata/gowikidata.go index 4ce3a752..6c73eda8 100644 --- a/internal/datasource/wikidata/gowikidata.go +++ b/internal/datasource/wikidata/gowikidata.go @@ -6,25 +6,47 @@ type Gowikidata struct { } func (w Gowikidata) NewSearch(search string, language string) (SearchEntitiesRequest, error) { - return gowikidata.NewSearch(search, language) + req, err := gowikidata.NewSearch(search, language) + if err != nil { + return nil, err + } + return searchRequest{url: req.URL}, nil } func (w Gowikidata) NewGetEntities(ids []string) (GetEntitiesRequest, error) { request, err := gowikidata.NewGetEntities(ids) + if err != nil { + return nil, err + } + return entitiesRequest{req: request}, nil +} + +type searchRequest struct { + url string +} - return EntitiesRequest{request}, err +func (s searchRequest) Get() (*gowikidata.SearchEntitiesResponse, error) { + var response gowikidata.SearchEntitiesResponse + if err := apiHTTPClient.getJSON(s.url, &response); err != nil { + return nil, err + } + return &response, nil } -type EntitiesRequest struct { +type entitiesRequest struct { req *gowikidata.WikiDataGetEntitiesRequest } -func (e EntitiesRequest) SetProps(props []string) { +func (e entitiesRequest) SetProps(props []string) { e.req.SetProps(props) } -func (e EntitiesRequest) SetLanguages(languages []string) { +func (e entitiesRequest) SetLanguages(languages []string) { e.req.SetLanguages(languages) } -func (e EntitiesRequest) Get() (*map[string]gowikidata.Entity, error) { - return e.req.Get() +func (e entitiesRequest) Get() (*map[string]gowikidata.Entity, error) { + var response gowikidata.GetEntitiesResponse + if err := apiHTTPClient.getJSON(e.req.URL, &response); err != nil { + return nil, err + } + return &response.Entities, nil } diff --git a/internal/datasource/wikidata/httpclient.go b/internal/datasource/wikidata/httpclient.go new file mode 100644 index 00000000..c9d92a21 --- /dev/null +++ b/internal/datasource/wikidata/httpclient.go @@ -0,0 +1,112 @@ +package wikidata + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strconv" + "sync" + "time" +) + +const defaultRetryAfter = 60 * time.Second + +var apiHTTPClient = newRateLimitedHTTPClient() + +type rateLimitedHTTPClient struct { + mu sync.Mutex + resumeAt time.Time + client *http.Client +} + +func newRateLimitedHTTPClient() *rateLimitedHTTPClient { + return &rateLimitedHTTPClient{ + client: &http.Client{}, + } +} + +func (c *rateLimitedHTTPClient) getJSON(url string, dest any) error { + for { + c.waitUntilAllowed() + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return err + } + for key, value := range wikidataAPIHeaders() { + req.Header.Set(key, value) + } + + resp, err := c.client.Do(req) + if err != nil { + return err + } + + if resp.StatusCode == http.StatusTooManyRequests { + wait := retryAfterDuration(resp) + _ = resp.Body.Close() + c.pauseUntil(time.Now().Add(wait)) + log.Printf("Wikidata rate limit reached, waiting %s before retrying", wait) + continue + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + return fmt.Errorf("request failed with status code %d: %s", resp.StatusCode, body) + } + + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + return err + } + return json.Unmarshal(body, dest) + } +} + +func (c *rateLimitedHTTPClient) waitUntilAllowed() { + c.mu.Lock() + wait := time.Until(c.resumeAt) + c.mu.Unlock() + if wait > 0 { + time.Sleep(wait) + } +} + +func (c *rateLimitedHTTPClient) pauseUntil(resumeAt time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + if resumeAt.After(c.resumeAt) { + c.resumeAt = resumeAt + } +} + +func retryAfterDuration(resp *http.Response) time.Duration { + retryAfter := resp.Header.Get("Retry-After") + if retryAfter == "" { + return defaultRetryAfter + } + if seconds, err := strconv.Atoi(retryAfter); err == nil { + if seconds <= 0 { + return defaultRetryAfter + } + return time.Duration(seconds) * time.Second + } + if retryTime, err := http.ParseTime(retryAfter); err == nil { + wait := time.Until(retryTime) + if wait <= 0 { + return defaultRetryAfter + } + return wait + } + return defaultRetryAfter +} + +func wikidataAPIHeaders() map[string]string { + return map[string]string{ + "User-Agent": "Mozilla/5.0 (compatible; coreander/1.0; +https://github.com/svera/coreander)", + } +} diff --git a/internal/datasource/wikidata/httpclient_test.go b/internal/datasource/wikidata/httpclient_test.go new file mode 100644 index 00000000..4610f480 --- /dev/null +++ b/internal/datasource/wikidata/httpclient_test.go @@ -0,0 +1,96 @@ +package wikidata + +import ( + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +func TestRetryAfterDuration(t *testing.T) { + t.Run("seconds", func(t *testing.T) { + resp := &http.Response{Header: http.Header{"Retry-After": []string{"30"}}} + if got := retryAfterDuration(resp); got != 30*time.Second { + t.Fatalf("expected 30s, got %s", got) + } + }) + + t.Run("http date", func(t *testing.T) { + retryTime := time.Now().Add(45 * time.Second).UTC() + resp := &http.Response{Header: http.Header{"Retry-After": []string{retryTime.Format(http.TimeFormat)}}} + got := retryAfterDuration(resp) + if got < 44*time.Second || got > 46*time.Second { + t.Fatalf("expected about 45s, got %s", got) + } + }) + + t.Run("missing header uses default", func(t *testing.T) { + resp := &http.Response{Header: http.Header{}} + if got := retryAfterDuration(resp); got != defaultRetryAfter { + t.Fatalf("expected default %s, got %s", defaultRetryAfter, got) + } + }) +} + +func TestRateLimitedHTTPClientWaitsForRetryAfter(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if requests.Add(1) == 1 { + w.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusTooManyRequests) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"searchinfo":[],"search":[]}`)) + })) + defer server.Close() + + client := newRateLimitedHTTPClient() + client.client = server.Client() + + start := time.Now() + var payload map[string]any + if err := client.getJSON(server.URL, &payload); err != nil { + t.Fatal(err) + } + if requests.Load() != 2 { + t.Fatalf("expected 2 requests, got %d", requests.Load()) + } + if elapsed := time.Since(start); elapsed < time.Second { + t.Fatalf("expected to wait at least 1s after 429, took %s", elapsed) + } +} + +func TestRateLimitedHTTPClientBlocksConcurrentRequests(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := requests.Add(1) + if count == 1 { + w.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusTooManyRequests) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"entities":{}}`)) + })) + defer server.Close() + + client := newRateLimitedHTTPClient() + client.client = server.Client() + + start := time.Now() + done := make(chan struct{}, 2) + for range 2 { + go func() { + var payload map[string]any + _ = client.getJSON(server.URL, &payload) + done <- struct{}{} + }() + } + <-done + <-done + if elapsed := time.Since(start); elapsed < time.Second { + t.Fatalf("expected concurrent requests to honor shared wait, took %s", elapsed) + } +} diff --git a/internal/index/author_search.go b/internal/index/author_search.go new file mode 100644 index 00000000..220f4235 --- /dev/null +++ b/internal/index/author_search.go @@ -0,0 +1,142 @@ +package index + +import ( + "strings" + "unicode" + + "github.com/blevesearch/bleve/v2" + "github.com/blevesearch/bleve/v2/search/query" + "github.com/rickb777/date/v2" + "github.com/svera/coreander/v4/internal/result" +) + +type AuthorSearchFields struct { + Name string + Gender *float64 + BirthDateFrom date.Date + BirthDateTo date.Date + DeathDateFrom date.Date + DeathDateTo date.Date + SortBy []string +} + +func (b *BleveIndexer) SearchAuthors(searchFields AuthorSearchFields, page, resultsPerPage int) (result.Paginated[[]Author], error) { + filtersQuery := bleve.NewConjunctionQuery() + + if q := authorNameQuery(searchFields.Name); q != nil { + filtersQuery.AddQuery(q) + } else { + filtersQuery.AddQuery(bleve.NewMatchAllQuery()) + } + + addAuthorFilters(searchFields, filtersQuery) + + return b.runAuthorsPaginatedQuery(filtersQuery, page, resultsPerPage, searchFields.SortBy) +} + +func authorNameQuery(name string) query.Query { + name = strings.TrimSpace(name) + if name == "" { + return nil + } + name = foldAuthorName(name) + + disj := bleve.NewDisjunctionQuery() + for _, field := range []string{"Name", "BirthName"} { + prefix := bleve.NewPrefixQuery(name) + prefix.SetField(field) + disj.AddQuery(prefix) + + wildcard := bleve.NewWildcardQuery("*" + escapeWildcard(name) + "*") + wildcard.SetField(field) + disj.AddQuery(wildcard) + } + return disj +} + +func foldAuthorName(name string) string { + return strings.Map(func(r rune) rune { + return unicode.ToLower(r) + }, name) +} + +func escapeWildcard(value string) string { + replacer := strings.NewReplacer(`\`, `\\`, `*`, `\*`, `?`, `\?`) + return replacer.Replace(value) +} + +func addAuthorFilters(searchFields AuthorSearchFields, filtersQuery *query.ConjunctionQuery) { + if searchFields.Gender != nil { + min := *searchFields.Gender + max := min + 1 + q := bleve.NewNumericRangeQuery(&min, &max) + q.SetField("Gender") + filtersQuery.AddQuery(q) + } + addDateRangeFilter(filtersQuery, "DateOfBirth.Date", searchFields.BirthDateFrom, searchFields.BirthDateTo) + addDeathDateRangeFilter(filtersQuery, searchFields.DeathDateFrom, searchFields.DeathDateTo) +} + +func addDeathDateRangeFilter(filtersQuery *query.ConjunctionQuery, from, to date.Date) { + if from == 0 && to == 0 { + return + } + addDateRangeFilter(filtersQuery, "DateOfDeath.Date", from, to) + + // Living authors are indexed with DateOfDeath.Date == 0, which otherwise matches + // an open-ended "died before" filter because 0 <= max. + zero := float64(0) + one := float64(1) + maxExclusive := false + zeroDeathDate := bleve.NewNumericRangeInclusiveQuery(&zero, &one, nil, &maxExclusive) + zeroDeathDate.SetField("DateOfDeath.Date") + excludeLiving := bleve.NewBooleanQuery() + excludeLiving.AddMustNot(zeroDeathDate) + filtersQuery.AddQuery(excludeLiving) +} + +func addDateRangeFilter(filtersQuery *query.ConjunctionQuery, field string, from, to date.Date) { + if from == 0 && to == 0 { + return + } + minDate := float64(from) + q := bleve.NewNumericRangeQuery(nil, nil) + if from != 0 { + q.Min = &minDate + } + if to != 0 { + maxDate := float64(to) + 1 + q.Max = &maxDate + } + q.SetField(field) + filtersQuery.AddQuery(q) +} + +func (b *BleveIndexer) runAuthorsPaginatedQuery(q query.Query, page, resultsPerPage int, sortBy []string) (result.Paginated[[]Author], error) { + if page < 1 { + page = 1 + } + + searchOptions := bleve.NewSearchRequestOptions(q, resultsPerPage, (page-1)*resultsPerPage, false) + searchOptions.SortBy(sortBy) + searchOptions.Fields = []string{"*"} + searchResult, err := b.authorsIdx.Search(searchOptions) + if err != nil { + return result.Paginated[[]Author]{}, err + } + if searchResult.Total == 0 { + return result.Paginated[[]Author]{}, nil + } + + authors := make([]Author, len(searchResult.Hits)) + for i, hit := range searchResult.Hits { + authors[i] = hydrateAuthor(hit) + } + + return result.NewPaginated( + resultsPerPage, + page, + int(searchResult.Total), + authors, + ), nil +} diff --git a/internal/index/author_search_test.go b/internal/index/author_search_test.go new file mode 100644 index 00000000..b4884d2b --- /dev/null +++ b/internal/index/author_search_test.go @@ -0,0 +1,137 @@ +package index_test + +import ( + "testing" + "time" + + "github.com/blevesearch/bleve/v2" + "github.com/rickb777/date/v2" + "github.com/svera/coreander/v4/internal/datasource/wikidata" + "github.com/svera/coreander/v4/internal/index" + "github.com/svera/coreander/v4/internal/precisiondate" +) + +func TestSearchAuthors(t *testing.T) { + authorsIndexMem, err := bleve.NewMemOnly(index.CreateAuthorsMapping()) + if err != nil { + t.Fatal(err) + } + documentsIndexMem, err := bleve.NewMemOnly(index.CreateDocumentsMapping()) + if err != nil { + t.Fatal(err) + } + + idx := index.NewBleve(documentsIndexMem, authorsIndexMem, nil, "", nil, index.Config{}) + + authors := []index.Author{ + { + Slug: "george-orwell", + Name: "George Orwell", + BirthName: "Eric Arthur Blair", + Gender: float64(wikidata.GenderMale), + DateOfBirth: precisiondate.NewPrecisionDate("+1903-06-25T00:00:00Z", precisiondate.PrecisionDay), + DateOfDeath: precisiondate.NewPrecisionDate("+1950-01-21T00:00:00Z", precisiondate.PrecisionDay), + RetrievedOn: mustParseTime("2020-01-01T00:00:00Z"), + }, + { + Slug: "jane-austen", + Name: "Jane Austen", + Gender: float64(wikidata.GenderFemale), + DateOfBirth: precisiondate.NewPrecisionDate("+1775-12-16T00:00:00Z", precisiondate.PrecisionDay), + DateOfDeath: precisiondate.NewPrecisionDate("+1817-07-18T00:00:00Z", precisiondate.PrecisionDay), + RetrievedOn: mustParseTime("2020-01-01T00:00:00Z"), + }, + { + Slug: "living-author", + Name: "Living Author", + Gender: float64(wikidata.GenderMale), + DateOfBirth: precisiondate.NewPrecisionDate("+1990-01-01T00:00:00Z", precisiondate.PrecisionDay), + RetrievedOn: mustParseTime("2020-01-01T00:00:00Z"), + }, + } + for _, author := range authors { + if err := idx.IndexAuthor(author); err != nil { + t.Fatal(err) + } + } + + t.Run("by name", func(t *testing.T) { + res, err := idx.SearchAuthors(index.AuthorSearchFields{Name: "Orwell"}, 1, 10) + if err != nil { + t.Fatal(err) + } + hits := res.Hits() + if res.TotalHits() != 1 || hits[0].Slug != "george-orwell" { + t.Fatalf("expected George Orwell, got %#v", hits) + } + }) + + t.Run("by name case insensitive", func(t *testing.T) { + for _, name := range []string{"orwell", "ORWELL", "oRwElL"} { + res, err := idx.SearchAuthors(index.AuthorSearchFields{Name: name}, 1, 10) + if err != nil { + t.Fatal(err) + } + hits := res.Hits() + if res.TotalHits() != 1 || hits[0].Slug != "george-orwell" { + t.Fatalf("search %q: expected George Orwell, got %#v", name, hits) + } + } + }) + + t.Run("by gender", func(t *testing.T) { + female := float64(wikidata.GenderFemale) + res, err := idx.SearchAuthors(index.AuthorSearchFields{Gender: &female}, 1, 10) + if err != nil { + t.Fatal(err) + } + hits := res.Hits() + if res.TotalHits() != 1 || hits[0].Slug != "jane-austen" { + t.Fatalf("expected Jane Austen, got %#v", hits) + } + }) + + t.Run("by birth date range", func(t *testing.T) { + from, _ := date.ParseISO("1900-01-01") + to, _ := date.ParseISO("1920-12-31") + res, err := idx.SearchAuthors(index.AuthorSearchFields{BirthDateFrom: from, BirthDateTo: to}, 1, 10) + if err != nil { + t.Fatal(err) + } + hits := res.Hits() + if res.TotalHits() != 1 || hits[0].Slug != "george-orwell" { + t.Fatalf("expected George Orwell, got %#v", hits) + } + }) + + t.Run("by death date range", func(t *testing.T) { + from, _ := date.ParseISO("1810-01-01") + to, _ := date.ParseISO("1820-12-31") + res, err := idx.SearchAuthors(index.AuthorSearchFields{DeathDateFrom: from, DeathDateTo: to}, 1, 10) + if err != nil { + t.Fatal(err) + } + hits := res.Hits() + if res.TotalHits() != 1 || hits[0].Slug != "jane-austen" { + t.Fatalf("expected Jane Austen, got %#v", hits) + } + }) + + t.Run("by death date to excludes living authors", func(t *testing.T) { + to, _ := date.ParseISO("2020-12-31") + res, err := idx.SearchAuthors(index.AuthorSearchFields{DeathDateTo: to}, 1, 10) + if err != nil { + t.Fatal(err) + } + for _, hit := range res.Hits() { + if hit.Slug == "living-author" { + t.Fatalf("living author should not match death date until filter, got %#v", res.Hits()) + } + } + }) +} + +func mustParseTime(value string) time.Time { + t, _ := time.Parse(time.RFC3339, value) + return t +} diff --git a/internal/index/authors_enrich.go b/internal/index/authors_enrich.go new file mode 100644 index 00000000..46995436 --- /dev/null +++ b/internal/index/authors_enrich.go @@ -0,0 +1,134 @@ +package index + +import ( + "log" + "time" + + "github.com/blevesearch/bleve/v2" + datasourcemodel "github.com/svera/coreander/v4/internal/datasource/model" +) + +const ( + AuthorEnrichRequestsPerMinute = 200 + DefaultAuthorEnrichInterval = time.Minute / AuthorEnrichRequestsPerMinute +) + +// AuthorDataSource retrieves author metadata from an external source such as Wikidata. +type AuthorDataSource interface { + SearchAuthor(name string, languages []string) (datasourcemodel.Author, error) + RetrieveAuthor(ids []string, languages []string) (datasourcemodel.Author, error) +} + +// CombineWithDataSource merges external author metadata into an indexed author. +func CombineWithDataSource(author *Author, authorDataSource datasourcemodel.Author, supportedLanguages []string) { + author.DataSourceID = authorDataSource.SourceID() + author.BirthName = authorDataSource.BirthName() + author.RetrievedOn = authorDataSource.RetrievedOn() + author.WikipediaLink = make(map[string]string) + author.InstanceOf = authorDataSource.InstanceOf() + author.Description = make(map[string]string) + author.DateOfBirth = authorDataSource.DateOfBirth() + author.DateOfDeath = authorDataSource.DateOfDeath() + author.Website = authorDataSource.Website() + author.DataSourceImage = authorDataSource.Image() + author.Gender = authorDataSource.Gender() + author.Pseudonyms = make([]string, 0, len(authorDataSource.Pseudonyms())) + + for _, pseudonym := range authorDataSource.Pseudonyms() { + if pseudonym != author.Name { + author.Pseudonyms = append(author.Pseudonyms, pseudonym) + } + } + + for _, lang := range supportedLanguages { + author.WikipediaLink[lang] = authorDataSource.WikipediaLink(lang) + author.Description[lang] = authorDataSource.Description(lang) + } +} + +// AuthorsWithoutInfo returns indexed authors that have not been enriched from an external source yet. +func (b *BleveIndexer) AuthorsWithoutInfo() ([]Author, error) { + count, err := b.authorsIdx.DocCount() + if err != nil { + return nil, err + } + if count == 0 { + return nil, nil + } + + searchReq := bleve.NewSearchRequest(bleve.NewMatchAllQuery()) + searchReq.Fields = []string{"*"} + searchReq.Size = int(count) + + searchResult, err := b.authorsIdx.Search(searchReq) + if err != nil { + return nil, err + } + + authors := make([]Author, 0, len(searchResult.Hits)) + for _, hit := range searchResult.Hits { + author := hydrateAuthor(hit) + if author.RetrievedOn.IsZero() { + authors = append(authors, author) + } + } + return authors, nil +} + +// EnrichAuthorsFromDataSource fetches metadata for authors missing external info and updates the index. +// Requests are throttled to at most one author lookup per interval. +func (b *BleveIndexer) EnrichAuthorsFromDataSource(dataSource AuthorDataSource, supportedLanguages []string, interval time.Duration) error { + if interval <= 0 { + interval = DefaultAuthorEnrichInterval + } + + authors, err := b.AuthorsWithoutInfo() + if err != nil { + return err + } + if len(authors) == 0 { + return nil + } + + log.Printf("Enriching %d authors from Wikidata", len(authors)) + + b.beginAuthorEnrichment(len(authors)) + defer b.endAuthorEnrichment() + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for i, author := range authors { + if i > 0 { + <-ticker.C + } + + authorDataSource, err := b.fetchAuthorFromDataSource(dataSource, author, supportedLanguages) + if err != nil { + log.Printf("Error retrieving author %s from Wikidata: %s", author.Name, err) + b.recordAuthorEnrichmentProgress() + continue + } + + if authorDataSource == nil { + author.RetrievedOn = time.Now().UTC() + } else { + CombineWithDataSource(&author, authorDataSource, supportedLanguages) + } + + if err := b.IndexAuthor(author); err != nil { + log.Printf("Error indexing enriched author %s: %s", author.Name, err) + } + b.recordAuthorEnrichmentProgress() + } + + log.Printf("Author enrichment finished") + return nil +} + +func (b *BleveIndexer) fetchAuthorFromDataSource(dataSource AuthorDataSource, author Author, supportedLanguages []string) (datasourcemodel.Author, error) { + if author.DataSourceID != "" { + return dataSource.RetrieveAuthor([]string{author.DataSourceID}, supportedLanguages) + } + return dataSource.SearchAuthor(author.Name, supportedLanguages) +} diff --git a/internal/index/authors_enrich_test.go b/internal/index/authors_enrich_test.go new file mode 100644 index 00000000..c3d3b645 --- /dev/null +++ b/internal/index/authors_enrich_test.go @@ -0,0 +1,203 @@ +package index_test + +import ( + "errors" + "sync" + "testing" + "time" + + "github.com/blevesearch/bleve/v2" + datasourcemodel "github.com/svera/coreander/v4/internal/datasource/model" + "github.com/svera/coreander/v4/internal/index" + "github.com/svera/coreander/v4/internal/precisiondate" +) + +type mockAuthorDataSource struct { + byName map[string]datasourcemodel.Author + calls int + delay time.Duration + errName string +} + +func (m *mockAuthorDataSource) SearchAuthor(name string, _ []string) (datasourcemodel.Author, error) { + m.calls++ + if m.delay > 0 { + time.Sleep(m.delay) + } + if m.errName == name { + return nil, errMockLookup + } + return m.byName[name], nil +} + +var errMockLookup = errors.New("mock lookup failed") + +func (m *mockAuthorDataSource) RetrieveAuthor(_ []string, _ []string) (datasourcemodel.Author, error) { + m.calls++ + return nil, nil +} + +type stubAuthor struct { + sourceID string +} + +func (a stubAuthor) BirthName() string { return "" } +func (a stubAuthor) Description(string) string { return "A writer" } +func (a stubAuthor) InstanceOf() float64 { return 1 } +func (a stubAuthor) Gender() float64 { return 0 } +func (a stubAuthor) DateOfBirth() precisiondate.PrecisionDate { return precisiondate.PrecisionDate{} } +func (a stubAuthor) DateOfDeath() precisiondate.PrecisionDate { return precisiondate.PrecisionDate{} } +func (a stubAuthor) Image() string { return "" } +func (a stubAuthor) Website() string { return "" } +func (a stubAuthor) WikipediaLink(string) string { return "" } +func (a stubAuthor) SourceID() string { return a.sourceID } +func (a stubAuthor) RetrievedOn() time.Time { return time.Now().UTC() } +func (a stubAuthor) Pseudonyms() []string { return nil } + +func TestAuthorsWithoutInfo(t *testing.T) { + authorsIndexMem, err := bleve.NewMemOnly(index.CreateAuthorsMapping()) + if err != nil { + t.Fatal(err) + } + documentsIndexMem, err := bleve.NewMemOnly(index.CreateDocumentsMapping()) + if err != nil { + t.Fatal(err) + } + + idx := index.NewBleve(documentsIndexMem, authorsIndexMem, nil, "", nil, index.Config{}) + + if err := idx.IndexAuthor(index.Author{Slug: "enriched", Name: "Enriched Author", RetrievedOn: time.Now().UTC()}); err != nil { + t.Fatal(err) + } + if err := idx.IndexAuthor(index.Author{Slug: "pending", Name: "Pending Author"}); err != nil { + t.Fatal(err) + } + + authors, err := idx.AuthorsWithoutInfo() + if err != nil { + t.Fatal(err) + } + if len(authors) != 1 { + t.Fatalf("expected 1 author without info, got %d", len(authors)) + } + if authors[0].Slug != "pending" { + t.Fatalf("expected pending author, got %q", authors[0].Slug) + } +} + +func TestEnrichAuthorsFromDataSource(t *testing.T) { + authorsIndexMem, err := bleve.NewMemOnly(index.CreateAuthorsMapping()) + if err != nil { + t.Fatal(err) + } + documentsIndexMem, err := bleve.NewMemOnly(index.CreateDocumentsMapping()) + if err != nil { + t.Fatal(err) + } + + idx := index.NewBleve(documentsIndexMem, authorsIndexMem, nil, "", nil, index.Config{}) + + if err := idx.IndexAuthor(index.Author{Slug: "found", Name: "Found Author"}); err != nil { + t.Fatal(err) + } + if err := idx.IndexAuthor(index.Author{Slug: "missing", Name: "Missing Author"}); err != nil { + t.Fatal(err) + } + if err := idx.IndexAuthor(index.Author{Slug: "done", Name: "Done Author", RetrievedOn: time.Now().UTC()}); err != nil { + t.Fatal(err) + } + + dataSource := &mockAuthorDataSource{ + byName: map[string]datasourcemodel.Author{ + "Found Author": stubAuthor{sourceID: "Q1"}, + }, + } + + start := time.Now() + if err := idx.EnrichAuthorsFromDataSource(dataSource, []string{"en"}, 50*time.Millisecond); err != nil { + t.Fatal(err) + } + elapsed := time.Since(start) + + if dataSource.calls != 2 { + t.Fatalf("expected 2 Wikidata lookups, got %d", dataSource.calls) + } + if elapsed < 50*time.Millisecond { + t.Fatalf("expected throttling between lookups, took %s", elapsed) + } + + found, err := idx.Author("found", "en") + if err != nil { + t.Fatal(err) + } + if found.DataSourceID != "Q1" { + t.Fatalf("expected enriched author Q1, got %q", found.DataSourceID) + } + + missing, err := idx.Author("missing", "en") + if err != nil { + t.Fatal(err) + } + if missing.RetrievedOn.IsZero() { + t.Fatal("expected missing author to be marked as processed") + } + if missing.DataSourceID != "" { + t.Fatalf("expected missing author to have no data source id, got %q", missing.DataSourceID) + } + + remaining, err := idx.AuthorsWithoutInfo() + if err != nil { + t.Fatal(err) + } + if len(remaining) != 0 { + t.Fatalf("expected no authors left to enrich, got %d", len(remaining)) + } +} + +func TestAuthorEnrichmentProgress(t *testing.T) { + authorsIndexMem, err := bleve.NewMemOnly(index.CreateAuthorsMapping()) + if err != nil { + t.Fatal(err) + } + documentsIndexMem, err := bleve.NewMemOnly(index.CreateDocumentsMapping()) + if err != nil { + t.Fatal(err) + } + + idx := index.NewBleve(documentsIndexMem, authorsIndexMem, nil, "", nil, index.Config{}) + + if err := idx.IndexAuthor(index.Author{Slug: "one", Name: "Author One"}); err != nil { + t.Fatal(err) + } + if err := idx.IndexAuthor(index.Author{Slug: "two", Name: "Author Two"}); err != nil { + t.Fatal(err) + } + + dataSource := &mockAuthorDataSource{delay: 40 * time.Millisecond} + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + _ = idx.EnrichAuthorsFromDataSource(dataSource, []string{"en"}, time.Millisecond) + }() + + deadline := time.Now().Add(2 * time.Second) + sawProgress := false + for time.Now().Before(deadline) { + p, err := idx.IndexingProgress() + if err != nil { + t.Fatal(err) + } + if p.InProgress && p.Percentage > 0 && p.Kind == index.ProgressAuthors { + sawProgress = true + break + } + time.Sleep(10 * time.Millisecond) + } + wg.Wait() + + if !sawProgress { + t.Fatal("expected IndexingProgress to report author enrichment percentage > 0") + } +} diff --git a/internal/index/bleve.go b/internal/index/bleve.go index 8e3567ef..1d84694b 100644 --- a/internal/index/bleve.go +++ b/internal/index/bleve.go @@ -32,7 +32,7 @@ const DocumentVersion = "v12" // AuthorVersion identifies the mapping used for indexing authors. Any changes in the mapping requires an increase // of version, to signal that a new index needs to be created. -const AuthorVersion = "1" +const AuthorVersion = "2" // Metadata fields var ( @@ -64,16 +64,19 @@ type Config struct { } type BleveIndexer struct { - fs afero.Fs - documentsIdx bleve.Index // Documents index - authorsIdx bleve.Index // Authors index - libraryPath string - reader map[string]metadata.Reader - indexStartNanos atomic.Int64 - indexedEntries atomic.Uint64 - indexTotalEntries atomic.Uint64 - illustratedMinAmount int // minimum number of illustrations (excl. cover) for a document to be considered illustrated - illustratedMinSize float64 // minimum size in megapixels for an image to count as an illustration + fs afero.Fs + documentsIdx bleve.Index // Documents index + authorsIdx bleve.Index // Authors index + libraryPath string + reader map[string]metadata.Reader + indexStartNanos atomic.Int64 + indexedEntries atomic.Uint64 + indexTotalEntries atomic.Uint64 + authorEnrichStartNanos atomic.Int64 + authorEnrichProcessed atomic.Uint64 + authorEnrichTotalEntries atomic.Uint64 + illustratedMinAmount int // minimum number of illustrations (excl. cover) for a document to be considered illustrated + illustratedMinSize float64 // minimum size in megapixels for an image to count as an illustration } // NewBleve creates a new BleveIndexer instance using the passed parameters @@ -197,16 +200,35 @@ func CreateDocumentsMapping() mapping.IndexMapping { func CreateAuthorsMapping() mapping.IndexMapping { indexMapping := bleve.NewIndexMapping() + err := indexMapping.AddCustomAnalyzer(defaultAnalyzer, + map[string]any{ + "type": custom.Name, + "char_filters": []string{ + asciifolding.Name, + }, + "tokenizer": unicode.Name, + "token_filters": []string{ + lowercase.Name, + }, + }) + if err != nil { + log.Fatal(err) + } + keywordFieldMapping := bleve.NewKeywordFieldMapping() keywordFieldMappingNotIndexable := bleve.NewKeywordFieldMapping() keywordFieldMappingNotIndexable.Index = false + simpleTextFieldMapping := bleve.NewTextFieldMapping() + simpleTextFieldMapping.Analyzer = defaultAnalyzer + numericFieldMapping := bleve.NewNumericFieldMapping() dateTimeFieldMapping := bleve.NewDateTimeFieldMapping() + indexMapping.DefaultMapping.DefaultAnalyzer = defaultAnalyzer indexMapping.DefaultMapping.AddFieldMappingsAt("Slug", keywordFieldMapping) - indexMapping.DefaultMapping.AddFieldMappingsAt("Name", keywordFieldMapping) - indexMapping.DefaultMapping.AddFieldMappingsAt("BirthName", keywordFieldMapping) + indexMapping.DefaultMapping.AddFieldMappingsAt("Name", simpleTextFieldMapping) + indexMapping.DefaultMapping.AddFieldMappingsAt("BirthName", simpleTextFieldMapping) indexMapping.DefaultMapping.AddFieldMappingsAt("RetrievedOn", dateTimeFieldMapping) indexMapping.DefaultMapping.AddFieldMappingsAt("DataSourceID", keywordFieldMappingNotIndexable) indexMapping.DefaultMapping.AddFieldMappingsAt("DataSourceImage", keywordFieldMappingNotIndexable) diff --git a/internal/index/bleve_read.go b/internal/index/bleve_read.go index 6efdecc0..642fd6ea 100644 --- a/internal/index/bleve_read.go +++ b/internal/index/bleve_read.go @@ -24,12 +24,17 @@ import ( ) func (b *BleveIndexer) IndexingProgress() (Progress, error) { - if b.indexStartNanos.Load() == 0 { - return Progress{}, nil + if b.indexStartNanos.Load() != 0 { + return b.progressFrom(ProgressDocuments, b.indexStartNanos.Load(), b.indexedEntries.Load(), b.indexTotalEntries.Load()), nil } - processed := b.indexedEntries.Load() - total := b.indexTotalEntries.Load() - progress := Progress{InProgress: true} + if b.authorEnrichStartNanos.Load() != 0 { + return b.progressFrom(ProgressAuthors, b.authorEnrichStartNanos.Load(), b.authorEnrichProcessed.Load(), b.authorEnrichTotalEntries.Load()), nil + } + return Progress{}, nil +} + +func (b *BleveIndexer) progressFrom(kind ProgressKind, startNanos int64, processed, total uint64) Progress { + progress := Progress{Kind: kind, InProgress: true} if total > 0 { progress.Percentage = math.Round(100 * float64(processed) / float64(total)) if progress.Percentage > 100 { @@ -37,10 +42,10 @@ func (b *BleveIndexer) IndexingProgress() (Progress, error) { } } if processed > 0 && processed < total { - elapsed := float64(time.Now().UnixNano()) - float64(b.indexStartNanos.Load()) + elapsed := float64(time.Now().UnixNano()) - float64(startNanos) progress.RemainingTime = time.Duration(elapsed * float64(total-processed) / float64(processed)) } - return progress, nil + return progress } func (b *BleveIndexer) beginIndexing() { @@ -55,6 +60,22 @@ func (b *BleveIndexer) endIndexing() { b.indexTotalEntries.Store(0) } +func (b *BleveIndexer) beginAuthorEnrichment(total int) { + b.authorEnrichStartNanos.Store(time.Now().UnixNano()) + b.authorEnrichProcessed.Store(0) + b.authorEnrichTotalEntries.Store(uint64(total)) +} + +func (b *BleveIndexer) endAuthorEnrichment() { + b.authorEnrichStartNanos.Store(0) + b.authorEnrichProcessed.Store(0) + b.authorEnrichTotalEntries.Store(0) +} + +func (b *BleveIndexer) recordAuthorEnrichmentProgress() { + b.authorEnrichProcessed.Add(1) +} + func countFiles(dir string, fileSystem afero.Fs) (float64, error) { var total float64 @@ -305,6 +326,11 @@ func (b *BleveIndexer) Count() (uint64, error) { return searchResult.Total, nil } +// AuthorsCount returns the number of indexed authors. +func (b *BleveIndexer) AuthorsCount() (uint64, error) { + return b.authorsIdx.DocCount() +} + func (b *BleveIndexer) Document(slug string) (Document, error) { query := bleve.NewTermQuery(slug) query.SetField("Slug") diff --git a/internal/index/progress.go b/internal/index/progress.go index ab479e03..5865d7d5 100644 --- a/internal/index/progress.go +++ b/internal/index/progress.go @@ -2,7 +2,15 @@ package index import "time" +type ProgressKind string + +const ( + ProgressDocuments ProgressKind = "documents" + ProgressAuthors ProgressKind = "authors" +) + type Progress struct { + Kind ProgressKind InProgress bool RemainingTime time.Duration Percentage float64 diff --git a/internal/webserver/controller/author/controller.go b/internal/webserver/controller/author/controller.go index 69e50f1b..74d4152f 100644 --- a/internal/webserver/controller/author/controller.go +++ b/internal/webserver/controller/author/controller.go @@ -15,6 +15,7 @@ type Sender interface { // IdxReader defines a set of author reading operations over an index type IdxReader interface { + SearchAuthors(searchFields index.AuthorSearchFields, page, resultsPerPage int) (result.Paginated[[]index.Author], error) SearchByAuthor(searchFields index.SearchFields, page, resultsPerPage int) (result.Paginated[[]index.Document], error) Author(slug, lang string) (index.Author, error) IndexAuthor(author index.Author) error diff --git a/internal/webserver/controller/author/image.go b/internal/webserver/controller/author/image.go index a2365f26..0c1c1656 100644 --- a/internal/webserver/controller/author/image.go +++ b/internal/webserver/controller/author/image.go @@ -143,7 +143,7 @@ func (a *Controller) readFromDataSource(path string) (image.Image, error) { return nil, fmt.Errorf("failed to fetch image from %s: HTTP %d", path, res.StatusCode) } - img, err := imaging.Decode(res.Body, imaging.Backends(imaging.GO_IMAGE)) + img, err := imaging.Decode(res.Body) if err != nil { return nil, fmt.Errorf("failed to decode image from %s: %w", path, err) } diff --git a/internal/webserver/controller/author/search.go b/internal/webserver/controller/author/search.go new file mode 100644 index 00000000..2053a429 --- /dev/null +++ b/internal/webserver/controller/author/search.go @@ -0,0 +1,159 @@ +package author + +import ( + "log" + "strconv" + + "github.com/gofiber/fiber/v3" + "github.com/rickb777/date/v2" + "github.com/svera/coreander/v4/internal/datasource/wikidata" + "github.com/svera/coreander/v4/internal/index" + "github.com/svera/coreander/v4/internal/result" + "github.com/svera/coreander/v4/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver/view" +) + +func (a *Controller) Search(c fiber.Ctx) error { + searchFields, err := a.parseAuthorSearchQuery(c) + if err != nil { + log.Println(err) + return fiber.ErrBadRequest + } + + page, err := strconv.Atoi(c.Query("page")) + if err != nil { + page = 1 + } + + var authorResults result.Paginated[[]index.Author] + if authorResults, err = a.idx.SearchAuthors(searchFields, page, int(model.ResultsPerPage)); err != nil { + log.Println(err) + return fiber.ErrInternalServerError + } + + templateVars := fiber.Map{ + "SearchFields": searchFields, + "SelectedGender": c.Query("gender"), + "Results": authorResults, + "Paginator": view.Pagination(model.MaxPagesNavigator, authorResults, c.Queries()), + "Title": "Search authors", + "AuthorsSearchPage": true, + "URL": view.URL(c), + "SortURL": view.BaseURLWithout(c, "sort-by", "page"), + "SortBy": c.Query("sort-by"), + "AdditionalSortOptions": []struct { + Key string + Value string + }{ + {"name-a-z", "name A-Z"}, + {"name-z-a", "name Z-A"}, + {"birth-older-first", "birth older first"}, + {"birth-newer-first", "birth newer first"}, + {"death-older-first", "death older first"}, + {"death-newer-first", "death newer first"}, + }, + } + + if c.Get("hx-request") == "true" { + if err = c.Render("partials/authors-list-fragments", templateVars); err != nil { + log.Println(err) + return fiber.ErrInternalServerError + } + return nil + } + + if err = c.Render("author/search", templateVars, "layout"); err != nil { + log.Println(err) + return fiber.ErrInternalServerError + } + + return nil +} + +func (a *Controller) parseAuthorSearchQuery(c fiber.Ctx) (index.AuthorSearchFields, error) { + searchFields := index.AuthorSearchFields{ + Name: c.Query("name"), + SortBy: a.parseAuthorSearchSortBy(c), + } + + if gender, ok := parseGenderQuery(c.Query("gender")); ok { + searchFields.Gender = &gender + } + + if c.Query("birthdate-from") != "" { + birthDateFrom, err := date.ParseISO(c.Query("birthdate-from")) + if err != nil { + return searchFields, err + } + searchFields.BirthDateFrom = birthDateFrom + } + + if c.Query("birthdate-to") != "" { + birthDateTo, err := date.ParseISO(c.Query("birthdate-to")) + if err != nil { + return searchFields, err + } + searchFields.BirthDateTo = birthDateTo + } + + if c.Query("deathdate-from") != "" { + deathDateFrom, err := date.ParseISO(c.Query("deathdate-from")) + if err != nil { + return searchFields, err + } + searchFields.DeathDateFrom = deathDateFrom + } + + if c.Query("deathdate-to") != "" { + deathDateTo, err := date.ParseISO(c.Query("deathdate-to")) + if err != nil { + return searchFields, err + } + searchFields.DeathDateTo = deathDateTo + } + + if searchFields.BirthDateTo != 0 && searchFields.BirthDateFrom > searchFields.BirthDateTo { + searchFields.BirthDateFrom, searchFields.BirthDateTo = searchFields.BirthDateTo, searchFields.BirthDateFrom + } + if searchFields.DeathDateTo != 0 && searchFields.DeathDateFrom > searchFields.DeathDateTo { + searchFields.DeathDateFrom, searchFields.DeathDateTo = searchFields.DeathDateTo, searchFields.DeathDateFrom + } + + return searchFields, nil +} + +func parseGenderQuery(value string) (float64, bool) { + switch value { + case "male": + return wikidata.GenderMale, true + case "female": + return wikidata.GenderFemale, true + case "intersex": + return wikidata.GenderIntersex, true + case "transgender-female": + return wikidata.GenderTrasgenderFemale, true + case "transgender-male": + return wikidata.GenderTrasgenderMale, true + case "unknown": + return wikidata.GenderUnknown, true + default: + return 0, false + } +} + +func (a *Controller) parseAuthorSearchSortBy(c fiber.Ctx) []string { + switch c.Query("sort-by") { + case "name-z-a": + return []string{"-Name"} + case "birth-older-first": + return []string{"DateOfBirth.Date"} + case "birth-newer-first": + return []string{"-DateOfBirth.Date"} + case "death-older-first": + return []string{"DateOfDeath.Date"} + case "death-newer-first": + return []string{"-DateOfDeath.Date"} + default: + return []string{"Name"} + } +} diff --git a/internal/webserver/controller/author/summary.go b/internal/webserver/controller/author/summary.go index 3aaebf1f..b17803fc 100644 --- a/internal/webserver/controller/author/summary.go +++ b/internal/webserver/controller/author/summary.go @@ -5,7 +5,7 @@ import ( "log" "github.com/gofiber/fiber/v3" - "github.com/svera/coreander/v4/internal/datasource/model" + datasourcemodel "github.com/svera/coreander/v4/internal/datasource/model" "github.com/svera/coreander/v4/internal/index" ) @@ -16,7 +16,7 @@ func (a *Controller) Summary(c fiber.Ctx) error { c.Set("Pragma", "no-cache") c.Set("Expires", "0") var ( - authorDataSource model.Author + authorDataSource datasourcemodel.Author err error ) @@ -68,7 +68,7 @@ func (a *Controller) Summary(c fiber.Ctx) error { return fiber.ErrNotFound } - combineWithDataSource(&author, authorDataSource, supportedLanguages) + index.CombineWithDataSource(&author, authorDataSource, supportedLanguages) if err := a.idx.IndexAuthor(author); err != nil { log.Println(err) @@ -86,32 +86,6 @@ func (a *Controller) Summary(c fiber.Ctx) error { return nil } -func combineWithDataSource(author *index.Author, authorDataSource model.Author, supportedLanguages []string) { - author.DataSourceID = authorDataSource.SourceID() - author.BirthName = authorDataSource.BirthName() - author.RetrievedOn = authorDataSource.RetrievedOn() - author.WikipediaLink = make(map[string]string) - author.InstanceOf = authorDataSource.InstanceOf() - author.Description = make(map[string]string) - author.DateOfBirth = authorDataSource.DateOfBirth() - author.DateOfDeath = authorDataSource.DateOfDeath() - author.Website = authorDataSource.Website() - author.DataSourceImage = authorDataSource.Image() - author.Gender = authorDataSource.Gender() - author.Pseudonyms = make([]string, 0, len(authorDataSource.Pseudonyms())) - - for _, pseudonym := range authorDataSource.Pseudonyms() { - if pseudonym != author.Name { - author.Pseudonyms = append(author.Pseudonyms, pseudonym) - } - } - - for _, lang := range supportedLanguages { - author.WikipediaLink[lang] = authorDataSource.WikipediaLink(lang) - author.Description[lang] = authorDataSource.Description(lang) - } -} - // getImageVersion returns the modification time of the cached image file as a cache-busting version // Returns empty string if file doesn't exist func (a *Controller) getImageVersion(authorSlug string) string { diff --git a/internal/webserver/controller/author/update.go b/internal/webserver/controller/author/update.go index dcb5235a..8324a0ca 100644 --- a/internal/webserver/controller/author/update.go +++ b/internal/webserver/controller/author/update.go @@ -50,7 +50,7 @@ func (a *Controller) Update(c fiber.Ctx) error { fmt.Println(err) } - combineWithDataSource(&author, authorDataSource, supportedLanguages) + index.CombineWithDataSource(&author, authorDataSource, supportedLanguages) } if err := a.idx.IndexAuthor(author); err != nil { diff --git a/internal/webserver/controller/home/controller.go b/internal/webserver/controller/home/controller.go index 06cea012..77badc26 100644 --- a/internal/webserver/controller/home/controller.go +++ b/internal/webserver/controller/home/controller.go @@ -15,6 +15,7 @@ type Sender interface { type IdxReaderWriter interface { Document(slug string) (index.Document, error) Count() (uint64, error) + AuthorsCount() (uint64, error) LatestDocs(limit int) ([]index.Document, error) Languages() ([]string, error) } diff --git a/internal/webserver/controller/home/index.go b/internal/webserver/controller/home/index.go index c3325dfa..d15587a0 100644 --- a/internal/webserver/controller/home/index.go +++ b/internal/webserver/controller/home/index.go @@ -19,6 +19,12 @@ func (d *Controller) Index(c fiber.Ctx) error { return fiber.ErrInternalServerError } + totalAuthorsCount, err := d.idx.AuthorsCount() + if err != nil { + log.Println(err) + return fiber.ErrInternalServerError + } + latestDocsRaw, err := d.idx.LatestDocs(d.config.LatestDocsLimit) if err != nil { log.Println(err) @@ -46,10 +52,11 @@ func (d *Controller) Index(c fiber.Ctx) error { } return c.Render("index", fiber.Map{ - "Count": totalDocumentsCount, - "EmailFrom": d.sender.From(), - "HomeNavbar": true, - "LatestDocs": latestDocs, - "Reading": readingDocs, + "Count": totalDocumentsCount, + "AuthorsCount": totalAuthorsCount, + "EmailFrom": d.sender.From(), + "HomeNavbar": true, + "LatestDocs": latestDocs, + "Reading": readingDocs, }, "layout") } diff --git a/internal/webserver/embedded/css/display.css b/internal/webserver/embedded/css/display.css index 0ece2ae5..be118468 100644 --- a/internal/webserver/embedded/css/display.css +++ b/internal/webserver/embedded/css/display.css @@ -282,6 +282,40 @@ main .form-control:focus { overflow: hidden; } +#authors-searchbox-container .input-group .form-control:focus, +#searchbox-container .input-group .form-control:focus { + box-shadow: none !important; +} + +.home-search-tabs { + max-width: 28rem; + margin-left: auto; + margin-right: auto; +} + +.home-search-tabs .nav-link { + color: var(--bs-secondary-color); + background-color: var(--bs-body-bg); + border: 1px solid var(--bs-border-color); + border-bottom: 0; +} + +.home-search-tabs .nav-link.active { + color: var(--bs-body-color); + background-color: var(--bs-body-bg); + font-weight: 600; +} + +.home-search-input-group:focus-within { + color: #212529; + border-color: #86b7fe; + outline: 0; + box-shadow: 0 0 0 0.25rem rgb(13 110 253 / 25%); + z-index: 3; + border-radius: .25rem; +} + +#authors-searchbox-container .btn:hover, #searchbox-container .btn:hover { color: var(--bs-btn-color); background-color: rgba(0, 0, 0, 0); @@ -296,11 +330,16 @@ main .form-control:focus { border-radius: .25rem; } -#searchbox-container .input-group .form-control:focus { - box-shadow: none !important; +#authors-searchbox-container .input-group:focus-within { + color: #212529; + border-color: #86b7fe; + outline: 0; + box-shadow: 0 0 0 0.25rem rgb(13 110 253 / 25%); + z-index: 3; + border-radius: .25rem; } -#latest-docs .carousel-indicators { +.home-search-tabs { position: relative; } diff --git a/internal/webserver/embedded/js/author-search-filters.js b/internal/webserver/embedded/js/author-search-filters.js new file mode 100644 index 00000000..270cc076 --- /dev/null +++ b/internal/webserver/embedded/js/author-search-filters.js @@ -0,0 +1,247 @@ +"use strict" + +function isLeapYear(year) { + return (year % 4 === 0) && (year % 100 !== 0 || year % 400 === 0) +} + +function updateMaxDays(monthSelect, dayInput, yearInput, dateControl = null) { + const month = parseInt(monthSelect.value) + const year = parseInt(yearInput.value) || new Date().getFullYear() + + let maxDays = 31 + switch (month) { + case 2: + maxDays = isLeapYear(year) ? 29 : 28 + break + case 4: + case 6: + case 9: + case 11: + maxDays = 30 + break + } + + dayInput.setAttribute('max', maxDays) + + const currentDay = parseInt(dayInput.value) + if (currentDay > maxDays) { + dayInput.value = maxDays + if (dateControl) { + updateHiddenDateInput(dateControl) + } + } +} + +function updateHiddenDateInput(dateControl) { + const yearInput = dateControl.querySelector('.input-year') + const monthSelect = dateControl.querySelector('.input-month') + const dayInput = dateControl.querySelector('.input-day') + const hiddenDateInput = dateControl.parentElement.querySelector('.date') + + if (!yearInput.value || yearInput.value === '' || yearInput.value === '0') { + hiddenDateInput.value = '' + return + } + + let year = yearInput.value + if (year.startsWith('-') || year.startsWith('+')) { + year = year.substring(0, 1) + year.substring(1).padStart(4, '0') + } else { + year = year.padStart(4, '0') + } + + const month = monthSelect.value || '01' + const day = (dayInput.value || '1').padStart(2, '0') + + hiddenDateInput.value = year + '-' + month + '-' + day +} + +function copyFormValues(sourceForm, targetForm) { + for (const el of sourceForm.elements) { + if (!el.name) continue + const target = targetForm.elements[el.name] + if (target && target !== el) { + if (target.type === 'checkbox' || target.type === 'radio') { + target.checked = el.checked + } else { + target.value = el.value + } + } + } +} + +function yearForDisplay(yearStr) { + if (!yearStr) return '' + if (yearStr.startsWith('-')) { + const rest = yearStr.slice(1).replace(/^0+/, '') + return rest === '' ? '' : '-' + rest + } + const stripped = yearStr.replace(/^0+/, '') + return stripped === '' ? '' : stripped +} + +function applyHiddenDatesToVisible(container) { + if (!container) return + container.querySelectorAll('.date-control').forEach(dateControl => { + const hiddenInput = dateControl.parentElement.querySelector('input.date') + if (!hiddenInput || !hiddenInput.value) return + const parts = hiddenInput.value.split('-') + if (parts.length < 3) return + const yearInput = dateControl.querySelector('.input-year') + const monthSelect = dateControl.querySelector('.input-month') + const dayInput = dateControl.querySelector('.input-day') + if (yearInput) yearInput.value = yearForDisplay(parts[0]) + if (monthSelect) monthSelect.value = parts[1] + if (dayInput) dayInput.value = String(parseInt(parts[2], 10)) + }) +} + +function syncSidebarFormToOffcanvas() { + const sidebarForm = document.getElementById('search-filters-form') + const offcanvasContainer = document.getElementById('author-search-filters') + if (!sidebarForm) return + + const searchValue = sidebarForm.elements['name'] ? sidebarForm.elements['name'].value : '' + const navSearchbox = document.getElementById('searchbox') + if (navSearchbox) navSearchbox.value = searchValue + + if (!offcanvasContainer) return + const offcanvasForm = offcanvasContainer.closest('form') + if (!offcanvasForm) return + + copyFormValues(sidebarForm, offcanvasForm) + applyHiddenDatesToVisible(offcanvasContainer) +} + +function initAuthorSearchFilters(searchFilters) { + if (!searchFilters) return + const searchFiltersForm = searchFilters.closest('form') + if (!searchFiltersForm) return + + searchFilters.querySelectorAll('.date-control').forEach(dateControl => { + const monthSelect = dateControl.querySelector('.input-month') + const dayInput = dateControl.querySelector('.input-day') + const yearInput = dateControl.querySelector('.input-year') + + monthSelect.addEventListener('change', () => { + updateMaxDays(monthSelect, dayInput, yearInput, dateControl) + updateHiddenDateInput(dateControl) + }) + + yearInput.addEventListener('change', () => { + if (parseInt(monthSelect.value) === 2) { + updateMaxDays(monthSelect, dayInput, yearInput, dateControl) + } + updateHiddenDateInput(dateControl) + }) + + yearInput.addEventListener('input', () => { + updateHiddenDateInput(dateControl) + }) + + dayInput.addEventListener('change', () => { + updateHiddenDateInput(dateControl) + }) + + dayInput.addEventListener('input', () => { + updateHiddenDateInput(dateControl) + }) + + updateMaxDays(monthSelect, dayInput, yearInput, dateControl) + updateHiddenDateInput(dateControl) + }) + + function composeDateControls() { + searchFiltersForm.querySelectorAll('.date-control').forEach(function (el) { + const yearEl = el.querySelector('.input-year') + if (!yearEl || (yearEl.value === '' || yearEl.value === '0')) return + const composed = el.parentElement.querySelector('.date') + if (!composed) return + let year = yearEl.value + if (year.startsWith('-') || year.startsWith('+')) { + year = year.substring(0, 1) + year.substring(1).padStart(4, '0') + } else { + year = year.padStart(4, '0') + } + const month = el.querySelector('.input-month').value || '01' + const day = (el.querySelector('.input-day').value || '1').padStart(2, '0') + composed.value = year + '-' + month + '-' + day + }) + } + + const isAuthorsPage = window.location.pathname === '/authors' + let applyingFilters = false + + function applyFilters() { + applyingFilters = true + composeDateControls() + const sidebarForm = document.getElementById('search-filters-form') + if (sidebarForm && isAuthorsPage) { + if (searchFiltersForm !== sidebarForm) { + copyFormValues(searchFiltersForm, sidebarForm) + } + const formData = new FormData(sidebarForm) + const params = new URLSearchParams() + for (const [k, v] of formData.entries()) { + if (v != null && String(v).trim() !== '') params.append(k, v) + } + const queryString = params.toString() + const url = '/authors' + (queryString ? '?' + queryString : '') + window.htmx.trigger(document.body, 'update') + history.replaceState(null, '', url) + syncSidebarFormToOffcanvas() + } else { + const params = new URLSearchParams(new FormData(searchFiltersForm)) + window.location.href = '/authors?' + params.toString() + } + setTimeout(() => { applyingFilters = false }, 0) + } + + const FILTER_DEBOUNCE_MS = 600 + let applyFiltersDebounced + + function scheduleApplyFilters() { + if (applyingFilters) return + if (applyFiltersDebounced) clearTimeout(applyFiltersDebounced) + applyFiltersDebounced = setTimeout(applyFilters, FILTER_DEBOUNCE_MS) + } + + if (isAuthorsPage) { + searchFiltersForm.addEventListener('submit', (e) => { + e.preventDefault() + if (applyFiltersDebounced) clearTimeout(applyFiltersDebounced) + applyFilters() + }) + + searchFiltersForm.addEventListener('input', () => scheduleApplyFilters()) + searchFiltersForm.addEventListener('change', () => scheduleApplyFilters()) + } else { + searchFiltersForm.addEventListener('submit', () => { + composeDateControls() + searchFilters.querySelectorAll('input').forEach(input => { + if (input.value === '' || input.value === '0') input.setAttribute('disabled', 'disabled') + }) + }) + } +} + +window.addEventListener('pageshow', () => { + ['author-search-filters', 'author-search-filters-sidebar'].forEach(id => { + const el = document.getElementById(id) + if (el) { + el.querySelectorAll('input').forEach(input => { + input.removeAttribute('disabled') + }) + } + }) +}) + +initAuthorSearchFilters(document.getElementById('author-search-filters')) +initAuthorSearchFilters(document.getElementById('author-search-filters-sidebar')) + +if (document.getElementById('search-filters-form') && document.getElementById('author-search-filters')) { + const offcanvasEl = document.getElementById('search-filters-offcanvas') + if (offcanvasEl) { + offcanvasEl.addEventListener('shown.bs.offcanvas', () => syncSidebarFormToOffcanvas()) + } +} diff --git a/internal/webserver/embedded/js/home-search-tabs.js b/internal/webserver/embedded/js/home-search-tabs.js new file mode 100644 index 00000000..4d1c0293 --- /dev/null +++ b/internal/webserver/embedded/js/home-search-tabs.js @@ -0,0 +1,47 @@ +"use strict" + +const STORAGE_KEY = "coreander-home-search-tab" + +function activateHomeSearchTab(tabName) { + document.querySelectorAll("[data-home-search-tab]").forEach((button) => { + const isActive = button.dataset.homeSearchTab === tabName + button.classList.toggle("active", isActive) + button.setAttribute("aria-selected", isActive ? "true" : "false") + }) + + document.querySelectorAll("[data-home-search-panel]").forEach((panel) => { + const isActive = panel.dataset.homeSearchPanel === tabName + panel.classList.toggle("d-none", !isActive) + }) + + try { + sessionStorage.setItem(STORAGE_KEY, tabName) + } catch (_) { + // ignore storage errors + } +} + +function initHomeSearchTabs() { + const tabs = document.getElementById("home-search-tabs") + if (!tabs) return + + tabs.addEventListener("click", (event) => { + const button = event.target.closest("[data-home-search-tab]") + if (!button) return + activateHomeSearchTab(button.dataset.homeSearchTab) + }) + + let initialTab = "documents" + try { + const stored = sessionStorage.getItem(STORAGE_KEY) + if (stored === "documents" || stored === "authors") { + initialTab = stored + } + } catch (_) { + // ignore storage errors + } + + activateHomeSearchTab(initialTab) +} + +initHomeSearchTabs() diff --git a/internal/webserver/embedded/js/keyboard-shortcuts.js b/internal/webserver/embedded/js/keyboard-shortcuts.js index 1104190b..d83a3f74 100644 --- a/internal/webserver/embedded/js/keyboard-shortcuts.js +++ b/internal/webserver/embedded/js/keyboard-shortcuts.js @@ -57,9 +57,11 @@ function isVisible(element) { function getSearchInput() { const candidates = [ document.querySelector('#searchbox-container input[name="search"]'), + document.querySelector('#authors-searchbox-container input[name="name"]'), document.querySelector('#sidebar-search'), ...document.querySelectorAll('#searchbox'), document.querySelector('#searchbox-offcanvas'), + document.querySelector('#authors-searchbox'), ] for (const input of candidates) { diff --git a/internal/webserver/embedded/translations/de.yml b/internal/webserver/embedded/translations/de.yml index cd4e8dcf..68f48639 100644 --- a/internal/webserver/embedded/translations/de.yml +++ b/internal/webserver/embedded/translations/de.yml @@ -1,7 +1,11 @@ "_language": "Deutsch" "Search": "Suche" +"Documents": "Dokumente" +"Authors": "Autoren" "Search results": "Suchergebnisse" "Search in %d documents": "In %d Dokumenten suchen" +"Search in %d authors": "In %d Autoren suchen" +"Search results": "Suchergebnisse" "Download": "Herunterladen" "%d documents found": "%d Dokumente gefunden" "No documents found": "Keine Dokumente gefunden" @@ -141,7 +145,12 @@ "An error occurred while uploading the image": "Beim Hochladen des Bildes ist ein Fehler aufgetreten" "Click to upload a custom image": "Klicken Sie, um ein benutzerdefiniertes Bild hochzuladen" "Indexing in progress search results may not be accurate.": "Indexierung läuft die Suchergebnisse sind möglicherweise nicht genau." +"Indexing in progress, search results may not be accurate.": "Indexierung läuft, Suchergebnisse sind möglicherweise nicht genau." +"Retrieving author information, author pages may not be complete yet.": "Autoreninformationen werden abgerufen, Autorenseiten sind möglicherweise noch unvollständig." +"Author information progress": "Fortschritt der Autoreninformationen" +"Indexing progress": "Indexierungsfortschritt" "Remaining time": "Verbleibende Zeit" +"Remaining time: %s minutes": "Verbleibende Zeit: %s Minuten" "There was an error deleting the user please try again later": "Beim Löschen des Benutzers ist ein Fehler aufgetreten bitte versuchen Sie es später erneut." "A user with this username already exists": "Ein Benutzer mit diesem Benutzernamen existiert bereits." "A user with this email address already exists": "Ein Benutzer mit dieser E-Mail-Adresse existiert bereits." @@ -165,6 +174,24 @@ "Never": "Nie" "Date of birth": "Geburtsdatum" "Date of death": "Sterbedatum" +"Search authors": "Autoren suchen" +"No authors found": "Keine Autoren gefunden" +"%d authors found": "%d Autoren gefunden" +"Name": "Name" +"Gender": "Geschlecht" +"All genders": "Alle Geschlechter" +"Male": "Männlich" +"Female": "Weiblich" +"Intersex": "Intersexuell" +"Transgender female": "Transgender weiblich" +"Transgender male": "Transgender männlich" +"Unknown": "Unbekannt" +"name A-Z": "Name A-Z" +"name Z-A": "Name Z-A" +"birth older first": "Geburt älteste zuerst" +"birth newer first": "Geburt neueste zuerst" +"death older first": "Tod älteste zuerst" +"death newer first": "Tod neueste zuerst" "%d years old": "%d Jahre alt" "BC": "v. Chr." "Before Christ": "Vor Christus" diff --git a/internal/webserver/embedded/translations/es.yml b/internal/webserver/embedded/translations/es.yml index 63e09a14..d75caa04 100644 --- a/internal/webserver/embedded/translations/es.yml +++ b/internal/webserver/embedded/translations/es.yml @@ -1,7 +1,10 @@ "_language": "Español" "Search": "Buscar" +"Documents": "Documentos" +"Authors": "Autores" "Search results": "Resultados de la búsqueda" "Search in %d documents": "Buscar en %d documentos" +"Search in %d authors": "Buscar en %d autores" "Download": "Descargar" "%d documents found": "Hallados %d documentos" "No documents found": "No se han encontrado documentos" @@ -139,6 +142,9 @@ "An error occurred while uploading the image": "Ocurrió un error al subir la imagen" "Click to upload a custom image": "Haz clic para subir una imagen personalizada" "Indexing in progress, search results may not be accurate.": "Indexando documentos, los resultados de búsqueda pueden no ser precisos." +"Retrieving author information, author pages may not be complete yet.": "Recuperando información de autores, las páginas de autor pueden estar incompletas." +"Author information progress": "Progreso de información de autores" +"Indexing progress": "Progreso de indexación" "Remaining time: %s minutes": "Tiempo restante: %s minutos" "There was an error deleting the user, please try again later": "Hubo un error al borrar el usuario, por favor, vuelva a intentarlo más tarde" "A user with this username already exists": "Ya existe un usuario con ese nombre de usuario" @@ -163,6 +169,24 @@ "Never": "Nunca" "Date of birth": "Nacimiento" "Date of death": "Fallecimiento" +"Search authors": "Buscar autores" +"No authors found": "No se encontraron autores" +"%d authors found": "%d autores encontrados" +"Name": "Nombre" +"Gender": "Género" +"All genders": "Todos los géneros" +"Male": "Masculino" +"Female": "Femenino" +"Intersex": "Intersexual" +"Transgender female": "Mujer trans" +"Transgender male": "Hombre trans" +"Unknown": "Desconocido" +"name A-Z": "nombre A-Z" +"name Z-A": "nombre Z-A" +"birth older first": "nacimiento más antiguo" +"birth newer first": "nacimiento más reciente" +"death older first": "fallecimiento más antiguo" +"death newer first": "fallecimiento más reciente" "%d years old": "%d años" "BC": "a. C." "Before Christ": "Antes de Cristo" diff --git a/internal/webserver/embedded/translations/fr.yml b/internal/webserver/embedded/translations/fr.yml index 71c56fec..d0ca2f7d 100644 --- a/internal/webserver/embedded/translations/fr.yml +++ b/internal/webserver/embedded/translations/fr.yml @@ -1,7 +1,10 @@ "_language": "Français" "Search": "Rechercher" +"Documents": "Documents" +"Authors": "Auteurs" "Search results": "Résultats de la recherche" "Search in %d documents": "Rechercher dans %d documents" +"Search in %d authors": "Rechercher dans %d auteurs" "Download": "Télécharger" "%d documents found": "%d documents trouvés" "No documents found": "Aucun document trouvé" @@ -140,6 +143,9 @@ "An error occurred while uploading the image": "Une erreur s'est produite lors du téléchargement de l'image" "Click to upload a custom image": "Cliquez pour télécharger une image personnalisée" "Indexing in progress, search results may not be accurate.": "Indexation en cours, les résultats de la recherche peuvent ne pas être précis." +"Retrieving author information, author pages may not be complete yet.": "Récupération des informations sur les auteurs, les pages auteur peuvent être incomplètes." +"Author information progress": "Progression des informations sur les auteurs" +"Indexing progress": "Progression de l'indexation" "Remaining time: %s minutes": "Temps restant: %s minutes" "There was an error deleting the user, please try again later": "Une erreur s'est produite lors de supprimer l'utilisateur, veuillez réessayer ultérieurement" "A user with this username already exists": "Un utilisateur avec ce nom d'utilisateur existe déjà" @@ -164,6 +170,24 @@ "Never": "Jamais" "Date of birth": "Date de naissance" "Date of death": "Date de décès" +"Search authors": "Rechercher des auteurs" +"No authors found": "Aucun auteur trouvé" +"%d authors found": "%d auteurs trouvés" +"Name": "Nom" +"Gender": "Genre" +"All genders": "Tous les genres" +"Male": "Homme" +"Female": "Femme" +"Intersex": "Intersexe" +"Transgender female": "Femme transgenre" +"Transgender male": "Homme transgenre" +"Unknown": "Inconnu" +"name A-Z": "nom A-Z" +"name Z-A": "nom Z-A" +"birth older first": "naissance plus ancienne" +"birth newer first": "naissance plus récente" +"death older first": "décès plus ancien" +"death newer first": "décès plus récent" "%d years old": "%d ans" "BC": "av. J.-C." "Before Christ": "Avant Jésus-Christ" diff --git a/internal/webserver/embedded/translations/ru.yml b/internal/webserver/embedded/translations/ru.yml index 43643f74..83c63320 100644 --- a/internal/webserver/embedded/translations/ru.yml +++ b/internal/webserver/embedded/translations/ru.yml @@ -1,7 +1,10 @@ "_language": "Русский" "Search": "Поиск" +"Documents": "Документы" +"Authors": "Авторы" "Search results": "Результаты поиска" "Search in %d documents": "Поиск по %d документам" +"Search in %d authors": "Поиск по %d авторам" "Download": "Скачать" "%d documents found": "Найдено %d документов" "No documents found": "Документы не найдены" @@ -141,6 +144,9 @@ "An error occurred while uploading the image": "Произошла ошибка при загрузке изображения" "Click to upload a custom image": "Нажмите, чтобы загрузить пользовательское изображение" "Indexing in progress, search results may not be accurate.": "Идёт индексация; результаты поиска могут быть неточными." +"Retrieving author information, author pages may not be complete yet.": "Получение информации об авторах; страницы авторов могут быть неполными." +"Author information progress": "Прогресс получения информации об авторах" +"Indexing progress": "Прогресс индексации" "Remaining time: %s minutes": "Оставшееся время: %s мин." "There was an error deleting the user, please try again later": "Ошибка при удалении пользователя. Повторите попытку позже." "A user with this username already exists": "Пользователь с таким именем уже существует" @@ -165,6 +171,24 @@ "Never": "Никогда" "Date of birth": "Дата рождения" "Date of death": "Дата смерти" +"Search authors": "Поиск авторов" +"No authors found": "Авторы не найдены" +"%d authors found": "Найдено авторов: %d" +"Name": "Имя" +"Gender": "Пол" +"All genders": "Любой пол" +"Male": "Мужской" +"Female": "Женский" +"Intersex": "Интерсекс" +"Transgender female": "Трансгендерная женщина" +"Transgender male": "Трансгендерный мужчина" +"Unknown": "Неизвестно" +"name A-Z": "имя А–Я" +"name Z-A": "имя Я–А" +"birth older first": "рождение: старые" +"birth newer first": "рождение: новые" +"death older first": "смерть: старые" +"death newer first": "смерть: новые" "%d years old": "%d лет" "BC": "до н. э." "Before Christ": "До Рождества Христова" diff --git a/internal/webserver/embedded/views/author/search.html b/internal/webserver/embedded/views/author/search.html new file mode 100644 index 00000000..a670a327 --- /dev/null +++ b/internal/webserver/embedded/views/author/search.html @@ -0,0 +1,24 @@ +
+
+ {{template "partials/authors-list-header" .}} +
+
+ +
+
+
+ +
+
+
+ {{template "partials/docs-list-placeholder" .}} +
+ {{ template "partials/authors-list-content" . }} +
+
+
+ + + diff --git a/internal/webserver/embedded/views/layout.html b/internal/webserver/embedded/views/layout.html index 6428ab42..2cb35e88 100644 --- a/internal/webserver/embedded/views/layout.html +++ b/internal/webserver/embedded/views/layout.html @@ -27,7 +27,7 @@ {{template "partials/navbar" .}}
- {{template "partials/main" dict "Lang" .Lang "IndexingInProgress" .IndexingInProgress "RemainingIndexingTime" .RemainingIndexingTime "IndexingProgressPercentage" .IndexingProgressPercentage "Error" .Error "Warning" .Warning "Success" .Success "Embed" .Embed}} + {{template "partials/main" dict "Lang" .Lang "IndexingInProgress" .IndexingInProgress "IndexingProgressKind" .IndexingProgressKind "RemainingIndexingTime" .RemainingIndexingTime "IndexingProgressPercentage" .IndexingProgressPercentage "Error" .Error "Warning" .Warning "Success" .Success "Embed" .Embed}}
{{template "partials/share-modal" .}} {{template "partials/keyboard-shortcuts-modal" .}} diff --git a/internal/webserver/embedded/views/partials/author-search-filters.html b/internal/webserver/embedded/views/partials/author-search-filters.html new file mode 100644 index 00000000..a218e97c --- /dev/null +++ b/internal/webserver/embedded/views/partials/author-search-filters.html @@ -0,0 +1,77 @@ +{{$idPrefix := or .FilterIdPrefix ""}} +
+ {{if $idPrefix}} +
+ {{t .Lang "Search"}} +
+
+ {{$name := ""}} + {{if .SearchFields}} + {{$name = .SearchFields.Name}} + {{end}} + + +
+
+
+ {{end}} +
+ {{t .Lang "Gender"}} +
+
+ {{$gender := or .SelectedGender ""}} + +
+
+
+
+ {{t .Lang "Date of birth"}} +
+
+ {{$birthDateFrom := ""}} + {{if .SearchFields}}{{$birthDateFrom = .SearchFields.BirthDateFrom}}{{end}} + {{template "partials/date-control" dict "Name" "birthdate-from" "Lang" .Lang "Date" $birthDateFrom "Label" "From" "IdPrefix" $idPrefix }} +
+
+ {{$birthDateTo := ""}} + {{if .SearchFields}}{{$birthDateTo = .SearchFields.BirthDateTo}}{{end}} + {{template "partials/date-control" dict "Name" "birthdate-to" "Lang" .Lang "Date" $birthDateTo "Label" "To" "IdPrefix" $idPrefix }} +
+
+
+
+ {{t .Lang "Date of death"}} +
+
+ {{$deathDateFrom := ""}} + {{if .SearchFields}}{{$deathDateFrom = .SearchFields.DeathDateFrom}}{{end}} + {{template "partials/date-control" dict "Name" "deathdate-from" "Lang" .Lang "Date" $deathDateFrom "Label" "From" "IdPrefix" $idPrefix }} +
+
+ {{$deathDateTo := ""}} + {{if .SearchFields}}{{$deathDateTo = .SearchFields.DeathDateTo}}{{end}} + {{template "partials/date-control" dict "Name" "deathdate-to" "Lang" .Lang "Date" $deathDateTo "Label" "To" "IdPrefix" $idPrefix }} +
+
+
+ {{if not .AuthorsSearchPage}} +
+
+ +
+
+ {{end}} +
+ +{{if or $idPrefix (not .AuthorsSearchPage)}} + + +{{end}} diff --git a/internal/webserver/embedded/views/partials/authors-list-content.html b/internal/webserver/embedded/views/partials/authors-list-content.html new file mode 100644 index 00000000..c915ed9f --- /dev/null +++ b/internal/webserver/embedded/views/partials/authors-list-content.html @@ -0,0 +1,13 @@ +{{if gt .Results.TotalHits 0}} + +{{end}} + +{{ $length := len .Paginator.Pages }} {{ if gt $length 1 }} +{{template "partials/pagination" .}} +{{end}} diff --git a/internal/webserver/embedded/views/partials/authors-list-fragments.html b/internal/webserver/embedded/views/partials/authors-list-fragments.html new file mode 100644 index 00000000..c25b59f7 --- /dev/null +++ b/internal/webserver/embedded/views/partials/authors-list-fragments.html @@ -0,0 +1,2 @@ +
{{template "partials/authors-list-header" .}}
+
{{template "partials/authors-list-content" .}}
diff --git a/internal/webserver/embedded/views/partials/authors-list-header.html b/internal/webserver/embedded/views/partials/authors-list-header.html new file mode 100644 index 00000000..fe08afcb --- /dev/null +++ b/internal/webserver/embedded/views/partials/authors-list-header.html @@ -0,0 +1,15 @@ +{{$lang := .Lang}} +
+ {{if eq .Results.TotalHits 0}} +
+

{{t .Lang "No authors found"}}

+
+ {{else}} +
+

{{t .Lang "%d authors found" .Results.TotalHits }}

+
+
+ {{template "partials/sort" dict "SortURL" .SortURL "SortBy" .SortBy "Lang" $lang "AdditionalSortOptions" .AdditionalSortOptions}} +
+ {{end}} +
diff --git a/internal/webserver/embedded/views/partials/authors-list-item.html b/internal/webserver/embedded/views/partials/authors-list-item.html new file mode 100644 index 00000000..ed95554a --- /dev/null +++ b/internal/webserver/embedded/views/partials/authors-list-item.html @@ -0,0 +1,28 @@ +
+ +
+

{{.Author.Name}}

+ {{if and (ne .Author.BirthName "") (not .Author.BirthNameIncludesName)}} +

{{.Author.BirthName}}

+ {{end}} + {{if index .Author.Description .Lang}} +

{{index .Author.Description .Lang}}

+ {{end}} +
+ {{if ne .Author.DateOfBirth.Date 0}} +
{{t .Lang "Date of birth"}}
+
+ {{end}} + {{if ne .Author.DateOfDeath.Date 0}} +
{{t .Lang "Date of death"}}
+
+ {{end}} +
+
+
diff --git a/internal/webserver/embedded/views/partials/main.html b/internal/webserver/embedded/views/partials/main.html index 0b3eb580..7fd1f8a8 100644 --- a/internal/webserver/embedded/views/partials/main.html +++ b/internal/webserver/embedded/views/partials/main.html @@ -2,8 +2,13 @@ {{if .IndexingInProgress}}
diff --git a/internal/webserver/remove_document_test.go b/internal/webserver/remove_document_test.go index 45dd0be4..b86d122c 100644 --- a/internal/webserver/remove_document_test.go +++ b/internal/webserver/remove_document_test.go @@ -71,6 +71,8 @@ func TestRemoveDocument(t *testing.T) { } assertDocumentResults(app, t, "john+doe", 3) + assertAuthorSearchResults(app, t, "john", 1) + assertAuthorDocuments(app, t, "john-doe", 3) } if response.StatusCode != tcase.expectedHTTPStatus { @@ -78,4 +80,30 @@ func TestRemoveDocument(t *testing.T) { } }) } + + t.Run("Remove document removes orphan author", func(t *testing.T) { + assertAuthorSearchResults(app, t, "sergio", 1) + assertAuthorDocuments(app, t, "sergio-vera", 1) + + cookie, err := login(app, "admin@example.com", "admin", t) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + + slug := documentSearchFirstSlug(app, t, "sergio+vera") + response, err := deleteRequest(url.Values{}, cookie, app, fmt.Sprintf("/documents/%s", slug), t) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + if response.StatusCode != http.StatusOK { + t.Fatalf("Expected status %d, received %d", http.StatusOK, response.StatusCode) + } + + if _, err := appFS.Stat("empty.pdf"); !os.IsNotExist(err) { + t.Errorf("Expected 'file not exist' error when trying to access a file that should have been removed") + } + + assertDocumentResults(app, t, "sergio+vera", 0) + assertAuthorSearchResults(app, t, "sergio", 0) + }) } diff --git a/internal/webserver/search_test.go b/internal/webserver/search_test.go index 23303f65..2a5aefdc 100644 --- a/internal/webserver/search_test.go +++ b/internal/webserver/search_test.go @@ -2,6 +2,7 @@ package webserver_test import ( "net/http" + "strings" "testing" "github.com/PuerkitoBio/goquery" @@ -78,3 +79,81 @@ func assertDocumentResults(app *fiber.App, t *testing.T, search string, expected t.Errorf("Expected %d results, got %d", expectedResults, actualResults) } } + +func assertAuthorSearchResults(app *fiber.App, t *testing.T, name string, expectedResults int) { + t.Helper() + + req, err := http.NewRequest(http.MethodGet, "/authors?name="+name, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + response, err := app.Test(req) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + if expectedStatus := http.StatusOK; response.StatusCode != expectedStatus { + t.Errorf("Expected status %d, received %d", expectedStatus, response.StatusCode) + } + + doc, err := goquery.NewDocumentFromReader(response.Body) + if err != nil { + t.Fatal(err) + } + + if actualResults := doc.Find("#list .list-group-item").Length(); actualResults != expectedResults { + t.Errorf("Expected %d author results, got %d", expectedResults, actualResults) + } +} + +func documentSearchFirstSlug(app *fiber.App, t *testing.T, search string) string { + t.Helper() + + req, err := http.NewRequest(http.MethodGet, "/documents?search="+search, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + response, err := app.Test(req) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + if response.StatusCode != http.StatusOK { + t.Fatalf("Expected status %d, received %d", http.StatusOK, response.StatusCode) + } + + doc, err := goquery.NewDocumentFromReader(response.Body) + if err != nil { + t.Fatal(err) + } + + href, ok := doc.Find("#list .list-group-item a").First().Attr("href") + if !ok { + t.Fatalf("Expected a document link for search %q", search) + } + + return strings.TrimPrefix(href, "/documents/") +} + +func assertAuthorDocuments(app *fiber.App, t *testing.T, authorSlug string, expectedResults int) { + t.Helper() + + req, err := http.NewRequest(http.MethodGet, "/authors/"+authorSlug, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + response, err := app.Test(req) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + if response.StatusCode != http.StatusOK { + t.Fatalf("Expected status %d, received %d", http.StatusOK, response.StatusCode) + } + + doc, err := goquery.NewDocumentFromReader(response.Body) + if err != nil { + t.Fatal(err) + } + + if actualResults := doc.Find("#list .list-group-item").Length(); actualResults != expectedResults { + t.Errorf("Expected %d documents for author %q, got %d", expectedResults, authorSlug, actualResults) + } +} From 50f01cff30bd4eb16ecfa18a723975425747b691 Mon Sep 17 00:00:00 2001 From: Sergio Vera Date: Thu, 11 Jun 2026 11:35:45 +0200 Subject: [PATCH 03/10] WIP --- internal/index/author_search.go | 105 ++++++++++++---------------- internal/index/bleve_read.go | 15 +--- internal/index/date_range_filter.go | 24 +++++++ 3 files changed, 70 insertions(+), 74 deletions(-) create mode 100644 internal/index/date_range_filter.go diff --git a/internal/index/author_search.go b/internal/index/author_search.go index 451250f4..32428921 100644 --- a/internal/index/author_search.go +++ b/internal/index/author_search.go @@ -7,6 +7,7 @@ import ( "unicode" "github.com/blevesearch/bleve/v2" + "github.com/blevesearch/bleve/v2/search" "github.com/blevesearch/bleve/v2/search/query" "github.com/rickb777/date/v2" "github.com/svera/coreander/v4/internal/result" @@ -97,30 +98,36 @@ func addDeathDateRangeFilter(filtersQuery *query.ConjunctionQuery, from, to date filtersQuery.AddQuery(excludeLiving) } -func addDateRangeFilter(filtersQuery *query.ConjunctionQuery, field string, from, to date.Date) { - if from == 0 && to == 0 { - return - } - minDate := float64(from) - q := bleve.NewNumericRangeQuery(nil, nil) - if from != 0 { - q.Min = &minDate - } - if to != 0 { - maxDate := float64(to) + 1 - q.Max = &maxDate - } - q.SetField(field) - filtersQuery.AddQuery(q) -} - func (b *BleveIndexer) runAuthorsPaginatedQuery(q query.Query, page, resultsPerPage int, sortBy []string) (result.Paginated[[]Author], error) { if page < 1 { page = 1 } - if documentCountSort, desc := authorDocumentCountSort(sortBy); documentCountSort { - return b.runAuthorsPaginatedQueryByDocumentCount(q, page, resultsPerPage, desc) + if desc, ok := authorDocumentCountSortDesc(sortBy); ok { + countResult, err := b.authorsIdx.Search(bleve.NewSearchRequestOptions(q, 0, 0, false)) + if err != nil { + return result.Paginated[[]Author]{}, err + } + total := int(countResult.Total) + if total == 0 { + return result.Paginated[[]Author]{}, nil + } + + searchOptions := bleve.NewSearchRequestOptions(q, total, 0, false) + searchOptions.Fields = []string{"*"} + searchResult, err := b.authorsIdx.Search(searchOptions) + if err != nil { + return result.Paginated[[]Author]{}, err + } + + authors := hydrateAuthors(searchResult.Hits) + counts, err := b.DocumentCountsByAuthorSlugs(authorSlugsFromAuthors(authors)) + if err != nil { + return result.Paginated[[]Author]{}, err + } + sortAuthorsByDocumentCount(authors, counts, desc) + + return paginateAuthors(resultsPerPage, page, total, authors), nil } searchOptions := bleve.NewSearchRequestOptions(q, resultsPerPage, (page-1)*resultsPerPage, false) @@ -134,26 +141,21 @@ func (b *BleveIndexer) runAuthorsPaginatedQuery(q query.Query, page, resultsPerP return result.Paginated[[]Author]{}, nil } - authors := make([]Author, len(searchResult.Hits)) - for i, hit := range searchResult.Hits { - authors[i] = hydrateAuthor(hit) - } - return result.NewPaginated( resultsPerPage, page, int(searchResult.Total), - authors, + hydrateAuthors(searchResult.Hits), ), nil } -func authorDocumentCountSort(sortBy []string) (bool, bool) { +func authorDocumentCountSortDesc(sortBy []string) (desc bool, ok bool) { if len(sortBy) != 1 { return false, false } switch sortBy[0] { case "DocumentCount": - return true, false + return false, true case "-DocumentCount": return true, true default: @@ -161,36 +163,23 @@ func authorDocumentCountSort(sortBy []string) (bool, bool) { } } -func (b *BleveIndexer) runAuthorsPaginatedQueryByDocumentCount(q query.Query, page, resultsPerPage int, desc bool) (result.Paginated[[]Author], error) { - countRequest := bleve.NewSearchRequestOptions(q, 0, 0, false) - countResult, err := b.authorsIdx.Search(countRequest) - if err != nil { - return result.Paginated[[]Author]{}, err - } - total := int(countResult.Total) - if total == 0 { - return result.Paginated[[]Author]{}, nil - } - - searchOptions := bleve.NewSearchRequestOptions(q, total, 0, false) - searchOptions.Fields = []string{"*"} - searchResult, err := b.authorsIdx.Search(searchOptions) - if err != nil { - return result.Paginated[[]Author]{}, err - } - - authors := make([]Author, len(searchResult.Hits)) - slugs := make([]string, len(searchResult.Hits)) - for i, hit := range searchResult.Hits { +func hydrateAuthors(hits search.DocumentMatchCollection) []Author { + authors := make([]Author, len(hits)) + for i, hit := range hits { authors[i] = hydrateAuthor(hit) - slugs[i] = authors[i].Slug } + return authors +} - counts, err := b.DocumentCountsByAuthorSlugs(slugs) - if err != nil { - return result.Paginated[[]Author]{}, err +func authorSlugsFromAuthors(authors []Author) []string { + slugs := make([]string, len(authors)) + for i, author := range authors { + slugs[i] = author.Slug } + return slugs +} +func sortAuthorsByDocumentCount(authors []Author, counts map[string]uint64, desc bool) { slices.SortFunc(authors, func(a, b Author) int { countA := counts[a.Slug] countB := counts[b.Slug] @@ -202,20 +191,16 @@ func (b *BleveIndexer) runAuthorsPaginatedQueryByDocumentCount(q query.Query, pa } return strings.Compare(a.Name, b.Name) }) +} +func paginateAuthors(resultsPerPage, page, total int, authors []Author) result.Paginated[[]Author] { start := (page - 1) * resultsPerPage if start >= total { - return result.NewPaginated(resultsPerPage, page, total, []Author{}), nil + return result.NewPaginated(resultsPerPage, page, total, []Author{}) } end := start + resultsPerPage if end > total { end = total } - - return result.NewPaginated( - resultsPerPage, - page, - total, - authors[start:end], - ), nil + return result.NewPaginated(resultsPerPage, page, total, authors[start:end]) } diff --git a/internal/index/bleve_read.go b/internal/index/bleve_read.go index 18e60aa2..89332e6c 100644 --- a/internal/index/bleve_read.go +++ b/internal/index/bleve_read.go @@ -181,20 +181,7 @@ func (b *BleveIndexer) addFilters(searchFields SearchFields, filtersQuery *query filtersQuery.AddQuery(subjectQueries) } } - if searchFields.PubDateFrom != 0 || searchFields.PubDateTo != 0 { - minDate := float64(searchFields.PubDateFrom) - maxDate := float64(searchFields.PubDateTo) - - q := bleve.NewNumericRangeQuery(nil, nil) - if minDate != 0 { - q.Min = &minDate - } - if maxDate != 0 { - q.Max = &maxDate - } - q.SetField("Publication.Date") - filtersQuery.AddQuery(q) - } + addDateRangeFilter(filtersQuery, "Publication.Date", searchFields.PubDateFrom, searchFields.PubDateTo) if searchFields.EstReadTimeFrom > 0 || searchFields.EstReadTimeTo > 0 { q := bleve.NewNumericRangeQuery(nil, nil) if searchFields.EstReadTimeFrom > 0 { diff --git a/internal/index/date_range_filter.go b/internal/index/date_range_filter.go new file mode 100644 index 00000000..137aa4f2 --- /dev/null +++ b/internal/index/date_range_filter.go @@ -0,0 +1,24 @@ +package index + +import ( + "github.com/blevesearch/bleve/v2" + "github.com/blevesearch/bleve/v2/search/query" + "github.com/rickb777/date/v2" +) + +func addDateRangeFilter(filtersQuery *query.ConjunctionQuery, field string, from, to date.Date) { + if from == 0 && to == 0 { + return + } + minDate := float64(from) + q := bleve.NewNumericRangeQuery(nil, nil) + if from != 0 { + q.Min = &minDate + } + if to != 0 { + maxDate := float64(to) + 1 + q.Max = &maxDate + } + q.SetField(field) + filtersQuery.AddQuery(q) +} From 3680022926623e2ab94cef716613d808a4fc3e7e Mon Sep 17 00:00:00 2001 From: Sergio Vera Date: Thu, 11 Jun 2026 11:55:27 +0200 Subject: [PATCH 04/10] WIP --- internal/index/author_search.go | 17 ++------------ internal/result/paginated.go | 27 ++++++++++++++++++++++ internal/result/paginated_test.go | 37 +++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 15 deletions(-) create mode 100644 internal/result/paginated_test.go diff --git a/internal/index/author_search.go b/internal/index/author_search.go index 32428921..88aef0ab 100644 --- a/internal/index/author_search.go +++ b/internal/index/author_search.go @@ -126,8 +126,7 @@ func (b *BleveIndexer) runAuthorsPaginatedQuery(q query.Query, page, resultsPerP return result.Paginated[[]Author]{}, err } sortAuthorsByDocumentCount(authors, counts, desc) - - return paginateAuthors(resultsPerPage, page, total, authors), nil + return result.Paginate(resultsPerPage, page, total, authors), nil } searchOptions := bleve.NewSearchRequestOptions(q, resultsPerPage, (page-1)*resultsPerPage, false) @@ -141,7 +140,7 @@ func (b *BleveIndexer) runAuthorsPaginatedQuery(q query.Query, page, resultsPerP return result.Paginated[[]Author]{}, nil } - return result.NewPaginated( + return result.Paginate( resultsPerPage, page, int(searchResult.Total), @@ -192,15 +191,3 @@ func sortAuthorsByDocumentCount(authors []Author, counts map[string]uint64, desc return strings.Compare(a.Name, b.Name) }) } - -func paginateAuthors(resultsPerPage, page, total int, authors []Author) result.Paginated[[]Author] { - start := (page - 1) * resultsPerPage - if start >= total { - return result.NewPaginated(resultsPerPage, page, total, []Author{}) - } - end := start + resultsPerPage - if end > total { - end = total - } - return result.NewPaginated(resultsPerPage, page, total, authors[start:end]) -} diff --git a/internal/result/paginated.go b/internal/result/paginated.go index be091978..7a09ab0e 100644 --- a/internal/result/paginated.go +++ b/internal/result/paginated.go @@ -22,6 +22,33 @@ func NewPaginated[T any](maxResultsPerPage, page, totalHits int, hits T) Paginat } } +// Paginate wraps hits in pagination metadata. When len(hits) equals totalHits, hits is treated +// as the full result set and sliced for the requested page. Otherwise hits is assumed to already +// contain the page window (for example from a database or Bleve query). +func Paginate[T any](maxResultsPerPage, page, totalHits int, hits []T) Paginated[[]T] { + if page < 1 { + page = 1 + } + if totalHits == 0 { + return NewPaginated(maxResultsPerPage, page, 0, []T{}) + } + + start := (page - 1) * maxResultsPerPage + if start >= totalHits { + return NewPaginated(maxResultsPerPage, page, totalHits, []T{}) + } + + if len(hits) == totalHits { + end := start + maxResultsPerPage + if end > totalHits { + end = totalHits + } + return NewPaginated(maxResultsPerPage, page, totalHits, hits[start:end]) + } + + return NewPaginated(maxResultsPerPage, page, totalHits, hits) +} + func (P Paginated[T]) MaxResultsPerPage() int { return P.maxResultsPerPage } diff --git a/internal/result/paginated_test.go b/internal/result/paginated_test.go new file mode 100644 index 00000000..84f22263 --- /dev/null +++ b/internal/result/paginated_test.go @@ -0,0 +1,37 @@ +package result_test + +import ( + "testing" + + "github.com/svera/coreander/v4/internal/result" +) + +func TestPaginateFullList(t *testing.T) { + items := []int{1, 2, 3, 4, 5} + + page := result.Paginate(2, 2, len(items), items) + if got := page.Hits(); len(got) != 2 || got[0] != 3 || got[1] != 4 { + t.Fatalf("expected page [3 4], got %#v", got) + } + if page.TotalHits() != 5 || page.Page() != 2 { + t.Fatalf("unexpected metadata: total=%d page=%d", page.TotalHits(), page.Page()) + } +} + +func TestPaginatePrePaginatedWindow(t *testing.T) { + pageWindow := []string{"c", "d"} + + page := result.Paginate(2, 2, 5, pageWindow) + if got := page.Hits(); len(got) != 2 || got[0] != "c" || got[1] != "d" { + t.Fatalf("expected pre-paginated window unchanged, got %#v", got) + } +} + +func TestPaginateEmptyPage(t *testing.T) { + items := []int{1, 2, 3} + + page := result.Paginate(2, 3, len(items), items) + if len(page.Hits()) != 0 { + t.Fatalf("expected empty page, got %#v", page.Hits()) + } +} From 7e047141f0dd6c04fa49cbd2c704614cac35dae2 Mon Sep 17 00:00:00 2001 From: Sergio Vera Date: Sun, 14 Jun 2026 10:26:59 +0200 Subject: [PATCH 05/10] WIP --- internal/datasource/wikidata/mockserver.go | 4 + internal/datasource/wikidata/service.go | 157 ++++++++++++++++--- internal/datasource/wikidata/service_test.go | 20 +++ internal/index/authors_enrich.go | 52 +++--- internal/index/authors_enrich_test.go | 60 ++++++- internal/index/bleve_write.go | 73 +++++++++ internal/index/rebuild_authors_test.go | 63 ++++++++ 7 files changed, 378 insertions(+), 51 deletions(-) create mode 100644 internal/index/rebuild_authors_test.go diff --git a/internal/datasource/wikidata/mockserver.go b/internal/datasource/wikidata/mockserver.go index 43895b87..3b3137a0 100644 --- a/internal/datasource/wikidata/mockserver.go +++ b/internal/datasource/wikidata/mockserver.go @@ -23,6 +23,10 @@ func NewMockServer(t *testing.T, fixturePath string) *httptest.Server { } if queryValues.Get("action") == "wbgetentities" { id := queryValues.Get("ids") + if strings.Contains(id, "|") { + parts := strings.Split(id, "|") + id = parts[0] + } returnResponse(fmt.Sprintf("wbgetentities-%s", id), w, fixturePath) return } diff --git a/internal/datasource/wikidata/service.go b/internal/datasource/wikidata/service.go index 9b1e786a..32d905ef 100644 --- a/internal/datasource/wikidata/service.go +++ b/internal/datasource/wikidata/service.go @@ -19,6 +19,10 @@ import ( const imgUrl = "https://upload.wikimedia.org/wikipedia/commons/%s/%s/%s" +const maxEntitiesPerRequest = 50 + +var entityFetchProps = []string{"descriptions", "claims", "sitelinks/urls", "labels"} + type wikidata interface { NewSearch(string, string) (SearchEntitiesRequest, error) NewGetEntities([]string) (GetEntitiesRequest, error) @@ -43,7 +47,7 @@ func NewWikidataSource(w wikidata) WikidataSource { } func (a WikidataSource) SearchAuthor(name string, languages []string) (model.Author, error) { - ids, err := a.getEntityIds(name) + ids, err := a.SearchEntityIDs(name) if err != nil { return nil, err } @@ -55,67 +59,170 @@ func (a WikidataSource) SearchAuthor(name string, languages []string) (model.Aut return a.RetrieveAuthor(ids, languages) } +// SearchEntityIDs returns Wikidata entity IDs matching the given author name. +func (a WikidataSource) SearchEntityIDs(name string) ([]string, error) { + return a.getEntityIds(name) +} + // RetrieveAuthor returns the first match from the list of passed Wikidata entity IDs that represents a human func (a WikidataSource) RetrieveAuthor(ids []string, languages []string) (model.Author, error) { - author := Author{ - wikipediaLink: make(map[string]string), - description: make(map[string]string), - } - for _, id := range ids { if !validateID(id) { return Author{}, fmt.Errorf("invalid author ID %s", id) } } - entitiesReq, err := a.wikidata.NewGetEntities(ids) + entities, err := a.fetchEntities(ids, languages) if err != nil { return nil, err } - entitiesReq.SetProps([]string{"descriptions", "claims", "sitelinks/urls", "labels"}) - entitiesReq.SetLanguages(languages) - // Call get to make the request based on the configurations - entities, err := entitiesReq.Get() + + author, err := a.authorFromEntityIDs(ids, entities, languages) if err != nil { return nil, err } - - author.wikidataEntityId, author.instanceOf = getMostAccurateID(ids, entities) if author.instanceOf == InstanceUnknown { return nil, nil } + return author, nil +} + +// RetrieveAuthors fetches metadata for multiple authors in as few Wikidata requests as possible. +// Keys are caller-defined identifiers (for example author slugs). Values are Wikidata entity ID +// candidates for each author, in search-result order. +func (a WikidataSource) RetrieveAuthors(candidates map[string][]string, languages []string, batchInterval time.Duration) (map[string]model.Author, error) { + if len(candidates) == 0 { + return map[string]model.Author{}, nil + } + + uniqueIDs := make([]string, 0) + seen := make(map[string]struct{}) + for _, ids := range candidates { + for _, id := range ids { + if !validateID(id) { + return nil, fmt.Errorf("invalid author ID %s", id) + } + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + uniqueIDs = append(uniqueIDs, id) + } + } - if value, exists := (*entities)[author.wikidataEntityId].Claims[propertyBirthName]; exists { + entities, err := a.fetchEntitiesBatched(uniqueIDs, languages, batchInterval) + if err != nil { + return nil, err + } + + results := make(map[string]model.Author, len(candidates)) + for key, ids := range candidates { + if len(ids) == 0 { + continue + } + author, err := a.authorFromEntityIDs(ids, entities, languages) + if err != nil { + return nil, err + } + if author.instanceOf == InstanceUnknown { + continue + } + results[key] = author + } + return results, nil +} + +func (a WikidataSource) fetchEntities(ids []string, languages []string) (map[string]gowikidata.Entity, error) { + return a.fetchEntitiesBatched(ids, languages, 0) +} + +func (a WikidataSource) fetchEntitiesBatched(ids []string, languages []string, batchInterval time.Duration) (map[string]gowikidata.Entity, error) { + entities := make(map[string]gowikidata.Entity, len(ids)) + if len(ids) == 0 { + return entities, nil + } + + for start := 0; start < len(ids); start += maxEntitiesPerRequest { + if start > 0 && batchInterval > 0 { + time.Sleep(batchInterval) + } + end := start + maxEntitiesPerRequest + if end > len(ids) { + end = len(ids) + } + chunk := ids[start:end] + + entitiesReq, err := a.wikidata.NewGetEntities(chunk) + if err != nil { + return nil, err + } + entitiesReq.SetProps(entityFetchProps) + entitiesReq.SetLanguages(languages) + batch, err := entitiesReq.Get() + if err != nil { + return nil, err + } + for id, entity := range *batch { + entities[id] = entity + } + } + return entities, nil +} + +func (a WikidataSource) authorFromEntityIDs(ids []string, entities map[string]gowikidata.Entity, languages []string) (Author, error) { + author := Author{ + wikipediaLink: make(map[string]string), + description: make(map[string]string), + } + if len(ids) == 0 { + return author, nil + } + + entityPtr := &entities + author.wikidataEntityId, author.instanceOf = getMostAccurateID(ids, entityPtr) + if author.instanceOf == InstanceUnknown { + return author, nil + } + + entity, ok := entities[author.wikidataEntityId] + if !ok { + return author, nil + } + + if value, exists := entity.Claims[propertyBirthName]; exists { author.birthName = value[0].MainSnak.DataValue.Value.ValueFields.Text - } else if value, exists := (*entities)[author.wikidataEntityId].Claims[propertyNameInNativeLanguage]; exists { + } else if value, exists := entity.Claims[propertyNameInNativeLanguage]; exists { author.birthName = value[0].MainSnak.DataValue.Value.ValueFields.Text - } else if value, exists := (*entities)[author.wikidataEntityId].Claims[propertyOfficialName]; exists { + } else if value, exists := entity.Claims[propertyOfficialName]; exists { author.birthName = value[0].MainSnak.DataValue.Value.ValueFields.Text } - if value, exists := (*entities)[author.wikidataEntityId].Claims[propertySexOrGender]; exists { + if value, exists := entity.Claims[propertySexOrGender]; exists { author.gender = parseGender(value[0]) } author.retrievedOn = time.Now().UTC() for _, lang := range languages { - if url := (*entities)[author.wikidataEntityId].SiteLinks[fmt.Sprintf("%swiki", lang)].URL; url != "" { + if url := entity.SiteLinks[fmt.Sprintf("%swiki", lang)].URL; url != "" { author.wikipediaLink[lang] = url } - if description := (*entities)[author.wikidataEntityId].Descriptions[lang].Value; description != "" { + if description := entity.Descriptions[lang].Value; description != "" { author.description[lang] = description } } - if claim, exists := (*entities)[author.wikidataEntityId].Claims[propertyDateOfBirth]; exists { + if claim, exists := entity.Claims[propertyDateOfBirth]; exists { author.dateOfBirth = parseDate(claim) } - if claim, exists := (*entities)[author.wikidataEntityId].Claims[propertyDateOfDeath]; exists { + if claim, exists := entity.Claims[propertyDateOfDeath]; exists { author.dateOfDeath = parseDate(claim) } - if value, exists := (*entities)[author.wikidataEntityId].Claims[propertyWebsite]; exists { + if value, exists := entity.Claims[propertyWebsite]; exists { author.website = value[0].MainSnak.DataValue.Value.S } - if value, exists := (*entities)[author.wikidataEntityId].Claims[propertyPseudonym]; exists { + if value, exists := entity.Claims[propertyPseudonym]; exists { author.pseudonyms = make([]string, 0, len(value)) for _, claim := range value { pseudonym, err := strconv.Unquote("\"" + claim.MainSnak.DataValue.Value.S + "\"") @@ -126,10 +233,10 @@ func (a WikidataSource) RetrieveAuthor(ids []string, languages []string) (model. } } - if value, exists := (*entities)[author.wikidataEntityId].Claims[propertyImage]; exists { + if value, exists := entity.Claims[propertyImage]; exists { img, err := strconv.Unquote("\"" + value[0].MainSnak.DataValue.Value.S + "\"") if err != nil { - return nil, err + return Author{}, err } if slices.Contains([]string{".png", ".jpg", ".jpeg", ".tif", ".tiff"}, strings.ToLower(filepath.Ext(img))) { diff --git a/internal/datasource/wikidata/service_test.go b/internal/datasource/wikidata/service_test.go index bfb63f5c..64f25be6 100644 --- a/internal/datasource/wikidata/service_test.go +++ b/internal/datasource/wikidata/service_test.go @@ -71,3 +71,23 @@ func testCases(t *testing.T) []testCase { }, } } + +func TestRetrieveAuthorsBatch(t *testing.T) { + mockServer := NewMockServer(t, "fixtures") + defer mockServer.Close() + gowikidata.WikidataDomain = mockServer.URL + + source := NewWikidataSource(Gowikidata{}) + authors, err := source.RetrieveAuthors(map[string][]string{ + "miguel": {"Q1234"}, + }, []string{"en"}, 0) + if err != nil { + t.Fatal(err) + } + if len(authors) != 1 { + t.Fatalf("expected 1 author from batch retrieve, got %d", len(authors)) + } + if authors["miguel"].SourceID() != "Q1234" { + t.Fatalf("expected Q1234, got %q", authors["miguel"].SourceID()) + } +} diff --git a/internal/index/authors_enrich.go b/internal/index/authors_enrich.go index 61e518fa..abe2587f 100644 --- a/internal/index/authors_enrich.go +++ b/internal/index/authors_enrich.go @@ -16,7 +16,9 @@ const ( // AuthorDataSource retrieves author metadata from an external source such as Wikidata. type AuthorDataSource interface { SearchAuthor(name string, languages []string) (datasourcemodel.Author, error) + SearchEntityIDs(name string) ([]string, error) RetrieveAuthor(ids []string, languages []string) (datasourcemodel.Author, error) + RetrieveAuthors(candidates map[string][]string, languages []string, batchInterval time.Duration) (map[string]datasourcemodel.Author, error) } // CombineWithDataSource merges external author metadata into an indexed author. @@ -76,7 +78,7 @@ func (b *BleveIndexer) AuthorsWithoutInfo() ([]Author, error) { } // EnrichAuthorsFromDataSource fetches metadata for authors missing external info and updates the index. -// Requests are throttled to at most one author lookup per interval. +// Name searches are throttled to at most one lookup per interval; entity details are fetched in batches. func (b *BleveIndexer) EnrichAuthorsFromDataSource(dataSource AuthorDataSource, supportedLanguages []string, interval time.Duration) error { if interval <= 0 { interval = DefaultAuthorEnrichInterval @@ -92,28 +94,49 @@ func (b *BleveIndexer) EnrichAuthorsFromDataSource(dataSource AuthorDataSource, log.Printf("Enriching %d authors from Wikidata", len(authors)) - b.beginAuthorEnrichment(len(authors)) + b.beginAuthorEnrichment(len(authors) * 2) defer b.endAuthorEnrichment() ticker := time.NewTicker(interval) defer ticker.Stop() - for i, author := range authors { - if i > 0 { - <-ticker.C - } + candidates := make(map[string][]string, len(authors)) + authorsBySlug := make(map[string]Author, len(authors)) - authorDataSource, err := b.fetchAuthorFromDataSource(dataSource, author, supportedLanguages) - if err != nil { - log.Printf("Error retrieving author %s from Wikidata: %s", author.Name, err) + searchIndex := 0 + for _, author := range authors { + authorsBySlug[author.Slug] = author + if author.DataSourceID != "" { + candidates[author.Slug] = []string{author.DataSourceID} b.recordAuthorEnrichmentProgress() continue } - if authorDataSource == nil { - author.RetrievedOn = time.Now().UTC() + if searchIndex > 0 { + <-ticker.C + } + searchIndex++ + + ids, err := dataSource.SearchEntityIDs(author.Name) + if err != nil { + log.Printf("Error searching author %s on Wikidata: %s", author.Name, err) + candidates[author.Slug] = nil } else { + candidates[author.Slug] = ids + } + b.recordAuthorEnrichmentProgress() + } + + enriched, err := dataSource.RetrieveAuthors(candidates, supportedLanguages, interval) + if err != nil { + return err + } + + for slug, author := range authorsBySlug { + if authorDataSource, ok := enriched[slug]; ok { CombineWithDataSource(&author, authorDataSource, supportedLanguages) + } else { + author.RetrievedOn = time.Now().UTC() } if err := b.IndexAuthor(author); err != nil { @@ -125,10 +148,3 @@ func (b *BleveIndexer) EnrichAuthorsFromDataSource(dataSource AuthorDataSource, log.Printf("Author enrichment finished") return nil } - -func (b *BleveIndexer) fetchAuthorFromDataSource(dataSource AuthorDataSource, author Author, supportedLanguages []string) (datasourcemodel.Author, error) { - if author.DataSourceID != "" { - return dataSource.RetrieveAuthor([]string{author.DataSourceID}, supportedLanguages) - } - return dataSource.SearchAuthor(author.Name, supportedLanguages) -} diff --git a/internal/index/authors_enrich_test.go b/internal/index/authors_enrich_test.go index bdeab4e8..d79c5542 100644 --- a/internal/index/authors_enrich_test.go +++ b/internal/index/authors_enrich_test.go @@ -13,14 +13,19 @@ import ( ) type mockAuthorDataSource struct { - byName map[string]datasourcemodel.Author - calls int - delay time.Duration - errName string + byName map[string]datasourcemodel.Author + bySlug map[string]datasourcemodel.Author + entityIDs map[string][]string + calls int + searchCalls int + retrieveCalls int + delay time.Duration + errName string } func (m *mockAuthorDataSource) SearchAuthor(name string, _ []string) (datasourcemodel.Author, error) { m.calls++ + m.searchCalls++ if m.delay > 0 { time.Sleep(m.delay) } @@ -32,11 +37,44 @@ func (m *mockAuthorDataSource) SearchAuthor(name string, _ []string) (datasource var errMockLookup = errors.New("mock lookup failed") +func (m *mockAuthorDataSource) SearchEntityIDs(name string) ([]string, error) { + m.calls++ + m.searchCalls++ + if m.delay > 0 { + time.Sleep(m.delay) + } + if m.errName == name { + return nil, errMockLookup + } + if m.entityIDs != nil { + if ids, ok := m.entityIDs[name]; ok { + return ids, nil + } + } + if author, ok := m.byName[name]; ok && author != nil { + return []string{author.SourceID()}, nil + } + return nil, nil +} + func (m *mockAuthorDataSource) RetrieveAuthor(_ []string, _ []string) (datasourcemodel.Author, error) { m.calls++ + m.retrieveCalls++ return nil, nil } +func (m *mockAuthorDataSource) RetrieveAuthors(candidates map[string][]string, _ []string, _ time.Duration) (map[string]datasourcemodel.Author, error) { + m.calls++ + m.retrieveCalls++ + results := make(map[string]datasourcemodel.Author, len(candidates)) + for slug := range candidates { + if author, ok := m.bySlug[slug]; ok { + results[slug] = author + } + } + return results, nil +} + type stubAuthor struct { sourceID string } @@ -108,8 +146,11 @@ func TestEnrichAuthorsFromDataSource(t *testing.T) { } dataSource := &mockAuthorDataSource{ - byName: map[string]datasourcemodel.Author{ - "Found Author": stubAuthor{sourceID: "Q1"}, + bySlug: map[string]datasourcemodel.Author{ + "found": stubAuthor{sourceID: "Q1"}, + }, + entityIDs: map[string][]string{ + "Found Author": {"Q1"}, }, } @@ -119,8 +160,11 @@ func TestEnrichAuthorsFromDataSource(t *testing.T) { } elapsed := time.Since(start) - if dataSource.calls != 2 { - t.Fatalf("expected 2 Wikidata lookups, got %d", dataSource.calls) + if dataSource.searchCalls != 2 { + t.Fatalf("expected 2 Wikidata name searches, got %d", dataSource.searchCalls) + } + if dataSource.retrieveCalls != 1 { + t.Fatalf("expected 1 batched Wikidata entity fetch, got %d", dataSource.retrieveCalls) } if elapsed < 50*time.Millisecond { t.Fatalf("expected throttling between lookups, took %s", elapsed) diff --git a/internal/index/bleve_write.go b/internal/index/bleve_write.go index e5c94309..5641fb6e 100644 --- a/internal/index/bleve_write.go +++ b/internal/index/bleve_write.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/blevesearch/bleve/v2" index "github.com/blevesearch/bleve_index_api" "github.com/gosimple/slug" "github.com/spf13/afero" @@ -238,10 +239,82 @@ func (b *BleveIndexer) AddLibrary(batchSize int, forceIndexing bool, metadataWor } } + if err := b.rebuildAuthorsFromDocumentsIfEmpty(); err != nil { + b.endIndexing() + return err + } + b.endIndexing() return nil } +// rebuildAuthorsFromDocumentsIfEmpty repopulates the authors index from indexed documents when +// the authors index is empty but documents remain indexed (for example after a mapping version +// bump recreates the authors index while the documents index is unchanged). +func (b *BleveIndexer) rebuildAuthorsFromDocumentsIfEmpty() error { + authorCount, err := b.authorsIdx.DocCount() + if err != nil { + return err + } + if authorCount > 0 { + return nil + } + + docCount, err := b.Count() + if err != nil { + return err + } + if docCount == 0 { + return nil + } + + log.Println("Authors index is empty but documents are indexed, rebuilding authors from documents index.") + return b.RebuildAuthorsFromDocuments() +} + +// RebuildAuthorsFromDocuments indexes all authors and illustrators referenced by documents +// in the documents index into the authors index. +func (b *BleveIndexer) RebuildAuthorsFromDocuments() error { + countResult, err := b.documentsIdx.Search(bleve.NewSearchRequestOptions(bleve.NewMatchAllQuery(), 0, 0, false)) + if err != nil { + return err + } + total := int(countResult.Total) + if total == 0 { + return nil + } + + searchOptions := bleve.NewSearchRequestOptions(bleve.NewMatchAllQuery(), total, 0, false) + searchOptions.Fields = []string{"*"} + searchResult, err := b.documentsIdx.Search(searchOptions) + if err != nil { + return err + } + + authorsBatch := b.authorsIdx.NewBatch() + authorsSeen := make(map[string]struct{}) + + for _, hit := range searchResult.Hits { + document := hydrateDocument(hit) + if err := b.indexAuthors(document, authorsBatch.Index, authorsSeen); err != nil { + return err + } + if authorsBatch.Size() >= 100 { + if err := b.authorsIdx.Batch(authorsBatch); err != nil { + return err + } + authorsBatch.Reset() + } + } + + if authorsBatch.Size() > 0 { + if err := b.authorsIdx.Batch(authorsBatch); err != nil { + return err + } + } + return nil +} + func (b *BleveIndexer) collectPendingLibraryPaths(forceIndexing bool) (pending []string, languages []string, err error) { languages = []string{} e := afero.Walk(b.fs, b.libraryPath, func(fullPath string, f os.FileInfo, walkErr error) error { diff --git a/internal/index/rebuild_authors_test.go b/internal/index/rebuild_authors_test.go new file mode 100644 index 00000000..fde0549f --- /dev/null +++ b/internal/index/rebuild_authors_test.go @@ -0,0 +1,63 @@ +package index_test + +import ( + "testing" + + "github.com/blevesearch/bleve/v2" + "github.com/svera/coreander/v5/internal/index" + "github.com/svera/coreander/v5/internal/metadata" +) + +func TestRebuildAuthorsFromDocuments(t *testing.T) { + documentsIndexMem, err := bleve.NewMemOnly(index.CreateDocumentsMapping()) + if err != nil { + t.Fatal(err) + } + authorsIndexMem, err := bleve.NewMemOnly(index.CreateAuthorsMapping()) + if err != nil { + t.Fatal(err) + } + + idx := index.NewBleve(documentsIndexMem, authorsIndexMem, nil, "", nil, index.Config{}) + + docs := []index.Document{ + { + ID: "doc-1", + Slug: "doc-1", + AuthorsSlugs: []string{"george-orwell"}, + Metadata: metadata.Metadata{Title: "1984", Authors: []string{"George Orwell"}}, + }, + { + ID: "doc-2", + Slug: "doc-2", + IllustratorsSlugs: []string{"jane-austen"}, + Metadata: metadata.Metadata{Title: "Illustrated", Illustrators: []string{"Jane Austen"}}, + }, + } + for _, doc := range docs { + if err := documentsIndexMem.Index(doc.ID, doc); err != nil { + t.Fatal(err) + } + } + + if count, err := authorsIndexMem.DocCount(); err != nil { + t.Fatal(err) + } else if count != 0 { + t.Fatalf("expected empty authors index, got %d", count) + } + + if err := idx.RebuildAuthorsFromDocuments(); err != nil { + t.Fatal(err) + } + + if count, err := authorsIndexMem.DocCount(); err != nil { + t.Fatal(err) + } else if count != 2 { + t.Fatalf("expected 2 authors after rebuild, got %d", count) + } + + author, err := idx.Author("george-orwell", "en") + if err != nil || author.Name != "George Orwell" { + t.Fatalf("expected George Orwell, got %#v err=%v", author, err) + } +} From c5b4ab181b7a78ecee53f501d4746990156b6b53 Mon Sep 17 00:00:00 2001 From: Sergio Vera Date: Sun, 14 Jun 2026 10:50:40 +0200 Subject: [PATCH 06/10] WIP --- internal/datasource/wikidata/service.go | 42 +++++++++++-------------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/internal/datasource/wikidata/service.go b/internal/datasource/wikidata/service.go index 32d905ef..58466cb3 100644 --- a/internal/datasource/wikidata/service.go +++ b/internal/datasource/wikidata/service.go @@ -61,7 +61,24 @@ func (a WikidataSource) SearchAuthor(name string, languages []string) (model.Aut // SearchEntityIDs returns Wikidata entity IDs matching the given author name. func (a WikidataSource) SearchEntityIDs(name string) ([]string, error) { - return a.getEntityIds(name) + query, err := a.wikidata.NewSearch(url.QueryEscape(name), "en") + if err != nil { + return nil, err + } + result, err := query.Get() + if err != nil { + return nil, err + } + + if len(result.SearchResult) == 0 { + return nil, nil + } + + ids := make([]string, 0, len(result.SearchResult)) + for _, entity := range result.SearchResult { + ids = append(ids, entity.ID) + } + return ids, nil } // RetrieveAuthor returns the first match from the list of passed Wikidata entity IDs that represents a human @@ -265,29 +282,6 @@ func getMostAccurateID(ids []string, entities *map[string]gowikidata.Entity) (st return "", InstanceUnknown } -// getEntityIds return all entity IDs from Wikidata which matches the passed name -func (a WikidataSource) getEntityIds(name string) ([]string, error) { - query, err := a.wikidata.NewSearch(url.QueryEscape(name), "en") - if err != nil { - return []string{}, err - } - result, err := query.Get() - if err != nil { - return []string{}, err - } - - if len(result.SearchResult) == 0 { - return []string{}, nil - } - - res := make([]string, 0, len(result.SearchResult)) - for _, entity := range result.SearchResult { - res = append(res, entity.ID) - } - - return res, nil -} - func parseGender(claim gowikidata.Claim) float64 { switch claim.MainSnak.DataValue.Value.ValueFields.ID { case qidGenderMale: From 8d504de32c76261dff97ab016a927b4b5a5a328f Mon Sep 17 00:00:00 2001 From: Sergio Vera Date: Sun, 14 Jun 2026 12:05:03 +0200 Subject: [PATCH 07/10] WIP --- internal/datasource/wikidata/service.go | 6 +- .../embedded/js/author-search-filters.js | 257 ++------------ .../embedded/js/search-filter-utils.js | 260 ++++++++++++++ .../webserver/embedded/js/search-filters.js | 331 +++--------------- 4 files changed, 332 insertions(+), 522 deletions(-) create mode 100644 internal/webserver/embedded/js/search-filter-utils.js diff --git a/internal/datasource/wikidata/service.go b/internal/datasource/wikidata/service.go index 58466cb3..de8454d6 100644 --- a/internal/datasource/wikidata/service.go +++ b/internal/datasource/wikidata/service.go @@ -89,7 +89,7 @@ func (a WikidataSource) RetrieveAuthor(ids []string, languages []string) (model. } } - entities, err := a.fetchEntities(ids, languages) + entities, err := a.fetchEntitiesBatched(ids, languages, 0) if err != nil { return nil, err } @@ -152,10 +152,6 @@ func (a WikidataSource) RetrieveAuthors(candidates map[string][]string, language return results, nil } -func (a WikidataSource) fetchEntities(ids []string, languages []string) (map[string]gowikidata.Entity, error) { - return a.fetchEntitiesBatched(ids, languages, 0) -} - func (a WikidataSource) fetchEntitiesBatched(ids []string, languages []string, batchInterval time.Duration) (map[string]gowikidata.Entity, error) { entities := make(map[string]gowikidata.Entity, len(ids)) if len(ids) == 0 { diff --git a/internal/webserver/embedded/js/author-search-filters.js b/internal/webserver/embedded/js/author-search-filters.js index 270cc076..03b08ecf 100644 --- a/internal/webserver/embedded/js/author-search-filters.js +++ b/internal/webserver/embedded/js/author-search-filters.js @@ -1,247 +1,44 @@ "use strict" -function isLeapYear(year) { - return (year % 4 === 0) && (year % 100 !== 0 || year % 400 === 0) -} - -function updateMaxDays(monthSelect, dayInput, yearInput, dateControl = null) { - const month = parseInt(monthSelect.value) - const year = parseInt(yearInput.value) || new Date().getFullYear() - - let maxDays = 31 - switch (month) { - case 2: - maxDays = isLeapYear(year) ? 29 : 28 - break - case 4: - case 6: - case 9: - case 11: - maxDays = 30 - break - } - - dayInput.setAttribute('max', maxDays) - - const currentDay = parseInt(dayInput.value) - if (currentDay > maxDays) { - dayInput.value = maxDays - if (dateControl) { - updateHiddenDateInput(dateControl) - } - } -} - -function updateHiddenDateInput(dateControl) { - const yearInput = dateControl.querySelector('.input-year') - const monthSelect = dateControl.querySelector('.input-month') - const dayInput = dateControl.querySelector('.input-day') - const hiddenDateInput = dateControl.parentElement.querySelector('.date') - - if (!yearInput.value || yearInput.value === '' || yearInput.value === '0') { - hiddenDateInput.value = '' - return - } - - let year = yearInput.value - if (year.startsWith('-') || year.startsWith('+')) { - year = year.substring(0, 1) + year.substring(1).padStart(4, '0') - } else { - year = year.padStart(4, '0') - } - - const month = monthSelect.value || '01' - const day = (dayInput.value || '1').padStart(2, '0') - - hiddenDateInput.value = year + '-' + month + '-' + day -} - -function copyFormValues(sourceForm, targetForm) { - for (const el of sourceForm.elements) { - if (!el.name) continue - const target = targetForm.elements[el.name] - if (target && target !== el) { - if (target.type === 'checkbox' || target.type === 'radio') { - target.checked = el.checked - } else { - target.value = el.value - } - } - } -} - -function yearForDisplay(yearStr) { - if (!yearStr) return '' - if (yearStr.startsWith('-')) { - const rest = yearStr.slice(1).replace(/^0+/, '') - return rest === '' ? '' : '-' + rest - } - const stripped = yearStr.replace(/^0+/, '') - return stripped === '' ? '' : stripped -} - -function applyHiddenDatesToVisible(container) { - if (!container) return - container.querySelectorAll('.date-control').forEach(dateControl => { - const hiddenInput = dateControl.parentElement.querySelector('input.date') - if (!hiddenInput || !hiddenInput.value) return - const parts = hiddenInput.value.split('-') - if (parts.length < 3) return - const yearInput = dateControl.querySelector('.input-year') - const monthSelect = dateControl.querySelector('.input-month') - const dayInput = dateControl.querySelector('.input-day') - if (yearInput) yearInput.value = yearForDisplay(parts[0]) - if (monthSelect) monthSelect.value = parts[1] - if (dayInput) dayInput.value = String(parseInt(parts[2], 10)) +import { + applyHiddenDatesToVisible, + bindOffcanvasFilterSync, + enableFilterInputsOnPageShow, + initDateControls, + initFilterFormBehavior, + syncSidebarFormToOffcanvas, +} from './search-filter-utils.js' + +function authorSyncOffcanvas() { + syncSidebarFormToOffcanvas({ + searchFieldName: 'name', + offcanvasContainerId: 'author-search-filters', }) } -function syncSidebarFormToOffcanvas() { - const sidebarForm = document.getElementById('search-filters-form') - const offcanvasContainer = document.getElementById('author-search-filters') - if (!sidebarForm) return - - const searchValue = sidebarForm.elements['name'] ? sidebarForm.elements['name'].value : '' - const navSearchbox = document.getElementById('searchbox') - if (navSearchbox) navSearchbox.value = searchValue - - if (!offcanvasContainer) return - const offcanvasForm = offcanvasContainer.closest('form') - if (!offcanvasForm) return - - copyFormValues(sidebarForm, offcanvasForm) - applyHiddenDatesToVisible(offcanvasContainer) -} - function initAuthorSearchFilters(searchFilters) { if (!searchFilters) return const searchFiltersForm = searchFilters.closest('form') if (!searchFiltersForm) return - searchFilters.querySelectorAll('.date-control').forEach(dateControl => { - const monthSelect = dateControl.querySelector('.input-month') - const dayInput = dateControl.querySelector('.input-day') - const yearInput = dateControl.querySelector('.input-year') - - monthSelect.addEventListener('change', () => { - updateMaxDays(monthSelect, dayInput, yearInput, dateControl) - updateHiddenDateInput(dateControl) - }) - - yearInput.addEventListener('change', () => { - if (parseInt(monthSelect.value) === 2) { - updateMaxDays(monthSelect, dayInput, yearInput, dateControl) - } - updateHiddenDateInput(dateControl) - }) - - yearInput.addEventListener('input', () => { - updateHiddenDateInput(dateControl) - }) - - dayInput.addEventListener('change', () => { - updateHiddenDateInput(dateControl) - }) - - dayInput.addEventListener('input', () => { - updateHiddenDateInput(dateControl) - }) - - updateMaxDays(monthSelect, dayInput, yearInput, dateControl) - updateHiddenDateInput(dateControl) + const composeDateControls = initDateControls(searchFilters, searchFiltersForm) + initFilterFormBehavior({ + searchFilters, + searchFiltersForm, + composeDateControls, + listPath: '/authors', + syncOffcanvas: authorSyncOffcanvas, }) - - function composeDateControls() { - searchFiltersForm.querySelectorAll('.date-control').forEach(function (el) { - const yearEl = el.querySelector('.input-year') - if (!yearEl || (yearEl.value === '' || yearEl.value === '0')) return - const composed = el.parentElement.querySelector('.date') - if (!composed) return - let year = yearEl.value - if (year.startsWith('-') || year.startsWith('+')) { - year = year.substring(0, 1) + year.substring(1).padStart(4, '0') - } else { - year = year.padStart(4, '0') - } - const month = el.querySelector('.input-month').value || '01' - const day = (el.querySelector('.input-day').value || '1').padStart(2, '0') - composed.value = year + '-' + month + '-' + day - }) - } - - const isAuthorsPage = window.location.pathname === '/authors' - let applyingFilters = false - - function applyFilters() { - applyingFilters = true - composeDateControls() - const sidebarForm = document.getElementById('search-filters-form') - if (sidebarForm && isAuthorsPage) { - if (searchFiltersForm !== sidebarForm) { - copyFormValues(searchFiltersForm, sidebarForm) - } - const formData = new FormData(sidebarForm) - const params = new URLSearchParams() - for (const [k, v] of formData.entries()) { - if (v != null && String(v).trim() !== '') params.append(k, v) - } - const queryString = params.toString() - const url = '/authors' + (queryString ? '?' + queryString : '') - window.htmx.trigger(document.body, 'update') - history.replaceState(null, '', url) - syncSidebarFormToOffcanvas() - } else { - const params = new URLSearchParams(new FormData(searchFiltersForm)) - window.location.href = '/authors?' + params.toString() - } - setTimeout(() => { applyingFilters = false }, 0) - } - - const FILTER_DEBOUNCE_MS = 600 - let applyFiltersDebounced - - function scheduleApplyFilters() { - if (applyingFilters) return - if (applyFiltersDebounced) clearTimeout(applyFiltersDebounced) - applyFiltersDebounced = setTimeout(applyFilters, FILTER_DEBOUNCE_MS) - } - - if (isAuthorsPage) { - searchFiltersForm.addEventListener('submit', (e) => { - e.preventDefault() - if (applyFiltersDebounced) clearTimeout(applyFiltersDebounced) - applyFilters() - }) - - searchFiltersForm.addEventListener('input', () => scheduleApplyFilters()) - searchFiltersForm.addEventListener('change', () => scheduleApplyFilters()) - } else { - searchFiltersForm.addEventListener('submit', () => { - composeDateControls() - searchFilters.querySelectorAll('input').forEach(input => { - if (input.value === '' || input.value === '0') input.setAttribute('disabled', 'disabled') - }) - }) - } } -window.addEventListener('pageshow', () => { - ['author-search-filters', 'author-search-filters-sidebar'].forEach(id => { - const el = document.getElementById(id) - if (el) { - el.querySelectorAll('input').forEach(input => { - input.removeAttribute('disabled') - }) - } - }) -}) +enableFilterInputsOnPageShow(['author-search-filters', 'author-search-filters-sidebar']) initAuthorSearchFilters(document.getElementById('author-search-filters')) initAuthorSearchFilters(document.getElementById('author-search-filters-sidebar')) -if (document.getElementById('search-filters-form') && document.getElementById('author-search-filters')) { - const offcanvasEl = document.getElementById('search-filters-offcanvas') - if (offcanvasEl) { - offcanvasEl.addEventListener('shown.bs.offcanvas', () => syncSidebarFormToOffcanvas()) - } -} +bindOffcanvasFilterSync({ + sidebarFormId: 'search-filters-form', + offcanvasContainerId: 'author-search-filters', + offcanvasElementId: 'search-filters-offcanvas', + syncOffcanvas: authorSyncOffcanvas, +}) diff --git a/internal/webserver/embedded/js/search-filter-utils.js b/internal/webserver/embedded/js/search-filter-utils.js new file mode 100644 index 00000000..6a9bed52 --- /dev/null +++ b/internal/webserver/embedded/js/search-filter-utils.js @@ -0,0 +1,260 @@ +"use strict" + +function isLeapYear(year) { + return (year % 4 === 0) && (year % 100 !== 0 || year % 400 === 0) +} + +export function updateHiddenDateInput(dateControl) { + const yearInput = dateControl.querySelector('.input-year') + const monthSelect = dateControl.querySelector('.input-month') + const dayInput = dateControl.querySelector('.input-day') + const hiddenDateInput = dateControl.parentElement.querySelector('.date') + + if (!yearInput.value || yearInput.value === '' || yearInput.value === '0') { + hiddenDateInput.value = '' + return + } + + let year = yearInput.value + if (year.startsWith('-') || year.startsWith('+')) { + year = year.substring(0, 1) + year.substring(1).padStart(4, '0') + } else { + year = year.padStart(4, '0') + } + + const month = monthSelect.value || '01' + const day = (dayInput.value || '1').padStart(2, '0') + + hiddenDateInput.value = year + '-' + month + '-' + day +} + +function updateMaxDays(monthSelect, dayInput, yearInput, dateControl = null) { + const month = parseInt(monthSelect.value) + const year = parseInt(yearInput.value) || new Date().getFullYear() + + let maxDays = 31 + switch (month) { + case 2: + maxDays = isLeapYear(year) ? 29 : 28 + break + case 4: + case 6: + case 9: + case 11: + maxDays = 30 + break + } + + dayInput.setAttribute('max', maxDays) + + const currentDay = parseInt(dayInput.value) + if (currentDay > maxDays) { + dayInput.value = maxDays + if (dateControl) { + updateHiddenDateInput(dateControl) + } + } +} + +export function copyFormValues(sourceForm, targetForm) { + for (const el of sourceForm.elements) { + if (!el.name) continue + const target = targetForm.elements[el.name] + if (target && target !== el) { + if (target.type === 'checkbox' || target.type === 'radio') { + target.checked = el.checked + } else { + target.value = el.value + } + } + } +} + +function yearForDisplay(yearStr) { + if (!yearStr) return '' + if (yearStr.startsWith('-')) { + const rest = yearStr.slice(1).replace(/^0+/, '') + return rest === '' ? '' : '-' + rest + } + const stripped = yearStr.replace(/^0+/, '') + return stripped === '' ? '' : stripped +} + +export function applyHiddenDatesToVisible(container) { + if (!container) return + container.querySelectorAll('.date-control').forEach(dateControl => { + const hiddenInput = dateControl.parentElement.querySelector('input.date') + if (!hiddenInput || !hiddenInput.value) return + const parts = hiddenInput.value.split('-') + if (parts.length < 3) return + const yearInput = dateControl.querySelector('.input-year') + const monthSelect = dateControl.querySelector('.input-month') + const dayInput = dateControl.querySelector('.input-day') + if (yearInput) yearInput.value = yearForDisplay(parts[0]) + if (monthSelect) monthSelect.value = parts[1] + if (dayInput) dayInput.value = String(parseInt(parts[2], 10)) + }) +} + +export function initDateControls(searchFilters, searchFiltersForm) { + searchFilters.querySelectorAll('.date-control').forEach(dateControl => { + const monthSelect = dateControl.querySelector('.input-month') + const dayInput = dateControl.querySelector('.input-day') + const yearInput = dateControl.querySelector('.input-year') + + monthSelect.addEventListener('change', () => { + updateMaxDays(monthSelect, dayInput, yearInput, dateControl) + updateHiddenDateInput(dateControl) + }) + + yearInput.addEventListener('change', () => { + if (parseInt(monthSelect.value) === 2) { + updateMaxDays(monthSelect, dayInput, yearInput, dateControl) + } + updateHiddenDateInput(dateControl) + }) + + yearInput.addEventListener('input', () => { + updateHiddenDateInput(dateControl) + }) + + dayInput.addEventListener('change', () => { + updateHiddenDateInput(dateControl) + }) + + dayInput.addEventListener('input', () => { + updateHiddenDateInput(dateControl) + }) + + updateMaxDays(monthSelect, dayInput, yearInput, dateControl) + updateHiddenDateInput(dateControl) + }) + + return function composeDateControls() { + searchFiltersForm.querySelectorAll('.date-control').forEach(el => { + const yearEl = el.querySelector('.input-year') + if (!yearEl || yearEl.value === '' || yearEl.value === '0') return + const composed = el.parentElement.querySelector('.date') + if (!composed) return + let year = yearEl.value + if (year.startsWith('-') || year.startsWith('+')) { + year = year.substring(0, 1) + year.substring(1).padStart(4, '0') + } else { + year = year.padStart(4, '0') + } + const month = el.querySelector('.input-month').value || '01' + const day = (el.querySelector('.input-day').value || '1').padStart(2, '0') + composed.value = year + '-' + month + '-' + day + }) + } +} + +export function syncSidebarFormToOffcanvas({ searchFieldName, offcanvasContainerId, afterCopy }) { + const sidebarForm = document.getElementById('search-filters-form') + const offcanvasContainer = document.getElementById(offcanvasContainerId) + if (!sidebarForm) return + + const field = sidebarForm.elements[searchFieldName] + const searchValue = field ? field.value : '' + const navSearchbox = document.getElementById('searchbox') + if (navSearchbox) navSearchbox.value = searchValue + + if (!offcanvasContainer) return + const offcanvasForm = offcanvasContainer.closest('form') + if (!offcanvasForm) return + + copyFormValues(sidebarForm, offcanvasForm) + if (afterCopy) afterCopy(offcanvasContainer, sidebarForm) + applyHiddenDatesToVisible(offcanvasContainer) +} + +const FILTER_DEBOUNCE_MS = 600 + +export function initFilterFormBehavior({ + searchFilters, + searchFiltersForm, + composeDateControls, + listPath, + syncOffcanvas, + beforeSidebarApply, +}) { + const isListPage = window.location.pathname === listPath + let applyingFilters = false + + function applyFilters() { + applyingFilters = true + composeDateControls() + const sidebarForm = document.getElementById('search-filters-form') + if (sidebarForm && isListPage) { + if (searchFiltersForm !== sidebarForm) { + copyFormValues(searchFiltersForm, sidebarForm) + if (beforeSidebarApply) beforeSidebarApply() + } + const formData = new FormData(sidebarForm) + const params = new URLSearchParams() + for (const [k, v] of formData.entries()) { + if (v != null && String(v).trim() !== '') params.append(k, v) + } + const queryString = params.toString() + const url = listPath + (queryString ? '?' + queryString : '') + window.htmx.trigger(document.body, 'update') + history.replaceState(null, '', url) + syncOffcanvas() + } else { + const params = new URLSearchParams(new FormData(searchFiltersForm)) + window.location.href = listPath + '?' + params.toString() + } + setTimeout(() => { applyingFilters = false }, 0) + } + + let scheduleApplyFilters = null + if (isListPage) { + let applyFiltersDebounced + scheduleApplyFilters = function () { + if (applyingFilters) return + if (applyFiltersDebounced) clearTimeout(applyFiltersDebounced) + applyFiltersDebounced = setTimeout(applyFilters, FILTER_DEBOUNCE_MS) + } + + searchFiltersForm.addEventListener('submit', (e) => { + e.preventDefault() + if (applyFiltersDebounced) clearTimeout(applyFiltersDebounced) + applyFilters() + }) + + searchFiltersForm.addEventListener('input', () => scheduleApplyFilters()) + searchFiltersForm.addEventListener('change', () => scheduleApplyFilters()) + } else { + searchFiltersForm.addEventListener('submit', () => { + composeDateControls() + searchFilters.querySelectorAll('input').forEach(input => { + if (input.value === '' || input.value === '0') input.setAttribute('disabled', 'disabled') + }) + }) + } + + return { scheduleApplyFilters } +} + +export function enableFilterInputsOnPageShow(containerIds) { + window.addEventListener('pageshow', () => { + containerIds.forEach(id => { + const el = document.getElementById(id) + if (el) { + el.querySelectorAll('input').forEach(input => { + input.removeAttribute('disabled') + }) + } + }) + }) +} + +export function bindOffcanvasFilterSync({ sidebarFormId, offcanvasContainerId, offcanvasElementId, syncOffcanvas }) { + if (!document.getElementById(sidebarFormId) || !document.getElementById(offcanvasContainerId)) { + return + } + const offcanvasEl = document.getElementById(offcanvasElementId) + if (offcanvasEl) { + offcanvasEl.addEventListener('shown.bs.offcanvas', () => syncOffcanvas()) + } +} diff --git a/internal/webserver/embedded/js/search-filters.js b/internal/webserver/embedded/js/search-filters.js index 77ba152e..c91d2c1a 100644 --- a/internal/webserver/embedded/js/search-filters.js +++ b/internal/webserver/embedded/js/search-filters.js @@ -1,217 +1,59 @@ "use strict" -// Load translations (shared) +import { + bindOffcanvasFilterSync, + enableFilterInputsOnPageShow, + initDateControls, + initFilterFormBehavior, + syncSidebarFormToOffcanvas, +} from './search-filter-utils.js' + +// Load translations (subjects UI) let translations = {} const i18nElement = document.getElementById('i18n') if (i18nElement) { translations = JSON.parse(i18nElement.textContent).i18n } -/** - * Determines if a given year is a leap year - * @param {number} year - The year to check - * @returns {boolean} - True if the year is a leap year, false otherwise - */ -function isLeapYear(year) { - // A year is a leap year if: - // 1. It's divisible by 4 AND - // 2. It's either NOT divisible by 100 OR it's divisible by 400 - return (year % 4 === 0) && (year % 100 !== 0 || year % 400 === 0) -} - -/** - * Updates the max attribute of the day input based on the selected month and year - * @param {HTMLElement} monthSelect - The month select element - * @param {HTMLElement} dayInput - The day input element - * @param {HTMLElement} yearInput - The year input element - * @param {HTMLElement} dateControl - The date-control container element (optional, for updating hidden input) - */ -function updateMaxDays(monthSelect, dayInput, yearInput, dateControl = null) { - const month = parseInt(monthSelect.value) - const year = parseInt(yearInput.value) || new Date().getFullYear() - - let maxDays = 31 // default max days - - switch (month) { - case 2: // February - maxDays = isLeapYear(year) ? 29 : 28 - break - case 4: // April - case 6: // June - case 9: // September - case 11: // November - maxDays = 30 - break - } - - // Update the max attribute - dayInput.setAttribute('max', maxDays) - - // If current day value is greater than max days, set it to max days - const currentDay = parseInt(dayInput.value) - if (currentDay > maxDays) { - dayInput.value = maxDays - // Update hidden input if dateControl is provided - if (dateControl) { - updateHiddenDateInput(dateControl) - } - } -} - -/** - * Updates the hidden date input field with the composed date value - * @param {HTMLElement} dateControl - The date-control container element - */ -function updateHiddenDateInput(dateControl) { - const yearInput = dateControl.querySelector('.input-year') - const monthSelect = dateControl.querySelector('.input-month') - const dayInput = dateControl.querySelector('.input-day') - const hiddenDateInput = dateControl.parentElement.querySelector('.date') - - // Only update if year has a value - if (!yearInput.value || yearInput.value === '' || yearInput.value === '0') { - hiddenDateInput.value = '' - return - } - - let year = yearInput.value - if (year.startsWith('-') || year.startsWith('+')) { - year = year.substring(0, 1) + year.substring(1).padStart(4, '0') - } else { - year = year.padStart(4, '0') - } - - const month = monthSelect.value || '01' - const day = (dayInput.value || '1').padStart(2, '0') - - hiddenDateInput.value = year + '-' + month + '-' + day +function documentsSyncOffcanvas() { + syncSidebarFormToOffcanvas({ + searchFieldName: 'search', + offcanvasContainerId: 'search-filters', + afterCopy: (offcanvasContainer) => { + const sidebarSubjectsHidden = document.getElementById('sidebar-subjects-hidden') + const offcanvasSubjectsHidden = document.getElementById('subjects-hidden') + if (sidebarSubjectsHidden && offcanvasSubjectsHidden) { + offcanvasSubjectsHidden.value = sidebarSubjectsHidden.value + } + offcanvasContainer.dispatchEvent(new CustomEvent('syncSubjectsFromHiddenInput')) + }, + }) } -/** - * Initialize search filters for a single container (main or sidebar). - * @param {HTMLElement} searchFilters - The container element (#search-filters or #search-filters-sidebar) - */ function initSearchFilters(searchFilters) { if (!searchFilters) return const searchFiltersForm = searchFilters.closest('form') if (!searchFiltersForm) return const idPrefix = searchFilters.id === 'search-filters-sidebar' ? 'sidebar-' : '' - - // Set up event listeners for all month selects - searchFilters.querySelectorAll('.date-control').forEach(dateControl => { - const monthSelect = dateControl.querySelector('.input-month') - const dayInput = dateControl.querySelector('.input-day') - const yearInput = dateControl.querySelector('.input-year') - - // Update max days when month changes - monthSelect.addEventListener('change', () => { - updateMaxDays(monthSelect, dayInput, yearInput, dateControl) - updateHiddenDateInput(dateControl) - }) - - // Update max days when year changes (for February) - yearInput.addEventListener('change', () => { - if (parseInt(monthSelect.value) === 2) { - updateMaxDays(monthSelect, dayInput, yearInput, dateControl) - } - updateHiddenDateInput(dateControl) - }) - - yearInput.addEventListener('input', () => { - updateHiddenDateInput(dateControl) - }) - - dayInput.addEventListener('change', () => { - updateHiddenDateInput(dateControl) - }) - - dayInput.addEventListener('input', () => { - updateHiddenDateInput(dateControl) - }) - - updateMaxDays(monthSelect, dayInput, yearInput, dateControl) - updateHiddenDateInput(dateControl) + const composeDateControls = initDateControls(searchFilters, searchFiltersForm) + + const { scheduleApplyFilters } = initFilterFormBehavior({ + searchFilters, + searchFiltersForm, + composeDateControls, + listPath: '/documents', + syncOffcanvas: documentsSyncOffcanvas, + beforeSidebarApply: () => { + const sidebarContainer = document.getElementById('search-filters-sidebar') + if (sidebarContainer) sidebarContainer.dispatchEvent(new CustomEvent('syncSubjectsFromHiddenInput')) + }, }) - function composeDateControls() { - searchFiltersForm.querySelectorAll('.date-control').forEach(function (el) { - const yearEl = el.querySelector('.input-year') - if (!yearEl || (yearEl.value === '' || yearEl.value === '0')) return - const composed = el.parentElement.querySelector('.date') - if (!composed) return - let year = yearEl.value - if (year.startsWith('-') || year.startsWith('+')) { - year = year.substring(0, 1) + year.substring(1).padStart(4, '0') - } else { - year = year.padStart(4, '0') - } - const month = el.querySelector('.input-month').value || '01' - const day = (el.querySelector('.input-day').value || '1').padStart(2, '0') - composed.value = year + '-' + month + '-' + day - }) - } - - const isDocumentsPage = window.location.pathname === '/documents' - - let applyingFilters = false - - function applyFilters() { - applyingFilters = true - composeDateControls() - const sidebarForm = document.getElementById('search-filters-form') - if (sidebarForm && isDocumentsPage) { - if (searchFiltersForm !== sidebarForm) { - copyFormValues(searchFiltersForm, sidebarForm) - const sidebarContainer = document.getElementById('search-filters-sidebar') - if (sidebarContainer) sidebarContainer.dispatchEvent(new CustomEvent('syncSubjectsFromHiddenInput')) - } - const formData = new FormData(sidebarForm) - const params = new URLSearchParams() - for (const [k, v] of formData.entries()) { - if (v != null && String(v).trim() !== '') params.append(k, v) - } - const queryString = params.toString() - const url = '/documents' + (queryString ? '?' + queryString : '') - window.htmx.trigger(document.body, 'update') - history.replaceState(null, '', url) - syncSidebarFormToOffcanvas() - } else { - const params = new URLSearchParams(new FormData(searchFiltersForm)) - window.location.href = '/documents?' + params.toString() - } - setTimeout(() => { applyingFilters = false }, 0) - } - - let triggerSearchUpdate = null - if (isDocumentsPage) { - const FILTER_DEBOUNCE_MS = 600 - let applyFiltersDebounced - function scheduleApplyFilters() { - if (applyingFilters) return - if (applyFiltersDebounced) clearTimeout(applyFiltersDebounced) - applyFiltersDebounced = setTimeout(applyFilters, FILTER_DEBOUNCE_MS) - } - triggerSearchUpdate = scheduleApplyFilters - - searchFiltersForm.addEventListener('submit', (e) => { - e.preventDefault() - if (applyFiltersDebounced) clearTimeout(applyFiltersDebounced) - applyFilters() - }) - - searchFiltersForm.addEventListener('input', () => scheduleApplyFilters()) - searchFiltersForm.addEventListener('change', () => scheduleApplyFilters()) - } else { - searchFiltersForm.addEventListener('submit', () => { - composeDateControls() - searchFilters.querySelectorAll('input').forEach(input => { - if (input.value === '' || input.value === '0') input.setAttribute('disabled', 'disabled') - }) - }) - } + initSubjectsFilters(searchFilters, idPrefix, scheduleApplyFilters) +} - // Subjects (scoped to this container): grouped by slug; selection stores slugs, badges show all names for that slug +function initSubjectsFilters(searchFilters, idPrefix, triggerSearchUpdate) { const subjectsList = document.getElementById(idPrefix + 'subjects-list') const subjectsInput = document.getElementById(idPrefix + 'subjects') const subjectsHiddenInput = document.getElementById(idPrefix + 'subjects-hidden') @@ -381,99 +223,14 @@ function initSearchFilters(searchFilters) { } } -// Enable inputs when the page is shown -window.addEventListener('pageshow', () => { - ['search-filters', 'search-filters-sidebar'].forEach(id => { - const el = document.getElementById(id) - if (el) { - el.querySelectorAll('input').forEach(input => { - input.removeAttribute('disabled') - }) - } - }) -}) - -/** - * Copy form values from source to target form (by field name). - */ -function copyFormValues(sourceForm, targetForm) { - for (const el of sourceForm.elements) { - if (!el.name) continue - const target = targetForm.elements[el.name] - if (target && target !== el) { - if (target.type === 'checkbox' || target.type === 'radio') { - target.checked = el.checked - } else { - target.value = el.value - } - } - } -} - -/** - * Strip leading zeroes from a year string so "0000" / "0001" become "" / "1", and "2024" stays "2024". - * Preserves negative years (e.g. "-0500" -> "-500"). - */ -function yearForDisplay(yearStr) { - if (!yearStr) return '' - if (yearStr.startsWith('-')) { - const rest = yearStr.slice(1).replace(/^0+/, '') - return rest === '' ? '' : '-' + rest - } - const stripped = yearStr.replace(/^0+/, '') - return stripped === '' ? '' : stripped -} - -/** - * Apply hidden date input values (YYYY-MM-DD) to the visible year/month/day inputs in each .date-control. - */ -function applyHiddenDatesToVisible(container) { - if (!container) return - container.querySelectorAll('.date-control').forEach(dateControl => { - const hiddenInput = dateControl.parentElement.querySelector('input.date') - if (!hiddenInput || !hiddenInput.value) return - const parts = hiddenInput.value.split('-') - if (parts.length < 3) return - const yearInput = dateControl.querySelector('.input-year') - const monthSelect = dateControl.querySelector('.input-month') - const dayInput = dateControl.querySelector('.input-day') - if (yearInput) yearInput.value = yearForDisplay(parts[0]) - if (monthSelect) monthSelect.value = parts[1] - if (dayInput) dayInput.value = String(parseInt(parts[2], 10)) - }) -} +enableFilterInputsOnPageShow(['search-filters', 'search-filters-sidebar']) -/** - * Sync sidebar filter form state to the offcanvas form and navbar searchbox. - */ -function syncSidebarFormToOffcanvas() { - const sidebarForm = document.getElementById('search-filters-form') - const offcanvasContainer = document.getElementById('search-filters') - if (!sidebarForm) return - const searchValue = sidebarForm.elements['search'] ? sidebarForm.elements['search'].value : '' - const navSearchbox = document.getElementById('searchbox') - if (navSearchbox) navSearchbox.value = searchValue - if (!offcanvasContainer) return - const offcanvasForm = offcanvasContainer.closest('form') - if (!offcanvasForm) return - copyFormValues(sidebarForm, offcanvasForm) - const sidebarSubjectsHidden = document.getElementById('sidebar-subjects-hidden') - const offcanvasSubjectsHidden = document.getElementById('subjects-hidden') - if (sidebarSubjectsHidden && offcanvasSubjectsHidden) { - offcanvasSubjectsHidden.value = sidebarSubjectsHidden.value - } - applyHiddenDatesToVisible(offcanvasContainer) - offcanvasContainer.dispatchEvent(new CustomEvent('syncSubjectsFromHiddenInput')) -} - -// Initialize all filter containers on the page initSearchFilters(document.getElementById('search-filters')) initSearchFilters(document.getElementById('search-filters-sidebar')) -// Keep sidebar and offcanvas filters in sync on the documents page -if (document.getElementById('search-filters-form') && document.getElementById('search-filters')) { - const offcanvasEl = document.getElementById('search-filters-offcanvas') - if (offcanvasEl) { - offcanvasEl.addEventListener('shown.bs.offcanvas', () => syncSidebarFormToOffcanvas()) - } -} +bindOffcanvasFilterSync({ + sidebarFormId: 'search-filters-form', + offcanvasContainerId: 'search-filters', + offcanvasElementId: 'search-filters-offcanvas', + syncOffcanvas: documentsSyncOffcanvas, +}) From 74cef840a27c1eba3f9125802879c486414160aa Mon Sep 17 00:00:00 2001 From: Sergio Vera Date: Sun, 14 Jun 2026 17:02:30 +0200 Subject: [PATCH 08/10] WIP --- ...rch-filters.js => document-search-filters.js} | 16 ++++++++-------- .../webserver/embedded/views/document/list.html | 2 +- ...filters.html => document-search-filters.html} | 4 ++-- .../embedded/views/partials/navbar.html | 2 +- .../embedded/views/partials/searchbox.html | 6 +++--- 5 files changed, 15 insertions(+), 15 deletions(-) rename internal/webserver/embedded/js/{search-filters.js => document-search-filters.js} (93%) rename internal/webserver/embedded/views/partials/{search-filters.html => document-search-filters.html} (97%) diff --git a/internal/webserver/embedded/js/search-filters.js b/internal/webserver/embedded/js/document-search-filters.js similarity index 93% rename from internal/webserver/embedded/js/search-filters.js rename to internal/webserver/embedded/js/document-search-filters.js index c91d2c1a..c1326daa 100644 --- a/internal/webserver/embedded/js/search-filters.js +++ b/internal/webserver/embedded/js/document-search-filters.js @@ -18,7 +18,7 @@ if (i18nElement) { function documentsSyncOffcanvas() { syncSidebarFormToOffcanvas({ searchFieldName: 'search', - offcanvasContainerId: 'search-filters', + offcanvasContainerId: 'document-search-filters', afterCopy: (offcanvasContainer) => { const sidebarSubjectsHidden = document.getElementById('sidebar-subjects-hidden') const offcanvasSubjectsHidden = document.getElementById('subjects-hidden') @@ -30,12 +30,12 @@ function documentsSyncOffcanvas() { }) } -function initSearchFilters(searchFilters) { +function initDocumentSearchFilters(searchFilters) { if (!searchFilters) return const searchFiltersForm = searchFilters.closest('form') if (!searchFiltersForm) return - const idPrefix = searchFilters.id === 'search-filters-sidebar' ? 'sidebar-' : '' + const idPrefix = searchFilters.id === 'document-search-filters-sidebar' ? 'sidebar-' : '' const composeDateControls = initDateControls(searchFilters, searchFiltersForm) const { scheduleApplyFilters } = initFilterFormBehavior({ @@ -45,7 +45,7 @@ function initSearchFilters(searchFilters) { listPath: '/documents', syncOffcanvas: documentsSyncOffcanvas, beforeSidebarApply: () => { - const sidebarContainer = document.getElementById('search-filters-sidebar') + const sidebarContainer = document.getElementById('document-search-filters-sidebar') if (sidebarContainer) sidebarContainer.dispatchEvent(new CustomEvent('syncSubjectsFromHiddenInput')) }, }) @@ -223,14 +223,14 @@ function initSubjectsFilters(searchFilters, idPrefix, triggerSearchUpdate) { } } -enableFilterInputsOnPageShow(['search-filters', 'search-filters-sidebar']) +enableFilterInputsOnPageShow(['document-search-filters', 'document-search-filters-sidebar']) -initSearchFilters(document.getElementById('search-filters')) -initSearchFilters(document.getElementById('search-filters-sidebar')) +initDocumentSearchFilters(document.getElementById('document-search-filters')) +initDocumentSearchFilters(document.getElementById('document-search-filters-sidebar')) bindOffcanvasFilterSync({ sidebarFormId: 'search-filters-form', - offcanvasContainerId: 'search-filters', + offcanvasContainerId: 'document-search-filters', offcanvasElementId: 'search-filters-offcanvas', syncOffcanvas: documentsSyncOffcanvas, }) diff --git a/internal/webserver/embedded/views/document/list.html b/internal/webserver/embedded/views/document/list.html index 5db03d20..c5f45b42 100644 --- a/internal/webserver/embedded/views/document/list.html +++ b/internal/webserver/embedded/views/document/list.html @@ -8,7 +8,7 @@
diff --git a/internal/webserver/embedded/views/partials/search-filters.html b/internal/webserver/embedded/views/partials/document-search-filters.html similarity index 97% rename from internal/webserver/embedded/views/partials/search-filters.html rename to internal/webserver/embedded/views/partials/document-search-filters.html index e3b2a07b..db2d976e 100644 --- a/internal/webserver/embedded/views/partials/search-filters.html +++ b/internal/webserver/embedded/views/partials/document-search-filters.html @@ -1,5 +1,5 @@ {{$idPrefix := or .FilterIdPrefix ""}} -
+
{{if $idPrefix}}
{{t .Lang "Search"}} @@ -124,5 +124,5 @@ "remove_subject": {{t .Lang "Remove subject: %s" "%s"}} }} - + {{end}} diff --git a/internal/webserver/embedded/views/partials/navbar.html b/internal/webserver/embedded/views/partials/navbar.html index 0233dfbb..180a85cd 100644 --- a/internal/webserver/embedded/views/partials/navbar.html +++ b/internal/webserver/embedded/views/partials/navbar.html @@ -225,7 +225,7 @@
{{t .Lang "Advanced search"}}
- {{template "partials/search-filters" dict "Lang" .Lang "SearchFields" .SearchFields "Version" .Version "AvailableLanguages" .AvailableLanguages "DocumentsSearchPage" .DocumentsSearchPage}} + {{template "partials/document-search-filters" dict "Lang" .Lang "SearchFields" .SearchFields "Version" .Version "AvailableLanguages" .AvailableLanguages "DocumentsSearchPage" .DocumentsSearchPage}} {{end}}
diff --git a/internal/webserver/embedded/views/partials/searchbox.html b/internal/webserver/embedded/views/partials/searchbox.html index fb45b871..cb419ad9 100644 --- a/internal/webserver/embedded/views/partials/searchbox.html +++ b/internal/webserver/embedded/views/partials/searchbox.html @@ -25,13 +25,13 @@

-

-
- {{template "partials/search-filters" dict "Lang" .Lang "Responsive" true "Version" .Version "AvailableLanguages" .AvailableLanguages}} +
+ {{template "partials/document-search-filters" dict "Lang" .Lang "Responsive" true "Version" .Version "AvailableLanguages" .AvailableLanguages}}
From ca4530b2be75d2b5186ff0a080b8808f8b059b9b Mon Sep 17 00:00:00 2001 From: Sergio Vera Date: Mon, 15 Jun 2026 12:49:47 +0200 Subject: [PATCH 09/10] WIP --- internal/index/author_search.go | 37 +++- internal/index/author_search_test.go | 32 ++- internal/webserver/controller.go | 7 + .../webserver/controller/document/search.go | 150 -------------- internal/webserver/controller/home/index.go | 6 + .../webserver/controller/search/controller.go | 55 ++++++ .../{author/search.go => search/parse.go} | 143 ++++++++------ .../webserver/controller/search/search.go | 183 ++++++++++++++++++ internal/webserver/embedded/css/display.css | 42 ++-- .../embedded/js/author-search-filters.js | 8 +- .../embedded/js/document-search-filters.js | 57 +++--- .../webserver/embedded/js/home-search-tabs.js | 47 ----- internal/webserver/embedded/js/home-search.js | 178 +++++++++++++++++ .../embedded/js/keyboard-shortcuts.js | 2 - .../embedded/js/search-filter-utils.js | 50 ++++- .../embedded/js/search-offcanvas-tabs.js | 55 ++++++ .../embedded/js/search-sidebar-tabs.js | 70 +++++++ .../webserver/embedded/translations/de.yml | 2 + .../webserver/embedded/translations/es.yml | 2 + .../webserver/embedded/translations/fr.yml | 2 + .../webserver/embedded/translations/ru.yml | 2 + .../embedded/views/author/search.html | 24 --- .../views/partials/author-search-filters.html | 8 +- .../partials/document-search-filters.html | 8 +- .../embedded/views/partials/navbar.html | 50 +++-- .../partials/search-filters-sidebar.html | 26 +++ .../embedded/views/partials/searchbox.html | 104 ++++------ .../views/{document => search}/list.html | 22 ++- internal/webserver/routes.go | 7 +- internal/webserver/search_test.go | 50 +++++ 30 files changed, 968 insertions(+), 461 deletions(-) delete mode 100644 internal/webserver/controller/document/search.go create mode 100644 internal/webserver/controller/search/controller.go rename internal/webserver/controller/{author/search.go => search/parse.go} (53%) create mode 100644 internal/webserver/controller/search/search.go delete mode 100644 internal/webserver/embedded/js/home-search-tabs.js create mode 100644 internal/webserver/embedded/js/home-search.js create mode 100644 internal/webserver/embedded/js/search-offcanvas-tabs.js create mode 100644 internal/webserver/embedded/js/search-sidebar-tabs.js delete mode 100644 internal/webserver/embedded/views/author/search.html create mode 100644 internal/webserver/embedded/views/partials/search-filters-sidebar.html rename internal/webserver/embedded/views/{document => search}/list.html (58%) diff --git a/internal/index/author_search.go b/internal/index/author_search.go index 848b2b44..9abf79ea 100644 --- a/internal/index/author_search.go +++ b/internal/index/author_search.go @@ -11,6 +11,9 @@ import ( "github.com/blevesearch/bleve/v2/search/query" "github.com/rickb777/date/v2" "github.com/svera/coreander/v5/internal/result" + "golang.org/x/text/runes" + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" ) type AuthorSearchFields struct { @@ -42,25 +45,47 @@ func authorNameQuery(name string) query.Query { if name == "" { return nil } - name = foldAuthorName(name) disj := bleve.NewDisjunctionQuery() for _, field := range []string{"Name", "BirthName"} { - prefix := bleve.NewPrefixQuery(name) + match := bleve.NewMatchQuery(name) + match.SetField(field) + match.Analyzer = defaultAnalyzer + match.Operator = query.MatchQueryOperatorAnd + disj.AddQuery(match) + + folded := foldAuthorName(name) + if !isSingleAuthorNameToken(folded) { + continue + } + + prefix := bleve.NewPrefixQuery(folded) prefix.SetField(field) disj.AddQuery(prefix) - wildcard := bleve.NewWildcardQuery("*" + escapeWildcard(name) + "*") + wildcard := bleve.NewWildcardQuery("*" + escapeWildcard(folded) + "*") wildcard.SetField(field) disj.AddQuery(wildcard) } return disj } +func isSingleAuthorNameToken(folded string) bool { + if folded == "" { + return false + } + return !strings.ContainsAny(folded, " \t-") +} + func foldAuthorName(name string) string { - return strings.Map(func(r rune) rune { - return unicode.ToLower(r) - }, name) + folded, _, err := transform.String( + transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC), + name, + ) + if err != nil { + folded = name + } + return strings.ToLower(folded) } func escapeWildcard(value string) string { diff --git a/internal/index/author_search_test.go b/internal/index/author_search_test.go index 46b86437..6fdd6bfa 100644 --- a/internal/index/author_search_test.go +++ b/internal/index/author_search_test.go @@ -42,6 +42,13 @@ func TestSearchAuthors(t *testing.T) { DateOfDeath: precisiondate.NewPrecisionDate("+1817-07-18T00:00:00Z", precisiondate.PrecisionDay), RetrievedOn: mustParseTime("2020-01-01T00:00:00Z"), }, + { + Slug: "arturo-perez-reverte", + Name: "Arturo Pérez-Reverte", + Gender: float64(wikidata.GenderMale), + DateOfBirth: precisiondate.NewPrecisionDate("+1951-11-24T00:00:00Z", precisiondate.PrecisionDay), + RetrievedOn: mustParseTime("2020-01-01T00:00:00Z"), + }, { Slug: "living-author", Name: "Living Author", @@ -80,6 +87,19 @@ func TestSearchAuthors(t *testing.T) { } }) + t.Run("by name unaccented and spaced", func(t *testing.T) { + for _, name := range []string{"perez reverte", "Pérez-Reverte", "perez-reverte", "PEREZ REVERTE"} { + res, err := idx.SearchAuthors(index.AuthorSearchFields{Name: name}, 1, 10) + if err != nil { + t.Fatal(err) + } + hits := res.Hits() + if res.TotalHits() != 1 || hits[0].Slug != "arturo-perez-reverte" { + t.Fatalf("search %q: expected Arturo Pérez-Reverte, got %#v", name, hits) + } + } + }) + t.Run("by gender", func(t *testing.T) { female := float64(wikidata.GenderFemale) res, err := idx.SearchAuthors(index.AuthorSearchFields{Gender: &female}, 1, 10) @@ -163,7 +183,11 @@ func TestSearchAuthors(t *testing.T) { t.Fatal(err) } moreHits := moreFirst.Hits() - if len(moreHits) != 3 || moreHits[0].Slug != "george-orwell" || moreHits[1].Slug != "jane-austen" || moreHits[2].Slug != "living-author" { + if len(moreHits) != 4 || + moreHits[0].Slug != "george-orwell" || + moreHits[1].Slug != "jane-austen" || + moreHits[2].Slug != "arturo-perez-reverte" || + moreHits[3].Slug != "living-author" { t.Fatalf("expected authors sorted by most documents first, got %#v", moreHits) } @@ -172,7 +196,11 @@ func TestSearchAuthors(t *testing.T) { t.Fatal(err) } fewerHits := fewerFirst.Hits() - if len(fewerHits) != 3 || fewerHits[0].Slug != "living-author" || fewerHits[1].Slug != "jane-austen" || fewerHits[2].Slug != "george-orwell" { + if len(fewerHits) != 4 || + fewerHits[0].Slug != "arturo-perez-reverte" || + fewerHits[1].Slug != "living-author" || + fewerHits[2].Slug != "jane-austen" || + fewerHits[3].Slug != "george-orwell" { t.Fatalf("expected authors sorted by fewest documents first, got %#v", fewerHits) } }) diff --git a/internal/webserver/controller.go b/internal/webserver/controller.go index dad27cfc..9e3551bc 100644 --- a/internal/webserver/controller.go +++ b/internal/webserver/controller.go @@ -10,6 +10,7 @@ import ( "github.com/svera/coreander/v5/internal/webserver/controller/document" "github.com/svera/coreander/v5/internal/webserver/controller/highlight" "github.com/svera/coreander/v5/internal/webserver/controller/home" + "github.com/svera/coreander/v5/internal/webserver/controller/search" "github.com/svera/coreander/v5/internal/webserver/controller/series" "github.com/svera/coreander/v5/internal/webserver/controller/user" "github.com/svera/coreander/v5/internal/webserver/model" @@ -24,6 +25,7 @@ type Controllers struct { Documents *document.Controller Home *home.Controller Authors *author.Controller + Search *search.Controller Series *series.Controller } @@ -85,6 +87,10 @@ func SetupControllers(cfg Config, db *gorm.DB, metadataReaders map[string]metada WordsPerMinute: cfg.WordsPerMinute, } + searchCfg := search.Config{ + WordsPerMinute: cfg.WordsPerMinute, + } + homeCfg := home.Config{ LibraryPath: cfg.LibraryPath, CoverMaxWidth: cfg.CoverMaxWidth, @@ -99,5 +105,6 @@ func SetupControllers(cfg Config, db *gorm.DB, metadataReaders map[string]metada Documents: document.NewController(highlightsRepository, usersRepository, readingRepository, sender, idx, metadataReaders, appFs, documentsCfg, translator), Home: home.NewController(highlightsRepository, readingRepository, sender, idx, homeCfg), Authors: author.NewController(highlightsRepository, readingRepository, sender, idx, authorsCfg, dataSource, appFs, imagesFS), + Search: search.NewController(highlightsRepository, readingRepository, sender, idx, searchCfg), Series: series.NewController(highlightsRepository, readingRepository, sender, idx, seriesCfg, appFs)} } diff --git a/internal/webserver/controller/document/search.go b/internal/webserver/controller/document/search.go deleted file mode 100644 index f027aaed..00000000 --- a/internal/webserver/controller/document/search.go +++ /dev/null @@ -1,150 +0,0 @@ -package document - -import ( - "log" - "strconv" - - "github.com/gofiber/fiber/v3" - "github.com/rickb777/date/v2" - "github.com/svera/coreander/v5/internal/index" - "github.com/svera/coreander/v5/internal/result" - "github.com/svera/coreander/v5/internal/webserver/model" - "github.com/svera/coreander/v5/internal/webserver/view" -) - -func (d *Controller) Search(c fiber.Ctx) error { - var session model.Session - if val, ok := c.Locals("Session").(model.Session); ok { - session = val - } - - if session.WordsPerMinute > 0 { - d.config.WordsPerMinute = session.WordsPerMinute - } - - var documentResults result.Paginated[[]index.Document] - searchFields, err := d.parseSearchQuery(c) - if err != nil { - log.Println(err) - return fiber.ErrBadRequest - } - - page, err := strconv.Atoi(c.Query("page")) - if err != nil { - page = 1 - } - - if documentResults, err = d.idx.Search(searchFields, page, model.ResultsPerPage); err != nil { - log.Println(err) - return fiber.ErrInternalServerError - } - - searchResults := model.AugmentedDocumentsFromDocuments(documentResults) - if session.ID > 0 { - searchResults = d.readingRepository.CompletedPaginatedResult(int(session.ID), searchResults) - searchResults = d.hlRepository.HighlightedPaginatedResult(int(session.ID), searchResults) - } - - templateVars := fiber.Map{ - "SearchFields": searchFields, - "Results": searchResults, - "Paginator": view.Pagination(model.MaxPagesNavigator, searchResults, c.Queries()), - "Title": "Search results", - "DocumentsSearchPage": true, - "EmailFrom": d.sender.From(), - "WordsPerMinute": d.config.WordsPerMinute, - "URL": view.URL(c), - "SortURL": view.BaseURLWithout(c, "sort-by", "page"), - "SortBy": c.Query("sort-by"), - "AdditionalSortOptions": []struct { - Key string - Value string - }{ - {"relevance", "relevance"}, - {"pub-date-older-first", "older"}, - {"pub-date-newer-first", "newer"}, - {"est-read-time-shorter-first", "shorter"}, - {"est-read-time-longer-first", "longer"}, - }, - } - - if c.Get("hx-request") == "true" { - if err = c.Render("partials/docs-list-fragments", templateVars); err != nil { - log.Println(err) - return fiber.ErrInternalServerError - } - return nil - } - - if err = c.Render("document/list", templateVars, "layout"); err != nil { - log.Println(err) - return fiber.ErrInternalServerError - } - - return nil -} - -func (d *Controller) parseSearchQuery(c fiber.Ctx) (index.SearchFields, error) { - searchFields := index.SearchFields{ - Keywords: c.Query("search"), - Language: c.Query("language"), - Subjects: c.Query("subjects"), - SortBy: d.parseSortBy(c), - EstReadTimeFrom: fiber.Query[float64](c, "est-read-time-from", 0), - EstReadTimeTo: fiber.Query[float64](c, "est-read-time-to", 0), - WordsPerMinute: d.config.WordsPerMinute, - IllustratedOnly: c.Query("illustrated-only") == "on" || c.Query("illustrated-only") == "1", - } - - if c.Query("pub-date-from") != "" { - pubDateFrom, err := date.ParseISO(c.Query("pub-date-from")) - if err != nil { - return searchFields, err - } - searchFields.PubDateFrom = pubDateFrom - } - - if c.Query("pub-date-to") != "" { - pubDateTo, err := date.ParseISO(c.Query("pub-date-to")) - if err != nil { - return searchFields, err - } - searchFields.PubDateTo = pubDateTo - } - - if searchFields.PubDateTo != 0 && searchFields.PubDateFrom > searchFields.PubDateTo { - searchFields.PubDateFrom, searchFields.PubDateTo = searchFields.PubDateTo, searchFields.PubDateFrom - } - - if searchFields.EstReadTimeTo != 0 && searchFields.EstReadTimeFrom > searchFields.EstReadTimeTo { - searchFields.EstReadTimeFrom, searchFields.EstReadTimeTo = searchFields.EstReadTimeTo, searchFields.EstReadTimeFrom - } - - return searchFields, nil -} - -func (d *Controller) parseSortBy(c fiber.Ctx) []string { - if c.Query("sort-by") != "" { - switch c.Query("sort-by") { - case "pub-date-older-first": - return []string{"Publication.Date"} - case "pub-date-newer-first": - return []string{"-Publication.Date"} - case "est-read-time-shorter-first": - return []string{"Words"} - case "est-read-time-longer-first": - return []string{"-Words"} - } - } - return []string{"-_score", "Series", "SeriesIndex"} -} - -// Subjects returns all subjects from the index grouped by slug (map[slug][]names), as JSON -func (d *Controller) Subjects(c fiber.Ctx) error { - bySlug, err := d.idx.Subjects() - if err != nil { - log.Println(err) - return fiber.ErrInternalServerError - } - return c.JSON(bySlug) -} diff --git a/internal/webserver/controller/home/index.go b/internal/webserver/controller/home/index.go index 422e0833..c416f572 100644 --- a/internal/webserver/controller/home/index.go +++ b/internal/webserver/controller/home/index.go @@ -2,12 +2,18 @@ package home import ( "log" + "net/url" + "strings" "github.com/gofiber/fiber/v3" "github.com/svera/coreander/v5/internal/webserver/model" ) func (d *Controller) Index(c fiber.Ctx) error { + if query := strings.TrimSpace(c.Query("search")); query != "" { + return c.Redirect().To("/search?type=documents&search=" + url.QueryEscape(query)) + } + var session model.Session if val, ok := c.Locals("Session").(model.Session); ok { session = val diff --git a/internal/webserver/controller/search/controller.go b/internal/webserver/controller/search/controller.go new file mode 100644 index 00000000..4389054e --- /dev/null +++ b/internal/webserver/controller/search/controller.go @@ -0,0 +1,55 @@ +package search + +import ( + "github.com/svera/coreander/v5/internal/index" + "github.com/svera/coreander/v5/internal/result" + "github.com/svera/coreander/v5/internal/webserver/model" +) + +const ( + TypeDocuments = "documents" + TypeAuthors = "authors" +) + +type Sender interface { + From() string +} + +type IdxReader interface { + Search(searchFields index.SearchFields, page, resultsPerPage int) (result.Paginated[[]index.Document], error) + SearchAuthors(searchFields index.AuthorSearchFields, page, resultsPerPage int) (result.Paginated[[]index.Author], error) + DocumentCountsByAuthorSlugs(slugs []string) (map[string]uint64, error) + Count() (uint64, error) + AuthorsCount() (uint64, error) + Subjects() (map[string][]string, error) +} + +type highlightsRepository interface { + HighlightedPaginatedResult(userID int, results result.Paginated[[]model.AugmentedDocument]) result.Paginated[[]model.AugmentedDocument] +} + +type readingRepository interface { + CompletedPaginatedResult(userID int, results result.Paginated[[]model.AugmentedDocument]) result.Paginated[[]model.AugmentedDocument] +} + +type Config struct { + WordsPerMinute float64 +} + +type Controller struct { + hlRepository highlightsRepository + readingRepository readingRepository + idx IdxReader + sender Sender + config Config +} + +func NewController(hlRepository highlightsRepository, readingRepository readingRepository, sender Sender, idx IdxReader, cfg Config) *Controller { + return &Controller{ + hlRepository: hlRepository, + readingRepository: readingRepository, + idx: idx, + sender: sender, + config: cfg, + } +} diff --git a/internal/webserver/controller/author/search.go b/internal/webserver/controller/search/parse.go similarity index 53% rename from internal/webserver/controller/author/search.go rename to internal/webserver/controller/search/parse.go index d120c3d8..a5a8ac15 100644 --- a/internal/webserver/controller/author/search.go +++ b/internal/webserver/controller/search/parse.go @@ -1,4 +1,4 @@ -package author +package search import ( "log" @@ -8,83 +8,56 @@ import ( "github.com/rickb777/date/v2" "github.com/svera/coreander/v5/internal/datasource/wikidata" "github.com/svera/coreander/v5/internal/index" - "github.com/svera/coreander/v5/internal/result" - "github.com/svera/coreander/v5/internal/webserver/model" - "github.com/svera/coreander/v5/internal/webserver/view" ) -func (a *Controller) Search(c fiber.Ctx) error { - searchFields, err := a.parseAuthorSearchQuery(c) - if err != nil { - log.Println(err) - return fiber.ErrBadRequest - } - - page, err := strconv.Atoi(c.Query("page")) - if err != nil { - page = 1 +func parseDocumentSearchQuery(c fiber.Ctx, wordsPerMinute float64) (index.SearchFields, error) { + searchFields := index.SearchFields{ + Keywords: c.Query("search"), + Language: c.Query("language"), + Subjects: c.Query("subjects"), + SortBy: parseDocumentSortBy(c), + EstReadTimeFrom: fiber.Query[float64](c, "est-read-time-from", 0), + EstReadTimeTo: fiber.Query[float64](c, "est-read-time-to", 0), + WordsPerMinute: wordsPerMinute, + IllustratedOnly: c.Query("illustrated-only") == "on" || c.Query("illustrated-only") == "1", } - var authorResults result.Paginated[[]index.Author] - if authorResults, err = a.idx.SearchAuthors(searchFields, page, int(model.ResultsPerPage)); err != nil { - log.Println(err) - return fiber.ErrInternalServerError + if c.Query("pub-date-from") != "" { + pubDateFrom, err := date.ParseISO(c.Query("pub-date-from")) + if err != nil { + return searchFields, err + } + searchFields.PubDateFrom = pubDateFrom } - documentCounts := map[string]uint64{} - if slugs := authorSlugs(authorResults.Hits()); len(slugs) > 0 { - if documentCounts, err = a.idx.DocumentCountsByAuthorSlugs(slugs); err != nil { - log.Println(err) - return fiber.ErrInternalServerError + if c.Query("pub-date-to") != "" { + pubDateTo, err := date.ParseISO(c.Query("pub-date-to")) + if err != nil { + return searchFields, err } + searchFields.PubDateTo = pubDateTo } - templateVars := fiber.Map{ - "SearchFields": searchFields, - "SelectedGender": c.Query("gender"), - "Results": authorResults, - "DocumentCounts": documentCounts, - "Paginator": view.Pagination(model.MaxPagesNavigator, authorResults, c.Queries()), - "Title": "Search authors", - "AuthorsSearchPage": true, - "URL": view.URL(c), - "SortURL": view.BaseURLWithout(c, "sort-by", "page"), - "SortBy": c.Query("sort-by"), - "AdditionalSortOptions": []struct { - Key string - Value string - }{ - {"name-a-z", "name A-Z"}, - {"name-z-a", "name Z-A"}, - {"birth-older-first", "birth older first"}, - {"birth-newer-first", "birth newer first"}, - {"death-older-first", "death older first"}, - {"death-newer-first", "death newer first"}, - {"documents-more-first", "documents more first"}, - {"documents-fewer-first", "documents fewer first"}, - }, - } - - if c.Get("hx-request") == "true" { - if err = c.Render("partials/authors-list-fragments", templateVars); err != nil { - log.Println(err) - return fiber.ErrInternalServerError - } - return nil + if searchFields.PubDateTo != 0 && searchFields.PubDateFrom > searchFields.PubDateTo { + searchFields.PubDateFrom, searchFields.PubDateTo = searchFields.PubDateTo, searchFields.PubDateFrom } - if err = c.Render("author/search", templateVars, "layout"); err != nil { - log.Println(err) - return fiber.ErrInternalServerError + if searchFields.EstReadTimeTo != 0 && searchFields.EstReadTimeFrom > searchFields.EstReadTimeTo { + searchFields.EstReadTimeFrom, searchFields.EstReadTimeTo = searchFields.EstReadTimeTo, searchFields.EstReadTimeFrom } - return nil + return searchFields, nil } -func (a *Controller) parseAuthorSearchQuery(c fiber.Ctx) (index.AuthorSearchFields, error) { +func parseAuthorSearchQuery(c fiber.Ctx) (index.AuthorSearchFields, error) { + name := c.Query("name") + if name == "" { + name = c.Query("search") + } + searchFields := index.AuthorSearchFields{ - Name: c.Query("name"), - SortBy: a.parseAuthorSearchSortBy(c), + Name: name, + SortBy: parseAuthorSortBy(c), } if gender, ok := parseGenderQuery(c.Query("gender")); ok { @@ -152,7 +125,23 @@ func parseGenderQuery(value string) (float64, bool) { } } -func (a *Controller) parseAuthorSearchSortBy(c fiber.Ctx) []string { +func parseDocumentSortBy(c fiber.Ctx) []string { + if c.Query("sort-by") != "" { + switch c.Query("sort-by") { + case "pub-date-older-first": + return []string{"Publication.Date"} + case "pub-date-newer-first": + return []string{"-Publication.Date"} + case "est-read-time-shorter-first": + return []string{"Words"} + case "est-read-time-longer-first": + return []string{"-Words"} + } + } + return []string{"-_score", "Series", "SeriesIndex"} +} + +func parseAuthorSortBy(c fiber.Ctx) []string { switch c.Query("sort-by") { case "name-z-a": return []string{"-Name"} @@ -173,6 +162,24 @@ func (a *Controller) parseAuthorSearchSortBy(c fiber.Ctx) []string { } } +func searchTypeFromContext(c fiber.Ctx) string { + if val, ok := c.Locals("SearchType").(string); ok && val != "" { + return val + } + if t := c.Query("type"); t == TypeAuthors { + return TypeAuthors + } + return TypeDocuments +} + +func parsePage(c fiber.Ctx) int { + page, err := strconv.Atoi(c.Query("page")) + if err != nil { + return 1 + } + return page +} + func authorSlugs(authors []index.Author) []string { slugs := make([]string, len(authors)) for i, author := range authors { @@ -180,3 +187,13 @@ func authorSlugs(authors []index.Author) []string { } return slugs } + +// Subjects returns all subjects from the index grouped by slug, as JSON. +func (s *Controller) Subjects(c fiber.Ctx) error { + bySlug, err := s.idx.Subjects() + if err != nil { + log.Println(err) + return fiber.ErrInternalServerError + } + return c.JSON(bySlug) +} diff --git a/internal/webserver/controller/search/search.go b/internal/webserver/controller/search/search.go new file mode 100644 index 00000000..d7e22e9a --- /dev/null +++ b/internal/webserver/controller/search/search.go @@ -0,0 +1,183 @@ +package search + +import ( + "log" + + "github.com/gofiber/fiber/v3" + "github.com/svera/coreander/v5/internal/index" + "github.com/svera/coreander/v5/internal/result" + "github.com/svera/coreander/v5/internal/webserver/model" + "github.com/svera/coreander/v5/internal/webserver/view" +) + +func (s *Controller) SearchDocuments(c fiber.Ctx) error { + c.Locals("SearchType", TypeDocuments) + return s.Search(c) +} + +func (s *Controller) SearchAuthors(c fiber.Ctx) error { + c.Locals("SearchType", TypeAuthors) + return s.Search(c) +} + +func (s *Controller) Search(c fiber.Ctx) error { + searchType := searchTypeFromContext(c) + + var session model.Session + if val, ok := c.Locals("Session").(model.Session); ok { + session = val + } + + wordsPerMinute := s.config.WordsPerMinute + if session.WordsPerMinute > 0 { + wordsPerMinute = session.WordsPerMinute + } + + page := parsePage(c) + + if searchType == TypeAuthors { + return s.renderAuthorSearch(c, session, page) + } + return s.renderDocumentSearch(c, session, page, wordsPerMinute) +} + +func (s *Controller) renderDocumentSearch(c fiber.Ctx, session model.Session, page int, wordsPerMinute float64) error { + searchFields, err := parseDocumentSearchQuery(c, wordsPerMinute) + if err != nil { + log.Println(err) + return fiber.ErrBadRequest + } + + documentResults, err := s.idx.Search(searchFields, page, model.ResultsPerPage) + if err != nil { + log.Println(err) + return fiber.ErrInternalServerError + } + + searchResults := model.AugmentedDocumentsFromDocuments(documentResults) + if session.ID > 0 { + searchResults = s.readingRepository.CompletedPaginatedResult(int(session.ID), searchResults) + searchResults = s.hlRepository.HighlightedPaginatedResult(int(session.ID), searchResults) + } + + templateVars := s.baseTemplateVars(c, TypeDocuments) + templateVars["SearchFields"] = searchFields + templateVars["DocumentSearchFields"] = searchFields + templateVars["AuthorSearchFields"] = index.AuthorSearchFields{} + templateVars["SearchQuery"] = searchFields.Keywords + templateVars["Results"] = searchResults + templateVars["Title"] = "Search results" + templateVars["WordsPerMinute"] = wordsPerMinute + templateVars["SortBy"] = c.Query("sort-by") + templateVars["AdditionalSortOptions"] = documentSortOptions() + + return s.renderSearch(c, templateVars, "partials/docs-list-fragments") +} + +func (s *Controller) renderAuthorSearch(c fiber.Ctx, session model.Session, page int) error { + searchFields, err := parseAuthorSearchQuery(c) + if err != nil { + log.Println(err) + return fiber.ErrBadRequest + } + + authorResults, err := s.idx.SearchAuthors(searchFields, page, int(model.ResultsPerPage)) + if err != nil { + log.Println(err) + return fiber.ErrInternalServerError + } + + documentCounts := map[string]uint64{} + if slugs := authorSlugs(authorResults.Hits()); len(slugs) > 0 { + if documentCounts, err = s.idx.DocumentCountsByAuthorSlugs(slugs); err != nil { + log.Println(err) + return fiber.ErrInternalServerError + } + } + + keywords := searchFields.Name + templateVars := s.baseTemplateVars(c, TypeAuthors) + templateVars["SearchFields"] = searchFields + templateVars["AuthorSearchFields"] = searchFields + templateVars["DocumentSearchFields"] = index.SearchFields{Keywords: keywords} + templateVars["SearchQuery"] = keywords + templateVars["SelectedGender"] = c.Query("gender") + templateVars["Results"] = authorResults + templateVars["DocumentCounts"] = documentCounts + templateVars["Title"] = "Search authors" + templateVars["SortBy"] = c.Query("sort-by") + templateVars["AdditionalSortOptions"] = authorSortOptions() + + return s.renderSearch(c, templateVars, "partials/authors-list-fragments") +} + +func (s *Controller) baseTemplateVars(c fiber.Ctx, searchType string) fiber.Map { + return fiber.Map{ + "SearchType": searchType, + "SearchPage": true, + "EmailFrom": s.sender.From(), + "URL": view.URL(c), + "SortURL": view.BaseURLWithout(c, "sort-by", "page"), + } +} + +func (s *Controller) renderSearch(c fiber.Ctx, templateVars fiber.Map, fragmentTemplate string) error { + if results, ok := templateVars["Results"]; ok { + switch r := results.(type) { + case result.Paginated[[]model.AugmentedDocument]: + templateVars["Paginator"] = view.Pagination(model.MaxPagesNavigator, r, c.Queries()) + case result.Paginated[[]index.Author]: + templateVars["Paginator"] = view.Pagination(model.MaxPagesNavigator, r, c.Queries()) + } + } + + if c.Get("hx-request") == "true" { + if err := c.Render(fragmentTemplate, templateVars); err != nil { + log.Println(err) + return fiber.ErrInternalServerError + } + return nil + } + + if err := c.Render("search/list", templateVars, "layout"); err != nil { + log.Println(err) + return fiber.ErrInternalServerError + } + + return nil +} + +func documentSortOptions() []struct { + Key string + Value string +} { + return []struct { + Key string + Value string + }{ + {"relevance", "relevance"}, + {"pub-date-older-first", "older"}, + {"pub-date-newer-first", "newer"}, + {"est-read-time-shorter-first", "shorter"}, + {"est-read-time-longer-first", "longer"}, + } +} + +func authorSortOptions() []struct { + Key string + Value string +} { + return []struct { + Key string + Value string + }{ + {"name-a-z", "name A-Z"}, + {"name-z-a", "name Z-A"}, + {"birth-older-first", "birth older first"}, + {"birth-newer-first", "birth newer first"}, + {"death-older-first", "death older first"}, + {"death-newer-first", "death newer first"}, + {"documents-more-first", "documents more first"}, + {"documents-fewer-first", "documents fewer first"}, + } +} diff --git a/internal/webserver/embedded/css/display.css b/internal/webserver/embedded/css/display.css index be118468..0c64022a 100644 --- a/internal/webserver/embedded/css/display.css +++ b/internal/webserver/embedded/css/display.css @@ -150,6 +150,15 @@ main .form-control:focus { white-space: nowrap; } +.home-search-tab-content { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.search-filters-panel .row { + margin-inline: 0; +} + .search-filters-sidebar-sticky { position: sticky; top: 5rem; /* clear fixed-top navbar */ @@ -282,28 +291,13 @@ main .form-control:focus { overflow: hidden; } -#authors-searchbox-container .input-group .form-control:focus, #searchbox-container .input-group .form-control:focus { box-shadow: none !important; } .home-search-tabs { - max-width: 28rem; - margin-left: auto; - margin-right: auto; -} - -.home-search-tabs .nav-link { - color: var(--bs-secondary-color); - background-color: var(--bs-body-bg); - border: 1px solid var(--bs-border-color); - border-bottom: 0; -} - -.home-search-tabs .nav-link.active { - color: var(--bs-body-color); - background-color: var(--bs-body-bg); - font-weight: 600; + --bs-nav-tabs-border-radius: var(--bs-border-radius-xl); + width: 100%; } .home-search-input-group:focus-within { @@ -315,7 +309,6 @@ main .form-control:focus { border-radius: .25rem; } -#authors-searchbox-container .btn:hover, #searchbox-container .btn:hover { color: var(--bs-btn-color); background-color: rgba(0, 0, 0, 0); @@ -330,19 +323,6 @@ main .form-control:focus { border-radius: .25rem; } -#authors-searchbox-container .input-group:focus-within { - color: #212529; - border-color: #86b7fe; - outline: 0; - box-shadow: 0 0 0 0.25rem rgb(13 110 253 / 25%); - z-index: 3; - border-radius: .25rem; -} - -.home-search-tabs { - position: relative; -} - /* Completion checkbox and date styles */ input[id^="complete-checkbox-"], label[id^="complete-label-"], diff --git a/internal/webserver/embedded/js/author-search-filters.js b/internal/webserver/embedded/js/author-search-filters.js index 03b08ecf..4636d52a 100644 --- a/internal/webserver/embedded/js/author-search-filters.js +++ b/internal/webserver/embedded/js/author-search-filters.js @@ -22,13 +22,19 @@ function initAuthorSearchFilters(searchFilters) { if (!searchFiltersForm) return const composeDateControls = initDateControls(searchFilters, searchFiltersForm) + searchFiltersForm._coreanderComposeDates = searchFiltersForm._coreanderComposeDates || [] + searchFiltersForm._coreanderComposeDates.push(composeDateControls) + if (searchFiltersForm.dataset.coreanderFilterBehavior === 'true') { + return + } initFilterFormBehavior({ searchFilters, searchFiltersForm, composeDateControls, - listPath: '/authors', + listPath: '/search', syncOffcanvas: authorSyncOffcanvas, }) + searchFiltersForm.dataset.coreanderFilterBehavior = 'true' } enableFilterInputsOnPageShow(['author-search-filters', 'author-search-filters-sidebar']) diff --git a/internal/webserver/embedded/js/document-search-filters.js b/internal/webserver/embedded/js/document-search-filters.js index c1326daa..80183f93 100644 --- a/internal/webserver/embedded/js/document-search-filters.js +++ b/internal/webserver/embedded/js/document-search-filters.js @@ -12,7 +12,11 @@ import { let translations = {} const i18nElement = document.getElementById('i18n') if (i18nElement) { - translations = JSON.parse(i18nElement.textContent).i18n + try { + translations = JSON.parse(i18nElement.textContent).i18n + } catch (_) { + translations = {} + } } function documentsSyncOffcanvas() { @@ -38,22 +42,27 @@ function initDocumentSearchFilters(searchFilters) { const idPrefix = searchFilters.id === 'document-search-filters-sidebar' ? 'sidebar-' : '' const composeDateControls = initDateControls(searchFilters, searchFiltersForm) - const { scheduleApplyFilters } = initFilterFormBehavior({ - searchFilters, - searchFiltersForm, - composeDateControls, - listPath: '/documents', - syncOffcanvas: documentsSyncOffcanvas, - beforeSidebarApply: () => { - const sidebarContainer = document.getElementById('document-search-filters-sidebar') - if (sidebarContainer) sidebarContainer.dispatchEvent(new CustomEvent('syncSubjectsFromHiddenInput')) - }, - }) + if (searchFiltersForm.dataset.coreanderFilterBehavior !== 'true') { + const { scheduleApplyFilters } = initFilterFormBehavior({ + searchFilters, + searchFiltersForm, + composeDateControls, + listPath: '/search', + syncOffcanvas: documentsSyncOffcanvas, + beforeSidebarApply: () => { + const sidebarContainer = document.getElementById('document-search-filters-sidebar') + if (sidebarContainer) sidebarContainer.dispatchEvent(new CustomEvent('syncSubjectsFromHiddenInput')) + }, + }) + searchFiltersForm.dataset.coreanderFilterBehavior = 'true' + initSubjectsFilters(searchFilters, idPrefix, scheduleApplyFilters) + return + } - initSubjectsFilters(searchFilters, idPrefix, scheduleApplyFilters) + initSubjectsFilters(searchFilters, idPrefix, null) } -function initSubjectsFilters(searchFilters, idPrefix, triggerSearchUpdate) { +export function initSubjectsFilters(searchFilters, idPrefix, triggerSearchUpdate) { const subjectsList = document.getElementById(idPrefix + 'subjects-list') const subjectsInput = document.getElementById(idPrefix + 'subjects') const subjectsHiddenInput = document.getElementById(idPrefix + 'subjects-hidden') @@ -223,14 +232,16 @@ function initSubjectsFilters(searchFilters, idPrefix, triggerSearchUpdate) { } } -enableFilterInputsOnPageShow(['document-search-filters', 'document-search-filters-sidebar']) +if (!document.getElementById('home-search-form')) { + enableFilterInputsOnPageShow(['document-search-filters', 'document-search-filters-sidebar']) -initDocumentSearchFilters(document.getElementById('document-search-filters')) -initDocumentSearchFilters(document.getElementById('document-search-filters-sidebar')) + initDocumentSearchFilters(document.getElementById('document-search-filters')) + initDocumentSearchFilters(document.getElementById('document-search-filters-sidebar')) -bindOffcanvasFilterSync({ - sidebarFormId: 'search-filters-form', - offcanvasContainerId: 'document-search-filters', - offcanvasElementId: 'search-filters-offcanvas', - syncOffcanvas: documentsSyncOffcanvas, -}) + bindOffcanvasFilterSync({ + sidebarFormId: 'search-filters-form', + offcanvasContainerId: 'document-search-filters', + offcanvasElementId: 'search-filters-offcanvas', + syncOffcanvas: documentsSyncOffcanvas, + }) +} diff --git a/internal/webserver/embedded/js/home-search-tabs.js b/internal/webserver/embedded/js/home-search-tabs.js deleted file mode 100644 index 4d1c0293..00000000 --- a/internal/webserver/embedded/js/home-search-tabs.js +++ /dev/null @@ -1,47 +0,0 @@ -"use strict" - -const STORAGE_KEY = "coreander-home-search-tab" - -function activateHomeSearchTab(tabName) { - document.querySelectorAll("[data-home-search-tab]").forEach((button) => { - const isActive = button.dataset.homeSearchTab === tabName - button.classList.toggle("active", isActive) - button.setAttribute("aria-selected", isActive ? "true" : "false") - }) - - document.querySelectorAll("[data-home-search-panel]").forEach((panel) => { - const isActive = panel.dataset.homeSearchPanel === tabName - panel.classList.toggle("d-none", !isActive) - }) - - try { - sessionStorage.setItem(STORAGE_KEY, tabName) - } catch (_) { - // ignore storage errors - } -} - -function initHomeSearchTabs() { - const tabs = document.getElementById("home-search-tabs") - if (!tabs) return - - tabs.addEventListener("click", (event) => { - const button = event.target.closest("[data-home-search-tab]") - if (!button) return - activateHomeSearchTab(button.dataset.homeSearchTab) - }) - - let initialTab = "documents" - try { - const stored = sessionStorage.getItem(STORAGE_KEY) - if (stored === "documents" || stored === "authors") { - initialTab = stored - } - } catch (_) { - // ignore storage errors - } - - activateHomeSearchTab(initialTab) -} - -initHomeSearchTabs() diff --git a/internal/webserver/embedded/js/home-search.js b/internal/webserver/embedded/js/home-search.js new file mode 100644 index 00000000..0fdba888 --- /dev/null +++ b/internal/webserver/embedded/js/home-search.js @@ -0,0 +1,178 @@ +"use strict" + +import { initDateControls, syncSearchTypeFromPane } from './search-filter-utils.js' + +const STORAGE_KEY = "coreander-home-search-tab" + +function getStoredTab() { + try { + const stored = sessionStorage.getItem(STORAGE_KEY) + if (stored === "documents" || stored === "authors") { + return stored + } + } catch (_) { + // ignore storage errors + } + return "documents" +} + +function setHomeSearchType(type) { + const typeInput = document.getElementById("home-search-type") + if (typeInput) typeInput.value = type +} + +function setHomeActivePanelInputs(activeType) { + const docPanel = document.getElementById("home-search-documents-panel") + const authorPanel = document.getElementById("home-search-authors-panel") + + docPanel?.querySelectorAll("input, select, textarea").forEach((el) => { + if (el.id === "home-search-type") return + el.disabled = activeType !== "documents" + }) + authorPanel?.querySelectorAll("input, select, textarea").forEach((el) => { + el.disabled = activeType !== "authors" + }) +} + +function isAdvancedSearchOpen(collapse) { + if (collapse?.classList.contains("show")) return true + const toggle = document.querySelector('[href="#home-advanced-search-collapse"], [data-bs-target="#home-advanced-search-collapse"]') + return toggle?.getAttribute("aria-expanded") === "true" +} + +function resolveSearchType(collapse) { + if (isAdvancedSearchOpen(collapse)) { + return syncSearchTypeFromPane("home-search-type", "home-search-authors-panel") + } + setHomeSearchType("documents") + return "documents" +} + +function hasFilterParams(params) { + for (const key of params.keys()) { + if (key !== "type") return true + } + return false +} + +function collectPanelParams(panel, composeDateControls, type) { + if (!panel) return new URLSearchParams() + + composeDateControls() + const params = new URLSearchParams() + params.set("type", type) + panel.querySelectorAll("input, select, textarea").forEach((el) => { + if (!el.name || el.disabled) return + if ((el.type === "checkbox" || el.type === "radio") && !el.checked) return + const value = String(el.value ?? "").trim() + if (value === "" || value === "0") return + params.append(el.name, value) + }) + return params +} + +function tabTypeFromEvent(event) { + const tabBtn = event.target?.closest?.("[data-home-search-tab]") ?? event.target + const tabName = tabBtn?.dataset?.homeSearchTab + return tabName === "authors" || tabName === "documents" ? tabName : null +} + +function restoreStoredTab() { + try { + const stored = sessionStorage.getItem(STORAGE_KEY) + if (stored !== "authors") return + const authorsTab = document.getElementById("home-search-tab-authors") + if (authorsTab && typeof bootstrap !== "undefined") { + bootstrap.Tab.getOrCreateInstance(authorsTab).show() + } + } catch (_) { + // ignore storage errors + } +} + +function initHomeSearchTabs() { + const tabs = document.getElementById("home-search-tabs") + if (!tabs) return + + let activeType = getStoredTab() + setHomeSearchType(activeType) + setHomeActivePanelInputs(activeType) + + tabs.addEventListener("show.bs.tab", (event) => { + const tabName = tabTypeFromEvent(event) + if (!tabName) return + activeType = tabName + setHomeSearchType(tabName) + setHomeActivePanelInputs(tabName) + }) + + tabs.addEventListener("shown.bs.tab", (event) => { + const tabName = tabTypeFromEvent(event) + if (!tabName) return + try { + sessionStorage.setItem(STORAGE_KEY, tabName) + } catch (_) { + // ignore storage errors + } + }) + + restoreStoredTab() + activeType = syncSearchTypeFromPane("home-search-type", "home-search-authors-panel") + setHomeActivePanelInputs(activeType) +} + +function safeInitDateControls(panel, form) { + if (!panel) return () => {} + try { + return initDateControls(panel, form) + } catch (error) { + console.error("Error initializing date controls:", error) + return () => {} + } +} + +function initHomeSearch() { + const form = document.getElementById("home-search-form") + if (!form) return + + const collapse = document.getElementById("home-advanced-search-collapse") + const docPanel = document.getElementById("home-search-documents-panel") + const authorPanel = document.getElementById("home-search-authors-panel") + const searchbox = form.querySelector("#searchbox") + + const composeDocumentDates = safeInitDateControls(docPanel, form) + const composeAuthorDates = safeInitDateControls(authorPanel, form) + form._coreanderComposeDates = [composeDocumentDates, composeAuthorDates] + + form.addEventListener("submit", (event) => { + event.preventDefault() + const query = searchbox?.value.trim() ?? "" + const searchType = resolveSearchType(collapse) + + if (!isAdvancedSearchOpen(collapse)) { + if (!query) return + const params = new URLSearchParams({ type: searchType, search: query }) + window.location.href = "/search?" + params.toString() + return + } + + const composeDates = searchType === "authors" ? composeAuthorDates : composeDocumentDates + const panel = searchType === "authors" ? authorPanel : docPanel + + const params = collectPanelParams(panel, composeDates, searchType) + if (query) params.set("search", query) + if (!query && !params.has("name") && !hasFilterParams(params)) return + window.location.href = "/search?" + params.toString() + }) + + initHomeSearchTabs() + + const docFilters = docPanel?.querySelector("#document-search-filters") + if (docFilters) { + import("./document-search-filters.js") + .then(({ initSubjectsFilters }) => initSubjectsFilters(docFilters, "", null)) + .catch(() => {}) + } +} + +initHomeSearch() diff --git a/internal/webserver/embedded/js/keyboard-shortcuts.js b/internal/webserver/embedded/js/keyboard-shortcuts.js index d83a3f74..1104190b 100644 --- a/internal/webserver/embedded/js/keyboard-shortcuts.js +++ b/internal/webserver/embedded/js/keyboard-shortcuts.js @@ -57,11 +57,9 @@ function isVisible(element) { function getSearchInput() { const candidates = [ document.querySelector('#searchbox-container input[name="search"]'), - document.querySelector('#authors-searchbox-container input[name="name"]'), document.querySelector('#sidebar-search'), ...document.querySelectorAll('#searchbox'), document.querySelector('#searchbox-offcanvas'), - document.querySelector('#authors-searchbox'), ] for (const input of candidates) { diff --git a/internal/webserver/embedded/js/search-filter-utils.js b/internal/webserver/embedded/js/search-filter-utils.js index 6a9bed52..14869646 100644 --- a/internal/webserver/embedded/js/search-filter-utils.js +++ b/internal/webserver/embedded/js/search-filter-utils.js @@ -101,6 +101,7 @@ export function initDateControls(searchFilters, searchFiltersForm) { const monthSelect = dateControl.querySelector('.input-month') const dayInput = dateControl.querySelector('.input-day') const yearInput = dateControl.querySelector('.input-year') + if (!monthSelect || !dayInput || !yearInput) return monthSelect.addEventListener('change', () => { updateMaxDays(monthSelect, dayInput, yearInput, dateControl) @@ -170,6 +171,39 @@ export function syncSidebarFormToOffcanvas({ searchFieldName, offcanvasContainer const FILTER_DEBOUNCE_MS = 600 +const SEARCH_LIST_PATHS = new Set(['/search', '/documents', '/authors']) + +export function syncSearchTypeFromPane(typeInputId, authorPaneId) { + const typeInput = document.getElementById(typeInputId) + const authorPane = document.getElementById(authorPaneId) + const type = authorPane?.classList.contains('active') ? 'authors' : 'documents' + if (typeInput) typeInput.value = type + return type +} + +export function syncSidebarSearchTypeFromPane() { + return syncSearchTypeFromPane('search-type', 'search-sidebar-authors-panel') +} + +function composeAllDateControls(form, fallbackCompose) { + const composers = form?._coreanderComposeDates + if (composers?.length) { + composers.forEach((fn) => fn()) + return + } + fallbackCompose() +} + +function activeSearchListPath(fallbackPath) { + if (document.getElementById('search-filters-form')) { + return '/search' + } + if (SEARCH_LIST_PATHS.has(window.location.pathname)) { + return window.location.pathname + } + return fallbackPath +} + export function initFilterFormBehavior({ searchFilters, searchFiltersForm, @@ -178,14 +212,16 @@ export function initFilterFormBehavior({ syncOffcanvas, beforeSidebarApply, }) { - const isListPage = window.location.pathname === listPath + const resolvedListPath = activeSearchListPath(listPath) + const isListPage = SEARCH_LIST_PATHS.has(window.location.pathname) && document.getElementById('search-filters-form') let applyingFilters = false function applyFilters() { applyingFilters = true - composeDateControls() const sidebarForm = document.getElementById('search-filters-form') if (sidebarForm && isListPage) { + syncSidebarSearchTypeFromPane() + composeAllDateControls(sidebarForm, composeDateControls) if (searchFiltersForm !== sidebarForm) { copyFormValues(searchFiltersForm, sidebarForm) if (beforeSidebarApply) beforeSidebarApply() @@ -196,13 +232,14 @@ export function initFilterFormBehavior({ if (v != null && String(v).trim() !== '') params.append(k, v) } const queryString = params.toString() - const url = listPath + (queryString ? '?' + queryString : '') - window.htmx.trigger(document.body, 'update') + const url = resolvedListPath + (queryString ? '?' + queryString : '') history.replaceState(null, '', url) + window.htmx.trigger(document.body, 'update') syncOffcanvas() } else { + composeAllDateControls(searchFiltersForm, composeDateControls) const params = new URLSearchParams(new FormData(searchFiltersForm)) - window.location.href = listPath + '?' + params.toString() + window.location.href = resolvedListPath + '?' + params.toString() } setTimeout(() => { applyingFilters = false }, 0) } @@ -233,6 +270,9 @@ export function initFilterFormBehavior({ }) } + searchFiltersForm._coreanderComposeDates = searchFiltersForm._coreanderComposeDates || [] + searchFiltersForm._coreanderComposeDates.push(composeDateControls) + return { scheduleApplyFilters } } diff --git a/internal/webserver/embedded/js/search-offcanvas-tabs.js b/internal/webserver/embedded/js/search-offcanvas-tabs.js new file mode 100644 index 00000000..8b9d033a --- /dev/null +++ b/internal/webserver/embedded/js/search-offcanvas-tabs.js @@ -0,0 +1,55 @@ +"use strict" + +function setOffcanvasSearchType(type) { + const typeInput = document.getElementById("search-offcanvas-type") + if (typeInput) typeInput.value = type +} + +function tabTypeFromEvent(event) { + const tabBtn = event.target?.closest?.("[data-search-tab]") ?? event.target + const tabName = tabBtn?.dataset?.searchTab + return tabName === "authors" || tabName === "documents" ? tabName : null +} + +function syncOffcanvasSearchTypeFromPane() { + const authorPane = document.getElementById("search-offcanvas-authors-panel") + const type = authorPane?.classList.contains("active") ? "authors" : "documents" + setOffcanvasSearchType(type) + return type +} + +function setOffcanvasActivePanelInputs(activeType) { + const docPanel = document.getElementById("search-offcanvas-documents-panel") + const authorPanel = document.getElementById("search-offcanvas-authors-panel") + + docPanel?.querySelectorAll("input, select, textarea").forEach((el) => { + if (el.id === "search-offcanvas-type") return + el.disabled = activeType !== "documents" + }) + authorPanel?.querySelectorAll("input, select, textarea").forEach((el) => { + el.disabled = activeType !== "authors" + }) +} + +function initSearchOffcanvasTabs() { + const tabs = document.getElementById("search-offcanvas-tabs") + if (!tabs) return + + let activeType = syncOffcanvasSearchTypeFromPane() + setOffcanvasActivePanelInputs(activeType) + + tabs.addEventListener("show.bs.tab", (event) => { + const tabName = tabTypeFromEvent(event) + if (!tabName) return + activeType = tabName + setOffcanvasSearchType(tabName) + setOffcanvasActivePanelInputs(tabName) + }) + + const form = tabs.closest("form") + form?.addEventListener("submit", () => { + syncOffcanvasSearchTypeFromPane() + }) +} + +initSearchOffcanvasTabs() diff --git a/internal/webserver/embedded/js/search-sidebar-tabs.js b/internal/webserver/embedded/js/search-sidebar-tabs.js new file mode 100644 index 00000000..b92fc800 --- /dev/null +++ b/internal/webserver/embedded/js/search-sidebar-tabs.js @@ -0,0 +1,70 @@ +"use strict" + +import { syncSidebarSearchTypeFromPane } from './search-filter-utils.js' + +function setSearchType(type) { + const typeInput = document.getElementById("search-type") + if (typeInput) typeInput.value = type +} + +function syncQueryFields(fromType, toType) { + const searchInput = document.getElementById("sidebar-search") + const nameInput = document.getElementById("sidebar-name") + if (!searchInput || !nameInput) return + if (fromType === "documents" && toType === "authors" && !nameInput.value.trim()) { + nameInput.value = searchInput.value + } + if (fromType === "authors" && toType === "documents" && !searchInput.value.trim()) { + searchInput.value = nameInput.value + } +} + +function tabTypeFromEvent(event) { + const tabBtn = event.target?.closest?.("[data-search-tab]") ?? event.target + const tabName = tabBtn?.dataset?.searchTab + return tabName === "authors" || tabName === "documents" ? tabName : null +} + +function setActivePanelInputs(activeType) { + const docPanel = document.getElementById("search-sidebar-documents-panel") + const authorPanel = document.getElementById("search-sidebar-authors-panel") + + docPanel?.querySelectorAll("input, select, textarea").forEach((el) => { + if (el.id === "search-type") return + el.disabled = activeType !== "documents" + }) + authorPanel?.querySelectorAll("input, select, textarea").forEach((el) => { + el.disabled = activeType !== "authors" + }) +} + +function initSearchSidebarTabs() { + const tabs = document.getElementById("search-sidebar-tabs") + const form = document.getElementById("search-filters-form") + if (!tabs || !form) return + + let activeType = syncSidebarSearchTypeFromPane() + setActivePanelInputs(activeType) + + tabs.addEventListener("show.bs.tab", (event) => { + const nextType = tabTypeFromEvent(event) + if (!nextType || nextType === activeType) return + syncQueryFields(activeType, nextType) + activeType = nextType + setSearchType(nextType) + setActivePanelInputs(nextType) + }) + + tabs.addEventListener("shown.bs.tab", (event) => { + const tabName = tabTypeFromEvent(event) + if (!tabName) return + activeType = tabName + setSearchType(tabName) + setActivePanelInputs(tabName) + if (window.htmx) { + window.htmx.trigger(document.body, "update") + } + }) +} + +initSearchSidebarTabs() diff --git a/internal/webserver/embedded/translations/de.yml b/internal/webserver/embedded/translations/de.yml index ac84607e..6aac5a7e 100644 --- a/internal/webserver/embedded/translations/de.yml +++ b/internal/webserver/embedded/translations/de.yml @@ -5,6 +5,8 @@ "Search results": "Suchergebnisse" "Search in %d documents": "In %d Dokumenten suchen" "Search in %d authors": "In %d Autoren suchen" +"Search in %d documents and %d authors": "In %d Dokumenten und %d Autoren suchen" +"No results found": "Keine Ergebnisse gefunden" "Search results": "Suchergebnisse" "Download": "Herunterladen" "%d documents found": "%d Dokumente gefunden" diff --git a/internal/webserver/embedded/translations/es.yml b/internal/webserver/embedded/translations/es.yml index 0c604014..8eae34df 100644 --- a/internal/webserver/embedded/translations/es.yml +++ b/internal/webserver/embedded/translations/es.yml @@ -5,6 +5,8 @@ "Search results": "Resultados de la búsqueda" "Search in %d documents": "Buscar en %d documentos" "Search in %d authors": "Buscar en %d autores" +"Search in %d documents and %d authors": "Buscar en %d documentos y %d autores" +"No results found": "No se encontraron resultados" "Download": "Descargar" "%d documents found": "Hallados %d documentos" "No documents found": "No se han encontrado documentos" diff --git a/internal/webserver/embedded/translations/fr.yml b/internal/webserver/embedded/translations/fr.yml index 917a9ce0..b65a8e95 100644 --- a/internal/webserver/embedded/translations/fr.yml +++ b/internal/webserver/embedded/translations/fr.yml @@ -5,6 +5,8 @@ "Search results": "Résultats de la recherche" "Search in %d documents": "Rechercher dans %d documents" "Search in %d authors": "Rechercher dans %d auteurs" +"Search in %d documents and %d authors": "Rechercher dans %d documents et %d auteurs" +"No results found": "Aucun résultat trouvé" "Download": "Télécharger" "%d documents found": "%d documents trouvés" "No documents found": "Aucun document trouvé" diff --git a/internal/webserver/embedded/translations/ru.yml b/internal/webserver/embedded/translations/ru.yml index 6a1d4d52..10c2a1a7 100644 --- a/internal/webserver/embedded/translations/ru.yml +++ b/internal/webserver/embedded/translations/ru.yml @@ -5,6 +5,8 @@ "Search results": "Результаты поиска" "Search in %d documents": "Поиск по %d документам" "Search in %d authors": "Поиск по %d авторам" +"Search in %d documents and %d authors": "Поиск по %d документам и %d авторам" +"No results found": "Результаты не найдены" "Download": "Скачать" "%d documents found": "Найдено %d документов" "No documents found": "Документы не найдены" diff --git a/internal/webserver/embedded/views/author/search.html b/internal/webserver/embedded/views/author/search.html deleted file mode 100644 index a670a327..00000000 --- a/internal/webserver/embedded/views/author/search.html +++ /dev/null @@ -1,24 +0,0 @@ -
-
- {{template "partials/authors-list-header" .}} -
-
- -
-
-
- -
-
-
- {{template "partials/docs-list-placeholder" .}} -
- {{ template "partials/authors-list-content" . }} -
-
-
- - - diff --git a/internal/webserver/embedded/views/partials/author-search-filters.html b/internal/webserver/embedded/views/partials/author-search-filters.html index a218e97c..40e31a72 100644 --- a/internal/webserver/embedded/views/partials/author-search-filters.html +++ b/internal/webserver/embedded/views/partials/author-search-filters.html @@ -1,5 +1,5 @@ {{$idPrefix := or .FilterIdPrefix ""}} -
+
{{if $idPrefix}}
{{t .Lang "Search"}} @@ -62,16 +62,14 @@
- {{if not .AuthorsSearchPage}} -
+
- {{end}}
-{{if or $idPrefix (not .AuthorsSearchPage)}} +{{if and (or $idPrefix (not .AuthorsSearchPage)) (not .HomeSearch)}} {{end}} diff --git a/internal/webserver/embedded/views/partials/document-search-filters.html b/internal/webserver/embedded/views/partials/document-search-filters.html index db2d976e..4b5bbb98 100644 --- a/internal/webserver/embedded/views/partials/document-search-filters.html +++ b/internal/webserver/embedded/views/partials/document-search-filters.html @@ -1,5 +1,5 @@ {{$idPrefix := or .FilterIdPrefix ""}} -
+
{{if $idPrefix}}
{{t .Lang "Search"}} @@ -109,17 +109,15 @@
- {{if not .DocumentsSearchPage}} -
+
- {{end}}
{{/* Include script and i18n from sidebar when on documents search page (so script runs after sidebar is in DOM); otherwise include from offcanvas. */}} -{{if or $idPrefix (not .DocumentsSearchPage)}} +{{if and (or $idPrefix (not .DocumentsSearchPage)) (not .HomeSearch)}} diff --git a/internal/webserver/embedded/views/partials/navbar.html b/internal/webserver/embedded/views/partials/navbar.html index 180a85cd..39ce8c84 100644 --- a/internal/webserver/embedded/views/partials/navbar.html +++ b/internal/webserver/embedded/views/partials/navbar.html @@ -8,24 +8,15 @@ {{end}} {{if not .HomeNavbar}} -
- {{if .AuthorsSearchPage}} -
-
- - - -
-
- {{else}} -
+
+ +
- +
- {{end}}
{{else}}
@@ -209,27 +200,34 @@
{{t .Lang "Advanced search"}}
- {{if .AuthorsSearchPage}} -
-
+ {{$searchType := or .SearchType "documents"}} + + +
- +
- {{template "partials/author-search-filters" dict "Lang" .Lang "SearchFields" .SearchFields "SelectedGender" .SelectedGender "Version" .Version "AuthorsSearchPage" .AuthorsSearchPage}} - - {{else}} -
-
-
- + +
+
+ {{template "partials/document-search-filters" dict "Lang" .Lang "SearchFields" .DocumentSearchFields "Version" .Version "AvailableLanguages" .AvailableLanguages "DocumentsSearchPage" .SearchPage}} +
+
+ {{template "partials/author-search-filters" dict "Lang" .Lang "SearchFields" .AuthorSearchFields "SelectedGender" .SelectedGender "Version" .Version "AuthorsSearchPage" .SearchPage}}
- {{template "partials/document-search-filters" dict "Lang" .Lang "SearchFields" .SearchFields "Version" .Version "AvailableLanguages" .AvailableLanguages "DocumentsSearchPage" .DocumentsSearchPage}} - {{end}}
+ {{end}} diff --git a/internal/webserver/embedded/views/partials/search-filters-sidebar.html b/internal/webserver/embedded/views/partials/search-filters-sidebar.html new file mode 100644 index 00000000..5e3f575a --- /dev/null +++ b/internal/webserver/embedded/views/partials/search-filters-sidebar.html @@ -0,0 +1,26 @@ +{{$searchType := or .SearchType "documents"}} +
+ +
+ + diff --git a/internal/webserver/embedded/views/partials/searchbox.html b/internal/webserver/embedded/views/partials/searchbox.html index cb419ad9..37d34dc6 100644 --- a/internal/webserver/embedded/views/partials/searchbox.html +++ b/internal/webserver/embedded/views/partials/searchbox.html @@ -1,68 +1,50 @@ -
+
- - -
-
-
-
- - - - - -
+ + +
+
+ + + + +
-

- - - - -

-
- {{template "partials/document-search-filters" dict "Lang" .Lang "Responsive" true "Version" .Version "AvailableLanguages" .AvailableLanguages}} -
- -
- -
-
-
-
- - - - - +
+

+ + + + +

+
+ +
+
+ {{template "partials/document-search-filters" dict "Lang" .Lang "Responsive" true "HomeSearch" true "Version" .Version "AvailableLanguages" .AvailableLanguages}} +
+
+ {{template "partials/author-search-filters" dict "Lang" .Lang "Responsive" true "HomeSearch" true "Version" .Version}}
-

- - - - -

-
- {{template "partials/author-search-filters" dict "Lang" .Lang "Responsive" true "Version" .Version}} -
- -
+
+
- + + + diff --git a/internal/webserver/embedded/views/document/list.html b/internal/webserver/embedded/views/search/list.html similarity index 58% rename from internal/webserver/embedded/views/document/list.html rename to internal/webserver/embedded/views/search/list.html index c5f45b42..5f550796 100644 --- a/internal/webserver/embedded/views/document/list.html +++ b/internal/webserver/embedded/views/search/list.html @@ -1,29 +1,37 @@
+ {{if eq .SearchType "authors"}} + {{template "partials/authors-list-header" .}} + {{else}} {{template "partials/docs-list-header" .}} + {{end}}
-
- -
+ {{template "partials/search-filters-sidebar" .}}
{{template "partials/docs-list-placeholder" .}} -
+
+ {{if eq .SearchType "authors"}} + {{ template "partials/authors-list-content" . }} + {{else}} {{ template "partials/docs-list-content" . }} + {{end}}
+{{if ne .SearchType "authors"}} {{template "partials/delete-modal" dict "Lang" .Lang "Action" "/documents" "ModalHeader" "Delete document" "ModalBody" "Are you sure you want to delete this document?" "ModalErrorMessage" "There was an error deleting the document"}} - +{{else}} + +{{end}} + diff --git a/internal/webserver/routes.go b/internal/webserver/routes.go index 9a37238d..f3d1170d 100644 --- a/internal/webserver/routes.go +++ b/internal/webserver/routes.go @@ -117,12 +117,12 @@ func routes(app *fiber.App, controllers Controllers, jwtSecret []byte, sender Se docsGroup.Post("/:slug/send", alwaysRequireAuthentication, controllers.Documents.Send) docsGroup.Post("/:slug/share", alwaysRequireAuthentication, controllers.Documents.Share) docsGroup.Get("/:slug", controllers.Documents.Detail) - docsGroup.Get("/", controllers.Documents.Search) + docsGroup.Get("/", controllers.Search.SearchDocuments) - app.Get("/subjects", controllers.Documents.Subjects) + app.Get("/subjects", controllers.Search.Subjects) app.Get("/authors/:slug.:extension", controllers.Authors.Image) - app.Get("/authors", controllers.Authors.Search) + app.Get("/authors", controllers.Search.SearchAuthors) app.Get("/authors/:slug", controllers.Authors.Documents) app.Get("/authors/:slug/summary", controllers.Authors.Summary) app.Put("/authors/:slug", controllers.Authors.Update, alwaysRequireAuthentication, RequireAdmin) @@ -131,5 +131,6 @@ func routes(app *fiber.App, controllers Controllers, jwtSecret []byte, sender Se app.Get("/series/:slug", controllers.Series.Documents) app.Get("/resume-reading", alwaysRequireAuthentication, controllers.Home.ResumeReading) + app.Get("/search", controllers.Search.Search) app.Get("/", controllers.Home.Index) } diff --git a/internal/webserver/search_test.go b/internal/webserver/search_test.go index 5c3ec46c..bc044b10 100644 --- a/internal/webserver/search_test.go +++ b/internal/webserver/search_test.go @@ -11,6 +11,56 @@ import ( "github.com/svera/coreander/v5/internal/webserver/infrastructure" ) +func TestUnifiedSearch(t *testing.T) { + db := infrastructure.Connect(":memory:", 250) + smtpMock := &infrastructure.SMTPMock{} + appFS := loadDirInMemoryFs("testdata/library") + + app := bootstrapApp(db, smtpMock, appFS, webserver.Config{}) + + req, err := http.NewRequest(http.MethodGet, "/search?search=john&type=documents", nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + response, err := app.Test(req) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + if response.StatusCode != http.StatusOK { + t.Errorf("Expected status %d, received %d", http.StatusOK, response.StatusCode) + } + + doc, err := goquery.NewDocumentFromReader(response.Body) + if err != nil { + t.Fatal(err) + } + + if documentResults := doc.Find("#list .list-group-item").Length(); documentResults == 0 { + t.Error("Expected document search results for john") + } + + req, err = http.NewRequest(http.MethodGet, "/search?search=john&type=authors", nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + response, err = app.Test(req) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + if response.StatusCode != http.StatusOK { + t.Errorf("Expected status %d, received %d", http.StatusOK, response.StatusCode) + } + + doc, err = goquery.NewDocumentFromReader(response.Body) + if err != nil { + t.Fatal(err) + } + + if authorResults := doc.Find("#list .list-group-item").Length(); authorResults == 0 { + t.Error("Expected author search results for john") + } +} + func TestSearch(t *testing.T) { db := infrastructure.Connect(":memory:", 250) smtpMock := &infrastructure.SMTPMock{} From b7c508a1e821141d8fd44da12c5794dca1aeabf6 Mon Sep 17 00:00:00 2001 From: Sergio Vera Date: Tue, 16 Jun 2026 12:03:02 +0200 Subject: [PATCH 10/10] WIP --- internal/index/author_search.go | 43 +++++++++---------- .../partials/search-filters-sidebar.html | 2 +- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/internal/index/author_search.go b/internal/index/author_search.go index 9abf79ea..1cdd492f 100644 --- a/internal/index/author_search.go +++ b/internal/index/author_search.go @@ -4,16 +4,12 @@ import ( "cmp" "slices" "strings" - "unicode" "github.com/blevesearch/bleve/v2" "github.com/blevesearch/bleve/v2/search" "github.com/blevesearch/bleve/v2/search/query" "github.com/rickb777/date/v2" "github.com/svera/coreander/v5/internal/result" - "golang.org/x/text/runes" - "golang.org/x/text/transform" - "golang.org/x/text/unicode/norm" ) type AuthorSearchFields struct { @@ -29,7 +25,7 @@ type AuthorSearchFields struct { func (b *BleveIndexer) SearchAuthors(searchFields AuthorSearchFields, page, resultsPerPage int) (result.Paginated[[]Author], error) { filtersQuery := bleve.NewConjunctionQuery() - if q := authorNameQuery(searchFields.Name); q != nil { + if q := b.authorNameQuery(searchFields.Name); q != nil { filtersQuery.AddQuery(q) } else { filtersQuery.AddQuery(bleve.NewMatchAllQuery()) @@ -40,13 +36,15 @@ func (b *BleveIndexer) SearchAuthors(searchFields AuthorSearchFields, page, resu return b.runAuthorsPaginatedQuery(filtersQuery, page, resultsPerPage, searchFields.SortBy) } -func authorNameQuery(name string) query.Query { +func (b *BleveIndexer) authorNameQuery(name string) query.Query { name = strings.TrimSpace(name) if name == "" { return nil } disj := bleve.NewDisjunctionQuery() + terms := b.analyzedAuthorNameTerms(name) + for _, field := range []string{"Name", "BirthName"} { match := bleve.NewMatchQuery(name) match.SetField(field) @@ -54,38 +52,37 @@ func authorNameQuery(name string) query.Query { match.Operator = query.MatchQueryOperatorAnd disj.AddQuery(match) - folded := foldAuthorName(name) - if !isSingleAuthorNameToken(folded) { + if len(terms) != 1 { continue } - prefix := bleve.NewPrefixQuery(folded) + term := terms[0] + prefix := bleve.NewPrefixQuery(term) prefix.SetField(field) disj.AddQuery(prefix) - wildcard := bleve.NewWildcardQuery("*" + escapeWildcard(folded) + "*") + wildcard := bleve.NewWildcardQuery("*" + escapeWildcard(term) + "*") wildcard.SetField(field) disj.AddQuery(wildcard) } return disj } -func isSingleAuthorNameToken(folded string) bool { - if folded == "" { - return false +func (b *BleveIndexer) analyzedAuthorNameTerms(name string) []string { + analyzer := b.authorsIdx.Mapping().AnalyzerNamed(defaultAnalyzer) + if analyzer == nil { + return nil } - return !strings.ContainsAny(folded, " \t-") -} -func foldAuthorName(name string) string { - folded, _, err := transform.String( - transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC), - name, - ) - if err != nil { - folded = name + tokens := analyzer.Analyze([]byte(name)) + terms := make([]string, 0, len(tokens)) + for _, token := range tokens { + if token == nil || len(token.Term) == 0 { + continue + } + terms = append(terms, string(token.Term)) } - return strings.ToLower(folded) + return terms } func escapeWildcard(value string) string { diff --git a/internal/webserver/embedded/views/partials/search-filters-sidebar.html b/internal/webserver/embedded/views/partials/search-filters-sidebar.html index 5e3f575a..96c88f4a 100644 --- a/internal/webserver/embedded/views/partials/search-filters-sidebar.html +++ b/internal/webserver/embedded/views/partials/search-filters-sidebar.html @@ -1,5 +1,5 @@ {{$searchType := or .SearchType "documents"}} -
+