"""Render a copy-ready holiday announcement from calendar data. HR posts a 放假通知 to the all-hands Feishu group before every long holiday. The dates, the day count and the 补班 line all already exist in the holiday calendar, so typing them by hand is both busywork and a place for the announcement to disagree with the calendar employees can see. This module renders that announcement deterministically from a :class:`~app.domains.hr.holiday_service.HolidayGroup`. Helix never sends it: the output is text HR copies into whichever channel they choose, which keeps the platform's "no new automated pushes to employees" rule intact and leaves the final wording under human control. Everything except the safety tips is derived. The closing line ships as a seasonal default that HR overwrites in the console (PR-4 adds an AI-written alternative), because that sentence is the one part of the notice whose whole job is to not read like a template. """ from __future__ import annotations from dataclasses import dataclass from datetime import date from typing import NotRequired, TypedDict from app.domains.hr.holiday_service import HolidayGroup # Language modes. ZH = "zh" EN = "en" BILINGUAL = "bilingual" LANGUAGES = (ZH, EN, BILINGUAL) _ZH_WEEKDAYS_FULL = ("星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日") _ZH_WEEKDAYS_SHORT = ("周一", "周二", "周三", "周四", "周五", "周六", "周日") _EN_WEEKDAYS = ( "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday", ) _EN_MONTHS = ( "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ) # The office-safety reminders HR includes in every notice. Fixed text, not # derived — they are a company practice, not calendar data. _TIPS_ZH: tuple[tuple[str, str], ...] = ( ("保管财物", "请妥善保管个人贵重物品,办公笔记本请务必随身携带;"), ("检查电源", "离岗前请确认关闭显示器、主机、插线板等所有设备电源,杜绝安全隐患;"), ( "锁闭门窗 & 关闭电器", "放假期间如需进出办公室,请随手关门,离开时确认灯、门窗、空调等设施设备处于关闭状态;", ), ("出行安全", "假期出行,请注意人身与财产安全"), ) _TIPS_EN: tuple[tuple[str, str], ...] = ( ( "Secure your belongings", "Please take valuables with you, and be sure to bring your laptop home;", ), ( "Check the power", "Before you leave, switch off monitors, desktops, power strips and every " "other device;", ), ( "Lock up & switch off", "If you need to enter the office during the holiday, please close the door " "behind you, and on the way out check that lights, windows, doors and air " "conditioning are all off;", ), ( "Travel safely", "If you are travelling over the break, please look after yourself and your " "belongings", ), ) # A LITERAL bullet character, never a Markdown "* ". The notice is copied out # of the console and pasted straight into a Feishu group or an email, neither # of which renders Markdown on paste — so "* " arrives as a visible asterisk # and the result does not match what HR has always sent by hand. Same reason # there is no "**bold**" anywhere in here: it would paste as asterisks too. _BULLET = "\u2022 " _TIPS_HEADING_ZH = "「温馨提示」" _TIPS_HEADING_EN = "Reminders" # Seasonal closing lines — the default HR starts from, one per month so a # mid-autumn notice does not close on a spring sentence. Overwritable per notice. _CLOSING_ZH: dict[int, str] = { 1: "新年伊始,万象更新。愿这几天的休息,为一整年攒足心力,节后见!", 2: "爆竹声里又一年,愿你与家人围坐灯下,把这一年的辛苦都换成团圆,节后见!", 3: "春风渐暖,草木初醒。趁着好天气出门走走,把冬天留下的倦意抖一抖,节后见!", 4: "四月人间正好,不妨慢一点。愿你在花开的日子里,把自己也照顾好,节后见!", 5: "五月初始,夏意未满,趁微风正好,让身心小憩。致敬每一份辛勤付出,愿大家假期愉快,节后见!", 6: "初夏晴长,蝉声渐起。愿这段假期像一杯凉茶,解一解连日的忙碌,节后见!", 7: "盛夏正当时,愿你有树荫、有凉风,也有一段不被打扰的空闲,节后见!", 8: "八月尚有余热,日子也该有余裕。放下手边的事,好好休息几天,节后见!", 9: "月圆人共,秋气初新。愿你与想见的人吃上一顿饭,把牵挂都说出口,节后见!", 10: "金秋十月,山河明净。愿这几天的远行或闲居,都让你觉得值得,节后见!", 11: "秋深叶落,正宜静养。愿你在渐凉的天气里,慢下来喘口气,节后见!", 12: "岁末将至,敬颂冬安。感谢这一年的同行,愿你休息得踏实,来年再见!", } _CLOSING_EN: dict[int, str] = { 1: "A new year, a fresh start. May these days off store up the energy for the " "year ahead — see you when we are back!", 2: "Wishing you a warm reunion with the people who matter most. See you when we " "are back!", 3: "Spring is finding its feet. Get outside if you can, and shake off the last " "of the winter — see you when we are back!", 4: "April is a good month to slow down. Look after yourself while everything is " "in bloom — see you when we are back!", 5: "Early May, before the summer sets in, with just enough breeze for a proper " "rest. Thank you for all your hard work — enjoy the break, and see you when " "we are back!", 6: "Long bright days and the first of the cicadas. May this break be the cold " "drink these busy weeks have earned — see you when we are back!", 7: "High summer. May you find shade, a cool breeze, and some genuinely " "uninterrupted time — see you when we are back!", 8: "There is still some heat in August, and there should be some slack in the " "days too. Put things down and rest properly — see you when we are back!", 9: "A full moon and the first crispness of autumn. Share a meal with the people " "you have been meaning to see — see you when we are back!", 10: "Clear skies and open country. However you spend these days, may they feel " "well spent — see you when we are back!", 11: "Deep autumn, and a good time to be still. Slow down and catch your breath " "in the cooling weather — see you when we are back!", 12: "As the year winds down, keep warm. Thank you for the year we have had " "together — rest well, and see you next year!", } # Leading space: the hand-written notices this mirrors put one between the # closing sentence's punctuation and the heart, and "!❤️" reads cramped. _CLOSING_EMOJI = " ❤️" class NoticeListItem(TypedDict): """One parsed bullet, optionally split into a bold label and body.""" label: str body: str text: str class NoticeBlock(TypedDict): """A rendered notice block shared by the HTML and poster projections.""" kind: str text: NotRequired[str] items: NotRequired[list[NoticeListItem]] @dataclass class NoticeRequest: """Everything that can vary about a rendered notice.""" language: str = ZH include_tips: bool = True include_emoji: bool = True # Overrides the seasonal default. Empty string means "no closing line". closing_zh: str | None = None closing_en: str | None = None # Qualifies the audience when the holiday is not the reader's own country, # e.g. "日本" → "2026 年日本 Marine Day 放假通知". region_label: str | None = None def default_closing(group: HolidayGroup, language: str) -> str: month = group.start.month return (_CLOSING_EN if language == EN else _CLOSING_ZH)[month] def validate_language(value: str | None) -> str: lang = (value or ZH).strip().lower() if lang not in LANGUAGES: raise ValueError(f"language must be one of {list(LANGUAGES)}") return lang def _zh_date(day: date, *, short_weekday: bool = False) -> str: """``2026-05-01`` → ``5 月 1 日(星期五)``. Spaces around the numerals match the house style of HR's existing notices. """ table = _ZH_WEEKDAYS_SHORT if short_weekday else _ZH_WEEKDAYS_FULL return f"{day.month} 月 {day.day} 日({table[day.weekday()]})" def _en_date(day: date, *, with_year: bool = False) -> str: base = f"{_EN_WEEKDAYS[day.weekday()]}, {_EN_MONTHS[day.month - 1]} {day.day}" return f"{base}, {day.year}" if with_year else base def _holiday_name(group: HolidayGroup, language: str) -> str: if language == EN: return group.name_en return group.name_local or group.name_en def _titled_name(group: HolidayGroup, language: str, region_label: str | None) -> str: name = _holiday_name(group, language) if not region_label: return name if language == EN: return f"{region_label} {name}" # An ASCII holiday name needs a space after the Chinese region label # ("日本 Marine Day"), a Chinese one does not ("香港中秋节"). joiner = " " if name[:1].isascii() else "" return f"{region_label}{joiner}{name}" def _zh_pad(text: str) -> str: """Space out a Chinese continuation *after* Latin text. ``"日本 Marine Day" + "放假通知"`` needs the gap; ``"劳动节" + "放假通知"`` must not have one, or the house style breaks on every China notice. """ return f"{text} " if text[-1:].isascii() and text[-1:].strip() else text def _zh_lead(text: str) -> str: """A space for the *other* side, where a Chinese character abuts Latin text. Only applied where the preceding character is CJK — after ``年`` an ASCII-only name rendered ``"2026 年Founders Day"``. Not applied after ``@All``, which already ends in a space; padding both sides unconditionally produced ``"@All Japan"``. """ return " " if text[:1].isascii() and text[:1].strip() else "" def _zh_body(group: HolidayGroup, req: NoticeRequest) -> str: name = _titled_name(group, ZH, req.region_label) padded = _zh_pad(name) year = group.start.year lead = _zh_lead(name) lines: list[str] = [ f"{year} 年{lead}{padded}放假通知", "", f"@All {padded}将至,为方便伙伴们提前做好假期安排," f"{year} 年{lead}{padded}放假安排通知如下:", ] bullets: list[str] = [] for seg_start, seg_end in group.segments: span = (seg_end - seg_start).days + 1 if span == 1: bullets.append(f"{_zh_date(seg_start)}放假") else: bullets.append( f"{_zh_date(seg_start)}至 {_zh_date(seg_end)}放假,共 {span} 天" ) for day in group.makeup_days: bullets.append(f"{_zh_date(day, short_weekday=True)}补班,正常上班") # Chinese enumeration punctuation: semicolons between items, full stop last. for idx, text in enumerate(bullets): suffix = "。" if idx == len(bullets) - 1 else ";" lines.append(f"{_BULLET}{text}{suffix}") if req.include_tips: lines += ["", _TIPS_HEADING_ZH] lines += [f"{_BULLET}{label}:{text}" for label, text in _TIPS_ZH] closing = req.closing_zh if closing is None: closing = default_closing(group, ZH) if closing.strip(): tail = _CLOSING_EMOJI if req.include_emoji else "" lines += ["", f"{closing.strip()}{tail}"] return "\n".join(lines) def _en_body(group: HolidayGroup, req: NoticeRequest) -> str: name = _titled_name(group, EN, req.region_label) year = group.start.year # Phrased so it does not read "Labor Day Holiday Holiday Notice" — many of # the source document's names already end in "Holiday" or "Holidays". lines: list[str] = [ f"Holiday Notice — {name}, {year}", "", f"@All {name} is coming up. So everyone can plan ahead, here is the " f"{year} schedule:", ] bullets: list[str] = [] for seg_start, seg_end in group.segments: span = (seg_end - seg_start).days + 1 if span == 1: bullets.append(f"{_en_date(seg_start, with_year=True)} — office closed") else: bullets.append( f"{_en_date(seg_start)} to {_en_date(seg_end, with_year=True)} — " f"office closed, {span} days" ) for day in group.makeup_days: bullets.append( f"{_en_date(day, with_year=True)} — makeup workday, normal working hours" ) lines += [ f"{_BULLET}{text};" if idx < len(bullets) - 1 else f"{_BULLET}{text}." for idx, text in enumerate(bullets) ] if req.include_tips: lines += ["", _TIPS_HEADING_EN] lines += [f"{_BULLET}{label}: {text}" for label, text in _TIPS_EN] closing = req.closing_en if closing is None: closing = default_closing(group, EN) if closing.strip(): tail = _CLOSING_EMOJI if req.include_emoji else "" lines += ["", f"{closing.strip()}{tail}"] return "\n".join(lines) def parse_notice_blocks(text: str) -> list[NoticeBlock]: """Split rendered notice text back into its blocks. ONE parser, shared by the HTML conversion and the poster, because HR edits the notice in a textarea before sending: the rich-text paste and the image both have to reflect what is on screen, and two parsers for one format would drift the moment either changed. Blocks are ``{"kind": "title" | "para" | "rule" | "list", ...}`` in document order. A ``list`` block carries its items with the label/body split already applied where the item reads ``label:body`` — the label is what both the HTML and the poster set in bold. """ blocks: list[NoticeBlock] = [] pending: list[str] = [] marker = _BULLET.strip() def flush() -> None: if pending: items: list[NoticeListItem] = [] for item in pending: label, body = "", item for sep in (":", ": "): head, found, tail = item.partition(sep) if found and len(head) <= 24 and tail.strip(): label, body = head, tail.strip() break items.append({"label": label, "body": body, "text": item}) blocks.append({"kind": "list", "items": items}) pending.clear() seen_title = False for raw in text.split("\n"): line = raw.strip() if not line: flush() continue if line.startswith(marker): pending.append(line[len(marker):].strip()) continue flush() if line.startswith("———"): blocks.append({"kind": "rule"}) elif not seen_title: blocks.append({"kind": "title", "text": line}) seen_title = True else: blocks.append({"kind": "para", "text": line}) flush() return blocks def notice_html(text: str) -> str: """The rich-text twin of the plain notice, DERIVED from it. HR pastes this into a Feishu group or an email, and the notices they used to write by hand had a bold title, a bold label on each reminder and real bullets. Markdown cannot deliver that: neither Feishu's composer nor a mail client interprets ``**bold**`` or ``* item`` on paste, so markup would show up as literal punctuation. What both DO honour is pasted HTML, so the copy button puts ``text/html`` and ``text/plain`` on the clipboard together and the destination picks. """ def esc(v: str) -> str: return v.replace("&", "&").replace("<", "<").replace(">", ">") out: list[str] = [] for block in parse_notice_blocks(text): kind = block["kind"] if kind == "rule": out.append("
") elif kind == "title": out.append(f"

