mirror of
https://github.com/Theodor-Springmann-Stiftung/kgpz_web.git
synced 2025-10-28 16:45:32 +00:00
650 lines
17 KiB
Go
650 lines
17 KiB
Go
package viewmodels
|
|
|
|
import (
|
|
"fmt"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/Theodor-Springmann-Stiftung/kgpz_web/functions"
|
|
"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
|
|
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"
|
|
}
|
|
|
|
type IssueImages struct {
|
|
MainPages []IssuePage
|
|
AdditionalPages map[int][]IssuePage // Beilage number -> pages
|
|
HasImages bool
|
|
}
|
|
|
|
type ImageFile struct {
|
|
Year int
|
|
Issue int
|
|
Page int
|
|
IsBeilage bool
|
|
BeilageNo int
|
|
Filename string
|
|
Path string
|
|
}
|
|
|
|
type ImageRegistry struct {
|
|
Files []ImageFile
|
|
ByYearIssue map[string][]ImageFile // "year-issue" -> files
|
|
ByYearPage map[string]ImageFile // "year-page" -> file
|
|
}
|
|
|
|
var imageRegistry *ImageRegistry
|
|
|
|
// 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) (*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)
|
|
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 {
|
|
fmt.Printf("DEBUG PiecesForIssue: Piece ID=%s, Von=%d, Bis=%d, Beilage=%d\n", 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) (IssueImages, error) {
|
|
// Initialize registry if not already done
|
|
if err := initImageRegistry(); err != nil {
|
|
return IssueImages{}, err
|
|
}
|
|
|
|
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
|
|
yearIssueKey := fmt.Sprintf("%d-%d", year, issueNo)
|
|
issueFiles := imageRegistry.ByYearIssue[yearIssueKey]
|
|
|
|
// Separate main pages from beilage pages
|
|
var mainFiles []ImageFile
|
|
var beilageFiles []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 *ImageFile
|
|
|
|
// Look for a file that has this page number
|
|
for _, file := range mainFiles {
|
|
if file.Page == page {
|
|
foundFile = &file
|
|
break
|
|
}
|
|
}
|
|
|
|
if foundFile != nil {
|
|
images.HasImages = true
|
|
images.MainPages = append(images.MainPages, IssuePage{
|
|
PageNumber: page,
|
|
ImagePath: foundFile.Path,
|
|
Available: true,
|
|
})
|
|
} else {
|
|
images.MainPages = append(images.MainPages, IssuePage{
|
|
PageNumber: page,
|
|
ImagePath: "",
|
|
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
|
|
beilagePages = append(beilagePages, IssuePage{
|
|
PageNumber: file.Page,
|
|
ImagePath: file.Path,
|
|
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
|
|
}
|
|
|
|
func initImageRegistry() error {
|
|
if imageRegistry != nil {
|
|
return nil
|
|
}
|
|
|
|
imageRegistry = &ImageRegistry{
|
|
Files: make([]ImageFile, 0),
|
|
ByYearIssue: make(map[string][]ImageFile),
|
|
ByYearPage: make(map[string]ImageFile),
|
|
}
|
|
|
|
return filepath.Walk("pictures", func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if info.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
filename := info.Name()
|
|
|
|
// Skip non-jpg files
|
|
if !strings.HasSuffix(strings.ToLower(filename), ".jpg") {
|
|
return nil
|
|
}
|
|
|
|
// Remove .jpg extension and split by -
|
|
nameWithoutExt := strings.TrimSuffix(filename, ".jpg")
|
|
parts := strings.Split(nameWithoutExt, "-")
|
|
|
|
// Need at least 3 parts: year-issue-page
|
|
if len(parts) != 3 {
|
|
return nil
|
|
}
|
|
|
|
// Parse year
|
|
year, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Check if second part ends with 'b' (beilage)
|
|
issueStr := strings.TrimSpace(parts[1])
|
|
isBeilage := strings.HasSuffix(issueStr, "b")
|
|
|
|
if isBeilage {
|
|
issueStr = strings.TrimSuffix(issueStr, "b")
|
|
}
|
|
|
|
// Parse issue number
|
|
issue, err := strconv.Atoi(issueStr)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Parse page number
|
|
page, err := strconv.Atoi(strings.TrimSpace(parts[2]))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
imageFile := ImageFile{
|
|
Year: year,
|
|
Issue: issue,
|
|
Page: page,
|
|
IsBeilage: isBeilage,
|
|
BeilageNo: 1, // Default beilage number
|
|
Filename: filename,
|
|
Path: fmt.Sprintf("/static/pictures/%s", path[9:]), // Remove "pictures/" prefix
|
|
}
|
|
|
|
imageRegistry.Files = append(imageRegistry.Files, imageFile)
|
|
|
|
yearIssueKey := fmt.Sprintf("%d-%d", year, issue)
|
|
imageRegistry.ByYearIssue[yearIssueKey] = append(imageRegistry.ByYearIssue[yearIssueKey], imageFile)
|
|
|
|
if !isBeilage {
|
|
yearPageKey := fmt.Sprintf("%d-%d", year, page)
|
|
imageRegistry.ByYearPage[yearPageKey] = imageFile
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|