From 79222998a8970ff2b965419e2d5875d6d4855889 Mon Sep 17 00:00:00 2001 From: Sergio Vera Date: Thu, 19 Mar 2026 15:50:16 +0100 Subject: [PATCH 1/9] Added support for cbz format --- internal/index/bleve_read.go | 5 +- internal/metadata/cbz.go | 369 ++++++++++++++++++ .../webserver/controller/document/reader.go | 1 + .../webserver/controller/document/upload.go | 20 +- internal/webserver/embedded/js/reader.js | 12 +- .../embedded/views/document/reader.html | 1 + .../embedded/views/document/upload.html | 2 +- internal/webserver/webserver_test.go | 1 + main.go | 3 +- 9 files changed, 406 insertions(+), 8 deletions(-) create mode 100644 internal/metadata/cbz.go diff --git a/internal/index/bleve_read.go b/internal/index/bleve_read.go index 434fee72..48912920 100644 --- a/internal/index/bleve_read.go +++ b/internal/index/bleve_read.go @@ -337,8 +337,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/metadata/cbz.go b/internal/metadata/cbz.go new file mode 100644 index 00000000..940b9df5 --- /dev/null +++ b/internal/metadata/cbz.go @@ -0,0 +1,369 @@ +package metadata + +import ( + "archive/zip" + "encoding/xml" + "fmt" + "html/template" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "log" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/kovidgoyal/imaging" + "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"` + 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{} + +// comicInfoFilenames are possible names for the metadata file (case-insensitive match). +var comicInfoFilenames = []string{"ComicInfo.xml", "comicinfo.xml", "COMICINFO.XML"} + +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 := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)) + if info != nil && strings.TrimSpace(info.Title) != "" { + title = strings.TrimSpace(info.Title) + } + + authors := []string{""} + if info != nil { + authors = collectComicAuthors(info) + if len(authors) == 0 { + authors = []string{""} + } + } + + 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) + if info.Number != "" { + if n, err := strconv.ParseFloat(strings.TrimSpace(info.Number), 64); err == nil { + seriesIndex = n + } + } + } + + pages := float64(countImageEntries(r)) + if info != nil && info.PageCount > 0 { + pages = float64(info.PageCount) + } + + var subjects []string + if info != nil && info.Genre != "" { + for _, s := range strings.FieldsFunc(info.Genre, func(r rune) bool { return r == ',' || r == ';' }) { + if s = strings.TrimSpace(s); s != "" { + subjects = append(subjects, s) + } + } + } + + 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: %s\n", file, err) + } + + bk = Metadata{ + Title: title, + Authors: authors, + 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) + coverIndex := 0 + if info != nil && info.Pages != nil { + for _, p := range info.Pages.Page { + if strings.EqualFold(strings.TrimSpace(p.Type), "FrontCover") { + coverIndex = p.Image + break + } + } + } + + names := sortedImageEntries(r) + if len(names) == 0 { + return nil, fmt.Errorf("cbz: no image found") + } + if coverIndex < 1 || coverIndex > len(names) { + coverIndex = 1 + } + coverName := names[coverIndex-1] + + rc, err := openZipEntry(r, coverName) + if err != nil { + return nil, err + } + defer rc.Close() + + src, err := imaging.Decode(rc) + if err != nil { + return nil, err + } + return resize(src, coverMaxWidth, nil) +} + +// 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) + coverIndex := 0 + if info != nil && info.Pages != nil { + for _, p := range info.Pages.Page { + if strings.EqualFold(strings.TrimSpace(p.Type), "FrontCover") { + coverIndex = p.Image + break + } + } + } + + names := sortedImageEntries(r) + if len(names) == 0 { + return 0, nil + } + if coverIndex < 1 || coverIndex > len(names) { + coverIndex = 1 + } + coverName := names[coverIndex-1] + + var count int + for _, name := range names { + if name == coverName { + continue + } + mp, err := cbzImageMegapixels(r, name) + if err != nil { + continue + } + if mp >= minMegapixels { + count++ + } + } + return count, nil +} + +func cbzImageMegapixels(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 +} + +func readComicInfoFromZip(r *zip.ReadCloser) (*ComicInfo, error) { + var entryName string + for _, f := range r.File { + base := filepath.Base(f.Name) + for _, want := range comicInfoFilenames { + if base == want { + entryName = f.Name + break + } + } + if entryName != "" { + 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 +} + +func collectComicAuthors(info *ComicInfo) []string { + var combined []string + for _, s := range []string{ + info.Writer, info.Penciller, info.Inker, info.CoverArtist, + info.Colorist, info.Letterer, info.Editor, + } { + 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 +} + +var imageExtensions = map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, + ".webp": true, ".bmp": true, ".tiff": true, ".tif": true, +} + +func sortedImageEntries(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 +} + +func countImageEntries(r *zip.ReadCloser) int { + return len(sortedImageEntries(r)) +} + +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("cbz: entry %q not found", name) +} diff --git a/internal/webserver/controller/document/reader.go b/internal/webserver/controller/document/reader.go index 89292c97..d899c706 100644 --- a/internal/webserver/controller/document/reader.go +++ b/internal/webserver/controller/document/reader.go @@ -43,5 +43,6 @@ func (d *Controller) Reader(c fiber.Ctx) error { "Author": strings.Join(document.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 0dca8334..f27f3c2a 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" @@ -33,8 +35,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/js/reader.js b/internal/webserver/embedded/js/reader.js index 77d16704..2abec5be 100644 --- a/internal/webserver/embedded/js/reader.js +++ b/internal/webserver/embedded/js/reader.js @@ -870,6 +870,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) { @@ -891,10 +894,13 @@ 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 + open(new File([blob], filename, { type: contentType })) + } }) .catch(e => { if (e.message !== 'Authentication required') { diff --git a/internal/webserver/embedded/views/document/reader.html b/internal/webserver/embedded/views/document/reader.html index 4cab5cf5..700753ab 100644 --- a/internal/webserver/embedded/views/document/reader.html +++ b/internal/webserver/embedded/views/document/reader.html @@ -15,6 +15,7 @@ +
diff --git a/internal/webserver/embedded/views/document/upload.html b/internal/webserver/embedded/views/document/upload.html index d5e3233b..1a8265b5 100644 --- a/internal/webserver/embedded/views/document/upload.html +++ b/internal/webserver/embedded/views/document/upload.html @@ -6,7 +6,7 @@

