diff --git a/internal/index/bleve_read.go b/internal/index/bleve_read.go index 75084e91..f155ab7b 100644 --- a/internal/index/bleve_read.go +++ b/internal/index/bleve_read.go @@ -336,8 +336,11 @@ func (b *BleveIndexer) File(slug string) (*IndexedFile, error) { FileName: filepath.Base(doc.ID), ContentType: "application/pdf", } - if ext == ".epub" { + switch ext { + case ".epub": result.ContentType = "application/epub+zip" + case ".cbz": + result.ContentType = "application/vnd.comicbook+zip" } return result, nil } diff --git a/internal/index/related.go b/internal/index/related.go index 92ccdfa5..7bd7b9c9 100644 --- a/internal/index/related.go +++ b/internal/index/related.go @@ -133,19 +133,30 @@ func distanceToDate(referenceDate float64, match *search.DocumentMatch) float64 } // SameAuthors returns an array of metadata of documents by the same authors which -// does not belong to the same collection +// does not belong to the same collection. Returns no results if the document has +// no authors or only empty author slugs. func (b *BleveIndexer) SameAuthors(slugID string, quantity int) ([]Document, error) { doc, err := b.Document(slugID) if err != nil { return []Document{}, err } - if len(doc.Authors) == 0 { - return []Document{}, err + hasAuthor := false + for _, slug := range doc.AuthorsSlugs { + if slug != "" { + hasAuthor = true + break + } + } + if !hasAuthor { + return []Document{}, nil } authorsCompoundQuery := bleve.NewDisjunctionQuery() for _, slug := range doc.AuthorsSlugs { + if slug == "" { + continue + } qu := bleve.NewTermQuery(slug) qu.SetField("AuthorsSlugs") authorsCompoundQuery.AddQuery(qu) diff --git a/internal/index/search_test.go b/internal/index/search_test.go index 32bea7cf..8569942d 100644 --- a/internal/index/search_test.go +++ b/internal/index/search_test.go @@ -1,8 +1,13 @@ package index_test import ( + "archive/zip" + "encoding/base64" + "encoding/xml" "fmt" "html/template" + "os" + "path/filepath" "reflect" "strconv" "testing" @@ -1628,3 +1633,110 @@ func testIndexAndSearchCases() []testCase { }, } } + +// cbzTestMinimalPNG is a 1×1 transparent PNG (valid image for CBZ tests). +var cbzTestMinimalPNG, _ = base64.StdEncoding.DecodeString( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", +) + +func TestCbzReader_Metadata_Illustrators(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cbPath := filepath.Join(dir, "issue.cbz") + + comicXML := metadata.ComicInfo{ + Title: "Test Issue", + Writer: "Alice Writer", + Penciller: "Bob Penciller", + Inker: "Carol Inker", + Colorist: "Dan Colorist", + Letterer: "Eve Letterer", + Editor: "Frank Editor", + } + comicXML.CoverArtist = "Gia Cover" + comicXML.Illustrator = "Helen Illustrator" + + data, err := xml.Marshal(comicXML) + if err != nil { + t.Fatal(err) + } + if err := writeTestCBZ(cbPath, string(data)); err != nil { + t.Fatal(err) + } + + meta, err := metadata.CbzReader{}.Metadata(cbPath) + if err != nil { + t.Fatal(err) + } + + wantAuthors := []string{"Alice Writer", "Frank Editor"} + if !reflect.DeepEqual(meta.Authors, wantAuthors) { + t.Errorf("Authors = %#v; want %#v", meta.Authors, wantAuthors) + } + + wantIll := []string{ + "Bob Penciller", + "Carol Inker", + "Dan Colorist", + "Eve Letterer", + "Gia Cover", + "Helen Illustrator", + } + if !reflect.DeepEqual(meta.Illustrators, wantIll) { + t.Errorf("Illustrators = %#v; want %#v", meta.Illustrators, wantIll) + } +} + +func TestCbzReader_Metadata_IllustratorsDedupe(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cbPath := filepath.Join(dir, "dedupe.cbz") + + comicXML := metadata.ComicInfo{ + Writer: "Same Person", + Penciller: "Same Person", + Inker: "Same Person", + } + data, err := xml.Marshal(comicXML) + if err != nil { + t.Fatal(err) + } + if err := writeTestCBZ(cbPath, string(data)); err != nil { + t.Fatal(err) + } + + meta, err := metadata.CbzReader{}.Metadata(cbPath) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(meta.Authors, []string{"Same Person"}) { + t.Errorf("Authors = %#v", meta.Authors) + } + if !reflect.DeepEqual(meta.Illustrators, []string{"Same Person"}) { + t.Errorf("Illustrators = %#v; want one deduped art credit", meta.Illustrators) + } +} + +func writeTestCBZ(path, comicInfoXML string) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + zw := zip.NewWriter(f) + wci, err := zw.Create("ComicInfo.xml") + if err != nil { + return err + } + if _, err := wci.Write([]byte(xml.Header + comicInfoXML)); err != nil { + return err + } + wimg, err := zw.Create("001.png") + if err != nil { + return err + } + if _, err := wimg.Write(cbzTestMinimalPNG); err != nil { + return err + } + return zw.Close() +} diff --git a/internal/metadata/cbz.go b/internal/metadata/cbz.go new file mode 100644 index 00000000..e8ab4763 --- /dev/null +++ b/internal/metadata/cbz.go @@ -0,0 +1,303 @@ +package metadata + +import ( + "archive/zip" + "encoding/xml" + "fmt" + "html/template" + "io" + "log" + "path/filepath" + "strconv" + "strings" + + "github.com/rickb777/date/v2" + "github.com/svera/coreander/v4/internal/precisiondate" +) + +// ComicInfo represents the ComicInfo.xml schema (ComicRack/Anansi) used in CBZ archives. +type ComicInfo struct { + XMLName xml.Name `xml:"ComicInfo"` + + Title string `xml:"Title"` + Series string `xml:"Series"` + Number string `xml:"Number"` + Count int `xml:"Count"` + Volume int `xml:"Volume"` + Summary string `xml:"Summary"` + Notes string `xml:"Notes"` + + Year int `xml:"Year"` + Month int `xml:"Month"` + Day int `xml:"Day"` + + Writer string `xml:"Writer"` + Penciller string `xml:"Penciller"` + Inker string `xml:"Inker"` + Colorist string `xml:"Colorist"` + Letterer string `xml:"Letterer"` + CoverArtist string `xml:"CoverArtist"` + // Illustrator is used by some tools alongside or instead of Penciller/Inker. + Illustrator string `xml:"Illustrator"` + Editor string `xml:"Editor"` + Publisher string `xml:"Publisher"` + Imprint string `xml:"Imprint"` + Genre string `xml:"Genre"` + Web string `xml:"Web"` + PageCount int `xml:"PageCount"` + LanguageISO string `xml:"LanguageISO"` + Format string `xml:"Format"` + Characters string `xml:"Characters"` + Teams string `xml:"Teams"` + Locations string `xml:"Locations"` + StoryArc string `xml:"StoryArc"` + SeriesGroup string `xml:"SeriesGroup"` + ScanInfo string `xml:"ScanInformation"` + AgeRating string `xml:"AgeRating"` + Review string `xml:"Review"` + + Pages *ComicPages `xml:"Pages"` +} + +// ComicPages holds the optional list of page descriptors (cover index, etc.). +type ComicPages struct { + Page []ComicPageInfo `xml:"Page"` +} + +// ComicPageInfo describes a single page (e.g. FrontCover at a given image index). +type ComicPageInfo struct { + Image int `xml:"Image,attr"` + Type string `xml:"Type,attr"` +} + +type CbzReader struct{} + +func (c CbzReader) Metadata(file string) (Metadata, error) { + bk := Metadata{} + + r, err := zip.OpenReader(file) + if err != nil { + return bk, err + } + defer r.Close() + + info, _ := readComicInfoFromZip(r) + + title := DefaultTitleFromFilename(file) + if info != nil && strings.TrimSpace(info.Title) != "" { + title = strings.TrimSpace(info.Title) + } + + authors := []string{""} + var illustrators []string + if info != nil { + authors = AuthorsOrEmptySlot(collectComicAuthors(info)) + illustrators = collectComicIllustrators(info) + } + + description := "" + if info != nil { + if info.Summary != "" { + description = SanitizeDescription(info.Summary) + } else if info.Notes != "" { + description = SanitizeDescription(info.Notes) + } + } + + lang := "" + if info != nil { + lang = strings.TrimSpace(info.LanguageISO) + } + + publication := precisiondate.PrecisionDate{Precision: precisiondate.PrecisionDay} + if info != nil && info.Year > 0 { + if info.Month > 0 && info.Month <= 12 && info.Day > 0 && info.Day <= 31 { + publication.Date, _ = date.Parse("2006-01-02", fmt.Sprintf("%04d-%02d-%02d", info.Year, info.Month, info.Day)) + } else { + publication.Precision = precisiondate.PrecisionYear + publication.Date, _ = date.Parse("2006", strconv.Itoa(info.Year)) + } + } + + seriesIndex := 0.0 + series := "" + if info != nil { + series = strings.TrimSpace(info.Series) + seriesIndex = ParseSeriesIndex(info.Number) + } + + // Prefer ComicInfo PageCount when present (canonical for the publication). + // If it is missing (0), fall back to counting image files in the zip — extensions must match the reader (see ImageExtensions). + pages := float64(len(SortedImageEntriesFromZip(r))) + if info != nil && info.PageCount > 0 { + pages = float64(info.PageCount) + } + + var subjects []string + if info != nil && info.Genre != "" { + subjects = ParseSubjectList(info.Genre) + } + + formatLabel := "CBZ" + if info != nil && info.Format != "" { + formatLabel = strings.TrimSpace(info.Format) + } + + illustrations, err := c.illustrations(file, 0.25) + if err != nil { + log.Printf("Cannot count illustrations in %s: %v\n", file, err) + } + + bk = Metadata{ + Title: title, + Authors: authors, + Illustrators: illustrators, + Description: template.HTML(description), + Language: lang, + Publication: publication, + Series: series, + SeriesIndex: seriesIndex, + Pages: pages, + Format: formatLabel, + Subjects: subjects, + Illustrations: illustrations, + } + return bk, nil +} + +func (c CbzReader) Cover(documentFullPath string, coverMaxWidth int) ([]byte, error) { + r, err := zip.OpenReader(documentFullPath) + if err != nil { + return nil, err + } + defer r.Close() + + info, _ := readComicInfoFromZip(r) + coverName, names := cbzCoverImageAndNames(r, info) + if len(names) == 0 { + return nil, fmt.Errorf("cbz: no image found") + } + + return DecodeResizeZipImageEntry(r, coverName, coverMaxWidth) +} + +// illustrations returns the number of images in the CBZ with size >= minMegapixels (excluding the cover). +func (c CbzReader) illustrations(documentFullPath string, minMegapixels float64) (int, error) { + r, err := zip.OpenReader(documentFullPath) + if err != nil { + return 0, err + } + defer r.Close() + + info, _ := readComicInfoFromZip(r) + coverName, names := cbzCoverImageAndNames(r, info) + if len(names) == 0 { + return 0, nil + } + + var count int + for _, name := range names { + if name == coverName { + continue + } + mp, err := ImageMegapixelsFromZip(r, name) + if err != nil { + continue + } + if mp >= minMegapixels { + count++ + } + } + return count, nil +} + +// cbzCoverImageAndNames returns sorted image paths in the archive and the path used as cover +// (ComicInfo FrontCover index when valid, otherwise the first image). +func cbzCoverImageAndNames(r *zip.ReadCloser, info *ComicInfo) (coverName string, names []string) { + names = SortedImageEntriesFromZip(r) + if len(names) == 0 { + return "", nil + } + coverIndex := 1 + if info != nil && info.Pages != nil { + for _, p := range info.Pages.Page { + if strings.EqualFold(strings.TrimSpace(p.Type), "FrontCover") { + coverIndex = p.Image + break + } + } + } + if coverIndex < 1 || coverIndex > len(names) { + coverIndex = 1 + } + return names[coverIndex-1], names +} + +func readComicInfoFromZip(r *zip.ReadCloser) (*ComicInfo, error) { + var entryName string + for _, f := range r.File { + base := filepath.Base(f.Name) + if strings.EqualFold(base, "comicinfo.xml") { + entryName = f.Name + break + } + } + if entryName == "" { + return nil, nil + } + + rc, err := OpenZipEntry(r, entryName) + if err != nil { + return nil, err + } + defer rc.Close() + + data, err := io.ReadAll(rc) + if err != nil { + return nil, err + } + + var info ComicInfo + if err := xml.Unmarshal(data, &info); err != nil { + return nil, nil + } + return &info, nil +} + +// collectComicAuthors returns writing and editorial credits (ComicInfo Writer, Editor). +func collectComicAuthors(info *ComicInfo) []string { + return uniqueComicNames(info.Writer, info.Editor) +} + +// collectComicIllustrators returns art credits from ComicInfo: penciller, inker, +// colorist, letterer, cover artist, and optional Illustrator element. +func collectComicIllustrators(info *ComicInfo) []string { + return uniqueComicNames( + info.Penciller, + info.Inker, + info.Colorist, + info.Letterer, + info.CoverArtist, + info.Illustrator, + ) +} + +func uniqueComicNames(fields ...string) []string { + var combined []string + for _, s := range fields { + combined = append(combined, ParseAuthorList(s)...) + } + seen := make(map[string]struct{}) + var out []string + for _, name := range combined { + if name == "" { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + out = append(out, name) + } + return out +} diff --git a/internal/metadata/common.go b/internal/metadata/common.go index 9863f6d4..103af2da 100644 --- a/internal/metadata/common.go +++ b/internal/metadata/common.go @@ -1,6 +1,8 @@ package metadata import ( + "path/filepath" + "strconv" "strings" "github.com/microcosm-cc/bluemonday" @@ -33,6 +35,20 @@ func SanitizeDescription(raw string) string { return p.Sanitize(raw) } +// DefaultTitleFromFilename returns the basename of fullPath without its extension (fallback book title). +func DefaultTitleFromFilename(fullPath string) string { + return strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) +} + +// AuthorsOrEmptySlot returns authors unchanged if non-empty, otherwise a single empty string +// so Metadata always has at least one author slot (same convention as EPUB/CBZ readers). +func AuthorsOrEmptySlot(authors []string) []string { + if len(authors) == 0 { + return []string{""} + } + return authors +} + // ParseAuthorList splits s by '&', ',', and ';', trims each part, and returns non-empty names. // Used for both EPUB creator lists and PDF author strings. func ParseAuthorList(s string) []string { @@ -46,3 +62,31 @@ func ParseAuthorList(s string) []string { } return names } + +// ParseSeriesIndex parses a series index or issue number string (e.g. EPUB calibre:series_index, CBZ Number). +// Returns 0 for empty or invalid input. +func ParseSeriesIndex(s string) float64 { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + n, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0 + } + return n +} + +// ParseSubjectList splits s by ',' and ';', trims each part, and returns non-empty strings. +// Used for EPUB subjects and CBZ genre (and similar comma/semicolon-separated fields). +func ParseSubjectList(s string) []string { + var list []string + for _, part := range strings.FieldsFunc(s, func(r rune) bool { + return r == ',' || r == ';' + }) { + if v := strings.TrimSpace(part); v != "" { + list = append(list, v) + } + } + return list +} diff --git a/internal/metadata/epub.go b/internal/metadata/epub.go index 71f8b60c..41357fe6 100644 --- a/internal/metadata/epub.go +++ b/internal/metadata/epub.go @@ -4,20 +4,14 @@ import ( "archive/zip" "fmt" "html/template" - "image" - _ "image/gif" - _ "image/jpeg" - _ "image/png" "io" "log" "path" "path/filepath" "regexp" - "strconv" "strings" "github.com/bmatcuk/doublestar/v4" - "github.com/kovidgoyal/imaging" "github.com/microcosm-cc/bluemonday" "github.com/pirmd/epub" "github.com/rickb777/date/v2" @@ -42,7 +36,7 @@ func (e EpubReader) Metadata(filename string) (Metadata, error) { if err != nil { return bk, err } - title := strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)) + title := DefaultTitleFromFilename(filename) if len(meta.Title) > 0 && len(meta.Title[0]) > 0 { title = meta.Title[0] } @@ -55,26 +49,12 @@ func (e EpubReader) Metadata(filename string) (Metadata, error) { for _, contributor := range meta.Contributor { classifyEpubPerson(contributor, false, &authors, &illustrators) } - if len(authors) == 0 { - authors = []string{""} - } + authors = AuthorsOrEmptySlot(authors) var subjects []string for _, subject := range meta.Subject { - subject = strings.TrimSpace(subject) - if subject == "" { - continue - } - // Some epub files mistakenly put all subjects in a single field instead of using a field for each one. - // We want to identify those cases looking for specific separators and then indexing each subject properly. - names := strings.FieldsFunc(subject, func(r rune) bool { - return r == ',' || r == ';' - }) - for _, name := range names { - if name = strings.TrimSpace(name); name != "" { - subjects = append(subjects, name) - } - } + // Some epub files put all subjects in a single field; ParseSubjectList handles comma/semicolon separators. + subjects = append(subjects, ParseSubjectList(subject)...) } description := "" @@ -98,18 +78,16 @@ func (e EpubReader) Metadata(filename string) (Metadata, error) { } } - var seriesIndex float64 = 0 - - seriesIndex, _ = strconv.ParseFloat(meta.SeriesIndex, 64) + seriesIndex := ParseSeriesIndex(meta.SeriesIndex) illustrations, err := e.illustrations(filename, 0.25) if err != nil { - log.Printf("Cannot count illustrations in %s: $%s\n", filename, err) + log.Printf("Cannot count illustrations in %s: %v\n", filename, err) } w, err := words(filename) if err != nil { - log.Printf("Cannot count words in %s: $%s\n", filename, err) + log.Printf("Cannot count words in %s: %v\n", filename, err) } bk = Metadata{ @@ -225,7 +203,7 @@ func (e EpubReader) illustrations(documentFullPath string, minMegapixels float64 if _, alreadyCounted := seen[zipPath]; alreadyCounted { continue } - mp, err := imageMegapixels(r, zipPath) + mp, err := ImageMegapixelsFromZip(r, zipPath) if err != nil { continue } @@ -263,27 +241,8 @@ func findZipEntryPath(r *zip.ReadCloser, candidates map[string]struct{}) string return "" } -func imageMegapixels(r *zip.ReadCloser, zipPath string) (float64, error) { - rc, err := readZipFileReader(r, zipPath) - if err != nil || rc == nil { - return 0, err - } - cfg, _, err := image.DecodeConfig(rc) - rc.Close() - if err == nil { - return float64(cfg.Width*cfg.Height) / 1e6, nil - } - return 0, err -} - func readZipFileReader(r *zip.ReadCloser, name string) (io.ReadCloser, error) { - for _, f := range r.File { - if f.Name != name { - continue - } - return f.Open() - } - return nil, fmt.Errorf("epub: no zip entry %q", name) + return OpenZipEntry(r, name) } func words(documentFullPath string) (int, error) { @@ -330,15 +289,7 @@ func extractCover(r *zip.ReadCloser, coverFile, opfBaseDir string, coverMaxWidth if _, ok := candidates[f.Name]; !ok { continue } - rc, err := f.Open() - if err != nil { - return nil, err - } - src, err := imaging.Decode(rc, imaging.Backends(imaging.GO_IMAGE)) - if err != nil { - return nil, err - } - return resize(src, coverMaxWidth, err) + return DecodeResizeZipImageEntry(r, f.Name, coverMaxWidth) } return nil, fmt.Errorf("no cover image found") } diff --git a/internal/metadata/pdf.go b/internal/metadata/pdf.go index 484ff692..0d15b8f0 100644 --- a/internal/metadata/pdf.go +++ b/internal/metadata/pdf.go @@ -53,7 +53,7 @@ func (p PdfReader) Metadata(file string) (Metadata, error) { title := strings.TrimSpace(info.Title) if title == "" { - title = strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)) + title = DefaultTitleFromFilename(file) } publication := precisiondate.PrecisionDate{Precision: precisiondate.PrecisionDay} @@ -65,10 +65,7 @@ func (p PdfReader) Metadata(file string) (Metadata, error) { description := SanitizeDescription(info.Subject) - authors := []string{""} - if names := ParseAuthorList(info.Author); len(names) > 0 { - authors = names - } + authors := AuthorsOrEmptySlot(ParseAuthorList(info.Author)) lang := "" if info.Properties != nil { diff --git a/internal/metadata/zip.go b/internal/metadata/zip.go new file mode 100644 index 00000000..0ee69454 --- /dev/null +++ b/internal/metadata/zip.go @@ -0,0 +1,79 @@ +package metadata + +import ( + "archive/zip" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "path/filepath" + "sort" + "strings" + + "github.com/kovidgoyal/imaging" +) + +// OpenZipEntry opens a file inside a zip by name and returns a ReadCloser. +func OpenZipEntry(r *zip.ReadCloser, name string) (io.ReadCloser, error) { + for _, f := range r.File { + if f.Name != name { + continue + } + return f.Open() + } + return nil, fmt.Errorf("zip entry %q not found", name) +} + +// DecodeResizeZipImageEntry opens a zip entry by name, decodes it as an image, and returns JPEG bytes resized to coverMaxWidth (height auto). +func DecodeResizeZipImageEntry(r *zip.ReadCloser, name string, coverMaxWidth int) ([]byte, error) { + rc, err := OpenZipEntry(r, name) + if err != nil { + return nil, err + } + defer rc.Close() + src, err := imaging.Decode(rc, imaging.Backends(imaging.GO_IMAGE)) + if err != nil { + return nil, err + } + return resize(src, coverMaxWidth, nil) +} + +// ImageMegapixelsFromZip reads a zip entry as an image and returns its size in megapixels (width*height/1e6). +func ImageMegapixelsFromZip(r *zip.ReadCloser, name string) (float64, error) { + rc, err := OpenZipEntry(r, name) + if err != nil || rc == nil { + return 0, err + } + defer rc.Close() + cfg, _, err := image.DecodeConfig(rc) + if err != nil { + return 0, err + } + return float64(cfg.Width*cfg.Height) / 1e6, nil +} + +// ImageExtensions is the set of file extensions treated as images (for listing images in zip-based comics). +// Keep in sync with foliate-js/comic-book.js so CBZ page fallbacks match what the reader can open. +var ImageExtensions = map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, + ".webp": true, ".bmp": true, ".tiff": true, ".tif": true, + ".svg": true, ".jxl": true, ".avif": true, +} + +// SortedImageEntriesFromZip returns the names of non-directory zip entries whose extension is in ImageExtensions, sorted by name. +func SortedImageEntriesFromZip(r *zip.ReadCloser) []string { + var names []string + for _, f := range r.File { + if f.FileInfo().IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(f.Name)) + if ImageExtensions[ext] { + names = append(names, f.Name) + } + } + sort.Slice(names, func(i, j int) bool { return names[i] < names[j] }) + return names +} diff --git a/internal/webserver/controller/document/reader.go b/internal/webserver/controller/document/reader.go index 89292c97..5ae2efe3 100644 --- a/internal/webserver/controller/document/reader.go +++ b/internal/webserver/controller/document/reader.go @@ -39,9 +39,12 @@ func (d *Controller) Reader(c fiber.Ctx) error { title = fmt.Sprintf("%s - %s", authors, document.Title) } return c.Render("document/reader", fiber.Map{ - "Title": title, - "Author": strings.Join(document.Authors, ", "), - "Description": document.Description, - "Slug": document.Slug, + "Title": title, + "IndexedTitle": document.Title, + "IndexedAuthors": authors, + "Author": authors, + "Description": document.Description, + "Slug": document.Slug, + "Format": document.Format, }) } diff --git a/internal/webserver/controller/document/upload.go b/internal/webserver/controller/document/upload.go index ca52b899..465332a6 100644 --- a/internal/webserver/controller/document/upload.go +++ b/internal/webserver/controller/document/upload.go @@ -6,7 +6,9 @@ import ( "fmt" "io" "mime/multipart" + "path/filepath" "slices" + "strings" "time" "github.com/gofiber/fiber/v3" @@ -45,8 +47,22 @@ func (d *Controller) Upload(c fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).Render("document/upload", templateVars, "layout") } - allowedTypes := []string{"application/epub+zip", "application/pdf"} - if !slices.Contains(allowedTypes, file.Header.Get("Content-Type")) { + allowedExtensions := []string{".epub", ".pdf", ".cbz"} + ext := strings.ToLower(filepath.Ext(file.Filename)) + if !slices.Contains(allowedExtensions, ext) { + templateVars["Error"] = "Invalid file type" + return c.Status(fiber.StatusBadRequest).Render("document/upload", templateVars, "layout") + } + + // Browsers often send application/zip or application/octet-stream for .cbz; accept by extension. + allowedTypes := []string{"application/epub+zip", "application/pdf", "application/vnd.comicbook+zip", "application/x-cbz", "application/zip", "application/octet-stream", ""} + contentType := strings.TrimSpace(file.Header.Get("Content-Type")) + if !slices.Contains(allowedTypes, contentType) { + templateVars["Error"] = "Invalid file type" + return c.Status(fiber.StatusBadRequest).Render("document/upload", templateVars, "layout") + } + // application/zip only allowed for .cbz (reject generic zip uploaded as .epub/.pdf) + if contentType == "application/zip" && ext != ".cbz" { templateVars["Error"] = "Invalid file type" return c.Status(fiber.StatusBadRequest).Render("document/upload", templateVars, "layout") } diff --git a/internal/webserver/embedded/css/reader.css b/internal/webserver/embedded/css/reader.css index fd7938fe..281d9802 100644 --- a/internal/webserver/embedded/css/reader.css +++ b/internal/webserver/embedded/css/reader.css @@ -99,6 +99,12 @@ foliate-view:focus { .d-none { display: none !important; } +#error-icon-container .reader-error-text { + max-width: 28rem; + margin: 0 auto 1rem; + text-align: center; +} + #spinner-container, #error-icon-container { height: 100vh; display: flex; diff --git a/internal/webserver/embedded/js/reader.js b/internal/webserver/embedded/js/reader.js index 2d4586ed..3c8e4712 100644 --- a/internal/webserver/embedded/js/reader.js +++ b/internal/webserver/embedded/js/reader.js @@ -59,6 +59,47 @@ const getCSS = ({ spacing, justify, hyphenate, theme, fontSize, fontFamily }) => const $ = document.querySelector.bind(document) +/** Message thrown by foliate comic-book.js when a CBZ has no supported images (must match upstream). */ +const emptyComicArchiveMessage = 'No supported image files in archive' + +/** + * Hides the loading spinner, removes a partially mounted foliate-view, and shows + * #error-icon-container with #reader-error-text (idempotent for the same page load). + */ +function showReaderOpenFailure(err) { + const errorIcon = document.querySelector('#error-icon-container') + if (errorIcon?.dataset.readerOpenFailureShown === '1') { + return + } + + const spinner = document.querySelector('#spinner-container') + if (spinner?.parentNode) { + spinner.parentNode.removeChild(spinner) + } + document.querySelector('foliate-view')?.remove() + + let translations = {} + try { + translations = JSON.parse(document.getElementById('i18n').textContent).i18n + } catch { + // ignore missing or invalid i18n + } + + if (!errorIcon) { + return + } + const msgEl = document.querySelector('#reader-error-text') + if (msgEl) { + const msg = err?.message === emptyComicArchiveMessage && translations.empty_comic_archive + ? translations.empty_comic_archive + : (err?.message || String(err)) + msgEl.textContent = msg + msgEl.classList.remove('d-none') + } + errorIcon.classList.remove('d-none') + errorIcon.dataset.readerOpenFailureShown = '1' +} + const locales = 'en' const percentFormat = new Intl.NumberFormat(locales, { style: 'percent' }) const listFormat = new Intl.ListFormat(locales, { style: 'short', type: 'conjunction' }) @@ -543,7 +584,13 @@ class Reader { const storage = window.localStorage const slug = document.getElementById('slug').value document.body.append(this.view) - await this.view.open(file) + try { + await this.view.open(file) + } catch (e) { + showReaderOpenFailure(e) + globalThis.reader = null + throw e + } // Get position, syncing with server if authenticated const localData = this.sync.getLocalPosition(slug) @@ -667,15 +714,18 @@ class Reader { document.addEventListener('keydown', this.#handleKeydown.bind(this)) - const title = formatLanguageMap(book.metadata?.title) || slug - document.title = title + // Foliate CBZ sets book.metadata.title to the file name only; prefer index title (e.g. ComicInfo) from the server. + const indexedTitle = document.getElementById('indexed-document-title')?.value?.trim() ?? '' + const indexedAuthors = document.getElementById('indexed-document-authors')?.value?.trim() ?? '' + const displayTitle = indexedTitle || formatLanguageMap(book.metadata?.title) || slug + document.title = indexedAuthors ? `${indexedAuthors} - ${displayTitle}` : displayTitle const titleEl = $('#side-bar-title') titleEl.replaceChildren() const detailLink = document.createElement('a') detailLink.href = `/documents/${slug}` - detailLink.textContent = title + detailLink.textContent = displayTitle titleEl.appendChild(detailLink) - $('#side-bar-author').innerText = formatContributor(book.metadata?.author) + $('#side-bar-author').innerText = indexedAuthors || formatContributor(book.metadata?.author) Promise.resolve(book.getCover?.())?.then(blob => blob ? $('#side-bar-cover').src = URL.createObjectURL(blob) : null) @@ -884,6 +934,9 @@ const open = async file => { } const url = document.getElementById('url').value +const slug = document.getElementById('slug')?.value || 'document' +const format = (document.getElementById('format')?.value || '').toLowerCase() +const ext = format === 'cbz' ? '.cbz' : format === 'pdf' ? '.pdf' : '.epub' if (url) fetch(url) .then(res => { if (res.status == 403) { @@ -905,16 +958,18 @@ if (url) fetch(url) if (!res.ok) { throw new Error(`HTTP error! status: ${res.status}`); } - return res.blob() + return res.blob().then(blob => ({ blob, contentType: res.headers.get('Content-Type') || '' })) }) - .then(blob => { - if (blob) open(new File([blob], new URL(url).pathname)) + .then(({ blob, contentType }) => { + if (blob) { + const filename = slug + ext + return open(new File([blob], filename, { type: contentType })) + } + return undefined }) .catch(e => { if (e.message !== 'Authentication required') { - const spinner = $('#spinner-container'); - if (spinner) document.body.removeChild(spinner); - $('#error-icon-container').classList.remove('d-none'); + showReaderOpenFailure(e) } - console.error(e); + console.error(e) }) diff --git a/internal/webserver/embedded/translations/de.yml b/internal/webserver/embedded/translations/de.yml index 4022212f..0cc68dd3 100644 --- a/internal/webserver/embedded/translations/de.yml +++ b/internal/webserver/embedded/translations/de.yml @@ -247,6 +247,7 @@ "Reading position updated from another device.": "Leseposition von einem anderen Gerät aktualisiert." "You are not logged in. Your reading position is saved locally only.": "Sie sind nicht angemeldet. Ihre Leseposition wird nur lokal gespeichert. Anmelden" "Your saved reading position was reset because this document changed.": "Ihre gespeicherte Leseposition wurde zurückgesetzt, weil sich dieses Dokument geändert hat." +"No supported image files in archive": "Keine unterstützten Bilddateien im Archiv" "Invite users": "Benutzer einladen" "Send invitation": "Einladung senden" "Invitations sent successfully": "Einladungen erfolgreich gesendet" diff --git a/internal/webserver/embedded/translations/es.yml b/internal/webserver/embedded/translations/es.yml index 271563ba..5ddd0b6f 100644 --- a/internal/webserver/embedded/translations/es.yml +++ b/internal/webserver/embedded/translations/es.yml @@ -244,6 +244,7 @@ "Session expired. Your reading position is still saved locally.": "Sesión expirada. Tu posición de lectura sigue guardada localmente. Iniciar sesión" "Reading position updated from another device.": "Posición de lectura actualizada desde otro dispositivo." "Your saved reading position was reset because this document changed.": "Tu posición de lectura guardada se restableció porque este documento cambió." +"No supported image files in archive": "No hay archivos de imagen compatibles en el archivo" "You are not logged in. Your reading position is saved locally only.": "No has iniciado sesión. Tu posición de lectura solo se guarda localmente. Iniciar sesión" "Invite users": "Invitar usuarios" "Send invitation": "Enviar invitación" diff --git a/internal/webserver/embedded/translations/fr.yml b/internal/webserver/embedded/translations/fr.yml index 274fff1b..914fc139 100644 --- a/internal/webserver/embedded/translations/fr.yml +++ b/internal/webserver/embedded/translations/fr.yml @@ -245,6 +245,7 @@ "Session expired. Your reading position is still saved locally.": "Session expirée. Votre position de lecture est toujours enregistrée localement. Se connecter" "Reading position updated from another device.": "Position de lecture mise à jour depuis un autre appareil." "Your saved reading position was reset because this document changed.": "Votre position de lecture enregistrée a été réinitialisée car ce document a changé." +"No supported image files in archive": "Aucun fichier image pris en charge dans l'archive" "You are not logged in. Your reading position is saved locally only.": "Vous n'êtes pas connecté. Votre position de lecture est enregistrée localement uniquement. Se connecter" "Invite users": "Inviter des utilisateurs" "Send invitation": "Envoyer l'invitation" diff --git a/internal/webserver/embedded/translations/ru.yml b/internal/webserver/embedded/translations/ru.yml index e1f051dc..d224300a 100644 --- a/internal/webserver/embedded/translations/ru.yml +++ b/internal/webserver/embedded/translations/ru.yml @@ -250,6 +250,7 @@ "Reading position updated from another device.": "Позиция чтения обновлена с другого устройства." "You are not logged in. Your reading position is saved locally only.": "Вы не вошли в систему. Ваша позиция чтения сохраняется только локально. Войти" "Your saved reading position was reset because this document changed.": "Сохраненная позиция чтения была сброшена, потому что документ изменился." +"No supported image files in archive": "В архиве нет поддерживаемых файлов изображений" "Invite users": "Пригласить пользователей" "Send invitation": "Отправить приглашение" "Invitations sent successfully": "Приглашения успешно отправлены" diff --git a/internal/webserver/embedded/views/document/detail.html b/internal/webserver/embedded/views/document/detail.html index 43c4aca8..d0d1fdfa 100644 --- a/internal/webserver/embedded/views/document/detail.html +++ b/internal/webserver/embedded/views/document/detail.html @@ -52,7 +52,7 @@
{{t .Lang "No description available"}}
{{t .Lang "No description available"}}