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 }