diff --git a/MANIFEST.in b/MANIFEST.in
index d0a413a0..ace499b4 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1 +1,2 @@
-recursive-include ./zeeguu/core/sql/queries/ *.sql
\ No newline at end of file
+recursive-include ./zeeguu/core/sql/queries/ *.sql
+recursive-include zeeguu/assets *.ttf *.png
diff --git a/zeeguu/api/endpoints/audio_lessons.py b/zeeguu/api/endpoints/audio_lessons.py
index a0fd0f3b..4b9fb2fb 100644
--- a/zeeguu/api/endpoints/audio_lessons.py
+++ b/zeeguu/api/endpoints/audio_lessons.py
@@ -1,8 +1,11 @@
import flask
+from markupsafe import escape
from zeeguu.api.utils.background import run_in_background
from zeeguu.api.utils.json_result import json_result
from zeeguu.api.utils.route_wrappers import cross_domain, requires_session
+from zeeguu.config import ZEEGUU_DATA_FOLDER
+from zeeguu.core.audio_lessons import og_image
from zeeguu.core.audio_lessons.daily_lesson_generator import DailyLessonGenerator
from zeeguu.core.audio_lessons.script_generator import VALID_LESSON_TYPES, THREE_WORDS_LESSON
from zeeguu.core.audio_lessons.suggestion_validator import validate_suggestion
@@ -10,6 +13,12 @@
from zeeguu.logging import log
from . import api
+# Production origins for shared-lesson link previews. The share page lives on
+# the web app; the OG card image is served by this API. (The frontend likewise
+# hardcodes zeeguu.org.)
+SHARE_WEB_ORIGIN = "https://zeeguu.org"
+SHARE_API_ORIGIN = "https://api.zeeguu.org"
+
def _generate_lesson_in_background(user_id, preparation):
"""
@@ -183,6 +192,104 @@ def create_lesson_share_link(lesson_id):
return json_result({"share_uuid": lesson.share_uuid})
+def _preview_texts(view):
+ """Build the (page/OG title, description) for a shared lesson's link preview."""
+ title = (view.get("title") or "Audio lesson").strip()
+ language = view.get("language_name")
+ cefr = view.get("cefr_level")
+ lesson_type = view.get("lesson_type")
+ words = [w.get("origin") for w in (view.get("words") or []) if w.get("origin")]
+
+ seconds = view.get("duration_seconds")
+ minutes = f"{max(1, round(seconds / 60))}-min" if seconds else None
+
+ # OG title: the lesson name, with light context appended.
+ context = f"{language} audio lesson" if language else "audio lesson"
+ og_title = f"{title} — {context}"
+
+ # Description.
+ lead = " ".join(p for p in [minutes, language] if p) # e.g. "4-min German"
+ opening = f"A {lead} audio lesson" if lead else "An audio lesson"
+ if cefr:
+ opening += f" at level {cefr}"
+ if lesson_type in ("topic", "situation"):
+ body = "Listen to a real conversation and pick up the words."
+ elif words:
+ body = f"Learn {len(words)} new words by listening."
+ else:
+ body = "Learn by listening."
+ description = f"{opening}. {body} With Zeeguu."
+ return og_title, description
+
+
+def _preview_html(view, share_uuid):
+ og_title, description = _preview_texts(view)
+ page_url = f"{SHARE_WEB_ORIGIN}/shared-lesson/{share_uuid}"
+ image_url = f"{SHARE_API_ORIGIN}/shared_lesson_image/{share_uuid}.png"
+ title = escape(og_title)
+ desc = escape(description)
+ return f"""
+
+
+
+
+{title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Opening this audio lesson on Zeeguu… Continue .
+
+
+"""
+
+
+@api.route("/shared_lesson_preview/", methods=["GET"])
+@cross_domain
+def shared_lesson_preview(share_uuid):
+ """Crawler-facing HTML with Open Graph tags for a shared lesson link.
+
+ nginx routes social-scraper user-agents hitting zeeguu.org/shared-lesson/
+ here; real users get the SPA. A missing lesson (or a human who lands here) is
+ redirected to the app."""
+ view = DailyLessonGenerator().get_shared_lesson_view(share_uuid)
+ if view.get("error"):
+ return flask.redirect(f"{SHARE_WEB_ORIGIN}/shared-lesson/{share_uuid}", code=302)
+ response = flask.Response(_preview_html(view, share_uuid), mimetype="text/html")
+ response.headers["Cache-Control"] = "public, max-age=3600"
+ return response
+
+
+@api.route("/shared_lesson_image/.png", methods=["GET"])
+@cross_domain
+def shared_lesson_image(share_uuid):
+ """1200x630 Open Graph card for a shared lesson, rendered once and cached."""
+ view = DailyLessonGenerator().get_shared_lesson_view(share_uuid)
+ if view.get("error"):
+ return flask.Response("Not found", status=404)
+ path = og_image.ensure_cached_card(view, ZEEGUU_DATA_FOLDER)
+ if not path:
+ return flask.Response("Not found", status=404)
+ response = flask.send_file(path, mimetype="image/png")
+ response.headers["Cache-Control"] = "public, max-age=86400"
+ return response
+
+
@api.route("/get_todays_lesson", methods=["GET"])
@cross_domain
@requires_session
diff --git a/zeeguu/assets/fonts/Montserrat-Bold.ttf b/zeeguu/assets/fonts/Montserrat-Bold.ttf
new file mode 100644
index 00000000..3bfd79b6
Binary files /dev/null and b/zeeguu/assets/fonts/Montserrat-Bold.ttf differ
diff --git a/zeeguu/assets/fonts/Montserrat-ExtraBold.ttf b/zeeguu/assets/fonts/Montserrat-ExtraBold.ttf
new file mode 100644
index 00000000..d0291d1c
Binary files /dev/null and b/zeeguu/assets/fonts/Montserrat-ExtraBold.ttf differ
diff --git a/zeeguu/assets/fonts/Montserrat-Regular.ttf b/zeeguu/assets/fonts/Montserrat-Regular.ttf
new file mode 100644
index 00000000..5da852a3
Binary files /dev/null and b/zeeguu/assets/fonts/Montserrat-Regular.ttf differ
diff --git a/zeeguu/assets/fonts/Montserrat-SemiBold.ttf b/zeeguu/assets/fonts/Montserrat-SemiBold.ttf
new file mode 100644
index 00000000..9fe924c0
Binary files /dev/null and b/zeeguu/assets/fonts/Montserrat-SemiBold.ttf differ
diff --git a/zeeguu/assets/images/zeeguu-logo.png b/zeeguu/assets/images/zeeguu-logo.png
new file mode 100644
index 00000000..a07d6e68
Binary files /dev/null and b/zeeguu/assets/images/zeeguu-logo.png differ
diff --git a/zeeguu/core/audio_lessons/daily_lesson_generator.py b/zeeguu/core/audio_lessons/daily_lesson_generator.py
index 0b1b9c93..7008af4d 100644
--- a/zeeguu/core/audio_lessons/daily_lesson_generator.py
+++ b/zeeguu/core/audio_lessons/daily_lesson_generator.py
@@ -612,6 +612,7 @@ def _format_lesson_response(self, lesson):
"lesson_type": lesson.lesson_type,
"language_code": lesson.language.code if lesson.language else None,
"title": lesson.display_title(),
+ "cefr_level": lesson.cefr_level(),
}
def get_shared_lesson_view(self, share_uuid):
@@ -632,7 +633,9 @@ def get_shared_lesson_view(self, share_uuid):
"canonical_suggestion": lesson.canonical_suggestion,
"lesson_type": lesson.lesson_type,
"language_code": lesson.language.code if lesson.language else None,
+ "language_name": lesson.language.name if lesson.language else None,
"title": lesson.display_title(),
+ "cefr_level": lesson.cefr_level(),
}
def get_daily_lesson_for_user(self, user, lesson_id=None):
@@ -832,6 +835,7 @@ def get_past_daily_lessons_for_user(
lesson_data = {
"lesson_id": lesson.id,
"title": lesson.display_title(),
+ "cefr_level": lesson.cefr_level(),
"audio_url": (
f"/audio/daily_lessons/{lesson.id}.mp3"
if audio_exists
diff --git a/zeeguu/core/audio_lessons/og_image.py b/zeeguu/core/audio_lessons/og_image.py
new file mode 100644
index 00000000..9e4ab2e1
--- /dev/null
+++ b/zeeguu/core/audio_lessons/og_image.py
@@ -0,0 +1,236 @@
+"""
+Open Graph preview card for a shared audio lesson.
+
+Social scrapers (WhatsApp, iMessage, Slack, Facebook, ...) don't run JS, so a
+shared `/shared-lesson/` link otherwise falls back to the generic site
+preview. This renders a 1200x630 PNG card from a lesson's public view dict
+(the same data returned by DailyLessonGenerator.get_shared_lesson_view) so the
+preview shows the actual lesson: title, language, duration, CEFR level, words.
+
+Pure function of the view dict + brand assets — no DB access — so it's trivial
+to unit-test and to render samples. Cards are immutable per lesson, so callers
+cache the PNG on disk (see ensure_cached_card).
+"""
+
+import os
+from functools import lru_cache
+
+import zeeguu
+from PIL import Image, ImageDraw, ImageFont
+
+# 1.91:1 — the ratio every major scraper crops to.
+WIDTH, HEIGHT = 1200, 630
+MARGIN = 70
+
+_ASSETS = os.path.join(os.path.dirname(zeeguu.__file__), "assets")
+_FONTS = os.path.join(_ASSETS, "fonts")
+_LOGO_PATH = os.path.join(_ASSETS, "images", "zeeguu-logo.png")
+
+# Warm Zeeguu palette (mirrors web/src/components/colors.js).
+BG_TOP = (255, 248, 230) # cream
+BG_BOTTOM = (255, 230, 188) # light amber
+INK = (74, 50, 8) # dark brown — primary text
+INK_SOFT = (140, 110, 60) # muted brown — secondary text
+ORANGE = (255, 168, 40) # zeeguuOrange-ish, brand accent
+ORANGE_DEEP = (196, 120, 24) # darker orange for the wordmark
+CHIP_BORDER = (224, 190, 130)
+WHITE = (255, 255, 255)
+
+
+@lru_cache(maxsize=None)
+def _font(weight, size):
+ return ImageFont.truetype(os.path.join(_FONTS, f"Montserrat-{weight}.ttf"), size)
+
+
+def _text_w(draw, text, font):
+ return draw.textlength(text, font=font)
+
+
+def _wrap(draw, text, font, max_width, max_lines):
+ """Greedy word wrap; the last line gets an ellipsis if text is truncated."""
+ words = text.split()
+ lines, current = [], ""
+ for word in words:
+ candidate = f"{current} {word}".strip()
+ if _text_w(draw, candidate, font) <= max_width or not current:
+ current = candidate
+ else:
+ lines.append(current)
+ current = word
+ if len(lines) == max_lines:
+ break
+ if len(lines) < max_lines and current:
+ lines.append(current)
+
+ truncated = len(lines) == max_lines and (
+ current != lines[-1] or len(" ".join(words)) > len(" ".join(lines))
+ )
+ if truncated:
+ last = lines[-1]
+ while last and _text_w(draw, last + "…", font) > max_width:
+ last = last[:-1].rstrip()
+ lines[-1] = last + "…"
+ return lines
+
+
+def _rounded(draw, box, radius, **kwargs):
+ draw.rounded_rectangle(box, radius=radius, **kwargs)
+
+
+def _draw_chip(draw, x, y, text, font, *, filled):
+ """Draws a pill at (x, y); returns the x just past its right edge."""
+ pad_x, pad_y = 22, 12
+ tw = _text_w(draw, text, font)
+ ascent, descent = font.getmetrics()
+ th = ascent + descent
+ box = (x, y, x + tw + 2 * pad_x, y + th + 2 * pad_y)
+ if filled:
+ _rounded(draw, box, radius=(th + 2 * pad_y) // 2, fill=ORANGE)
+ draw.text((x + pad_x, y + pad_y), text, font=font, fill=WHITE)
+ else:
+ _rounded(draw, box, radius=(th + 2 * pad_y) // 2,
+ fill=WHITE, outline=CHIP_BORDER, width=2)
+ draw.text((x + pad_x, y + pad_y), text, font=font, fill=INK_SOFT)
+ return box[2]
+
+
+def _play_chip(draw, x, y, text, font):
+ """A duration chip with a little play triangle in front of the text."""
+ pad_x, pad_y, gap = 22, 12, 14
+ tri = 22
+ tw = _text_w(draw, text, font)
+ ascent, descent = font.getmetrics()
+ th = ascent + descent
+ box = (x, y, x + pad_x + tri + gap + tw + pad_x, y + th + 2 * pad_y)
+ _rounded(draw, box, radius=(th + 2 * pad_y) // 2,
+ fill=WHITE, outline=CHIP_BORDER, width=2)
+ cy = y + (th + 2 * pad_y) / 2
+ tx = x + pad_x
+ draw.polygon(
+ [(tx, cy - tri / 2), (tx, cy + tri / 2), (tx + tri * 0.9, cy)],
+ fill=ORANGE_DEEP,
+ )
+ draw.text((tx + tri + gap, y + pad_y), text, font=font, fill=INK_SOFT)
+ return box[2]
+
+
+def _format_duration(seconds):
+ if not seconds:
+ return None
+ minutes = max(1, round(seconds / 60))
+ return f"{minutes} min"
+
+
+def _lesson_type_label(lesson_type):
+ return {
+ "three_words_lesson": "Vocabulary",
+ "topic": "Conversation",
+ "situation": "Conversation",
+ }.get(lesson_type)
+
+
+def render_card(view):
+ """Render the OG card for a lesson view dict and return a PIL Image."""
+ img = Image.new("RGB", (WIDTH, HEIGHT), BG_TOP)
+ # Vertical cream→amber gradient.
+ top, bottom = BG_TOP, BG_BOTTOM
+ for y in range(HEIGHT):
+ t = y / HEIGHT
+ img.paste(
+ tuple(round(top[i] + (bottom[i] - top[i]) * t) for i in range(3)),
+ (0, y, WIDTH, y + 1),
+ )
+ draw = ImageDraw.Draw(img)
+
+ # Left brand accent bar.
+ draw.rectangle((0, 0, 12, HEIGHT), fill=ORANGE)
+
+ # --- Header: logo + wordmark (left), language pill (right) ---
+ logo_size = 92
+ try:
+ logo = Image.open(_LOGO_PATH).convert("RGBA").resize((logo_size, logo_size))
+ img.paste(logo, (MARGIN, MARGIN), logo)
+ except OSError:
+ logo = None
+ wordmark_font = _font("ExtraBold", 44)
+ wm_x = MARGIN + logo_size + 24
+ wm_y = MARGIN + (logo_size - sum(wordmark_font.getmetrics())) // 2
+ draw.text((wm_x, wm_y), "Zeeguu", font=wordmark_font, fill=ORANGE_DEEP)
+
+ language = view.get("language_name")
+ if language:
+ lang_font = _font("Bold", 32)
+ lw = _text_w(draw, language, lang_font)
+ _draw_chip(draw, WIDTH - MARGIN - lw - 44, MARGIN + 20, language,
+ lang_font, filled=True)
+
+ # --- Title (vertically anchored in the middle band) ---
+ title = (view.get("title") or "Audio lesson").strip()
+ title_font = _font("Bold", 62)
+ title_lines = _wrap(draw, title, title_font, WIDTH - 2 * MARGIN, max_lines=2)
+ line_h = sum(title_font.getmetrics()) + 12
+ title_y = MARGIN + logo_size + 60
+ for i, line in enumerate(title_lines):
+ draw.text((MARGIN, title_y + i * line_h), line, font=title_font, fill=INK)
+
+ # --- Meta chips: duration · CEFR · type ---
+ chips_y = title_y + len(title_lines) * line_h + 36
+ chip_font = _font("SemiBold", 30)
+ cursor = MARGIN
+ duration = _format_duration(view.get("duration_seconds"))
+ if duration:
+ cursor = _play_chip(draw, cursor, chips_y, duration, chip_font) + 16
+ cefr = view.get("cefr_level")
+ if cefr:
+ cursor = _draw_chip(draw, cursor, chips_y, cefr, chip_font, filled=True) + 16
+ type_label = _lesson_type_label(view.get("lesson_type"))
+ if type_label:
+ cursor = _draw_chip(draw, cursor, chips_y, type_label, chip_font, filled=False)
+
+ # --- Footer: the words being learned, else a tagline ---
+ words = [w.get("origin") for w in (view.get("words") or []) if w.get("origin")]
+ footer_font = _font("SemiBold", 30)
+ if words:
+ footer = " · ".join(words[:5])
+ footer = _wrap(draw, footer, footer_font, WIDTH - 2 * MARGIN, max_lines=1)[0]
+ else:
+ footer = "Listen, and pick up the words"
+ draw.text((MARGIN, HEIGHT - MARGIN - sum(footer_font.getmetrics())),
+ footer, font=footer_font, fill=INK_SOFT)
+
+ # Bottom-right domain.
+ dom_font = _font("Bold", 28)
+ dom = "zeeguu.org"
+ draw.text((WIDTH - MARGIN - _text_w(draw, dom, dom_font),
+ HEIGHT - MARGIN - sum(dom_font.getmetrics())),
+ dom, font=dom_font, fill=ORANGE_DEEP)
+
+ return img
+
+
+def card_png_bytes(view):
+ from io import BytesIO
+
+ buffer = BytesIO()
+ render_card(view).save(buffer, format="PNG")
+ return buffer.getvalue()
+
+
+def cached_card_path(data_folder, lesson_id):
+ return os.path.join(data_folder, "og-images", "shared-lessons", f"{lesson_id}.png")
+
+
+def ensure_cached_card(view, data_folder):
+ """Render (once) and cache the card PNG for this lesson; return its path.
+
+ Lessons are immutable, so an existing file is reused. Returns None if the
+ view has no lesson_id to key the cache on.
+ """
+ lesson_id = view.get("lesson_id")
+ if not lesson_id:
+ return None
+ path = cached_card_path(data_folder, lesson_id)
+ if not os.path.exists(path):
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ render_card(view).save(path, format="PNG")
+ return path
diff --git a/zeeguu/core/model/daily_audio_lesson.py b/zeeguu/core/model/daily_audio_lesson.py
index bd7a4bed..0e67cc02 100644
--- a/zeeguu/core/model/daily_audio_lesson.py
+++ b/zeeguu/core/model/daily_audio_lesson.py
@@ -187,6 +187,34 @@ def display_title(self):
]
return ", ".join(origins) if origins else None
+ def cefr_level(self):
+ """
+ CEFR level this lesson was generated for (e.g. "A1", "B2"), or None.
+
+ Like display_title(), this is the single source of truth used by every
+ lesson response. The level isn't stored on the lesson itself; it lives
+ on each segment's content (AudioLessonDialogue / AudioLessonMeaning,
+ column difficulty_level). Dialogue lessons (topic/situation) have a
+ single segment, so it's unambiguous. Word lessons have one segment per
+ word, and those can disagree: a meaning's audio row is cached and reused
+ across users, so a word may carry the level it was first generated at
+ rather than this user's level. We therefore return the most common level
+ across segments, which tracks the user's generation level (ties resolve
+ to the first segment encountered).
+ """
+ from collections import Counter
+
+ levels = []
+ for segment in self.segments:
+ if segment.segment_type == "dialogue_lesson" and segment.audio_lesson_dialogue:
+ levels.append(segment.audio_lesson_dialogue.difficulty_level)
+ elif segment.segment_type == "meaning_lesson" and segment.audio_lesson_meaning:
+ levels.append(segment.audio_lesson_meaning.difficulty_level)
+ levels = [level for level in levels if level]
+ if not levels:
+ return None
+ return Counter(levels).most_common(1)[0][0]
+
def ensure_share_uuid(self):
if not self.share_uuid:
self.share_uuid = str(uuid.uuid4())