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())