diff --git a/src/ComicInfoBuilder.py b/src/ComicInfoBuilder.py
index d3a28ce..759946b 100644
--- a/src/ComicInfoBuilder.py
+++ b/src/ComicInfoBuilder.py
@@ -105,6 +105,42 @@ def _format_term(value: str) -> str:
return str(value).replace("_", " ").strip().title() if value else ""
+# Markdown backslash escape sequences recognised by CommonMark (e.g. \- → -)
+_MD_ESCAPE_RE = re.compile(r'\\([\\`*_{}\[\]()\#+\-.!|~])')
+
+
+def _md_to_html(text: str) -> str:
+ """
+ Converts a subset of Markdown (as produced by MangaBaka) to HTML.
+
+ Handles: backslash escapes, [text](url) links, **bold**, *italic*,
+ blank-line paragraph splits, and single-newline line breaks.
+ Produces compact HTML with no raw newline characters — Kavita renders
+ every bare \\n as a
, so all line-breaks must be explicit.
+ """
+ if not text:
+ return ""
+ # Unescape Markdown backslash sequences (\- → -, \* → *, …)
+ text = _MD_ESCAPE_RE.sub(r'\1', text)
+ # [text](url) → text
+ text = re.sub(
+ r'\[([^\]]+)\]\(([^)]+)\)',
+ lambda m: f'{m.group(1)}',
+ text,
+ )
+ # **bold** before *italic* so ** is not mistaken for two *
+ text = re.sub(r'\*\*(.+?)\*\*', r'\1', text, flags=re.DOTALL)
+ text = re.sub(r'\*(.+?)\*', r'\1', text, flags=re.DOTALL)
+ # Split on blank lines →
blocks; single newlines →
+ parts: list[str] = []
+ for para in re.split(r'\n{2,}', text.strip()):
+ para = para.strip()
+ if para:
+ parts.append(f"
{para.replace(chr(10), '
')}
")
+ return "".join(parts) # no raw \n — every \n becomes a
in Kavita
+
+
+
# --------------------------------------------------------------------------
# Main class
# --------------------------------------------------------------------------
@@ -320,8 +356,7 @@ class ComicInfoBuilder:
timeout=self.request_timeout)
resp.raise_for_status()
data = resp.json().get("data") or []
-
- return data[0] # I trust the API's relevance sorting and just take the first result, if any
+ return data[0] if data else None
def _fetch_series_by_id(self, series_id) -> dict:
url = f"{self.api_base_url}/series/{series_id}"
@@ -665,60 +700,69 @@ class ComicInfoBuilder:
def _build_summary(self, md: dict, sd: dict,
mal_stats: "dict | None") -> "str | None":
"""
- Builds the content.
- Appends a MAL statistics table (if available) after the description.
+ Builds as HTML (Kavita supports HTML in this field).
+
+ Structure (top → bottom):
+ 1. MAL statistics — HTML link + table with padded columns
+ 2. Series description — Markdown converted to HTML
+ 3. Alternate titles — HTML table
+
+ IMPORTANT: no raw \\n characters anywhere in the output — Kavita
+ renders every bare newline as a
. Sections are separated with
+ an explicit
instead.
"""
- desc = (md.get("description") or sd.get("Summary") or "").strip()
+ # Inline style applied to label cells for readable column spacing.
+ _TD = 'style="padding-right:1.5em"'
- if not mal_stats:
- return desc or None
-
- as_of = mal_stats.get("as_of", "")
- score = mal_stats.get("score")
- rank = mal_stats.get("rank")
- scored = mal_stats.get("scored_by")
- pop = mal_stats.get("popularity")
- members = mal_stats.get("members")
- favs = mal_stats.get("favorites")
- url = mal_stats.get("url", "")
-
- rows: list[str] = []
- if score is not None: rows.append(f"Score\t{score}")
- if rank is not None: rows.append(f"Ranked\t#{rank}")
- if scored is not None: rows.append(f"Scored by\t{scored:,} users")
- if pop is not None: rows.append(f"Popularity\t#{pop}")
- if members is not None: rows.append(f"Members\t{members:,}")
- if favs is not None: rows.append(f"Favorites\t{favs:,}")
-
- if not rows:
- return desc or None
-
- table = f"[MyAnimeList]({url}) stats as of {as_of}:\n" + "\n".join(rows)
- return f"{desc}\n\n{table}" if desc else table
-
- def _build_notes(self, md: dict) -> "str | None":
- """
- Builds the field containing alternate titles and the
- MangaBaka metadata source URL.
- """
parts: list[str] = []
+ # 1. MAL stats table (top) ----------------------------------------
+ if mal_stats:
+ url = mal_stats.get("url", "")
+ as_of = mal_stats.get("as_of", "")
+ score = mal_stats.get("score")
+ rank = mal_stats.get("rank")
+ scored = mal_stats.get("scored_by")
+ pop = mal_stats.get("popularity")
+ members = mal_stats.get("members")
+ favs = mal_stats.get("favorites")
+
+ rows: list[str] = []
+ if score is not None: rows.append(f"| Score | {score} |
")
+ if rank is not None: rows.append(f"| Ranked | #{rank} |
")
+ if scored is not None: rows.append(f"| Scored by | {scored:,} users |
")
+ if pop is not None: rows.append(f"| Popularity | #{pop} |
")
+ if members is not None: rows.append(f"| Members | {members:,} |
")
+ if favs is not None: rows.append(f"| Favorites | {favs:,} |
")
+
+ if rows:
+ link = f'MyAnimeList' if url else "MyAnimeList"
+ parts.append(f"{link} stats as of {as_of}:
")
+
+ # 2. Description — Markdown → HTML (middle) -----------------------
+ desc_raw = (md.get("description") or sd.get("Summary") or "").strip()
+ if desc_raw:
+ parts.append(_md_to_html(desc_raw))
+
+ # 3. Alternate titles table (bottom) ------------------------------
alt = self._collect_alt_titles(md)
if alt:
- label_map = {"en": "EN", "de": "DE",
- "romaji": "Romaji", "jp": "JP (kanji)"}
- lines = []
+ label_map = {"en": "EN", "de": "DE", "romaji": "Romaji", "jp": "JP (Kanji)"}
+ alt_rows: list[str] = []
for code in ("en", "de", "romaji", "jp"):
if code in alt:
- lines.append(f"• {label_map[code]}: {alt[code]}")
- if lines:
- parts.append("Alternate titles:\n" + "\n".join(lines))
+ alt_rows.append(
+ f"| {label_map[code]} | {alt[code]} |
"
+ )
+ if alt_rows:
+ parts.append(f"")
+ return "
".join(parts) if parts else None
+
+ def _build_notes(self, md: dict) -> "str | None":
+ """Builds the field with the MangaBaka metadata source URL."""
series_id = str(md.get("id") or "")
- if series_id:
- parts.append(f"Metadata source: https://mangabaka.org/{series_id}")
-
- return "\n\n".join(parts) if parts else None
+ return f"Metadata source: https://mangabaka.org/{series_id}" if series_id else None
# ======================================================================
# Static helpers