Simplify bilingual changelogs by platform and change type

Keep dated history and release notes generated from one source.
This commit is contained in:
OverSu 2026-10-13 21:37:55 +02:00
commit 1c20a0b6d5
5 changed files with 1586 additions and 911 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -2,6 +2,7 @@
import copy import copy
import importlib.util import importlib.util
import json import json
import re
from pathlib import Path from pathlib import Path
import tempfile import tempfile
import unittest import unittest
@ -41,7 +42,7 @@ class ChangelogTests(unittest.TestCase):
self.assertTrue(documents['release-body.md'].startswith(self.source['releases'][self.tag]['summary'])) self.assertTrue(documents['release-body.md'].startswith(self.source['releases'][self.tag]['summary']))
for lang in export.LANGUAGES: for lang in export.LANGUAGES:
text = documents[f'CHANGELOG.{lang}.md'] text = documents[f'CHANGELOG.{lang}.md']
self.assertIn('## 2.7-preview.14', text) self.assertIn('## 30/09/2026 : 2.7-preview.14', text)
self.assertNotIn('## 2.7-156.0.1', text) self.assertNotIn('## 2.7-156.0.1', text)
future = copy.deepcopy(self.source['entries'][0]) future = copy.deepcopy(self.source['entries'][0])
future['id'] = 'future' future['id'] = 'future'
@ -62,16 +63,47 @@ class ChangelogTests(unittest.TestCase):
export.generate(p, Path(d), Path(d)/'site.json', Path(d)/'releases') export.generate(p, Path(d), Path(d)/'site.json', Path(d)/'releases')
self.assertFalse((Path(d)/'site.json').exists()) self.assertFalse((Path(d)/'site.json').exists())
def test_flat_entry_keeps_explicit_platforms_in_both_languages(self): def test_grouped_entries_keep_explicit_platforms_in_both_languages(self):
for language in export.LANGUAGES: for language in export.LANGUAGES:
for source, rendered in zip(self.source['entries'], self.languages[language]): for source, rendered in zip(self.source['entries'], self.languages[language]):
self.assertEqual(rendered['platforms'], source['metadata']['platforms']) self.assertEqual(rendered['platforms'], source['metadata']['platforms'])
self.assertEqual(rendered['models'], source['metadata']['models']) self.assertEqual(rendered['models'], source['metadata']['models'])
self.assertEqual(rendered['status'], source['metadata']['status']) self.assertEqual(rendered['status'], source['metadata']['status'])
self.assertEqual(rendered['groups'][0]['platforms'], rendered['platforms']) self.assertEqual({p for group in rendered['groups'] for p in group['platforms']},
set(rendered['platforms']))
def test_date_first_heading_and_platform_badges(self):
for language in export.LANGUAGES:
entry = self.languages[language][0]
self.assertEqual(entry['id'], '2.7-preview.20')
self.assertEqual(entry['date'], '2026-10-13')
common = next(g for g in entry['groups'] if g['common'])
self.assertEqual(common['platforms'], ['pc', 'apk', 'models'])
web = next(g for g in entry['groups'] if g['name'] == 'Web')
self.assertEqual(web['platforms'], ['web'])
self.assertIn('>Web</span>', web['nameHtml'])
self.assertEqual(entry['notes'], '')
self.assertEqual(export.group_scope('![Desktop + Sargo](https://example.org/badge)')[1], ['pc', 'models'])
with self.assertRaisesRegex(ValueError, 'Unknown platform'):
export.group_scope('Unknown')
def test_tags_alphabetical_in_every_platform_group(self):
for entry in self.source['entries']:
for lang in export.LANGUAGES:
for group in re.split(r'^### ', entry['text'][lang], flags=re.M)[1:]:
tags = re.findall(r'^- !\[([^\]]+)\]', group, re.M)
self.assertTrue(tags, (entry['id'], lang))
self.assertEqual(tags, sorted(tags), (entry['id'], lang))
self.assertLessEqual(set(tags), {'Add', 'Change', 'Fix', 'Note', 'Test'})
def test_release_summary_follows_the_entry_without_copying_text(self):
tag = 'v2.7-preview.20'
self.source['entries'][0]['text']['fr'] += '\nA new correction.\n'
docs = export.release_documents(self.source, tag)
self.assertIn('A new correction.', docs['release-body.md'])
def test_badges_are_images_with_escaped_attributes(self): def test_badges_are_images_with_escaped_attributes(self):
rendered = export.inline('![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) **Camera**') rendered = export.inline('![Fix](https://example.org/badge.svg) **Camera**')
self.assertIn('<img class="cl-badge"', rendered) self.assertIn('<img class="cl-badge"', rendered)
self.assertIn('alt="Fix"', rendered) self.assertIn('alt="Fix"', rendered)
self.assertIn('<strong>Camera</strong>', rendered) self.assertIn('<strong>Camera</strong>', rendered)
@ -79,6 +111,11 @@ class ChangelogTests(unittest.TestCase):
self.assertNotIn('<img', export.inline('![unsafe](javascript:alert)')) self.assertNotIn('<img', export.inline('![unsafe](javascript:alert)'))
self.assertNotIn(' onerror="', export.inline('![\" onerror=\"bad](https://example.org/a.png)')) self.assertNotIn(' onerror="', export.inline('![\" onerror=\"bad](https://example.org/a.png)'))
def test_website_badges_do_not_require_external_images(self):
rendered = export.inline('![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square)')
self.assertEqual(rendered, '<span class="cl-badge" style="background:#52677D">Fix</span>')
self.assertNotIn('<script>', export.inline('![<script>](https://img.shields.io/badge/Fix-52677D?style=flat-square)'))
def test_invalid_platform_is_rejected(self): def test_invalid_platform_is_rejected(self):
self.source['entries'][0]['metadata']['platforms'] = ['unknown'] self.source['entries'][0]['metadata']['platforms'] = ['unknown']
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:

