1a6308aba7
A test and code-analysis round of 0.2.72 to 0.2.76 (suites at every tag, sanitizers, ESP32 and GitHub builds, host harnesses on both boards, the editor in a browser, best practice checked against ESPHome, LVGL, Home Assistant and Vue) found these, fixed here with Max's choices for the product questions: - Firmware 0.2.65: the second hand of a single analog clock moves again; on a page that is one full on/off tile the waking push also switches it; on the Guition a flick is never a tap and a finger that slides off a tile doesn't tap it; Previous/Next react to a firm press; the same Go to page tile may sit on several pages; a layout the screen has no memory for is refused instead of crashing; 64-bit redraw mask for 48 tiles; Montserrat 14 no longer built. - App: Save refuses a layout too long for one message or too big for eight pages; an offline screen keeps its firmware version from the device registry; HA refusals reach the editor; 128 KB requests; changelog out of the live stream; cached hashed assets; tini as init and a clean stop; changelog parsing without asterisks or crashes. - Editor: clicking the open screen keeps unsaved edits, edits during a save stay unsaved, a Page row and phone dragging to other pages, repeated page tiles, limits from the server, HA's error messages, a type-checked build. - tools/check.sh + GitHub Actions + a CYD flash budget; docs match 48 tiles and the new editor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
76 lines
3.3 KiB
Python
76 lines
3.3 KiB
Python
"""What's new for the Update badge (app 0.2.73): the CHANGELOG sections since a screen's firmware.
|
|
|
|
The Dockerfile copies CHANGELOG.md next to this file; a development checkout has it one folder up.
|
|
Each `## <app> (firmware <fw>)` section gives its bullet lines as plain text, newest first.
|
|
"""
|
|
import logging
|
|
import re
|
|
from pathlib import Path
|
|
|
|
LOG = logging.getLogger('screen_manager')
|
|
|
|
HEADER = re.compile(r'^## (\d+\.\d+\.\d+) \(firmware (\d+\.\d+\.\d+)\)')
|
|
# A heading of level one or two ends a section; a `### ` inside one doesn't.
|
|
SECTION_END = re.compile(r'^#{1,2}(\s|$)')
|
|
BULLET = re.compile(r'^[-*+]\s+(.*)$')
|
|
BOLD = re.compile(r'\*\*(.+?)\*\*')
|
|
# *Full page*: single asterisks around text. A lone asterisk (5 * 3) or one inside a word stays (app 0.2.78).
|
|
ITALIC = re.compile(r'(?<![\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])')
|
|
CODE = re.compile(r'`([^`]*)`')
|
|
LINK = re.compile(r'\[([^\]]+)\]\([^)]+\)')
|
|
|
|
|
|
def load(path=None, limit=20):
|
|
"""[{app, firmware, lines}] for the latest sections; [] without a changelog, or with one that can't be read (a
|
|
folder, no permission, not UTF-8): the Update badge then has no notes, and the app still starts (app 0.2.78)."""
|
|
candidates = [Path(path)] if path else [Path(__file__).with_name('CHANGELOG.md'), Path(__file__).parent.parent / 'CHANGELOG.md']
|
|
for candidate in candidates:
|
|
if candidate.exists():
|
|
try:
|
|
return parse(candidate.read_text(encoding='utf-8'), limit)
|
|
except (OSError, UnicodeDecodeError) as error:
|
|
LOG.warning("What's new stays empty: %s can't be read (%s)", candidate, type(error).__name__)
|
|
return []
|
|
return []
|
|
|
|
|
|
def parse(text, limit=20):
|
|
"""The sections of a changelog. A bullet (`- ` or `* `) takes the lines that continue it, indented or wrapped
|
|
right under it; bullets under any other heading of level one or two belong to no section (app 0.2.78)."""
|
|
sections, current, item, blank = [], None, None, False
|
|
|
|
def close():
|
|
if item is not None and current is not None:
|
|
current['lines'].append(plain(' '.join(item)))
|
|
|
|
for line in text.splitlines():
|
|
stripped = line.strip()
|
|
if SECTION_END.match(line):
|
|
close()
|
|
item, match = None, HEADER.match(line)
|
|
if match and len(sections) >= limit:
|
|
return sections
|
|
current = {'app': match.group(1), 'firmware': match.group(2), 'lines': []} if match else None
|
|
if current:
|
|
sections.append(current)
|
|
elif (bullet := BULLET.match(line)) and current is not None:
|
|
close()
|
|
item = [bullet.group(1).strip()]
|
|
elif stripped and item is not None and (line[:1].isspace() or not blank) and not line.startswith('#'):
|
|
# Markdown continues a bullet with an indented line, or with any line right under it.
|
|
item.append(stripped)
|
|
elif stripped:
|
|
close()
|
|
item = None
|
|
blank = not stripped
|
|
close()
|
|
return sections
|
|
|
|
|
|
def plain(markdown):
|
|
"""The text of one bullet without Markdown: bold, italics and links outside code, and the code's backticks."""
|
|
parts = CODE.split(markdown)
|
|
for index in range(0, len(parts), 2):
|
|
parts[index] = LINK.sub(r'\1', ITALIC.sub(r'\1', BOLD.sub(r'\1', parts[index])))
|
|
return ''.join(parts).strip()
|