mirror of
https://github.com/Theodor-Springmann-Stiftung/musenalm.git
synced 2026-02-04 10:35:30 +00:00
482 lines
12 KiB
Go
482 lines
12 KiB
Go
package controllers
|
|
|
|
import (
|
|
"net/url"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/Theodor-Springmann-Stiftung/musenalm/app"
|
|
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
|
|
|
"github.com/Theodor-Springmann-Stiftung/musenalm/middleware"
|
|
"github.com/Theodor-Springmann-Stiftung/musenalm/pagemodels"
|
|
"github.com/Theodor-Springmann-Stiftung/musenalm/templating"
|
|
"github.com/pocketbase/dbx"
|
|
|
|
"github.com/pocketbase/pocketbase/core"
|
|
"github.com/pocketbase/pocketbase/tools/router"
|
|
)
|
|
|
|
const (
|
|
URL_BAENDE = "/baende/"
|
|
URL_BAENDE_RESULTS = "/baende/results/"
|
|
TEMPLATE_BAENDE = "/baende/"
|
|
URL_BAENDE_DETAILS = "/baende/details/{id}"
|
|
)
|
|
|
|
func init() {
|
|
bp := &BaendePage{
|
|
StaticPage: pagemodels.StaticPage{
|
|
Name: pagemodels.P_BAENDE_NAME,
|
|
URL: URL_BAENDE,
|
|
Template: TEMPLATE_BAENDE,
|
|
Layout: templating.DEFAULT_LAYOUT_NAME,
|
|
},
|
|
}
|
|
app.Register(bp)
|
|
}
|
|
|
|
type BaendePage struct {
|
|
pagemodels.StaticPage
|
|
}
|
|
|
|
type BaendeResult 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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("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 {
|
|
return func(e *core.RequestEvent) error {
|
|
req := templating.NewRequest(e)
|
|
if req.User() == nil {
|
|
redirectTo := url.QueryEscape(req.FullURL())
|
|
return e.Redirect(303, "/login/?redirectTo="+redirectTo)
|
|
}
|
|
|
|
data, err := p.buildResultData(app, e, req)
|
|
if err != nil {
|
|
return engine.Response404(e, err, data)
|
|
}
|
|
return engine.Response200(e, p.Template, data, p.Layout)
|
|
}
|
|
}
|
|
|
|
func (p *BaendePage) handleResults(engine *templating.Engine, app core.App) HandleFunc {
|
|
return func(e *core.RequestEvent) error {
|
|
req := templating.NewRequest(e)
|
|
if req.User() == nil {
|
|
redirectTo := url.QueryEscape(req.FullURL())
|
|
return e.Redirect(303, "/login/?redirectTo="+redirectTo)
|
|
}
|
|
|
|
data, err := p.buildResultData(app, e, req)
|
|
if err != nil {
|
|
return engine.Response404(e, err, data)
|
|
}
|
|
return engine.Response200(e, URL_BAENDE_RESULTS, data, "fragment")
|
|
}
|
|
}
|
|
|
|
func (p *BaendePage) handleRow(engine *templating.Engine, app core.App) HandleFunc {
|
|
return func(e *core.RequestEvent) error {
|
|
req := templating.NewRequest(e)
|
|
if req.User() == nil {
|
|
return e.Redirect(303, "/login/")
|
|
}
|
|
|
|
id := e.Request.PathValue("id")
|
|
if id == "" {
|
|
return engine.Response404(e, nil, nil)
|
|
}
|
|
|
|
entry, err := dbmodels.Entries_MusenalmID(app, id)
|
|
if err != nil {
|
|
return engine.Response404(e, err, nil)
|
|
}
|
|
|
|
items, err := dbmodels.Items_Entry(app, entry.Id)
|
|
if err != nil {
|
|
app.Logger().Error("Failed to get items for entry", "error", err)
|
|
}
|
|
|
|
data := map[string]any{
|
|
"entry": entry,
|
|
"items": items,
|
|
"is_admin": req.IsAdmin(),
|
|
"csrf_token": req.Session().Token,
|
|
}
|
|
|
|
return engine.Response200(e, "/baende/row/", data, "fragment")
|
|
}
|
|
}
|
|
|
|
func (p *BaendePage) handleDetails(engine *templating.Engine, app core.App) HandleFunc {
|
|
return func(e *core.RequestEvent) error {
|
|
req := templating.NewRequest(e)
|
|
if req.User() == nil {
|
|
return e.Redirect(303, "/login/")
|
|
}
|
|
|
|
id := e.Request.PathValue("id")
|
|
if id == "" {
|
|
return engine.Response404(e, nil, nil)
|
|
}
|
|
|
|
entry, err := dbmodels.Entries_MusenalmID(app, id)
|
|
if err != nil {
|
|
return engine.Response404(e, err, nil)
|
|
}
|
|
|
|
entryIDs := []any{entry.Id}
|
|
|
|
series, relations, err := Series_Entries(app, []*dbmodels.Entry{entry})
|
|
if err != nil {
|
|
app.Logger().Error("Failed to get series for entry", "error", err)
|
|
}
|
|
|
|
agents, arelations, err := Agents_Entries_IDs(app, entryIDs)
|
|
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 {
|
|
res[i] = s
|
|
}
|
|
return res
|
|
}
|
|
|
|
places, err := dbmodels.Places_IDs(app, toStringAny(entry.Places()))
|
|
if err != nil {
|
|
app.Logger().Error("Failed to get places for entry", "error", err)
|
|
}
|
|
|
|
items, err := dbmodels.Items_Entry(app, entry.Id)
|
|
if err != nil {
|
|
app.Logger().Error("Failed to get items for entry", "error", err)
|
|
}
|
|
|
|
data := map[string]any{
|
|
"result": &BaendeDetailsResult{
|
|
Entry: entry,
|
|
Series: series,
|
|
Places: places,
|
|
Agents: agents,
|
|
Items: items,
|
|
SeriesRels: relations,
|
|
AgentRels: arelations,
|
|
IsAdmin: req.IsAdmin(),
|
|
CSRFToken: req.Session().Token,
|
|
},
|
|
}
|
|
|
|
return engine.Response200(e, "/baende/details/", data, "fragment")
|
|
}
|
|
}
|
|
|
|
func (p *BaendePage) buildResultData(app core.App, e *core.RequestEvent, req *templating.Request) (map[string]any, error) {
|
|
data := map[string]any{}
|
|
|
|
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"
|
|
}
|
|
|
|
entries := []*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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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["result"] = &BaendeResult{
|
|
Entries: entries,
|
|
Series: seriesMap,
|
|
EntriesSeries: entrySeriesMap,
|
|
Places: placesMap,
|
|
Agents: agentsMap,
|
|
EntriesAgents: entryAgentsMap,
|
|
Items: itemsMap,
|
|
}
|
|
data["letter"] = letter
|
|
data["letters"] = letters
|
|
data["csrf_token"] = req.Session().Token
|
|
|
|
return data, nil
|
|
}
|
|
|
|
func searchBaendeEntries(app core.App, search string) ([]*dbmodels.Entry, error) {
|
|
query := strings.TrimSpace(search)
|
|
if query == "" {
|
|
return []*dbmodels.Entry{}, nil
|
|
}
|
|
if utf8.RuneCountInString(query) < 3 {
|
|
return searchBaendeEntriesQuick(app, query)
|
|
}
|
|
|
|
entries, err := searchBaendeEntriesFTS(app, query)
|
|
if err != nil {
|
|
app.Logger().Error("FTS search failed", "error", err)
|
|
return nil, err
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func searchBaendeEntriesFTS(app core.App, query string) ([]*dbmodels.Entry, error) {
|
|
entryIDs := map[string]struct{}{}
|
|
terms := dbmodels.NormalizeQuery(query)
|
|
|
|
entryRequests := dbmodels.IntoQueryRequests(dbmodels.ENTRIES_FTS5_FIELDS, terms)
|
|
if len(entryRequests) > 0 {
|
|
ids, err := dbmodels.FTS5Search(app, dbmodels.ENTRIES_TABLE, entryRequests...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, id := range ids {
|
|
entryIDs[id.ID] = struct{}{}
|
|
}
|
|
}
|
|
|
|
itemRequests := dbmodels.IntoQueryRequests(dbmodels.ITEMS_FTS5_FIELDS, terms)
|
|
if len(itemRequests) > 0 {
|
|
ids, err := dbmodels.FTS5Search(app, dbmodels.ITEMS_TABLE, itemRequests...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
itemIDs := []any{}
|
|
for _, id := range ids {
|
|
itemIDs = append(itemIDs, id.ID)
|
|
}
|
|
if len(itemIDs) > 0 {
|
|
items, err := dbmodels.TableByIDs[*dbmodels.Item](app, dbmodels.ITEMS_TABLE, itemIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, item := range items {
|
|
if item == nil {
|
|
continue
|
|
}
|
|
if entryID := item.Entry(); entryID != "" {
|
|
entryIDs[entryID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(entryIDs) == 0 {
|
|
return []*dbmodels.Entry{}, nil
|
|
}
|
|
|
|
entryIDList := []any{}
|
|
for id := range entryIDs {
|
|
entryIDList = append(entryIDList, id)
|
|
}
|
|
|
|
entries, err := dbmodels.Entries_IDs(app, entryIDList)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dbmodels.Sort_Entries_Title_Year(entries)
|
|
return entries, nil
|
|
}
|
|
|
|
|
|
|
|
func searchBaendeEntriesQuick(app core.App, query string) ([]*dbmodels.Entry, error) {
|
|
trimmed := strings.TrimSpace(query)
|
|
if trimmed == "" {
|
|
return []*dbmodels.Entry{}, nil
|
|
}
|
|
if utf8.RuneCountInString(trimmed) == 1 {
|
|
return []*dbmodels.Entry{}, nil
|
|
}
|
|
|
|
entryFields := []string{
|
|
dbmodels.PREFERRED_TITLE_FIELD,
|
|
}
|
|
|
|
entryConditions := make([]dbx.Expression, 0, len(entryFields))
|
|
for _, field := range entryFields {
|
|
entryConditions = append(entryConditions, dbx.Like(field, trimmed).Match(true, true))
|
|
}
|
|
|
|
entryIDs := map[string]struct{}{}
|
|
if len(entryConditions) > 0 {
|
|
entries := []*dbmodels.Entry{}
|
|
if err := app.RecordQuery(dbmodels.ENTRIES_TABLE).
|
|
Where(dbx.Or(entryConditions...)).
|
|
All(&entries); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, entry := range entries {
|
|
entryIDs[entry.Id] = struct{}{}
|
|
}
|
|
}
|
|
|
|
itemConditions := []dbx.Expression{
|
|
dbx.Like(dbmodels.ITEMS_IDENTIFIER_FIELD, trimmed).Match(true, true),
|
|
}
|
|
if len(itemConditions) > 0 {
|
|
items := []*dbmodels.Item{}
|
|
if err := app.RecordQuery(dbmodels.ITEMS_TABLE).
|
|
Where(dbx.Or(itemConditions...)).
|
|
All(&items); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, item := range items {
|
|
if entryID := item.Entry(); entryID != "" {
|
|
entryIDs[entryID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(entryIDs) == 0 {
|
|
return []*dbmodels.Entry{}, nil
|
|
}
|
|
|
|
entryIDList := make([]any, 0, len(entryIDs))
|
|
for id := range entryIDs {
|
|
entryIDList = append(entryIDList, id)
|
|
}
|
|
|
|
entries, err := dbmodels.Entries_IDs(app, entryIDList)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dbmodels.Sort_Entries_Title_Year(entries)
|
|
return entries, nil
|
|
}
|