Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
recursive-include ./zeeguu/core/sql/queries/ *.sql
recursive-include ./zeeguu/core/sql/queries/ *.sql
recursive-include zeeguu/assets *.ttf *.png
107 changes: 107 additions & 0 deletions zeeguu/api/endpoints/audio_lessons.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
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
from zeeguu.core.model import db, User, UserWord, AudioLessonGenerationProgress, DailyAudioLesson
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):
"""
Expand Down Expand Up @@ -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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<meta name="description" content="{desc}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Zeeguu">
<meta property="og:title" content="{title}">
<meta property="og:description" content="{desc}">
<meta property="og:url" content="{page_url}">
<meta property="og:image" content="{image_url}">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{title}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{title}">
<meta name="twitter:description" content="{desc}">
<meta name="twitter:image" content="{image_url}">
<link rel="canonical" href="{page_url}">
<meta http-equiv="refresh" content="0; url={page_url}">
</head>
<body>
<p>Opening this audio lesson on Zeeguu… <a href="{page_url}">Continue</a>.</p>
</body>
</html>
"""


@api.route("/shared_lesson_preview/<string:share_uuid>", 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/<uuid>
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/<string:share_uuid>.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
Expand Down
Binary file added zeeguu/assets/fonts/Montserrat-Bold.ttf
Binary file not shown.
Binary file added zeeguu/assets/fonts/Montserrat-ExtraBold.ttf
Binary file not shown.
Binary file added zeeguu/assets/fonts/Montserrat-Regular.ttf
Binary file not shown.
Binary file added zeeguu/assets/fonts/Montserrat-SemiBold.ttf
Binary file not shown.
Binary file added zeeguu/assets/images/zeeguu-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions zeeguu/core/audio_lessons/daily_lesson_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down
236 changes: 236 additions & 0 deletions zeeguu/core/audio_lessons/og_image.py
Original file line number Diff line number Diff line change
@@ -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/<uuid>` 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
Loading
Loading