From 6b32695cc92c7a001ea1d65ac7fc7e3c92d5a222 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 18:17:29 +0000 Subject: [PATCH] =?UTF-8?q?queue:=20line=20kinds=20=E2=80=94=20a=20heading?= =?UTF-8?q?=20is=20not=20a=20paper=20you=20can=20download?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backfill's 105 misses turned out not to be 105 papers arXiv couldn't find. A good share of them were never papers: `__Dark Matter__` and five other bold groupings, tracking-wrapped links pasted from email, stray prose. The board was rendering each one as a paper โ€” a search link, three issue buttons, a slot in the "N waiting" count โ€” and the nightly backfill was spending an arXiv lookup on each of them, every night, forever. So a queue line now has a kind, decided in `_parse_paper`: * `__Bold__` on its own line is a **subhead** โ€” a grouping the human wrote. Renders as a heading: no link, no buttons, not counted. * `NOTE ` is a **note** โ€” an annotation, sibling of the `DONE ` prefix (same shape, same never-delete rule). A bare non-arXiv link is one automatically, rendered as a link labelled by host rather than 700 characters of Outlook safelink. * Everything else is a **paper**, and only papers are counted, given buttons, or looked up on arXiv. `backfill_arxiv_refs.py --mark-unresolved` writes the `NOTE ` prefix in bulk for lines it could not identify. Deliberately opt-in and never used by the nightly: an unresolved title is more often a real paper whose line lost something on the way in (a LaTeX redshift, a wrapped subtitle) than a genuine non-paper, and those deserve re-trying once a human fixes the line. One fix falls out of the same parse: a line that is *only* an arXiv link was being read as a titleless paper with no ref, so it got a search box instead of the PDF it plainly names. It is now a paper labelled `arXiv:`, with its abstract page and its ๐Ÿ“„ button. On the live queue: 6 headings and 3 links stop pretending to be papers, the waiting count drops 232 โ†’ 223, and PDF coverage rises 127 โ†’ 133. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PjWdVFPn2WDzcqpMXTMp7G --- reading-queue.md | 14 +++++ scripts/backfill_arxiv_refs.py | 65 +++++++++++++++++---- scripts/board.py | 104 +++++++++++++++++++++++++++++++-- tests/test_arxiv_refs.py | 54 +++++++++++++++++ tests/test_board.py | 92 +++++++++++++++++++++++------ 5 files changed, 298 insertions(+), 31 deletions(-) diff --git a/reading-queue.md b/reading-queue.md index 1185d1f..8283774 100644 --- a/reading-queue.md +++ b/reading-queue.md @@ -13,6 +13,20 @@ Papers waiting to be read and filed. Format: and only when exactly one arXiv paper's title matches. - A read/consumed paper is never deleted: prefix its line with `DONE โ€” ` and leave it in place (the reading history). +- **Not every line is a paper.** Two kinds are not, and marking them keeps the + Dashboard from offering a download button for a heading and stops the nightly + ref backfill spending an arXiv lookup on one: + - `__Bold Text__` on its own line is a **sub-heading** grouping the papers + below it. Rendered as a heading, never counted, never looked up. + - `NOTE ` is an **annotation** โ€” a remark, a link that is not a paper, + a stray line of pasted prose. Sibling of the `DONE ` prefix: same shape, + same never-delete rule. A bare non-arXiv link counts as one automatically. + `scripts/backfill_arxiv_refs.py --mark-unresolved` applies the prefix in + bulk to lines it could not identify โ€” run it after reading a dry run, since + an unresolved title is more often a real paper whose line lost something + (a LaTeX redshift, a wrapped subtitle) than a genuine non-paper. + - A line that is *only* an arXiv link is still a paper โ€” the title was just + never written, so the board labels it `arXiv:`. - Filing a read paper: add its canonical entry to `bibliography/`, stub it in the matching `wiki//sources/*.md` page per `wiki/CLAUDE.md`, then mark the line DONE and run `make validate`. diff --git a/scripts/backfill_arxiv_refs.py b/scripts/backfill_arxiv_refs.py index 1dcb988..dbdd3a9 100644 --- a/scripts/backfill_arxiv_refs.py +++ b/scripts/backfill_arxiv_refs.py @@ -73,12 +73,35 @@ def candidates(text: str, source: str) -> list[tuple[int, str]]: continue body = raw.strip() paper = board._parse_paper(body) + # `kind != "paper"` is the whole point of the marker: a `__Heading__` + # and a `NOTE ` line are not papers, so they are never looked up โ€” not + # this run and not any future one. Without that, every night spends an + # arXiv call on a line that can never resolve. + if paper["kind"] != "paper": + continue if paper["done"] or paper["ref"] or not paper["title"]: continue out.append((i, paper["title"])) return out +def mark_unresolved_line(raw_line: str) -> str: + """`raw_line` prefixed `NOTE `, the "stop asking about this one" marker. + + Written only under ``--mark-unresolved``, never by the nightly run. The + distinction matters: a title arXiv cannot identify is usually not a + non-paper but a real paper whose line lost something on the way in (a + LaTeX redshift, a wrapped subtitle), and those are worth re-trying after a + human fixes the line. So the nightly keeps asking, and a human who has + looked at the misses runs this once to retire the ones that are genuinely + prose, links or headings. The text is untouched and the line stays in + place โ€” the never-delete rule covers annotations too. + """ + stripped = raw_line.lstrip() + indent = raw_line[:len(raw_line) - len(stripped)] + return f"{indent}NOTE {stripped.rstrip()}" + + def apply_ref(raw_line: str, ref: str) -> str: """`raw_line` with ``โ€” `` appended, preserving its exact prefix. @@ -89,12 +112,18 @@ def apply_ref(raw_line: str, ref: str) -> str: return f"{raw_line.rstrip()} โ€” {ref}" -def backfill(root: Path, *, write: bool, limit: int, +def backfill(root: Path, *, write: bool, limit: int, mark_unresolved: bool = False, resolve=arxiv_refs.resolve_title, delay: float = arxiv_refs.API_DELAY_SECONDS, sleep=time.sleep, log=print) -> dict: - """Resolve up to `limit` ref-less titles across the queue and the inbox.""" - stats = {"scanned": 0, "matched": 0, "unmatched": 0, "files": []} + """Resolve up to `limit` ref-less titles across the queue and the inbox. + + With `mark_unresolved`, a line that fails its lookup is also `NOTE `-marked + so no later run asks about it again โ€” a human-driven cleanup, never the + nightly's own decision (see :func:`mark_unresolved_line`). + """ + stats = {"scanned": 0, "matched": 0, "unmatched": 0, "marked": 0, + "files": []} budget = limit for name in TARGETS: path = root / name @@ -103,7 +132,7 @@ def backfill(root: Path, *, write: bool, limit: int, lines = path.read_text(errors="replace").splitlines() todo = candidates("\n".join(lines), name) stats["scanned"] += len(todo) - changed = 0 + changed = file_marked = 0 for idx, title in todo: if budget <= 0: break @@ -116,7 +145,14 @@ def backfill(root: Path, *, write: bool, limit: int, log(f" โœ” {ref} {title[:90]}") else: stats["unmatched"] += 1 - log(f" ยท no unique match {title[:90]}") + if mark_unresolved: + lines[idx] = mark_unresolved_line(lines[idx]) + changed += 1 + file_marked += 1 + stats["marked"] += 1 + log(f" โœ– NOTE-marked {title[:90]}") + else: + log(f" ยท no unique match {title[:90]}") # Politeness, not throughput: arXiv asks for a few seconds between # calls, and this job has all night. if budget > 0: @@ -125,8 +161,9 @@ def backfill(root: Path, *, write: bool, limit: int, stats["files"].append(name) if write: path.write_text("\n".join(lines) + "\n") - log(f"{name}: {len(todo)} ref-less, {changed} resolved" - f"{'' if write else ' (dry run โ€” nothing written)'}") + marked = f", {file_marked} NOTE-marked" if file_marked else "" + log(f"{name}: {len(todo)} ref-less, {changed - file_marked} resolved" + f"{marked}{'' if write else ' (dry run โ€” nothing written)'}") return stats @@ -136,12 +173,20 @@ def main(argv: list[str] | None = None) -> int: help="apply the refs (default: report only)") ap.add_argument("--limit", type=int, default=60, help="most arXiv lookups to make in one run (default 60)") + ap.add_argument("--mark-unresolved", action="store_true", + help="also NOTE-mark every line that fails to resolve, so " + "later runs skip it. A human-reviewed cleanup for " + "lines that are prose, links or headings rather than " + "papers โ€” never used by the nightly run, which keeps " + "re-trying in case a line gets fixed.") ap.add_argument("--root", type=Path, default=MEMORY_HOME) args = ap.parse_args(argv) - stats = backfill(args.root, write=args.write, limit=args.limit) - print(f"\n{stats['matched']} resolved, {stats['unmatched']} unresolved, " - f"{stats['scanned']} ref-less lines in total.") + stats = backfill(args.root, write=args.write, limit=args.limit, + mark_unresolved=args.mark_unresolved) + marked = f", {stats['marked']} NOTE-marked" if stats["marked"] else "" + print(f"\n{stats['matched']} resolved, {stats['unmatched']} unresolved" + f"{marked}, {stats['scanned']} ref-less lines in total.") # Not an error: an unresolved title is a normal outcome (not on arXiv, or # too ambiguous to be safe), and a run that resolves nothing must not fail # the nightly job. diff --git a/scripts/board.py b/scripts/board.py index b4ff0f4..3515b2a 100644 --- a/scripts/board.py +++ b/scripts/board.py @@ -30,6 +30,12 @@ warning is computed in the reader's browser (see ``_EXTRA_JS``), because the page freezes at its last good render in exactly the failure the warning is for. +A queue section is not a flat list of papers: a `__Bold__` line is a +sub-heading the human wrote, and a `NOTE `-prefixed line (or a bare non-arXiv +link) is an annotation. Both render as themselves โ€” no link, no buttons, no +slot in the "N waiting" count โ€” because a heading is not a paper you can +download. `_parse_paper` decides which is which; see `reading-queue.md`. + **Contents-level only.** The board shows titles and counts, never claim text or summaries โ€” the knowledge itself stays in the wiki pages. @@ -123,6 +129,22 @@ def theme(): # is part of the title. QUEUE_REF_RE = re.compile( r"^(.*\S)\s+โ€”\s+((?:arXiv:)?\d{4}\.\d{4,5}(?:v\d+)?|https?://\S+)$") +# Not every line under a `## Section` is a paper. Two kinds are not, and saying +# so is what stops the board rendering a search button for a heading and the +# backfill spending an arXiv lookup on one every night: +# `__Dark Matter__` a bold sub-heading the human wrote to group the papers +# below it โ€” structure, rendered as structure. +# `NOTE ` an annotation: a free-text remark, a link that is not a +# paper, a stray line of pasted prose. Sibling of the +# `DONE ` prefix โ€” same shape, same never-delete rule, and +# the marker `backfill_arxiv_refs.py --mark-unresolved` +# writes so a line it cannot identify is asked about once +# and then left alone. +QUEUE_SUBHEAD_RE = re.compile(r"^__(.+?)__$") +QUEUE_NOTE_RE = re.compile(r"^NOTE\b\s*(?:โ€”\s*)?(.*)$") +#: A line that is nothing but a URL. An arXiv one is a paper whose title was +#: never written (the ref is the whole line); anything else is an annotation. +QUEUE_BARE_URL_RE = re.compile(r"^https?://\S+$") def _owner_repo() -> tuple[str, str]: @@ -195,7 +217,11 @@ def _flush(): if section is not None: snapshot["queue"].append({ "section": section, - "count": sum(1 for p in papers if not p["done"]), + # Headings and annotations are carried in `papers` so the + # section renders in file order, but they are not work: + # counting them would inflate every "N waiting" on the board. + "count": sum(1 for p in papers + if p["kind"] == "paper" and not p["done"]), "done": sum(1 for p in papers if p["done"]), "papers": papers, }) @@ -236,19 +262,59 @@ def _flush(): # --- pure helpers -------------------------------------------------------------- def _parse_paper(text: str) -> dict: - """One queue line โ†’ {title, ref, done, done_date, line} (line = as written).""" + """One queue line โ†’ {kind, title, ref, done, done_date, line}. + + `line` is the line as written โ€” the token every issue button round-trips. + `kind` is what the line *is*: a `paper`, a `subhead` (a `__Bold__` grouping + the human wrote), or a `note` (a `NOTE `-prefixed annotation, or a bare + non-arXiv link). Only a `paper` is counted, given buttons, or looked up on + arXiv; the other two are carried through and rendered as themselves, which + is the whole point โ€” a heading is not a paper you can download. + """ raw, done_date = text, None m = QUEUE_DONE_LINE_RE.match(text) if m: done_date, text = m.group(1), m.group(2) elif QUEUE_DONE_RE.match(text): done_date, text = "", text[4:].lstrip(" โ€”-") + + def _built(kind, title, ref=None): + return {"kind": kind, "title": title, "ref": ref, + "done": done_date is not None, + "done_date": done_date or None, "line": raw} + + # Checked before the ref split: a heading or an annotation has no ref to + # find, and `NOTE https://โ€ฆ` must not be read as a titleless paper. + m = QUEUE_SUBHEAD_RE.match(text) + if m: + return _built("subhead", m.group(1).strip()) + m = QUEUE_NOTE_RE.match(text) + if m: + return _built("note", m.group(1).strip()) + ref = None m = QUEUE_REF_RE.match(text) if m: text, ref = m.group(1), m.group(2) - return {"title": text, "ref": ref, "done": done_date is not None, - "done_date": done_date or None, "line": raw} + elif QUEUE_BARE_URL_RE.match(text): + # A line that is only a URL. If it points at arXiv the human did write a + # paper โ€” just as a link instead of a title โ€” so it earns its abstract + # page and its PDF button, labelled by the identifier the line already + # contains rather than by a hundred characters of raw URL. (No title is + # invented: the id is read out of the line, and the line itself is what + # every issue button still round-trips.) Any other bare link is an + # annotation. + aid = arxiv_refs.arxiv_id(text) + if not aid: + return _built("note", text) + return _built("paper", f"arXiv:{aid}", text) + return _built("paper", text, ref) + + +def _url_host(url: str) -> str: + """`https://sub.example.org/a/b?c` โ†’ `sub.example.org` (the url if unparsable).""" + m = re.match(r"^https?://([^/?#]+)", url) + return m.group(1) if m else url def paper_url(paper: dict) -> str: @@ -554,6 +620,14 @@ def _render_md(snapshot: dict) -> str: if p["done"]: continue label = p["title"].replace("[", "\\[").replace("]", "\\]") + if p["kind"] == "subhead": + lines.append(f"- **{label}**") + continue + if p["kind"] == "note": + if QUEUE_BARE_URL_RE.match(p["title"]): + label = f"[{_url_host(p['title'])} โ†’]({p['title']})" + lines.append(f"- _{label}_") + continue bits = [f"- [{label}]({paper_url(p)})"] pdf = paper_pdf_url(p) if pdf: @@ -627,6 +701,11 @@ def _bar(statuses: dict) -> str: ul.papers{margin:.5rem 0 .25rem;padding-left:1.3rem} ul.papers li{margin:.4rem 0} ul.papers li.done{color:var(--muted)} +/* A heading the human wrote inside a section, and a line that is not a paper: + both render as themselves โ€” no link, no buttons, nothing to tap. */ +ul.papers li.subhead{list-style:none;margin:.7rem 0 .2rem -1.3rem; + font-weight:600;letter-spacing:.02em} +ul.papers li.note{color:var(--muted);font-style:italic} a.act{margin-left:.35rem;padding:.05rem .4rem;font-size:.85rem; border:1px solid var(--line);border-radius:6px;background:var(--btn)} a.act:hover{background:var(--tint);border-color:var(--accent); @@ -728,6 +807,23 @@ def _render_html(snapshot: dict) -> str: f"
  • DONE {_html.escape(p.get('done_date') or '?')}" f" โ€” {_html.escape(p['title'])}
  • ") continue + if p["kind"] == "subhead": + items.append(f"
  • " + f"{_html.escape(p['title'])}
  • ") + continue + if p["kind"] == "note": + text = _html.escape(p["title"]) + if QUEUE_BARE_URL_RE.match(p["title"]): + # A saved link is still a link: render it clickable, just + # without the paper buttons it cannot honour. Labelled by + # host โ€” a tracking-wrapped URL is hundreds of characters + # of noise on a phone, and the line itself keeps the whole + # thing. + text = (f"" + f"{_html.escape(_url_host(p['title']))} โ†’") + items.append(f"
  • {text}
  • ") + continue acts = [_pdf_act(p)] for action, icon, hint in ( ("intake", "๐Ÿ“ฅ", "intake into memory: really interesting โ€” " diff --git a/tests/test_arxiv_refs.py b/tests/test_arxiv_refs.py index fa4d666..cf30dee 100644 --- a/tests/test_arxiv_refs.py +++ b/tests/test_arxiv_refs.py @@ -177,3 +177,57 @@ def test_backfill_is_idempotent(tmp_path): stats = backfill.backfill(root, **kw) assert stats["scanned"] == 0 assert (root / "reading-queue.md").read_text() == once + + +# --- line kinds: what the backfill refuses to look up ------------------------- +KINDED = ("# Reading queue\n\nprose\n\n---\n\n## Strong Lensing\n" + "__A Bold Heading__\n" + "Bare Title\n" + "NOTE a stray remark someone pasted\n" + "NOTE โ€” https://example.org/not-a-paper\n" + "https://example.org/also-not-a-paper\n" + "https://arxiv.org/abs/2202.00099\n") + + +def test_headings_notes_and_non_arxiv_links_are_never_looked_up(): + """The marker's whole purpose: these lines cost zero arXiv calls, forever.""" + got = [t for _, t in backfill.candidates(KINDED, "reading-queue.md")] + assert got == ["Bare Title"] # the bare arXiv link already has its ref + + +def test_mark_unresolved_line_prefixes_without_touching_the_text(): + assert backfill.mark_unresolved_line("Some Prose Line") == \ + "NOTE Some Prose Line" + # indentation survives; the text is never rewritten + assert backfill.mark_unresolved_line(" Indented ") == " NOTE Indented" + + +def test_mark_unresolved_retires_a_line_from_all_future_runs(tmp_path): + (tmp_path / "reading-queue.md").write_text( + "# Q\n\n---\n\n## S\nResolves Fine\nNever Resolves\n") + (tmp_path / "arxiv-inbox.md").write_text("# I\n\n---\n") + kw = dict(write=True, limit=10, sleep=lambda _: None, log=lambda *a: None, + resolve=lambda t: "2608.11111" if t == "Resolves Fine" else None) + + stats = backfill.backfill(tmp_path, mark_unresolved=True, **kw) + assert stats["matched"] == 1 and stats["marked"] == 1 + text = (tmp_path / "reading-queue.md").read_text() + assert "Resolves Fine โ€” 2608.11111" in text + assert "NOTE Never Resolves" in text + + # the next run has nothing left to ask arXiv about + assert backfill.backfill(tmp_path, mark_unresolved=True, **kw)["scanned"] == 0 + + +def test_the_nightly_never_marks_on_its_own(tmp_path): + """Default off: an unresolved title is usually a real paper with a mangled + line, and retrying it after a human fixes the line is the right behaviour.""" + (tmp_path / "reading-queue.md").write_text( + "# Q\n\n---\n\n## S\nNever Resolves\n") + (tmp_path / "arxiv-inbox.md").write_text("# I\n\n---\n") + stats = backfill.backfill(tmp_path, write=True, limit=10, + resolve=lambda t: None, sleep=lambda _: None, + log=lambda *a: None) + assert stats["marked"] == 0 + assert (tmp_path / "reading-queue.md").read_text().endswith("Never Resolves\n") + diff --git a/tests/test_board.py b/tests/test_board.py index c6ba5c0..720db7a 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -44,7 +44,10 @@ def _tree(tmp_path: Path) -> Path: "# Reading queue\n\nintro prose\n\n---\n\n" "## Demo Papers\n\nTitle One\nTitle Two โ€” 2406.01234\n" "DONE 2026-06-01 โ€” Old Title\n\n## Other Things\n\nTitle Three\n" - "Title Four โ€” https://arxiv.org/pdf/2201.00042.pdf\n") + "Title Four โ€” https://arxiv.org/pdf/2201.00042.pdf\n" + "__A Sub Heading__\nNOTE โ€” just a remark\n" + "https://example.org/not-a-paper\n" + "https://arxiv.org/abs/2202.00099\n") (tmp_path / "arxiv-inbox.md").write_text( "# arXiv inbox\n\nintro prose\n\n---\n" "2026-08-24 โ€” Suggested One โ€” 2608.00001\n" @@ -67,21 +70,33 @@ def test_counts_from_a_synthetic_tree(tmp_path): assert w["statuses"] == {"drafted": 1, "stub": 1} assert w["sections"] == 2 and w["todo"] == 1 and w["resolved_keys"] == 1 assert snap["bib_entries"] == 2 + def _paper(kind, title, ref, line, done=False, done_date=None): + return {"kind": kind, "title": title, "ref": ref, "done": done, + "done_date": done_date, "line": line} + assert snap["queue"] == [ {"section": "Demo Papers", "count": 2, "done": 1, "papers": [ - {"title": "Title One", "ref": None, "done": False, - "done_date": None, "line": "Title One"}, - {"title": "Title Two", "ref": "2406.01234", "done": False, - "done_date": None, "line": "Title Two โ€” 2406.01234"}, - {"title": "Old Title", "ref": None, "done": True, - "done_date": "2026-06-01", - "line": "DONE 2026-06-01 โ€” Old Title"}]}, - {"section": "Other Things", "count": 2, "done": 0, "papers": [ - {"title": "Title Three", "ref": None, "done": False, - "done_date": None, "line": "Title Three"}, - {"title": "Title Four", "ref": "https://arxiv.org/pdf/2201.00042.pdf", - "done": False, "done_date": None, - "line": "Title Four โ€” https://arxiv.org/pdf/2201.00042.pdf"}]}] + _paper("paper", "Title One", None, "Title One"), + _paper("paper", "Title Two", "2406.01234", + "Title Two โ€” 2406.01234"), + _paper("paper", "Old Title", None, "DONE 2026-06-01 โ€” Old Title", + done=True, done_date="2026-06-01")]}, + # count is 3, not 6: the heading, the remark and the non-arXiv link are + # carried in file order but are not queue work. + {"section": "Other Things", "count": 3, "done": 0, "papers": [ + _paper("paper", "Title Three", None, "Title Three"), + _paper("paper", "Title Four", + "https://arxiv.org/pdf/2201.00042.pdf", + "Title Four โ€” https://arxiv.org/pdf/2201.00042.pdf"), + _paper("subhead", "A Sub Heading", None, "__A Sub Heading__"), + _paper("note", "just a remark", None, "NOTE โ€” just a remark"), + _paper("note", "https://example.org/not-a-paper", None, + "https://example.org/not-a-paper"), + # a bare arXiv link IS a paper โ€” the title was just never written, + # so the id in the line stands in for it + _paper("paper", "arXiv:2202.00099", + "https://arxiv.org/abs/2202.00099", + "https://arxiv.org/abs/2202.00099")]}] # alpha exists; beta/gamma are wanted assert snap["links"]["wanted"] == 2 @@ -368,12 +383,55 @@ def test_no_pdf_button_where_there_is_no_ref(tmp_path): snap = _snap_with_remote(tmp_path) for fmt in ("html", "md"): out = board.render(snap, fmt) - # Title One / Title Three / Suggested Two are ref-less; the only PDF - # links on the page belong to the three papers that carry a ref. - assert out.count("arxiv.org/pdf/") == 3 + # Title One / Title Three / Suggested Two are ref-less, and the + # heading and the two annotations are not papers at all; the only PDF + # links on the page belong to the four papers that carry a ref. + assert out.count("arxiv.org/pdf/") == 4 bare = {"title": "Title One", "ref": None} assert board.paper_pdf_url(bare) == "" # a non-arXiv ref is still a link, but there is no PDF to derive from it assert board.paper_url({"title": "T", "ref": "https://doi.org/10.1/x"}) == \ "https://doi.org/10.1/x" assert board.paper_pdf_url({"title": "T", "ref": "https://doi.org/10.1/x"}) == "" + + +def test_headings_and_notes_render_as_themselves_not_as_papers(tmp_path): + """A `__Bold__` grouping and a `NOTE ` annotation are structure, not work. + + Before line kinds the board rendered both as papers: a search link, three + issue buttons and a slot in the "N waiting" count, for a line that is a + heading or a remark. + """ + snap = _snap_with_remote(tmp_path) + html = board.render(snap, "html") + md = board.render(snap, "md") + + assert "
  • A Sub Heading
  • " in html + assert "- **A Sub Heading**" in md + assert "just a remark" in html and "- _just a remark_" in md + + # neither carries a paper's buttons + for fragment in ("A Sub Heading", "just a remark"): + i = html.index(fragment) + assert "class='act'" not in html[i:html.index("", i)] + assert "issues/new" not in html[i:html.index("", i)] + + # and neither inflates the count: 3 papers waiting in Other Things, not 6 + other = next(q for q in snap["queue"] if q["section"] == "Other Things") + assert other["count"] == 3 + assert "Other Things: 3 waiting" in md + + +def test_a_bare_link_is_a_paper_only_when_it_points_at_arxiv(tmp_path): + snap = _snap_with_remote(tmp_path) + html = board.render(snap, "html") + # the arXiv one becomes a real paper, labelled by its id, with a PDF button + assert "arXiv:2202.00099" in html + assert "https://arxiv.org/pdf/2202.00099" in html + # the other stays an annotation: still clickable, but no paper buttons, + # and labelled by host rather than a wall of URL + assert "
  • ", i)] +