{{t .Lang "Upload document" }}

- +
From dcb4d2f074bceaca44a9c704513e1a5f5b10fce3 Mon Sep 17 00:00:00 2001 From: Sergio Vera Date: Thu, 19 Mar 2026 17:03:33 +0100 Subject: [PATCH 4/9] WIP --- internal/webserver/upload_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/webserver/upload_test.go b/internal/webserver/upload_test.go index 6b6f8348..8c89efda 100644 --- a/internal/webserver/upload_test.go +++ b/internal/webserver/upload_test.go @@ -130,7 +130,7 @@ func TestUpload(t *testing.T) { multipartWriter := multipart.NewWriter(&buf) h := make(textproto.MIMEHeader) - h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, "filename", "file.txt")) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, "filename", "file.epub")) h.Set("Content-Type", "application/epub+zip") part, _ := multipartWriter.CreatePart(h) part.Write([]byte(`sample`)) From 12c7cb083091665cae5dd0ef1de625600094e9a1 Mon Sep 17 00:00:00 2001 From: Sergio Vera Date: Mon, 23 Mar 2026 08:38:27 +0100 Subject: [PATCH 5/9] WIP --- internal/webserver/embedded/views/layout.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/webserver/embedded/views/layout.html b/internal/webserver/embedded/views/layout.html index cc51de23..6521db57 100644 --- a/internal/webserver/embedded/views/layout.html +++ b/internal/webserver/embedded/views/layout.html @@ -8,6 +8,8 @@ {{if .Title}}{{t .Lang .Title}} | {{end}}Coreander + + From 2c099a58170d0b41599eb9ccf4a455ec11ee5c5a Mon Sep 17 00:00:00 2001 From: Sergio Vera Date: Thu, 23 Apr 2026 14:23:32 +0200 Subject: [PATCH 6/9] Added illustrator support --- internal/index/search_test.go | 112 ++++++++++++++++++++++++++++++++++ internal/metadata/cbz.go | 87 +++++++++++++++----------- 2 files changed, 165 insertions(+), 34 deletions(-) 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 index d709df71..1608d072 100644 --- a/internal/metadata/cbz.go +++ b/internal/metadata/cbz.go @@ -20,40 +20,42 @@ import ( 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"` + 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"` - 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"` + 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"` } @@ -91,11 +93,13 @@ func (c CbzReader) Metadata(file string) (Metadata, error) { } authors := []string{""} + var illustrators []string if info != nil { authors = collectComicAuthors(info) if len(authors) == 0 { authors = []string{""} } + illustrators = collectComicIllustrators(info) } description := "" @@ -156,6 +160,7 @@ func (c CbzReader) Metadata(file string) (Metadata, error) { bk = Metadata{ Title: title, Authors: authors, + Illustrators: illustrators, Description: template.HTML(description), Language: lang, Publication: publication, @@ -289,12 +294,27 @@ func readComicInfoFromZip(r *zip.ReadCloser) (*ComicInfo, error) { 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 []string{ - info.Writer, info.Penciller, info.Inker, info.CoverArtist, - info.Colorist, info.Letterer, info.Editor, - } { + for _, s := range fields { combined = append(combined, ParseAuthorList(s)...) } seen := make(map[string]struct{}) @@ -311,4 +331,3 @@ func collectComicAuthors(info *ComicInfo) []string { } return out } - From c23ff3b548ea84eac83593258292cad3c0971058 Mon Sep 17 00:00:00 2001 From: Sergio Vera Date: Mon, 4 May 2026 10:06:29 +0200 Subject: [PATCH 7/9] WIP --- internal/metadata/cbz.go | 82 +++++++++++++------------------------ internal/metadata/common.go | 30 ++++++++++++++ internal/metadata/epub.go | 26 +++--------- internal/metadata/pdf.go | 7 +--- internal/metadata/zip.go | 16 ++++++++ 5 files changed, 83 insertions(+), 78 deletions(-) diff --git a/internal/metadata/cbz.go b/internal/metadata/cbz.go index 1608d072..c83bb3e5 100644 --- a/internal/metadata/cbz.go +++ b/internal/metadata/cbz.go @@ -11,7 +11,6 @@ import ( "strconv" "strings" - "github.com/kovidgoyal/imaging" "github.com/rickb777/date/v2" "github.com/svera/coreander/v4/internal/precisiondate" ) @@ -87,7 +86,7 @@ func (c CbzReader) Metadata(file string) (Metadata, error) { info, _ := readComicInfoFromZip(r) - title := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)) + title := DefaultTitleFromFilename(file) if info != nil && strings.TrimSpace(info.Title) != "" { title = strings.TrimSpace(info.Title) } @@ -95,10 +94,7 @@ func (c CbzReader) Metadata(file string) (Metadata, error) { authors := []string{""} var illustrators []string if info != nil { - authors = collectComicAuthors(info) - if len(authors) == 0 { - authors = []string{""} - } + authors = AuthorsOrEmptySlot(collectComicAuthors(info)) illustrators = collectComicIllustrators(info) } @@ -130,11 +126,7 @@ func (c CbzReader) Metadata(file string) (Metadata, error) { series := "" if info != nil { series = strings.TrimSpace(info.Series) - if info.Number != "" { - if n, err := strconv.ParseFloat(strings.TrimSpace(info.Number), 64); err == nil { - seriesIndex = n - } - } + seriesIndex = ParseSeriesIndex(info.Number) } pages := float64(len(SortedImageEntriesFromZip(r))) @@ -154,7 +146,7 @@ func (c CbzReader) Metadata(file string) (Metadata, error) { illustrations, err := c.illustrations(file, 0.25) if err != nil { - log.Printf("Cannot count illustrations in %s: %s\n", file, err) + log.Printf("Cannot count illustrations in %s: %v\n", file, err) } bk = Metadata{ @@ -182,36 +174,12 @@ func (c CbzReader) Cover(documentFullPath string, coverMaxWidth int) ([]byte, er defer r.Close() info, _ := readComicInfoFromZip(r) - coverIndex := 0 - if info != nil && info.Pages != nil { - for _, p := range info.Pages.Page { - if strings.EqualFold(strings.TrimSpace(p.Type), "FrontCover") { - coverIndex = p.Image - break - } - } - } - - names := SortedImageEntriesFromZip(r) + coverName, names := cbzCoverImageAndNames(r, info) if len(names) == 0 { return nil, fmt.Errorf("cbz: no image found") } - if coverIndex < 1 || coverIndex > len(names) { - coverIndex = 1 - } - coverName := names[coverIndex-1] - - rc, err := OpenZipEntry(r, coverName) - if err != nil { - return nil, err - } - defer rc.Close() - src, err := imaging.Decode(rc) - if err != nil { - return nil, err - } - return resize(src, coverMaxWidth, nil) + return DecodeResizeZipImageEntry(r, coverName, coverMaxWidth) } // illustrations returns the number of images in the CBZ with size >= minMegapixels (excluding the cover). @@ -223,24 +191,10 @@ func (c CbzReader) illustrations(documentFullPath string, minMegapixels float64) defer r.Close() info, _ := readComicInfoFromZip(r) - coverIndex := 0 - if info != nil && info.Pages != nil { - for _, p := range info.Pages.Page { - if strings.EqualFold(strings.TrimSpace(p.Type), "FrontCover") { - coverIndex = p.Image - break - } - } - } - - names := SortedImageEntriesFromZip(r) + coverName, names := cbzCoverImageAndNames(r, info) if len(names) == 0 { return 0, nil } - if coverIndex < 1 || coverIndex > len(names) { - coverIndex = 1 - } - coverName := names[coverIndex-1] var count int for _, name := range names { @@ -258,6 +212,28 @@ func (c CbzReader) illustrations(documentFullPath string, minMegapixels float64) 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 { diff --git a/internal/metadata/common.go b/internal/metadata/common.go index 7f85aefa..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 { @@ -47,6 +63,20 @@ 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 { diff --git a/internal/metadata/epub.go b/internal/metadata/epub.go index 520940eb..41357fe6 100644 --- a/internal/metadata/epub.go +++ b/internal/metadata/epub.go @@ -9,11 +9,9 @@ import ( "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" @@ -38,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] } @@ -51,9 +49,7 @@ 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 { @@ -82,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{ @@ -295,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 index d898524f..23f129db 100644 --- a/internal/metadata/zip.go +++ b/internal/metadata/zip.go @@ -11,6 +11,8 @@ import ( "path/filepath" "sort" "strings" + + "github.com/kovidgoyal/imaging" ) // OpenZipEntry opens a file inside a zip by name and returns a ReadCloser. @@ -24,6 +26,20 @@ func OpenZipEntry(r *zip.ReadCloser, name string) (io.ReadCloser, error) { 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) From b7a089b34abd87f65a26b131c3cb1ed484c2c2b1 Mon Sep 17 00:00:00 2001 From: Sergio Vera Date: Mon, 4 May 2026 10:31:24 +0200 Subject: [PATCH 8/9] WIP --- internal/metadata/cbz.go | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/internal/metadata/cbz.go b/internal/metadata/cbz.go index c83bb3e5..46a381fd 100644 --- a/internal/metadata/cbz.go +++ b/internal/metadata/cbz.go @@ -72,9 +72,6 @@ type ComicPageInfo struct { type CbzReader struct{} -// comicInfoFilenames are possible names for the metadata file (case-insensitive match). -var comicInfoFilenames = []string{"ComicInfo.xml", "comicinfo.xml", "COMICINFO.XML"} - func (c CbzReader) Metadata(file string) (Metadata, error) { bk := Metadata{} @@ -238,13 +235,8 @@ func readComicInfoFromZip(r *zip.ReadCloser) (*ComicInfo, error) { var entryName string for _, f := range r.File { base := filepath.Base(f.Name) - for _, want := range comicInfoFilenames { - if base == want { - entryName = f.Name - break - } - } - if entryName != "" { + if strings.EqualFold(base, "comicinfo.xml") { + entryName = f.Name break } } From a80fb32f4a31a623fe4870ae46d1dab36221a824 Mon Sep 17 00:00:00 2001 From: Sergio Vera Date: Mon, 4 May 2026 14:38:30 +0200 Subject: [PATCH 9/9] WIP --- internal/metadata/cbz.go | 2 + internal/metadata/zip.go | 2 + .../webserver/controller/document/reader.go | 12 ++-- internal/webserver/embedded/css/reader.css | 6 ++ internal/webserver/embedded/js/reader.js | 69 ++++++++++++++++--- .../webserver/embedded/translations/de.yml | 1 + .../webserver/embedded/translations/es.yml | 1 + .../webserver/embedded/translations/fr.yml | 1 + .../webserver/embedded/translations/ru.yml | 1 + .../embedded/views/document/reader.html | 6 +- 10 files changed, 85 insertions(+), 16 deletions(-) diff --git a/internal/metadata/cbz.go b/internal/metadata/cbz.go index 46a381fd..e8ab4763 100644 --- a/internal/metadata/cbz.go +++ b/internal/metadata/cbz.go @@ -126,6 +126,8 @@ func (c CbzReader) Metadata(file string) (Metadata, error) { 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) diff --git a/internal/metadata/zip.go b/internal/metadata/zip.go index 23f129db..0ee69454 100644 --- a/internal/metadata/zip.go +++ b/internal/metadata/zip.go @@ -55,9 +55,11 @@ func ImageMegapixelsFromZip(r *zip.ReadCloser, name string) (float64, error) { } // 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. diff --git a/internal/webserver/controller/document/reader.go b/internal/webserver/controller/document/reader.go index d899c706..5ae2efe3 100644 --- a/internal/webserver/controller/document/reader.go +++ b/internal/webserver/controller/document/reader.go @@ -39,10 +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, - "Format": document.Format, + "Title": title, + "IndexedTitle": document.Title, + "IndexedAuthors": authors, + "Author": authors, + "Description": document.Description, + "Slug": document.Slug, + "Format": document.Format, }) } 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 8fd9ed3c..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) @@ -913,14 +963,13 @@ if (url) fetch(url) .then(({ blob, contentType }) => { if (blob) { const filename = slug + ext - open(new File([blob], filename, { type: contentType })) + 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/reader.html b/internal/webserver/embedded/views/document/reader.html index 18a5c4de..4514745d 100644 --- a/internal/webserver/embedded/views/document/reader.html +++ b/internal/webserver/embedded/views/document/reader.html @@ -16,6 +16,8 @@ + +
@@ -25,6 +27,7 @@
+ @@ -141,7 +144,8 @@

"session_expired_reading": {{t .Lang "Session expired. Your reading position is still saved locally."}}, "position_updated_from_server": {{t .Lang "Reading position updated from another device."}}, "not_logged_in_reading": {{t .Lang "You are not logged in. Your reading position is saved locally only."}}, - "position_reset_reading": {{t .Lang "Your saved reading position was reset because this document changed."}} + "position_reset_reading": {{t .Lang "Your saved reading position was reset because this document changed."}}, + "empty_comic_archive": {{t .Lang "No supported image files in archive"}} }}