render mentions on a separate line from the reply content.

This commit is contained in:
ed committed 2026-08-25 15:03:12 -04:00
1 parent f8380db665
commit 880640d935
422 files changed
+2864 -920

No files matched your search

+59 -9
View File
@@ -33,7 +33,13 @@ try:
from twitter.media_refs import remote_markup
from twitter.models import MediaItem, ThreadData, load_thread
from twitter.paths import FROZEN, SCRATCH
from twitter.render import render_branch, render_spine, title_text
from twitter.render import (
format_post_text,
render_branch,
render_spine,
split_leading_mentions,
title_text,
)
from twitter.slug import branch_file_name, date_prefix, thread_dir_name
from twitter.tree import (
by_id,
@@ -58,7 +64,13 @@ except ImportError: # pragma: no cover - script-mode import
from media_refs import remote_markup
from models import MediaItem, ThreadData, load_thread
from paths import FROZEN, SCRATCH
from render import render_branch, render_spine, title_text
from render import (
format_post_text,
render_branch,
render_spine,
split_leading_mentions,
title_text,
)
from slug import branch_file_name, date_prefix, thread_dir_name
from tree import (
by_id,
@@ -846,24 +858,55 @@ def _frontmatter_handle(text: str) -> str:
return ""
def _body_match_keys(body: str) -> set[str]:
"""Keys used to pair a note body with ``post.text`` before or after mention split."""
mentions, rest = split_leading_mentions(body)
keys: set[str] = set()
if rest:
rest_first = _first_nonempty_line(rest)
if rest_first:
keys.add(rest_first)
if mentions:
keys.add(f"{mentions} {rest_first}")
else:
first = _first_nonempty_line(body)
if first:
keys.add(first)
return keys
def _post_match_keys(text: str) -> set[str]:
mentions, rest = split_leading_mentions(text)
keys: set[str] = set()
first = _first_nonempty_line(text)
if first:
keys.add(first)
if rest:
rest_first = _first_nonempty_line(rest)
if rest_first:
keys.add(rest_first)
if mentions:
keys.add(f"{mentions} {rest_first}")
return keys
def match_post_for_body(
body: str,
posts: tuple,
used: set[str],
) -> object | None:
"""Return one unused post whose first line matches ``body``.
"""Return one unused post whose text matches ``body``.
Duplicate first lines are fine when every hit has the same handle.
Mixed-handle collisions return ``None``.
"""
first = _first_nonempty_line(body)
if not first:
keys = _body_match_keys(body)
if not keys:
return None
hits = [
post
for post in posts
if post.post_id not in used
and _first_nonempty_line(post.text) == first
if post.post_id not in used and keys & _post_match_keys(post.text)
]
if not hits:
return None
@@ -898,16 +941,23 @@ def patch_note_text(text: str, posts: tuple) -> tuple[str, str, str]:
used.add(post.post_id)
if not handle and index == 0 and root_handle:
handle = root_handle
region = text[match.end():end]
if not handle:
unmatched.append(f"**{number}/**")
pieces.append(match.group(0).rstrip())
last = match.end()
pieces.append(region)
last = end
continue
new_line = f"**{number}/** @{handle}"
if new_line != match.group(0).rstrip():
changed = True
formatted = format_post_text(body)
if body and formatted != body:
region = region.replace(body, formatted, 1)
changed = True
pieces.append(new_line)
last = match.end()
pieces.append(region)
last = end
pieces.append(text[last:])
new_text = "".join(pieces)
reason = ",".join(unmatched)
+31 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import re
import sys
from pathlib import Path
@@ -100,6 +101,35 @@ def _source_block(post: PostData) -> str:
)
_LEADING_MENTIONS: re.Pattern[str] = re.compile(
r"^((?:@[A-Za-z0-9_]+(?:[ \t]+@[A-Za-z0-9_]+)*)[ \t]*)(.*)$",
re.DOTALL,
)
def split_leading_mentions(text: str) -> tuple[str, str]:
"""Split reply ``@handles`` at the start of a tweet from the rest."""
raw = text or ""
match = _LEADING_MENTIONS.match(raw)
if match is None:
return "", raw
mentions = " ".join(match.group(1).split())
rest = match.group(2).lstrip(" \t")
if rest.startswith("\n"):
rest = rest.lstrip("\n")
return mentions, rest
def format_post_text(text: str) -> str:
"""Put leading reply mentions on their own line, then the tweet body."""
mentions, rest = split_leading_mentions(text)
if not mentions:
return text
if not rest:
return mentions
return f"{mentions}\n\n{rest}"
def _post_block(
n: int,
post: PostData,
@@ -109,7 +139,7 @@ def _post_block(
"""Render one post body (``**N/** @handle`` + text + media + branch links)."""
if not post.handle:
raise ValueError(f"empty handle on post {post.post_id}")
parts = [f"**{n}/** @{post.handle}", "", post.text]
parts = [f"**{n}/** @{post.handle}", "", format_post_text(post.text)]
media = (media_by_post or {}).get(post.post_id) or ()
if media:
parts.append("")
@@ -7,7 +7,13 @@ from unittest.mock import patch
from twitter.emit_archive import apply_relabel, format_relabel_plan, plan_relabel
from twitter.models import PostData, PostMetrics, ThreadData
from twitter.render import _post_block, render_branch, render_spine
from twitter.render import (
_post_block,
format_post_text,
render_branch,
render_spine,
split_leading_mentions,
)
from twitter.tw import build_parser, cmd_relabel
@@ -44,6 +50,26 @@ class PostBlockAttributionTests(unittest.TestCase):
with self.assertRaises(ValueError):
_post_block(1, post("1", "", "text", None), None)
def test_leading_mentions_are_their_own_line(self) -> None:
block = _post_block(
19,
post(
"1898440015182729598",
"SebAaltonen",
"@NOTimothyLottes I said WG_RR_EN",
"1",
),
None,
)
self.assertIn("**19/** @SebAaltonen\n\n@NOTimothyLottes\n\nI said WG_RR_EN", block)
def test_format_post_text_is_idempotent(self) -> None:
original = "@AgileJebrim @SebAaltonen The higher the occupancy"
once = format_post_text(original)
self.assertEqual(once, "@AgileJebrim @SebAaltonen\n\nThe higher the occupancy")
self.assertEqual(format_post_text(once), once)
self.assertEqual(split_leading_mentions("holy truthnuke")[0], "")
class RenderAttributionTests(unittest.TestCase):
def test_mixed_spine_labels_each_post(self) -> None:
@@ -223,7 +249,8 @@ class RelabelPatchTests(unittest.TestCase):
(notes / "branch.md").write_text(
"---\ndraft: false\n---\n\n"
"**19/** @SebAaltonen\n\n"
"@NOTimothyLottes I said WG_RR_EN\n\n"
"@NOTimothyLottes\n\n"
"I said WG_RR_EN\n\n"
"**33/** @AgileJebrim\n\n"
"On the flip side, it appears that using 128\n",
encoding="utf-8",
@@ -235,6 +262,29 @@ class RelabelPatchTests(unittest.TestCase):
apply_relabel(plan)
self.assertEqual((notes / "index.md").read_bytes(), before)
def test_patch_splits_leading_mentions(self) -> None:
from twitter.emit_archive import patch_note_text
posts = (
post(
"1898440015182729598",
"SebAaltonen",
"@NOTimothyLottes I said WG_RR_EN",
None,
),
)
text = (
"**19/** @SebAaltonen\n\n"
"@NOTimothyLottes I said WG_RR_EN\n"
)
new, state, reason = patch_note_text(text, posts)
self.assertEqual(state, "rewrite")
self.assertEqual(reason, "")
self.assertIn("@NOTimothyLottes\n\nI said WG_RR_EN", new)
again, state2, _reason = patch_note_text(new, posts)
self.assertEqual(state2, "noop")
self.assertEqual(again, new)
def test_duplicate_first_line_same_handle_rewrites(self) -> None:
from twitter.emit_archive import patch_note_text