From 58ef235a099ab876ab1406495a7eda74a122b6a4 Mon Sep 17 00:00:00 2001 From: Simon Martens Date: Sun, 25 Jan 2026 16:43:53 +0100 Subject: [PATCH] +/baende endpoint --- app/pb.go | 214 +++++++++- controllers/almanach_edit.go | 7 +- controllers/baende.go | 397 ++++++++++++------ dbmodels/sorting.go | 49 +++ pagemodels/page.go | 14 + views/routes/baende/body.gohtml | 247 ++++++++--- .../baende/components/_baende_table.gohtml | 66 ++- views/routes/baende/more/body.gohtml | 166 ++++++++ views/routes/baende/results/body.gohtml | 25 +- 9 files changed, 985 insertions(+), 200 deletions(-) create mode 100644 views/routes/baende/more/body.gohtml diff --git a/app/pb.go b/app/pb.go index abc1af6..9b5ee80 100644 --- a/app/pb.go +++ b/app/pb.go @@ -8,6 +8,7 @@ import ( "sort" "strings" "sync" + "time" "github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels" "github.com/Theodor-Springmann-Stiftung/musenalm/middleware" @@ -31,17 +32,59 @@ type BootFunc = func(e *core.BootstrapEvent) error // INFO: this is the main application that mainly is a pocketbase wrapper type App struct { - PB *pocketbase.PocketBase - MAConfig Config - Pages []pagemodels.IPage - dataCache *PrefixCache - dataMutex sync.RWMutex - htmlCache *PrefixCache - htmlMutex sync.RWMutex - pagesCache map[string]PageMetaData - pagesMutex sync.RWMutex - imagesCache map[string]*dbmodels.Image - imagesMutex sync.RWMutex + PB *pocketbase.PocketBase + MAConfig Config + Pages []pagemodels.IPage + dataCache *PrefixCache + dataMutex sync.RWMutex + htmlCache *PrefixCache + htmlMutex sync.RWMutex + pagesCache map[string]PageMetaData + pagesMutex sync.RWMutex + imagesCache map[string]*dbmodels.Image + imagesMutex sync.RWMutex + baendeCache *BaendeCache + baendeCacheMutex sync.RWMutex +} + +type BaendeCache struct { + Entries []*dbmodels.Entry + Series map[string]*dbmodels.Series + EntriesSeries map[string][]*dbmodels.REntriesSeries + Places map[string]*dbmodels.Place + Agents map[string]*dbmodels.Agent + EntriesAgents map[string][]*dbmodels.REntriesAgents + Items map[string][]*dbmodels.Item + CachedAt time.Time +} + +// Implement BaendeCacheInterface methods +func (bc *BaendeCache) GetEntries() interface{} { + return bc.Entries +} + +func (bc *BaendeCache) GetSeries() interface{} { + return bc.Series +} + +func (bc *BaendeCache) GetEntriesSeries() interface{} { + return bc.EntriesSeries +} + +func (bc *BaendeCache) GetPlaces() interface{} { + return bc.Places +} + +func (bc *BaendeCache) GetAgents() interface{} { + return bc.Agents +} + +func (bc *BaendeCache) GetEntriesAgents() interface{} { + return bc.EntriesAgents +} + +func (bc *BaendeCache) GetItems() interface{} { + return bc.Items } const ( @@ -486,6 +529,155 @@ func (app *App) ensureImagesCache() { app.imagesMutex.Unlock() } +func (app *App) ResetBaendeCache() { + app.baendeCacheMutex.Lock() + defer app.baendeCacheMutex.Unlock() + app.baendeCache = nil +} + +func (app *App) EnsureBaendeCache() (*BaendeCache, error) { + // Check if cache is valid with read lock + app.baendeCacheMutex.RLock() + if app.baendeCache != nil && time.Since(app.baendeCache.CachedAt) < time.Hour { + cache := app.baendeCache + app.baendeCacheMutex.RUnlock() + return cache, nil + } + app.baendeCacheMutex.RUnlock() + + // Acquire write lock to populate cache + app.baendeCacheMutex.Lock() + defer app.baendeCacheMutex.Unlock() + + // Double-check after acquiring write lock + if app.baendeCache != nil && time.Since(app.baendeCache.CachedAt) < time.Hour { + return app.baendeCache, nil + } + + // Load all entries sorted by PreferredTitle + entries := []*dbmodels.Entry{} + if err := app.PB.RecordQuery(dbmodels.ENTRIES_TABLE). + OrderBy(dbmodels.PREFERRED_TITLE_FIELD). + All(&entries); err != nil { + return nil, err + } + + // Collect entry IDs + entryIDs := make([]any, 0, len(entries)) + for _, entry := range entries { + entryIDs = append(entryIDs, entry.Id) + } + + // Load series and relations + seriesMap := map[string]*dbmodels.Series{} + entrySeriesMap := map[string][]*dbmodels.REntriesSeries{} + if len(entries) > 0 { + relations, err := dbmodels.REntriesSeries_Entries(app.PB.App, dbmodels.Ids(entries)) + if err != nil { + return nil, err + } + + seriesIDs := []any{} + for _, r := range relations { + seriesIDs = append(seriesIDs, r.Series()) + entrySeriesMap[r.Entry()] = append(entrySeriesMap[r.Entry()], r) + } + + if len(seriesIDs) > 0 { + series, err := dbmodels.Series_IDs(app.PB.App, seriesIDs) + if err != nil { + return nil, err + } + for _, s := range series { + seriesMap[s.Id] = s + } + } + } + + // Load agents and relations + agentsMap := map[string]*dbmodels.Agent{} + entryAgentsMap := map[string][]*dbmodels.REntriesAgents{} + if len(entryIDs) > 0 { + arelations, err := dbmodels.REntriesAgents_Entries(app.PB.App, entryIDs) + if err != nil { + return nil, err + } + + agentIDs := []any{} + for _, r := range arelations { + agentIDs = append(agentIDs, r.Agent()) + entryAgentsMap[r.Entry()] = append(entryAgentsMap[r.Entry()], r) + } + + if len(agentIDs) > 0 { + agents, err := dbmodels.Agents_IDs(app.PB.App, agentIDs) + if err != nil { + return nil, err + } + for _, a := range agents { + agentsMap[a.Id] = a + } + } + } + + // Load places + placesMap := map[string]*dbmodels.Place{} + placesIDs := []any{} + for _, entry := range entries { + for _, placeID := range entry.Places() { + placesIDs = append(placesIDs, placeID) + } + } + if len(placesIDs) > 0 { + places, err := dbmodels.Places_IDs(app.PB.App, placesIDs) + if err != nil { + return nil, err + } + for _, place := range places { + placesMap[place.Id] = place + } + } + + // Load items + itemsMap := map[string][]*dbmodels.Item{} + if len(entryIDs) > 0 { + allItems, err := dbmodels.Items_Entries(app.PB.App, entryIDs) + if err != nil { + return nil, err + } + + interestedEntries := make(map[string]struct{}) + for _, id := range entryIDs { + interestedEntries[id.(string)] = struct{}{} + } + + for _, item := range allItems { + for _, entryID := range item.Entries() { + if _, ok := interestedEntries[entryID]; ok { + itemsMap[entryID] = append(itemsMap[entryID], item) + } + } + } + } + + app.baendeCache = &BaendeCache{ + Entries: entries, + Series: seriesMap, + EntriesSeries: entrySeriesMap, + Places: placesMap, + Agents: agentsMap, + EntriesAgents: entryAgentsMap, + Items: itemsMap, + CachedAt: time.Now(), + } + + return app.baendeCache, nil +} + +func (app *App) GetBaendeCache() (pagemodels.BaendeCacheInterface, error) { + return app.EnsureBaendeCache() +} + func (app *App) setWatchers(engine *templating.Engine) { // INFO: hot reloading for poor people watcher, err := EngineWatcher(engine) diff --git a/controllers/almanach_edit.go b/controllers/almanach_edit.go index fc237c2..a9df19e 100644 --- a/controllers/almanach_edit.go +++ b/controllers/almanach_edit.go @@ -45,7 +45,7 @@ func (p *AlmanachEditPage) Setup(router *router.Router[*core.RequestEvent], ia p rg := router.Group(URL_ALMANACH) rg.BindFunc(middleware.IsAdminOrEditor()) rg.GET(URL_ALMANACH_EDIT, p.GET(engine, app)) - rg.POST(URL_ALMANACH_EDIT+"save", p.POSTSave(engine, app)) + rg.POST(URL_ALMANACH_EDIT+"save", p.POSTSave(engine, app, ia)) rg.POST(URL_ALMANACH_EDIT+"delete", p.POSTDelete(engine, app)) return nil } @@ -117,7 +117,7 @@ func NewAlmanachEditResult(app core.App, id string, filters BeitraegeFilterParam }, nil } -func (p *AlmanachEditPage) POSTSave(engine *templating.Engine, app core.App) HandleFunc { +func (p *AlmanachEditPage) POSTSave(engine *templating.Engine, app core.App, ma pagemodels.IApp) HandleFunc { return func(e *core.RequestEvent) error { id := e.Request.PathValue("id") req := templating.NewRequest(e) @@ -194,6 +194,9 @@ func (p *AlmanachEditPage) POSTSave(engine *templating.Engine, app core.App) Han // Invalidate sorted entries cache since entry was modified InvalidateSortedEntriesCache() + // Invalidate Bände cache since entry was modified + ma.ResetBaendeCache() + // Check if fields that affect contents changed contentsNeedUpdate := entry.PreferredTitle() != oldPreferredTitle || entry.Year() != oldYear || diff --git a/controllers/baende.go b/controllers/baende.go index 7814357..05753a4 100644 --- a/controllers/baende.go +++ b/controllers/baende.go @@ -1,7 +1,10 @@ package controllers import ( + "fmt" "net/url" + "slices" + "strconv" "strings" "unicode/utf8" @@ -20,8 +23,10 @@ import ( const ( URL_BAENDE = "/baende/" URL_BAENDE_RESULTS = "/baende/results/" + URL_BAENDE_MORE = "/baende/more/" TEMPLATE_BAENDE = "/baende/" URL_BAENDE_DETAILS = "/baende/details/{id}" + BAENDE_PAGE_SIZE = 150 ) func init() { @@ -51,29 +56,30 @@ type BaendeResult struct { } type BaendeDetailsResult struct { - Entry *dbmodels.Entry - Series []*dbmodels.Series - Places []*dbmodels.Place - Agents []*dbmodels.Agent - Items []*dbmodels.Item - SeriesRels []*dbmodels.REntriesSeries - AgentRels []*dbmodels.REntriesAgents - IsAdmin bool - CSRFToken string + Entry *dbmodels.Entry + Series []*dbmodels.Series + Places []*dbmodels.Place + Agents []*dbmodels.Agent + Items []*dbmodels.Item + SeriesRels []*dbmodels.REntriesSeries + AgentRels []*dbmodels.REntriesAgents + IsAdmin bool + CSRFToken string } func (p *BaendePage) Setup(router *router.Router[*core.RequestEvent], ia pagemodels.IApp, engine *templating.Engine) error { app := ia.Core() rg := router.Group(URL_BAENDE) rg.BindFunc(middleware.Authenticated(app)) - rg.GET("", p.handlePage(engine, app)) - rg.GET("results/", p.handleResults(engine, app)) + rg.GET("", p.handlePage(engine, app, ia)) + rg.GET("results/", p.handleResults(engine, app, ia)) + rg.GET("more/", p.handleMore(engine, app, ia)) rg.GET("details/{id}", p.handleDetails(engine, app)) rg.GET("row/{id}", p.handleRow(engine, app)) return nil } -func (p *BaendePage) handlePage(engine *templating.Engine, app core.App) HandleFunc { +func (p *BaendePage) handlePage(engine *templating.Engine, app core.App, ma pagemodels.IApp) HandleFunc { return func(e *core.RequestEvent) error { req := templating.NewRequest(e) if req.User() == nil { @@ -81,7 +87,7 @@ func (p *BaendePage) handlePage(engine *templating.Engine, app core.App) HandleF return e.Redirect(303, "/login/?redirectTo="+redirectTo) } - data, err := p.buildResultData(app, e, req) + data, err := p.buildResultData(app, ma, e, req, true) if err != nil { return engine.Response404(e, err, data) } @@ -89,7 +95,7 @@ func (p *BaendePage) handlePage(engine *templating.Engine, app core.App) HandleF } } -func (p *BaendePage) handleResults(engine *templating.Engine, app core.App) HandleFunc { +func (p *BaendePage) handleResults(engine *templating.Engine, app core.App, ma pagemodels.IApp) HandleFunc { return func(e *core.RequestEvent) error { req := templating.NewRequest(e) if req.User() == nil { @@ -97,7 +103,7 @@ func (p *BaendePage) handleResults(engine *templating.Engine, app core.App) Hand return e.Redirect(303, "/login/?redirectTo="+redirectTo) } - data, err := p.buildResultData(app, e, req) + data, err := p.buildResultData(app, ma, e, req, true) if err != nil { return engine.Response404(e, err, data) } @@ -166,7 +172,7 @@ func (p *BaendePage) handleDetails(engine *templating.Engine, app core.App) Hand if err != nil { app.Logger().Error("Failed to get agents for entry", "error", err) } - + toStringAny := func(ss []string) []any { res := make([]any, len(ss)) for i, s := range ss { @@ -203,126 +209,171 @@ func (p *BaendePage) handleDetails(engine *templating.Engine, app core.App) Hand } } -func (p *BaendePage) buildResultData(app core.App, e *core.RequestEvent, req *templating.Request) (map[string]any, error) { +func (p *BaendePage) buildResultData(app core.App, ma pagemodels.IApp, e *core.RequestEvent, req *templating.Request, showAggregated bool) (map[string]any, error) { data := map[string]any{} + // Get offset from query params (default 0) + offset := 0 + if offsetStr := e.Request.URL.Query().Get("offset"); offsetStr != "" { + if val, err := strconv.Atoi(offsetStr); err == nil && val >= 0 { + offset = val + } + } + + // Get filters from query params search := strings.TrimSpace(e.Request.URL.Query().Get("search")) - if search != "" { - data["search"] = search - } - letter := strings.ToUpper(strings.TrimSpace(e.Request.URL.Query().Get("letter"))) - if letter == "" { - letter = "A" - } - if len(letter) > 1 { - letter = letter[:1] - } - if letter < "A" || letter > "Z" { - letter = "A" + + // Validate letter + if letter != "" { + if len(letter) > 1 { + letter = letter[:1] + } + if letter < "A" || letter > "Z" { + letter = "" + } } - entries := []*dbmodels.Entry{} + // Get sort parameters + sort := strings.ToLower(strings.TrimSpace(e.Request.URL.Query().Get("sort"))) + order := strings.ToLower(strings.TrimSpace(e.Request.URL.Query().Get("order"))) + + // Validate sort field - whitelist approach for security + validSorts := map[string]bool{ + "title": true, + "alm": true, + "year": true, + "signatur": true, + } + if !validSorts[sort] { + sort = "title" // default + } + + // Validate order + if order != "asc" && order != "desc" { + order = "asc" // default + } + + // Load from cache + cacheInterface, err := ma.GetBaendeCache() + if err != nil { + return data, err + } + + // Extract data from cache using interface methods + allEntries, ok := cacheInterface.GetEntries().([]*dbmodels.Entry) + if !ok { + return data, fmt.Errorf("failed to get entries from cache") + } + + itemsMap, ok := cacheInterface.GetItems().(map[string][]*dbmodels.Item) + if !ok { + return data, fmt.Errorf("failed to get items from cache") + } + + seriesMap, ok := cacheInterface.GetSeries().(map[string]*dbmodels.Series) + if !ok { + return data, fmt.Errorf("failed to get series from cache") + } + + entrySeriesMap, ok := cacheInterface.GetEntriesSeries().(map[string][]*dbmodels.REntriesSeries) + if !ok { + return data, fmt.Errorf("failed to get entries series from cache") + } + + placesMap, ok := cacheInterface.GetPlaces().(map[string]*dbmodels.Place) + if !ok { + return data, fmt.Errorf("failed to get places from cache") + } + + agentsMap, ok := cacheInterface.GetAgents().(map[string]*dbmodels.Agent) + if !ok { + return data, fmt.Errorf("failed to get agents from cache") + } + + entryAgentsMap, ok := cacheInterface.GetEntriesAgents().(map[string][]*dbmodels.REntriesAgents) + if !ok { + return data, fmt.Errorf("failed to get entries agents from cache") + } + + // Apply search or letter filter + var filteredEntries []*dbmodels.Entry if search != "" { - var err error - entries, err = searchBaendeEntries(app, search) - if err != nil { - return data, err - } - } else { - if err := app.RecordQuery(dbmodels.ENTRIES_TABLE). - Where(dbx.Like(dbmodels.PREFERRED_TITLE_FIELD, letter).Match(false, true)). - OrderBy(dbmodels.PREFERRED_TITLE_FIELD). - All(&entries); err != nil { - return data, err - } - } - - entryIDs := []any{} - for _, entry := range entries { - entryIDs = append(entryIDs, entry.Id) - } - - seriesMap := map[string]*dbmodels.Series{} - entrySeriesMap := map[string][]*dbmodels.REntriesSeries{} - if len(entries) > 0 { - series, relations, err := Series_Entries(app, entries) - if err != nil { - return data, err - } - for _, s := range series { - seriesMap[s.Id] = s - } - for _, r := range relations { - entrySeriesMap[r.Entry()] = append(entrySeriesMap[r.Entry()], r) - } - } - - agentsMap := map[string]*dbmodels.Agent{} - entryAgentsMap := map[string][]*dbmodels.REntriesAgents{} - if len(entryIDs) > 0 { - agents, arelations, err := Agents_Entries_IDs(app, entryIDs) - if err != nil { - return data, err - } - for _, a := range agents { - agentsMap[a.Id] = a - } - for _, r := range arelations { - entryAgentsMap[r.Entry()] = append(entryAgentsMap[r.Entry()], r) - } - } - - placesMap := map[string]*dbmodels.Place{} - placesIDs := []any{} - for _, entry := range entries { - for _, placeID := range entry.Places() { - placesIDs = append(placesIDs, placeID) - } - } - if len(placesIDs) > 0 { - places, err := dbmodels.Places_IDs(app, placesIDs) - if err != nil { - return data, err - } - for _, place := range places { - placesMap[place.Id] = place - } - } - - itemsMap := map[string][]*dbmodels.Item{} - if len(entryIDs) > 0 { - // 1. Fetch all items related to any of the entry IDs in a single query. - allItems, err := dbmodels.Items_Entries(app, entryIDs) - if err != nil { - return data, err - } - - // 2. Create a lookup map for the entries we are interested in. - interestedEntries := make(map[string]struct{}) - for _, id := range entryIDs { - interestedEntries[id.(string)] = struct{}{} - } - - // 3. Group the fetched items by their associated entry ID. - for _, item := range allItems { - // An item can be related to multiple entries. We need to check which of its entries are in our current list. - for _, entryID := range item.Entries() { - // If the item's entry ID is in our list of interested entries, add the item to the map. - if _, ok := interestedEntries[entryID]; ok { - itemsMap[entryID] = append(itemsMap[entryID], item) - } + trimmedSearch := strings.TrimSpace(search) + if utf8.RuneCountInString(trimmedSearch) >= 3 { + entries, err := searchBaendeEntries(app, trimmedSearch) + if err != nil { + return data, err } + filteredEntries = entries + } else { + filteredEntries = filterEntriesBySearch(allEntries, itemsMap, trimmedSearch) } + data["search"] = trimmedSearch + } else if letter != "" { + // Apply letter filter + filteredEntries = filterEntriesByLetter(allEntries, letter) + } else { + filteredEntries = allEntries } - letters := []string{ - "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", - "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", + // Apply sorting based on sort parameter + switch sort { + case "alm": + dbmodels.Sort_Entries_MusenalmID(filteredEntries) + case "year": + dbmodels.Sort_Entries_Year_Title(filteredEntries) + case "signatur": + dbmodels.Sort_Entries_Signatur(filteredEntries, itemsMap) + default: // "title" + dbmodels.Sort_Entries_Title_Year(filteredEntries) } + // Reverse for descending order + if order == "desc" { + slices.Reverse(filteredEntries) + } + + // Calculate pagination + totalCount := len(filteredEntries) + var pageEntries []*dbmodels.Entry + nextOffset := offset + hasMore := false + currentCount := 0 + + if showAggregated { + displayLimit := offset + BAENDE_PAGE_SIZE + if displayLimit > totalCount { + displayLimit = totalCount + } + if displayLimit < 0 { + displayLimit = 0 + } + pageEntries = filteredEntries[:displayLimit] + nextOffset = displayLimit + currentCount = len(pageEntries) + hasMore = displayLimit < totalCount + } else { + start := offset + if start < 0 { + start = 0 + } + if start > totalCount { + start = totalCount + } + endIndex := start + BAENDE_PAGE_SIZE + if endIndex > totalCount { + endIndex = totalCount + } + pageEntries = filteredEntries[start:endIndex] + nextOffset = endIndex + currentCount = start + len(pageEntries) + hasMore = endIndex < totalCount + } + + // Build result with cached associated data data["result"] = &BaendeResult{ - Entries: entries, + Entries: pageEntries, Series: seriesMap, EntriesSeries: entrySeriesMap, Places: placesMap, @@ -330,13 +381,110 @@ func (p *BaendePage) buildResultData(app core.App, e *core.RequestEvent, req *te EntriesAgents: entryAgentsMap, Items: itemsMap, } + data["offset"] = offset + data["total_count"] = totalCount + data["current_count"] = currentCount + data["has_more"] = hasMore + data["next_offset"] = nextOffset data["letter"] = letter - data["letters"] = letters + data["sort_field"] = sort + data["sort_order"] = order data["csrf_token"] = req.Session().Token + // Keep letters array for navigation + letters := []string{ + "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", + "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", + } + data["letters"] = letters + return data, nil } +func (p *BaendePage) handleMore(engine *templating.Engine, app core.App, ma pagemodels.IApp) HandleFunc { + return func(e *core.RequestEvent) error { + req := templating.NewRequest(e) + if req.User() == nil { + return e.Redirect(303, "/login/") + } + + data, err := p.buildResultData(app, ma, e, req, false) + if err != nil { + return engine.Response404(e, err, data) + } + + // Add header to indicate if more results exist + hasMore := "false" + if hasMoreVal, ok := data["has_more"].(bool); ok && hasMoreVal { + hasMore = "true" + } + e.Response.Header().Set("X-Has-More", hasMore) + if nextOffsetVal, ok := data["next_offset"].(int); ok { + e.Response.Header().Set("X-Next-Offset", strconv.Itoa(nextOffsetVal)) + } else { + e.Response.Header().Set("X-Next-Offset", "0") + } + + return engine.Response200(e, URL_BAENDE_MORE, data, "fragment") + } +} + +// filterEntriesBySearch performs in-memory filtering of entries by search term +func filterEntriesBySearch(entries []*dbmodels.Entry, itemsMap map[string][]*dbmodels.Item, search string) []*dbmodels.Entry { + query := strings.ToLower(strings.TrimSpace(search)) + if query == "" { + return entries + } + + var results []*dbmodels.Entry + for _, entry := range entries { + if matchesShortSearch(entry, itemsMap, query) { + results = append(results, entry) + continue + } + } + + return results +} + +func matchesShortSearch(entry *dbmodels.Entry, itemsMap map[string][]*dbmodels.Item, query string) bool { + if strings.Contains(strings.ToLower(entry.PreferredTitle()), query) { + return true + } + if strings.Contains(strings.ToLower(entry.TitleStmt()), query) { + return true + } + if strings.Contains(strings.ToLower(entry.PlaceStmt()), query) { + return true + } + if strings.Contains(strings.ToLower(entry.ResponsibilityStmt()), query) { + return true + } + if strings.Contains(strconv.Itoa(entry.MusenalmID()), query) { + return true + } + if items, ok := itemsMap[entry.Id]; ok { + for _, item := range items { + if strings.Contains(strings.ToLower(item.Identifier()), query) { + return true + } + } + } + return false +} + +// filterEntriesByLetter performs in-memory filtering of entries by first letter +func filterEntriesByLetter(entries []*dbmodels.Entry, letter string) []*dbmodels.Entry { + var results []*dbmodels.Entry + for _, entry := range entries { + preferredTitle := entry.PreferredTitle() + if len(preferredTitle) > 0 && strings.HasPrefix(strings.ToUpper(preferredTitle), letter) { + results = append(results, entry) + } + } + return results +} + func searchBaendeEntries(app core.App, search string) ([]*dbmodels.Entry, error) { query := strings.TrimSpace(search) if query == "" { @@ -412,8 +560,6 @@ func searchBaendeEntriesFTS(app core.App, query string) ([]*dbmodels.Entry, erro return entries, nil } - - func searchBaendeEntriesQuick(app core.App, query string) ([]*dbmodels.Entry, error) { trimmed := strings.TrimSpace(query) if trimmed == "" { @@ -425,6 +571,7 @@ func searchBaendeEntriesQuick(app core.App, query string) ([]*dbmodels.Entry, er entryFields := []string{ dbmodels.PREFERRED_TITLE_FIELD, + dbmodels.MUSENALMID_FIELD, } entryConditions := make([]dbx.Expression, 0, len(entryFields)) diff --git a/dbmodels/sorting.go b/dbmodels/sorting.go index 9c64d7d..3c50f55 100644 --- a/dbmodels/sorting.go +++ b/dbmodels/sorting.go @@ -64,3 +64,52 @@ func Sort_Places_Name(places []*Place) { return collator.CompareString(i.Name(), j.Name()) }) } + +// Sort_Entries_MusenalmID sorts entries by MusenalmID (Alm number) in ascending order +func Sort_Entries_MusenalmID(entries []*Entry) { + slices.SortFunc(entries, func(i, j *Entry) int { + return i.MusenalmID() - j.MusenalmID() + }) +} + +// Sort_Entries_Signatur sorts entries by their lowest signature (identifier) alphabetically +// Entries with no items sort last, entries with items but empty identifiers also sort last +func Sort_Entries_Signatur(entries []*Entry, itemsMap map[string][]*Item) { + collator := collate.New(language.German) + slices.SortFunc(entries, func(i, j *Entry) int { + iItems := itemsMap[i.Id] + jItems := itemsMap[j.Id] + + // Find lowest signature for entry i + var iLowestSig string + for _, item := range iItems { + sig := item.Identifier() + if sig != "" && (iLowestSig == "" || collator.CompareString(sig, iLowestSig) < 0) { + iLowestSig = sig + } + } + + // Find lowest signature for entry j + var jLowestSig string + for _, item := range jItems { + sig := item.Identifier() + if sig != "" && (jLowestSig == "" || collator.CompareString(sig, jLowestSig) < 0) { + jLowestSig = sig + } + } + + // Entries without signatures sort last + if iLowestSig == "" && jLowestSig == "" { + return 0 + } + if iLowestSig == "" { + return 1 // i goes after j + } + if jLowestSig == "" { + return -1 // i goes before j + } + + // Compare using German collation for natural sorting + return collator.CompareString(iLowestSig, jLowestSig) + }) +} diff --git a/pagemodels/page.go b/pagemodels/page.go index 27933d4..32bfdb2 100644 --- a/pagemodels/page.go +++ b/pagemodels/page.go @@ -7,11 +7,25 @@ import ( "log/slog" ) +// BaendeCacheInterface defines the interface for Bände cache operations +// We use an interface with accessor methods to avoid circular dependencies +type BaendeCacheInterface interface { + GetEntries() interface{} // Returns []*dbmodels.Entry + GetSeries() interface{} // Returns map[string]*dbmodels.Series + GetEntriesSeries() interface{} // Returns map[string][]*dbmodels.REntriesSeries + GetPlaces() interface{} // Returns map[string]*dbmodels.Place + GetAgents() interface{} // Returns map[string]*dbmodels.Agent + GetEntriesAgents() interface{} // Returns map[string][]*dbmodels.REntriesAgents + GetItems() interface{} // Returns map[string][]*dbmodels.Item +} + type IApp interface { Core() core.App ResetDataCache() ResetHtmlCache() ResetPagesCache() + ResetBaendeCache() + GetBaendeCache() (BaendeCacheInterface, error) Logger() *slog.Logger } diff --git a/views/routes/baende/body.gohtml b/views/routes/baende/body.gohtml index 3618cc2..f3a4b1f 100644 --- a/views/routes/baende/body.gohtml +++ b/views/routes/baende/body.gohtml @@ -1,29 +1,97 @@ {{ $model := . }} -
+

Bände A–Z

- -
-
- {{- range $_, $ch := $model.letters -}} - - {{ $ch }} - - {{- end -}} - -
-
- - +
+
@@ -38,10 +106,10 @@ hx-swap="outerHTML" hx-target="#baenderesults" role="search" + @submit="offset = 0; hasMore = true" aria-label="Bändesuche"> - {{- if $model.letter -}} - - {{- end -}} + +
+ + +
+
+ + Alphabet + + +
+
+ {{- range $_, $ch := $model.letters -}} + + {{ $ch }} + + {{- end -}} + + + Alle + +
+
+
+
+
+ + +
+ + + + +
+
+ + Spalten + + +
+
+ + + + + +
+
+
+
+ + +
+
+ {{ if $model.current_count }}{{ $model.current_count }} / {{ end }}{{ if $model.total_count }}{{ $model.total_count }}{{ else }}{{ len $model.result.Entries }}{{ end }} Bände +
+ +
- - -
-
-
- - Spalten - - -
-
- - - - - -
-
-
-
-
-
- {{ len $model.result.Entries }} Bände -
- -
-
-
+
{{ template "_baende_table" $model }} + + +
+ +
diff --git a/views/routes/baende/components/_baende_table.gohtml b/views/routes/baende/components/_baende_table.gohtml index 2e337a1..8383724 100644 --- a/views/routes/baende/components/_baende_table.gohtml +++ b/views/routes/baende/components/_baende_table.gohtml @@ -4,16 +4,72 @@ - - + + - + - + - + {{- range $_, $entry := $model.result.Entries -}} + + + + + + + + +{{- end -}} + + +
+ {{ if $model.current_count }}{{ $model.current_count }} / {{ end }}{{ if $model.total_count }}{{ $model.total_count }}{{ else }}{{ len $model.result.Entries }}{{ end }} Bände +
diff --git a/views/routes/baende/results/body.gohtml b/views/routes/baende/results/body.gohtml index 2355ebd..036c84f 100644 --- a/views/routes/baende/results/body.gohtml +++ b/views/routes/baende/results/body.gohtml @@ -1,11 +1,32 @@ {{ $model := . }} -
+
+
- {{ len $model.result.Entries }} Bände + {{ if $model.current_count }}{{ $model.current_count }} / {{ end }}{{ if $model.total_count }}{{ $model.total_count }}{{ else }}{{ len $model.result.Entries }}{{ end }} Bände
+ {{ template "_baende_table" $model }} + +
+ +
+ {{ if $model.search }}
Titel + + + + Erscheinung Umfang / MaßeSignaturen + +
+
+ Alm {{ $entry.MusenalmID }} + {{- if $entry.References -}} + {{ $entry.References }} + {{- end -}} +
+ + + + +
Ansehen
+
+ {{- if (IsAdminOrEditor $model.request.user) -}} + + + + +
Bearbeiten
+
+
+ + + +
Löschen
+
+
+ {{- end -}} +
+
+
+
+ {{- if $entry.PreferredTitle -}} + {{ $entry.PreferredTitle }} + {{- else if ne $entry.Year 0 -}} + {{ $entry.Year }} + {{- else -}} + [o.J.] + {{- end -}} + + +
+ {{- if eq $entry.EditState "Unknown" -}} + Gesucht + {{- else if eq $entry.EditState "ToDo" -}} + Zu erledigen + {{- else if eq $entry.EditState "Review" -}} + Überprüfen + {{- else if eq $entry.EditState "Seen" -}} + Autopsiert + {{- else if eq $entry.EditState "Edited" -}} + Vollständig Erfasst + {{- else -}} + {{ $entry.EditState }} + {{- end -}} +
+
+
+ {{- if $entry.TitleStmt -}} +
+ {{ $entry.TitleStmt }} +
+ {{- end -}} + {{- if or $entry.SubtitleStmt $entry.VariantTitle $entry.ParallelTitle $entry.IncipitStmt -}} +
+ {{- if $entry.SubtitleStmt -}} +
Untertitel: {{ $entry.SubtitleStmt }}
+ {{- end -}} + {{- if $entry.VariantTitle -}} +
Varianten: {{ $entry.VariantTitle }}
+ {{- end -}} + {{- if $entry.ParallelTitle -}} +
Parallel: {{ $entry.ParallelTitle }}
+ {{- end -}} + {{- if $entry.IncipitStmt -}} +
Incipit: {{ $entry.IncipitStmt }}
+ {{- end -}} +
+ {{- end -}} +
+ {{- if or $entry.ResponsibilityStmt $entry.PublicationStmt $entry.PlaceStmt -}} +
+ {{- if and $entry.ResponsibilityStmt (not (eq $entry.ResponsibilityStmt "unbezeichnet")) -}} +
+
Herausgaberangabe
+
{{ $entry.ResponsibilityStmt }}
+
+ {{- end -}} + {{- if $entry.PublicationStmt -}} +
+
Publikationsangabe
+
{{ $entry.PublicationStmt }}
+
+ {{- end -}} + {{- if $entry.PlaceStmt -}} +
+
Ortsangabe
+
{{ $entry.PlaceStmt }}
+
+ {{- end -}} +
+ {{- end -}} +
+ {{- if or $entry.Extent $entry.Dimensions -}} +
+ {{- if $entry.Extent -}} +
Struktur{{ $entry.Extent }}
+ {{- end -}} + {{- if $entry.Dimensions -}} +
Maße: {{ $entry.Dimensions }}
+ {{- end -}} +
+ {{- end -}} +
+ {{- $items := index $model.result.Items $entry.Id -}} + {{- if $items -}} +
+ {{- range $_, $item := $items -}} +
+ {{- if $item.Identifier -}} +
{{ $item.Identifier }}
+ {{- end -}} + {{- if $item.Media -}} +
+ {{- range $i, $media := $item.Media -}}{{- if $i }}, {{ end -}}{{ $media }}{{- end -}} +
+ {{- end -}} +
+ {{- end -}} +
+ {{- end -}} +