View file

@ -21,6 +21,10 @@ def inline(value):
return '<strong>' + html.escape(text[2:-2]) + '</strong>' return '<strong>' + html.escape(text[2:-2]) + '</strong>'
if text.startswith('!['): if text.startswith('!['):
label, url = re.fullmatch(r'!\[([^\]]+)\]\(([^)]+)\)', text).groups() label, url = re.fullmatch(r'!\[([^\]]+)\]\(([^)]+)\)', text).groups()
badge = re.fullmatch(r'https://img\.shields\.io/badge/[^?]+-([0-9A-Fa-f]{6})\?style=flat-square', url)
if badge:
# Website badges are text: visible offline, without image-loading delays.
return '<span class="cl-badge" style="background:#' + badge[1] + '">' + html.escape(label) + '</span>'
if url.startswith('https://'): if url.startswith('https://'):
return '<img class="cl-badge" src="' + html.escape(url, quote=True) + '" alt="' + html.escape(label, quote=True) + '" height="20" loading="lazy" style="vertical-align:middle;width:auto">' return '<img class="cl-badge" src="' + html.escape(url, quote=True) + '" alt="' + html.escape(label, quote=True) + '" height="20" loading="lazy" style="vertical-align:middle;width:auto">'
return html.escape(label) return html.escape(label)
@ -62,6 +66,22 @@ def parse(path):
return parse_text(path.read_text()) return parse_text(path.read_text())
def group_scope(name):
"""Read platform badges as labels, not as image URLs."""
label = re.sub(r'!\[([^\]]+)\]\([^)]+\)', r'\1', name)
common = label.startswith(('Commun', 'Shared', 'Toutes les plateformes', 'All Platforms'))
if common:
return label, ['pc', 'apk', 'models'], True
scope = []
if re.search(r'\bPC\b|Desktop', label): scope.append('pc')
if 'Android' in label: scope.append('apk')
if 'Sargo' in label or 'Tundra' in label: scope.append('models')
if label == 'Web': scope.append('web')
if not scope:
raise ValueError('Unknown platform group: ' + label)
return label, scope, False
def parse_text(markdown): def parse_text(markdown):
entries = [] entries = []
months = {name: index for names in ( months = {name: index for names in (
@ -71,23 +91,26 @@ def parse_text(markdown):
for chunk in re.split(r'^## ', markdown, flags=re.MULTILINE)[1:]: for chunk in re.split(r'^## ', markdown, flags=re.MULTILINE)[1:]:
heading, _, body = chunk.partition('\n') heading, _, body = chunk.partition('\n')
if heading.startswith(('Tenue du journal', 'Maintaining this journal')): continue if heading.startswith(('Tenue du journal', 'Maintaining this journal')): continue
version = re.match(r'\d+\.\d+[.\w-]*', heading) title = re.sub(r'^\d{2}/\d{2}/\d{4}\s*:\s*', '', heading)
key = version.group() if version else ('development' if heading.startswith(('En développement', 'In development')) else 'history') version = re.match(r'\d+\.\d+[.\w-]*', title)
key = version.group() if version else ('development' if title.startswith(('En développement', 'In development')) else 'history')
kind = 'version' if version else key kind = 'version' if version else key
stamp = re.search(r'(\d{1,2}) ([A-Za-zéû]+) (\d{4})', heading) stamp = re.search(r'(\d{1,2}) ([A-Za-zéû]+) (\d{4})', heading)
date = None date = None
if stamp and stamp[2].lower() in months: if stamp and stamp[2].lower() in months:
date = f'{stamp[3]}-{months[stamp[2].lower()]:02d}-{int(stamp[1]):02d}' date = f'{stamp[3]}-{months[stamp[2].lower()]:02d}-{int(stamp[1]):02d}'
numeric_date = re.match(r'(\d{2})/(\d{2})/(\d{4})\s*:', heading)
if numeric_date:
date = f'{numeric_date[3]}-{numeric_date[2]}-{numeric_date[1]}'
names = re.findall(r'^### (.+)', body, re.MULTILINE) names = re.findall(r'^### (.+)', body, re.MULTILINE)
platforms = [] platforms = [p for p in ('pc', 'apk', 'models', 'web')
if any(re.search(r'\bPC\b|Desktop', name) for name in names): platforms.append('pc') if any(p in group_scope(name)[1] for name in names)] or ['apk']
if any('Android' in name for name in names): platforms.append('apk')
if any('Tundra' in name or 'Sargo' in name for name in names): platforms.append('models')
if not platforms:
platforms = ['pc', 'apk', 'models'] if any(name.startswith(('Commun', 'Shared')) for name in names) else ['apk']
status = 'development' if key == 'development' else 'history' if key == 'history' else 'published' if re.search(r'publi[ée]|published', heading, re.I) else 'built' status = 'development' if key == 'development' else 'history' if key == 'history' else 'published' if re.search(r'publi[ée]|published', heading, re.I) else 'built'
release = re.search(r'^\[Release\]\((https://[^)]+)\)', body, re.MULTILINE) release = re.search(r'^\[Release\]\((https://[^)]+)\)', body, re.MULTILINE)
body = re.sub(r'^\[Release\]\([^)]+\)\s*', '', body, flags=re.MULTILINE) body = re.sub(r'^\[Release\]\([^)]+\)\s*', '', body, flags=re.MULTILINE)
# The website already displays the status beside the date/version.
body = re.sub(r'^!\[(?:Publié|Published|En développement|In development|APK construit · non publié|APK built · not published)\]\([^)]+\)\s*',
'', body, flags=re.MULTILINE)
parts = re.split(r'^### (.+)\n', body, flags=re.MULTILINE) parts = re.split(r'^### (.+)\n', body, flags=re.MULTILINE)
groups = [] groups = []
intro = blocks(parts[0].splitlines()) intro = blocks(parts[0].splitlines())
@ -95,16 +118,11 @@ def parse_text(markdown):
groups.append({'name': '', 'platforms': platforms, 'common': False, 'html': intro}) groups.append({'name': '', 'platforms': platforms, 'common': False, 'html': intro})
intro = '' intro = ''
for name, text in zip(parts[1::2], parts[2::2]): for name, text in zip(parts[1::2], parts[2::2]):
common = name.startswith(('Commun', 'Shared')) label, scope, common = group_scope(name)
scope = platforms if common else []
if not common:
if re.search(r'\bPC\b|Desktop', name): scope.append('pc')
if 'Android' in name: scope.append('apk')
if 'Sargo' in name or 'Tundra' in name: scope.append('models')
if not scope: scope = ['pc']
# Closing limits belong to the whole entry, not to the last group. # Closing limits belong to the whole entry, not to the last group.
split = re.split(r'(?=^\*\*(?:Limites|Limitations) :)|(?=^\*\*Limitations:)', text, maxsplit=1, flags=re.MULTILINE) split = re.split(r'(?=^\*\*(?:Limites|Limitations) :)|(?=^\*\*Limitations:)', text, maxsplit=1, flags=re.MULTILINE)
groups.append({'name': name, 'platforms': scope, 'common': common, 'html': blocks(split[0].splitlines())}) groups.append({'name': label, 'nameHtml': inline(name), 'platforms': scope,
'common': common, 'html': blocks(split[0].splitlines())})
if len(split)>1: intro += blocks(split[1].splitlines()) if len(split)>1: intro += blocks(split[1].splitlines())
entries.append({'id': key, 'kind': kind, 'date': date, 'title': heading, 'status': status, 'platforms': platforms, entries.append({'id': key, 'kind': kind, 'date': date, 'title': heading, 'status': status, 'platforms': platforms,
'models': ['sargo'] if 'models' in platforms else [], 'notes': intro, 'models': ['sargo'] if 'models' in platforms else [], 'notes': intro,
@ -148,7 +166,7 @@ def load_source(path):
require(metadata['status'] in ('development', 'history', 'built', 'published'), 'Invalid entry status: ' + key) require(metadata['status'] in ('development', 'history', 'built', 'published'), 'Invalid entry status: ' + key)
require(isinstance(metadata['platforms'], list) and bool(metadata['platforms']) and require(isinstance(metadata['platforms'], list) and bool(metadata['platforms']) and
len(set(metadata['platforms'])) == len(metadata['platforms']) and len(set(metadata['platforms'])) == len(metadata['platforms']) and
set(metadata['platforms']) <= {'pc', 'apk', 'models'}, 'Invalid platforms: ' + key) set(metadata['platforms']) <= {'pc', 'apk', 'models', 'web'}, 'Invalid platforms: ' + key)
require(isinstance(metadata['models'], list) and require(isinstance(metadata['models'], list) and
all(isinstance(model, str) and re.fullmatch(r'[a-z0-9-]+', model) for model in metadata['models']) and all(isinstance(model, str) and re.fullmatch(r'[a-z0-9-]+', model) for model in metadata['models']) and
(not metadata['models'] or 'models' in metadata['platforms']), 'Invalid models: ' + key) (not metadata['models'] or 'models' in metadata['platforms']), 'Invalid models: ' + key)
@ -162,6 +180,9 @@ def load_source(path):
# Flat changelogs keep scope independently of translated prose. # Flat changelogs keep scope independently of translated prose.
if len(item['groups']) == 1 and not item['groups'][0]['name']: if len(item['groups']) == 1 and not item['groups'][0]['name']:
item['groups'][0]['platforms'] = metadata['platforms'] item['groups'][0]['platforms'] = metadata['platforms']
else:
require(set(metadata['platforms']) == {p for g in item['groups'] for p in g['platforms']},
'Group/entry platform mismatch: ' + key)
if item['kind'] == 'version': if item['kind'] == 'version':
require(item['id'] == key, 'Version/ID mismatch: ' + key) require(item['id'] == key, 'Version/ID mismatch: ' + key)
# Development IDs are fixed in the source, independent of order/date. # Development IDs are fixed in the source, independent of order/date.
@ -188,15 +209,19 @@ def release_documents(source, tag):
histories = {} histories = {}
for lang in LANGUAGES: for lang in LANGUAGES:
title = 'Historique cumulatif' if lang == 'fr' else 'Cumulative history' title = 'Historique cumulatif' if lang == 'fr' else 'Cumulative history'
note = ('Les sections datées décrivent l’état à chaque étape. Les limites et correctifs ont évolué ; ' note = ('Chaque entrée décrit la situation à sa date. Consulter la synthèse de la release pour son état final.'
'consulter la synthèse de la release pour l’état final. Les chemins de documentation désignent ' if lang == 'fr' else
'les sources locales, dont la publication est distincte de cet APK.' if lang == 'fr' else 'Each entry describes the state at its date. See the release summary for its final state.')
'Dated sections describe each development stage. Limitations and fixes evolved; the release '
'summary describes the final state. Documentation paths refer to local sources; source '
'publication is separate from this APK.')
histories[lang] = (f'# {title} — {release["after"]} → {release["version"]}\n\n{note}\n\n' + histories[lang] = (f'# {title} — {release["after"]} → {release["version"]}\n\n{note}\n\n' +
'\n\n'.join(e['text'][lang].strip() for e in selected) + '\n') '\n\n'.join(e['text'][lang].strip() for e in selected) + '\n')
body = release['summary'].strip() + '\n\n' body = release['summary'].strip() + '\n\n'
if release.get('include_entry'):
for lang, label in [('fr', '🇫🇷 Français'), ('en', '🇬🇧 English')]:
summary = selected[0]['text'][lang].strip()
# Historical build status remains in the dated history, not in a published release's summary.
summary = re.sub(r'^!\[(?:APK construit · non publié|APK built · not published)\]\([^)]+\)\s*',
'', summary, flags=re.MULTILINE)
body += label + '\n\n' + summary + '\n\n'
for lang, label in [('fr', '🇫🇷 Historique complet, étape par étape'), for lang, label in [('fr', '🇫🇷 Historique complet, étape par étape'),
('en', '🇬🇧 Full history, stage by stage')]: ('en', '🇬🇧 Full history, stage by stage')]:
body += f'<details>\n<summary>{label}</summary>\n\n{histories[lang]}\n</details>\n\n' body += f'<details>\n<summary>{label}</summary>\n\n{histories[lang]}\n</details>\n\n'