{esc(str(block['text']))}

") elif kind == "para": out.append(f"

{esc(str(block['text']))}

") else: items = [] for item in block["items"]: # type: ignore[index] label = str(item["label"]) if label: items.append( f"
  • {esc(label)}: " f"{esc(str(item['body']))}
  • " ) else: items.append(f"
  • {esc(str(item['text']))}
  • ") out.append(f"") return "".join(out) _ZH_NOTICE_HEADING = "放假通知" def _parse_zh_notice_title(raw: str) -> tuple[int, str, str] | None: """Parse ``2026 年 国庆节 放假通知`` in bounded linear time.""" title = raw.strip() year_text, separator, remainder = title.partition("年") if not separator or not remainder.rstrip().endswith(_ZH_NOTICE_HEADING): return None year_text = year_text.strip() if len(year_text) != 4 or not year_text.isascii() or not year_text.isdecimal(): return None holiday_name = remainder.rstrip()[: -len(_ZH_NOTICE_HEADING)].strip() return int(year_text), holiday_name, _ZH_NOTICE_HEADING def poster_from_text(text: str, base: dict[str, object]) -> dict[str, object]: """Rebuild the poster's content from the (possibly edited) notice text. Without this the image ignored HR's edits — they would change a sentence, download the picture, and get the un-edited wording. Reported as 「我改了文字以后也不会改图片吗 那有啥用」. Everything the text can express is taken from the text. What the text does not carry — the month/day the calendar glyph shows, the country the skyline is chosen from, the footer region — stays from ``base``, the structured spec rendered alongside it. """ spec = dict(base) blocks = parse_notice_blocks(text) lists = [b for b in blocks if b["kind"] == "list"] paras = [b for b in blocks if b["kind"] == "para"] title = next((b for b in blocks if b["kind"] == "title"), None) if title: raw = str(title["text"]) parsed_title = _parse_zh_notice_title(raw) if parsed_title: spec["year"], spec["holiday_name"], spec["heading"] = parsed_title else: # An English or hand-rewritten title: show it whole rather than # guessing at a split that would drop half of it. spec["holiday_name"] = raw spec["heading"] = "" if paras: spec["intro"] = str(paras[0]["text"]) # The dates list is the first bullet block; 调休 rows are the ones that say # so, which is how the poster colours them differently. if lists: dates = [str(i["text"]) for i in lists[0]["items"]] # type: ignore[index] spec["lines"] = [d for d in dates if "补班" not in d and "makeup" not in d.lower()] spec["makeup_lines"] = [ d for d in dates if "补班" in d or "makeup" in d.lower() ] if len(lists) > 1: spec["tips"] = [ {"label": str(i["label"]) or str(i["text"]), "body": str(i["body"])} for i in lists[1]["items"] # type: ignore[index] ] # The paragraph just before the tips list is its heading. idx = blocks.index(lists[1]) heading = next( (str(b["text"]) for b in reversed(blocks[:idx]) if b["kind"] == "para"), None, ) if heading: spec["tips_heading"] = heading else: spec["tips"] = [] # The closing is the last paragraph that is not the tips heading. tail_paras = [b for b in blocks if b["kind"] == "para"] if len(tail_paras) >= 2: spec["closing"] = str(tail_paras[-1]["text"]) spec["emoji"] = "" # the emoji, if any, is already inside that line return spec def poster_spec( group: HolidayGroup, req: NoticeRequest, *, footer_region: str | None = None, ) -> dict[str, object]: """The structured pieces the console draws the shareable image from. Built here, next to the text, for the same reason the HTML is: one place decides what a notice says. The frontend gets every string already worded and localised — title parts, intro, date lines, 调休 lines, the reminder list, the closing — and only does layout. No parsing of the rendered text, which would be a second implementation of this format waiting to drift. The reminders ARE included: HR's own posters carry them, and an image that omits them is not a substitute for the text version. """ language = EN if req.language == EN else ZH name = _holiday_name(group, language) year = group.start.year region = (req.region_label or "").strip() # The footer names the region even when the TITLE does not: a Chinese # notice about a Chinese holiday needs no "中国" qualifier in its heading, # but "PingCAP 中国大陆" is exactly what belongs at the foot of the poster. footer = (footer_region or region or "").strip() if language == EN: heading = "Holiday Notice" intro = ( f"@All {name} is coming up. So everyone can plan ahead, here is the " f"{year} schedule:" ) lines = [] for seg_start, seg_end in group.segments: span = (seg_end - seg_start).days + 1 lines.append( f"{_en_date(seg_start, with_year=True)} — office closed" if span == 1 else f"{_en_date(seg_start)} to {_en_date(seg_end, with_year=True)}" f" — office closed, {span} days" ) makeup = [ f"{_en_date(d, with_year=True)} — makeup workday" for d in group.makeup_days ] tips = [{"label": lab, "body": body} for lab, body in _TIPS_EN] tips_heading = _TIPS_HEADING_EN closing = ( req.closing_en if req.closing_en is not None else default_closing(group, EN) ) else: padded = _zh_pad(_titled_name(group, language, region)) lead = _zh_lead(_titled_name(group, language, region)) heading = "放假通知" intro = ( f"@All {padded}将至,为方便伙伴们提前做好假期安排," f"{year} 年{lead}{padded}放假安排通知如下:" ) lines = [] for seg_start, seg_end in group.segments: span = (seg_end - seg_start).days + 1 lines.append( f"{_zh_date(seg_start)}放假" if span == 1 else f"{_zh_date(seg_start)}至 {_zh_date(seg_end)}放假,共 {span} 天" ) makeup = [ f"{_zh_date(d, short_weekday=True)}补班,正常上班" for d in group.makeup_days ] tips = [{"label": lab, "body": body} for lab, body in _TIPS_ZH] tips_heading = _TIPS_HEADING_ZH closing = ( req.closing_zh if req.closing_zh is not None else default_closing(group, ZH) ) first = group.start return { # Title in three parts so the poster can stack and weight them the way # HR's own designs do, instead of wrapping one long string. "year": year, "holiday_name": name, "heading": heading, "intro": intro, "lines": lines, "makeup_lines": makeup, "tips_heading": tips_heading, "tips": tips if req.include_tips else [], "closing": (closing or "").strip(), "emoji": _CLOSING_EMOJI.strip() if req.include_emoji else "", "month": first.month, "day": first.day, "country": group.country, # Footer reads "PingCAP " — the region comes from the calendar's # own country label. Nothing invented: an earlier draft signed the poster # with a team name that does not exist. "region_label": footer, } def render_notice( group: HolidayGroup, req: NoticeRequest, *, poster_region: str | None = None, ) -> dict[str, object]: """Render one holiday's announcement. Returns the assembled text plus the pieces it was built from, so the console can show HR an editable closing line and a facts panel without re-deriving dates client-side (frontend stays presentation-only). """ language = validate_language(req.language) req = NoticeRequest( language=language, include_tips=req.include_tips, include_emoji=req.include_emoji, closing_zh=req.closing_zh, closing_en=req.closing_en, region_label=(req.region_label or None), ) if group.is_makeup_only: raise ValueError( "cannot build a holiday notice for a group that has no days off", ) zh = _zh_body(group, req) if language in (ZH, BILINGUAL) else None en = _en_body(group, req) if language in (EN, BILINGUAL) else None if language == BILINGUAL: assert zh is not None and en is not None text = f"{zh}\n\n———\n\n{en}" elif language == ZH: assert zh is not None text = zh else: assert en is not None text = en return { "language": language, "text": text, "html": notice_html(text), "poster": poster_spec(group, req, footer_region=poster_region), "text_zh": zh, "text_en": en, "closing_zh": ( req.closing_zh if req.closing_zh is not None else default_closing(group, ZH) ), "closing_en": ( req.closing_en if req.closing_en is not None else default_closing(group, EN) ), "facts": { "country": group.country, "group_key": group.group_key, "name_en": group.name_en, "name_local": group.name_local, "year": group.start.year, "start": group.start.isoformat(), "end": group.end.isoformat(), "total_days": group.total_days, "observed_days": group.observed_days, "segments": [ { "start": s.isoformat(), "end": e.isoformat(), "days": (e - s).days + 1, } for s, e in group.segments ], "makeup_days": [d.isoformat() for d in group.makeup_days], }, }