Files
kgpz_web/viewmodels/issue_view.go
2025-09-30 01:53:19 +02:00

571 lines
16 KiB
Go

package viewmodels
import (
"fmt"
"maps"
"slices"
"sort"
"strconv"
"github.com/Theodor-Springmann-Stiftung/kgpz_web/functions"
"github.com/Theodor-Springmann-Stiftung/kgpz_web/helpers/logging"
"github.com/Theodor-Springmann-Stiftung/kgpz_web/providers/pictures"
"github.com/Theodor-Springmann-Stiftung/kgpz_web/xmlmodels"
)
type PieceByIssue struct {
xmlmodels.Piece
// TODO: this is a bit hacky, but it refences the page number of the piece in the issue
Reference xmlmodels.IssueRef
// Indicates if this is a continuation from a previous page
IsContinuation bool
}
type PiecesByPage struct {
Items map[int][]PieceByIssue
Pages []int
}
// IndividualPieceByIssue represents a piece with metadata for individual page display
type IndividualPieceByIssue struct {
PieceByIssue
IssueRefs []xmlmodels.IssueRef // All issues this piece appears in
PageIcon string // Icon type: "first", "last", "even", "odd"
}
// IndividualPiecesByPage holds pieces as individual page entries
type IndividualPiecesByPage struct {
Items map[int][]IndividualPieceByIssue
Pages []int
}
type IssuePage struct {
PageNumber int
ImagePath string // Full-quality image path (prefers WebP over JPEG)
PreviewPath string // Compressed WebP path for layout views
JpegPath string // JPEG path for download button
Available bool
GridColumn int // 1 or 2 for left/right positioning
GridRow int // Row number in grid
HasHeader bool // Whether this page has a double-spread header
HeaderText string // Text for double-spread header
PageIcon string // Icon type: "first", "last", "even", "odd"
}
// Deprecated: Use pictures.ImageFile from provider instead
type ImageFile = pictures.ImageFile
type IssueImages struct {
MainPages []IssuePage
AdditionalPages map[int][]IssuePage // Beilage number -> pages
HasImages bool
}
// TODO: Next & Prev
type IssueVM struct {
xmlmodels.Issue
Next *xmlmodels.Issue
Prev *xmlmodels.Issue
Pieces IndividualPiecesByPage
AdditionalPieces IndividualPiecesByPage
Images IssueImages
HasBeilageButton bool // Whether to show beilage navigation button
}
func NewSingleIssueView(y, no int, lib *xmlmodels.Library, pics *pictures.PicturesProvider) (*IssueVM, error) {
lib.Issues.Lock()
var issue *xmlmodels.Issue = nil
var next *xmlmodels.Issue = nil
var prev *xmlmodels.Issue = nil
for i, iss := range lib.Issues.Array {
if iss.Datum.When.Year == y && iss.Number.No == no {
issue = &iss
if i > 0 {
prev = &lib.Issues.Array[i-1]
}
if i < len(lib.Issues.Array)-1 {
next = &lib.Issues.Array[i+1]
}
}
}
if issue == nil {
return nil, fmt.Errorf("No issue found for %v-%v", y, no)
}
sivm := IssueVM{Issue: *issue, Next: next, Prev: prev}
lib.Issues.Unlock()
ppi, ppa, err := PiecesForIssue(lib, *issue)
if err != nil {
return nil, err
}
slices.Sort(ppi.Pages)
slices.Sort(ppa.Pages)
images, err := LoadIssueImages(*issue, pics)
if err != nil {
return nil, err
}
sivm.Images = images
// Group consecutive continuation pieces, including empty pages from images
sivm.Pieces = CreateIndividualPagesWithMetadata(ppi, lib)
sivm.AdditionalPieces = CreateIndividualPagesWithMetadataIncludingEmpty(ppa, lib, images.AdditionalPages)
sivm.HasBeilageButton = len(sivm.AdditionalPieces.Pages) > 0
return &sivm, nil
}
func PiecesForIssue(lib *xmlmodels.Library, issue xmlmodels.Issue) (PiecesByPage, PiecesByPage, error) {
year := issue.Datum.When.Year
ppi := PiecesByPage{Items: make(map[int][]PieceByIssue)}
ppa := PiecesByPage{Items: make(map[int][]PieceByIssue)}
lib.Pieces.Lock()
defer lib.Pieces.Unlock()
for _, piece := range lib.Pieces.Array {
// Process ALL IssueRefs for this piece, not just the first match
for _, issueRef := range piece.IssueRefs {
if issueRef.Nr == issue.Number.No && issueRef.When.Year == year {
// DEBUG: Log piece details for specific issue
if year == 1771 && issue.Number.No == 29 {
logging.Debug(fmt.Sprintf("PiecesForIssue: Piece ID=%s, Von=%d, Bis=%d, Beilage=%d", piece.Identifier.ID, issueRef.Von, issueRef.Bis, issueRef.Beilage))
}
// Add main entry on starting page
p := PieceByIssue{Piece: piece, Reference: issueRef, IsContinuation: false}
if issueRef.Beilage > 0 {
functions.MapArrayInsert(ppa.Items, issueRef.Von, p)
} else {
functions.MapArrayInsert(ppi.Items, issueRef.Von, p)
}
// Add continuation entries for subsequent pages (if Bis > Von)
if issueRef.Bis > issueRef.Von {
for page := issueRef.Von + 1; page <= issueRef.Bis; page++ {
pContinuation := PieceByIssue{Piece: piece, Reference: issueRef, IsContinuation: true}
if issueRef.Beilage > 0 {
functions.MapArrayInsert(ppa.Items, page, pContinuation)
} else {
functions.MapArrayInsert(ppi.Items, page, pContinuation)
}
}
}
}
}
}
ppi.Pages = slices.Collect(maps.Keys(ppi.Items))
ppa.Pages = slices.Collect(maps.Keys(ppa.Items))
return ppi, ppa, nil
}
// pagesHaveIdenticalContent checks if two pages have the same pieces (ignoring continuation status)
func pagesHaveIdenticalContent(items1, items2 []PieceByIssue) bool {
if len(items1) != len(items2) {
return false
}
// Create maps for comparison (ignore IsContinuation flag)
pieces1 := make(map[string]bool)
pieces2 := make(map[string]bool)
for _, piece := range items1 {
// Use piece ID and reference range as key (ignore continuation status)
key := piece.ID + "|" + strconv.Itoa(piece.Reference.Von) + "|" + strconv.Itoa(piece.Reference.Bis)
pieces1[key] = true
}
for _, piece := range items2 {
key := piece.ID + "|" + strconv.Itoa(piece.Reference.Von) + "|" + strconv.Itoa(piece.Reference.Bis)
pieces2[key] = true
}
// Check if maps are identical
for key := range pieces1 {
if !pieces2[key] {
return false
}
}
for key := range pieces2 {
if !pieces1[key] {
return false
}
}
return true
}
// sortPiecesOnPage sorts pieces on a given page according to the ordering rules:
// 1. Continuation pieces come first
// 2. Within the same category (continuation/new), pieces are sorted by Order field if > 0
// 3. If no Order field or Order = 0, maintain current order
func sortPiecesOnPage(pieces []PieceByIssue, pageNumber int) []PieceByIssue {
if len(pieces) <= 1 {
return pieces
}
// Create a copy to avoid modifying the original slice
sorted := make([]PieceByIssue, len(pieces))
copy(sorted, pieces)
sort.Slice(sorted, func(i, j int) bool {
pieceA := sorted[i]
pieceB := sorted[j]
// Rule 1: Continuation pieces come before new pieces
if pieceA.IsContinuation != pieceB.IsContinuation {
return pieceA.IsContinuation // true comes before false
}
// Rule 2: Within same category, use Order field if both have valid orders
orderA := pieceA.Reference.Order
orderB := pieceB.Reference.Order
// Both have valid orders (> 0)
if orderA > 0 && orderB > 0 {
return orderA < orderB
}
// Only A has valid order
if orderA > 0 && orderB <= 0 {
return true
}
// Only B has valid order
if orderA <= 0 && orderB > 0 {
return false
}
// Rule 3: Neither has valid order, maintain original order
// This is automatically handled by the stable sort
return false
})
return sorted
}
// CreateIndividualPagesWithMetadata creates individual page entries with metadata
func CreateIndividualPagesWithMetadata(pieces PiecesByPage, lib *xmlmodels.Library) IndividualPiecesByPage {
individual := IndividualPiecesByPage{
Items: make(map[int][]IndividualPieceByIssue),
Pages: []int{},
}
if len(pieces.Pages) == 0 {
return individual
}
// Process each page individually
for _, page := range pieces.Pages {
pageItems := pieces.Items[page]
// Sort pieces according to the ordering rules
sortedPageItems := sortPiecesOnPage(pageItems, page)
individualItems := []IndividualPieceByIssue{}
// Convert sorted pieces to individual pieces
for _, piece := range sortedPageItems {
individualPiece := IndividualPieceByIssue{
PieceByIssue: piece,
IssueRefs: getPieceIssueRefs(piece.Piece, lib),
PageIcon: determinePageIcon(page, pieces.Pages),
}
individualItems = append(individualItems, individualPiece)
}
if len(individualItems) > 0 {
individual.Items[page] = individualItems
individual.Pages = append(individual.Pages, page)
}
}
slices.Sort(individual.Pages)
return individual
}
// CreateIndividualPagesWithMetadataIncludingEmpty creates individual page entries with metadata, including empty pages that have images
func CreateIndividualPagesWithMetadataIncludingEmpty(pieces PiecesByPage, lib *xmlmodels.Library, imagePages map[int][]IssuePage) IndividualPiecesByPage {
individual := IndividualPiecesByPage{
Items: make(map[int][]IndividualPieceByIssue),
Pages: []int{},
}
// Collect all page numbers that should be included (from pieces and from images)
allPageNumbers := make(map[int]bool)
// Add pages with content
for _, page := range pieces.Pages {
allPageNumbers[page] = true
}
// Add pages with images (even if they have no content)
for _, pages := range imagePages {
for _, page := range pages {
if page.Available {
allPageNumbers[page.PageNumber] = true
}
}
}
// Create sorted list of all page numbers for icon determination
allPagesList := make([]int, 0, len(allPageNumbers))
for pageNum := range allPageNumbers {
allPagesList = append(allPagesList, pageNum)
}
slices.Sort(allPagesList)
// Process each page
for pageNum := range allPageNumbers {
pageItems := pieces.Items[pageNum]
if len(pageItems) > 0 {
// Page has content - process normally
sortedPageItems := sortPiecesOnPage(pageItems, pageNum)
individualItems := []IndividualPieceByIssue{}
for _, piece := range sortedPageItems {
individualPiece := IndividualPieceByIssue{
PieceByIssue: piece,
IssueRefs: getPieceIssueRefs(piece.Piece, lib),
PageIcon: determinePageIcon(pageNum, allPagesList),
}
individualItems = append(individualItems, individualPiece)
}
individual.Items[pageNum] = individualItems
} else {
// Page is empty but has images - create empty entry
individual.Items[pageNum] = []IndividualPieceByIssue{}
}
individual.Pages = append(individual.Pages, pageNum)
}
slices.Sort(individual.Pages)
return individual
}
// determinePageIcon determines the icon type for a page based on newspaper layout positioning
func determinePageIcon(pageNum int, allPages []int) string {
if len(allPages) == 0 {
return "first"
}
// Create a copy to avoid modifying the original slice
sortedPages := make([]int, len(allPages))
copy(sortedPages, allPages)
slices.Sort(sortedPages)
firstPage := sortedPages[0]
lastPage := sortedPages[len(sortedPages)-1]
// Newspaper layout logic based on physical page positioning
if pageNum == firstPage {
return "first" // Front page - normal icon
} else if pageNum == lastPage {
return "last" // Back page - mirrored icon
} else {
// For middle pages in a 4-page newspaper layout:
// Page 2 (left side of middle spread) should be "even"
// Page 3 (right side of middle spread) should be "odd"
// But we need to consider the actual page position in layout
if pageNum == firstPage+1 {
return "even" // Page 2 - black + mirrored grey
} else if pageNum == lastPage-1 {
return "odd" // Page 3 - grey + black
} else {
// For newspapers with more than 4 pages, use alternating pattern
if pageNum%2 == 0 {
return "even"
} else {
return "odd"
}
}
}
}
// getPieceIssueRefs gets all issue references for a piece
func getPieceIssueRefs(piece xmlmodels.Piece, lib *xmlmodels.Library) []xmlmodels.IssueRef {
refs := []xmlmodels.IssueRef{}
for _, ref := range piece.IssueRefs {
refs = append(refs, ref)
}
return refs
}
// calculateGridLayout calculates grid positioning for newspaper pages
func calculateGridLayout(pages []IssuePage) []IssuePage {
if len(pages) == 0 {
return pages
}
result := make([]IssuePage, len(pages))
copy(result, pages)
for i := range result {
page := &result[i]
pageNum := i + 1 // 1-based page numbers
// Determine grid position based on newspaper layout logic
switch pageNum {
case 1:
// Page 1: Left, Row 1
page.GridColumn = 1
page.GridRow = 1
page.PageIcon = "first"
case 2, 3:
// Pages 2-3: Double spread with header, Row 2
if pageNum == 2 {
page.GridColumn = 1
page.HasHeader = true
page.HeaderText = fmt.Sprintf("%d-%d", pageNum, pageNum+1)
} else {
page.GridColumn = 2
}
page.GridRow = 2
page.PageIcon = determinePageIconForLayout(pageNum)
case 4:
// Page 4: Right, Row 3
page.GridColumn = 2
page.GridRow = 3
page.PageIcon = "last"
default:
// Handle additional pages if needed
page.GridColumn = ((pageNum - 1) % 2) + 1
page.GridRow = ((pageNum - 1) / 2) + 1
page.PageIcon = determinePageIconForLayout(pageNum)
}
}
return result
}
// determinePageIconForLayout determines icon for layout positioning
func determinePageIconForLayout(pageNum int) string {
if pageNum%2 == 0 {
return "even"
}
return "odd"
}
func LoadIssueImages(issue xmlmodels.Issue, pics *pictures.PicturesProvider) (IssueImages, error) {
year := issue.Datum.When.Year
issueNo := issue.Number.No
images := IssueImages{
MainPages: make([]IssuePage, 0),
AdditionalPages: make(map[int][]IssuePage),
HasImages: false,
}
// Get all image files for this year-issue combination
issueFiles := pics.GetByYearIssue(year, issueNo)
// Separate main pages from beilage pages
var mainFiles []pictures.ImageFile
var beilageFiles []pictures.ImageFile
for _, file := range issueFiles {
if file.IsBeilage {
beilageFiles = append(beilageFiles, file)
} else {
mainFiles = append(mainFiles, file)
}
}
// Create main pages - match with issue page range
for page := issue.Von; page <= issue.Bis; page++ {
var foundFile *pictures.ImageFile
// Look for a file that has this page number
for _, file := range mainFiles {
if file.Page == page {
fileCopy := file
foundFile = &fileCopy
break
}
}
if foundFile != nil {
images.HasImages = true
// Use preview path if available, otherwise fallback to original
previewPath := foundFile.PreviewPath
if previewPath == "" {
previewPath = foundFile.Path
}
// Use JPEG path if available, otherwise fallback to primary
jpegPath := foundFile.JpegPath
if jpegPath == "" {
jpegPath = foundFile.Path
}
images.MainPages = append(images.MainPages, IssuePage{
PageNumber: page,
ImagePath: foundFile.Path,
PreviewPath: previewPath,
JpegPath: jpegPath,
Available: true,
})
} else {
images.MainPages = append(images.MainPages, IssuePage{
PageNumber: page,
ImagePath: "",
PreviewPath: "",
JpegPath: "",
Available: false,
})
}
}
// Create beilage pages - use ALL detected beilage files regardless of XML definitions
if len(beilageFiles) > 0 {
beilagePages := make([]IssuePage, 0)
// Add ALL beilage files found for this issue
for _, file := range beilageFiles {
images.HasImages = true
// Use preview path if available, otherwise fallback to original
previewPath := file.PreviewPath
if previewPath == "" {
previewPath = file.Path
}
// Use JPEG path if available, otherwise fallback to primary
jpegPath := file.JpegPath
if jpegPath == "" {
jpegPath = file.Path
}
beilagePages = append(beilagePages, IssuePage{
PageNumber: file.Page,
ImagePath: file.Path,
PreviewPath: previewPath,
JpegPath: jpegPath,
Available: true,
})
}
if len(beilagePages) > 0 {
// Calculate grid layout for beilage pages
beilagePages = calculateGridLayout(beilagePages)
// Use beilage number 1 as default
images.AdditionalPages[1] = beilagePages
}
}
// Calculate grid layout for main pages
if len(images.MainPages) > 0 {
images.MainPages = calculateGridLayout(images.MainPages)
}
return images, nil
}