A link written without a scheme is found too, by www. or a real TLD
meshterm.net/map and www.example.org are links now, as surely as their https forms: a host starting www., or ending in a top-level domain IANA has delegated, read from the registry itself, kept verbatim in assets/tlds.txt. So file.txt stays text, txt being no TLD, and so do a decimal, an abbreviation, an address, and a path. A scheme-less link opens, and its QR code encodes, as https://. Link finding moves out of the chat screen into core/links.py. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c6c80b4c1f
commit
810232cfb6
6 files changed
+1632
-50
No files matched your search
@@ -456,6 +456,11 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
long for its hanging lane steps out to the gutter on a line of its own
|
||||
(`render_hanging(whole=…, gutter=…)`), since a terminal opens a link, and a reader
|
||||
copies one, only while it sits on one line. Only a URL wider than the screen folds.
|
||||
**A link is what `core/links.py` finds**, and nothing guesses beside it: a scheme
|
||||
(`scheme://…`), or a host written without one where nothing else could be meant —
|
||||
starting `www.`, or ending in a TLD IANA has delegated (`assets/tlds.txt`, the registry
|
||||
verbatim; refreshing it is downloading it again). So `meshterm.net` is a link and
|
||||
`file.txt` is not. A scheme-less link opens, and encodes, as `https://…`.
|
||||
- Dialogs anchor slightly above true centre, sized for their populated state.
|
||||
- Radio traffic: single transmissions or a user-chosen sample count with cooldown pacing
|
||||
— never bursts.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,104 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Find the links in a line of text — written with a scheme, or without one where it is plain.
|
||||
|
||||
A link with a scheme is anything ``scheme://…``: a scheme rather than a list of them, so a
|
||||
``meshcore://`` share pasted into a chat is found as surely as a web link is. One written
|
||||
without a scheme is accepted only where nothing else could be meant — a host starting
|
||||
``www.``, or one whose last label is a top-level domain IANA has delegated
|
||||
(``meshterm.net``, ``github.com/…``). So ``file.txt`` is not a link, ``txt`` being no TLD,
|
||||
and neither is ``3.14``, ``e.g.`` or an address like ``jp@meshterm.net``: a host only
|
||||
counts where a word begins, never inside an address, a path or another host.
|
||||
|
||||
The registry is IANA's own list, kept verbatim in ``assets/tlds.txt`` with its version
|
||||
line on top; refreshing it is downloading the file again
|
||||
(https://data.iana.org/TLD/tlds-alpha-by-domain.txt). Some delegated TLDs are also file
|
||||
extensions — ``.py``, ``.md``, ``.zip`` — and a name ending in one reads as a link here.
|
||||
That is the registry's answer rather than a guess, and the cost of it is a code offered
|
||||
for a link nobody meant, which is the cheaper way to be wrong.
|
||||
|
||||
Every link is handed back as the URL it opens: one written without a scheme gains
|
||||
``https://``, because a phone handed a bare host in a QR code reads it as text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
|
||||
#: IANA's list of delegated top-level domains, one per line, upper case, ``#`` comments.
|
||||
TLDS_FILE = Path(__file__).resolve().parent.parent / "assets" / "tlds.txt"
|
||||
|
||||
#: One host label: letters, digits and inner hyphens, at most 63 long.
|
||||
_LABEL = r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
|
||||
|
||||
#: A link with a scheme: the scheme, ``://``, and everything up to the next space.
|
||||
_SCHEMED = r"\b[a-z][a-z0-9+.-]*://[^\s<>\"]+"
|
||||
|
||||
#: A host written without a scheme, then an optional port and path. It must start a word —
|
||||
#: not follow a letter, a digit, ``@`` (an address), ``/`` or ``:`` (a path), or a ``.``
|
||||
#: or ``-`` (the middle of a longer name) — and its last label must be all letters, the
|
||||
#: shape of every TLD, so a version number or a decimal never gets this far.
|
||||
_BARE = (
|
||||
r"(?<![\w@/:.-])"
|
||||
rf"(?P<host>(?:{_LABEL}\.)+(?P<tld>[a-z]{{2,63}}))(?![\w-])"
|
||||
r"(?::\d{1,5})?"
|
||||
r"(?:[/?#][^\s<>\"]*)?"
|
||||
)
|
||||
|
||||
#: Either kind. The scheme comes first, so a host inside a scheme's link is consumed with
|
||||
#: it and never found a second time on its own.
|
||||
_LINK = re.compile(rf"(?P<scheme>{_SCHEMED})|(?P<bare>{_BARE})", re.IGNORECASE)
|
||||
|
||||
#: Punctuation that follows a link in a sentence and isn't part of it.
|
||||
_TRAILING = ".,;:!?'\""
|
||||
|
||||
#: Each closing bracket and its opener: a closer ends a link only when it has no opener
|
||||
#: inside it, so ``(see https://example.com/a_(b))`` keeps the ``)`` that belongs to it.
|
||||
_CLOSERS = {")": "(", "]": "[", "}": "{"}
|
||||
|
||||
|
||||
@cache
|
||||
def tlds() -> frozenset[str]:
|
||||
"""Every delegated top-level domain, lower case — read once, on the first link looked for."""
|
||||
lines = TLDS_FILE.read_text(encoding="ascii").splitlines()
|
||||
return frozenset(line.strip().lower() for line in lines if line.strip()[:1] not in ("", "#"))
|
||||
|
||||
|
||||
def _trimmed(link: str) -> str:
|
||||
"""``link`` without the sentence's punctuation after it, or a bracket it never opened."""
|
||||
while link:
|
||||
last = link[-1]
|
||||
if last in _TRAILING or (
|
||||
last in _CLOSERS and link.count(last) > link.count(_CLOSERS[last])
|
||||
):
|
||||
link = link[:-1]
|
||||
else:
|
||||
break
|
||||
return link
|
||||
|
||||
|
||||
def urls(text: str) -> list[str]:
|
||||
"""Every link in ``text`` as the URL it opens, in reading order, each only once.
|
||||
|
||||
Trailing punctuation is the sentence's, not the link's; a link written without a
|
||||
scheme comes back with ``https://`` in front of it, so ``meshterm.net`` and
|
||||
``https://meshterm.net`` in one message are the same link, found once.
|
||||
"""
|
||||
found: list[str] = []
|
||||
for match in _LINK.finditer(text):
|
||||
link = _trimmed(match.group())
|
||||
if match.group("scheme"):
|
||||
if link.endswith("://"):
|
||||
continue # a scheme and nothing after it
|
||||
else:
|
||||
host = match.group("host").lower()
|
||||
if not host.startswith("www.") and match.group("tld").lower() not in tlds():
|
||||
continue
|
||||
link = "https://" + link
|
||||
if link not in found:
|
||||
found.append(link)
|
||||
return found
|
||||
|
||||
|
||||
__all__ = ["TLDS_FILE", "tlds", "urls"]
|
||||
+4
-33
@@ -24,6 +24,7 @@ from typing import TYPE_CHECKING, Any
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.text import Text
|
||||
|
||||
from ..core import links
|
||||
from ..core.channels import MENTION, split_channel_sender
|
||||
from ..core.connection import ContactNotOnDeviceError
|
||||
from ..core.events import EventKind, MeshEvent
|
||||
@@ -55,18 +56,6 @@ if TYPE_CHECKING:
|
||||
_DELIVERED = ("✓", "ok")
|
||||
_FAILED = ("✗", "err")
|
||||
|
||||
#: A URL in a message body: a scheme, ``://``, and everything up to the next space. A
|
||||
#: scheme rather than a list of them, so a ``meshcore://`` share pasted into a chat gets
|
||||
#: its code as surely as a web link does.
|
||||
_URL = re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s<>\"]+", re.IGNORECASE)
|
||||
|
||||
#: Punctuation that follows a URL in a sentence and isn't part of it.
|
||||
_TRAILING = ".,;:!?'\""
|
||||
|
||||
#: Each closing bracket and its opener: a closer ends a URL only when it has no opener
|
||||
#: inside it, so ``(see https://example.com/a_(b))`` keeps the ``)`` that belongs to it.
|
||||
_CLOSERS = {")": "(", "]": "[", "}": "{"}
|
||||
|
||||
#: A whitespace-delimited word — the unit the wrap moves whole, so the run a URL must not be
|
||||
#: cut across is the word it sits in, any bracket or full stop beside it included.
|
||||
_WORD = re.compile(r"\S+")
|
||||
@@ -87,24 +76,6 @@ def _bind(platform: Platform) -> None:
|
||||
_URL_CODES = platform.url_codes
|
||||
|
||||
|
||||
def _urls(body: str) -> list[str]:
|
||||
"""Every URL in ``body`` in reading order, each only once, without trailing punctuation."""
|
||||
found: list[str] = []
|
||||
for match in _URL.finditer(body):
|
||||
url = match.group()
|
||||
while url:
|
||||
last = url[-1]
|
||||
if last in _TRAILING or (
|
||||
last in _CLOSERS and url.count(last) > url.count(_CLOSERS[last])
|
||||
):
|
||||
url = url[:-1]
|
||||
else:
|
||||
break
|
||||
if not url.endswith("://") and url not in found:
|
||||
found.append(url)
|
||||
return found
|
||||
|
||||
|
||||
class ChatScreen(Screen):
|
||||
"""A live conversation: a scrolling transcript above a pinned input line.
|
||||
|
||||
@@ -609,11 +580,11 @@ class ChatScreen(Screen):
|
||||
prefix.append("❯ " if selected else " ", style="cursor" if selected else None)
|
||||
prefix.append(f"{stamp:%H:%M} ", style="cursor" if selected else "muted")
|
||||
body_text = self._body_text(body, message, selected=selected)
|
||||
whole = [m.span() for m in _WORD.finditer(body_text.plain) if _URL.search(m.group())]
|
||||
whole = [m.span() for m in _WORD.finditer(body_text.plain) if links.urls(m.group())]
|
||||
lines = render_hanging(
|
||||
prefix, body_text, width, indent=prefix.cell_len, whole=whole, gutter=_URL_GUTTER
|
||||
)
|
||||
urls = _urls(body) if _URL_CODES else []
|
||||
urls = links.urls(body) if _URL_CODES else []
|
||||
if urls:
|
||||
lines += render_lines(qr_strip(urls, width, indent=prefix.cell_len), width)
|
||||
return lines
|
||||
@@ -836,7 +807,7 @@ class ChatScreen(Screen):
|
||||
if self._selected is None or not self._messages:
|
||||
return []
|
||||
message = self._messages[max(0, min(self._selected, len(self._messages) - 1))]
|
||||
return _urls(self._sender_and_body(message)[1])
|
||||
return links.urls(self._sender_and_body(message)[1])
|
||||
|
||||
def _open_codes(self) -> None:
|
||||
"""Show the picked message's links as QR codes on the share screen (^U).
|
||||
|
||||
+21
-17
@@ -2332,23 +2332,6 @@ def _decode_braille(rows: list[str], left: int, size: int) -> list[list[bool]]:
|
||||
]
|
||||
|
||||
|
||||
def test_urls_are_found_whole_once_and_without_the_sentence_around_them() -> None:
|
||||
"""A URL ends where its sentence's punctuation begins, and keeps a bracket it opened."""
|
||||
from meshterm.ui.chat import _urls
|
||||
|
||||
body = (
|
||||
"map at https://meshterm.net. see (https://en.wikipedia.org/wiki/Foo_(bar)), "
|
||||
"'https://example.org/a?b=1' and meshcore://channel/add?name=Ops&secret=ab — "
|
||||
"again https://meshterm.net! nothing here: https:// or note:x"
|
||||
)
|
||||
assert _urls(body) == [
|
||||
"https://meshterm.net",
|
||||
"https://en.wikipedia.org/wiki/Foo_(bar)",
|
||||
"https://example.org/a?b=1",
|
||||
"meshcore://channel/add?name=Ops&secret=ab",
|
||||
]
|
||||
|
||||
|
||||
def test_on_the_picocalc_each_url_hangs_a_code_under_its_message_side_by_side() -> None:
|
||||
"""Two links, two braille codes on the same rows, hung at the body's own indent.
|
||||
|
||||
@@ -2530,3 +2513,24 @@ def test_ctrl_u_is_named_only_on_a_picked_message_with_a_link() -> None:
|
||||
for screen in (linked, direct, scoped):
|
||||
assert "^U QR" in screen.footer_hint
|
||||
assert len(screen.footer_hint) <= 72, screen.footer_hint
|
||||
|
||||
|
||||
def test_a_link_written_without_a_scheme_gets_its_code_too() -> None:
|
||||
"""``meshterm.net/map`` is as much a link as its https form, and opens as that."""
|
||||
from meshterm.ui.qr import QrScreen
|
||||
|
||||
opened: list = []
|
||||
|
||||
class Session(_StubSession):
|
||||
async def run_screen(self, screen): # noqa: ANN001, ANN202
|
||||
opened.append(screen)
|
||||
|
||||
async def scenario() -> None:
|
||||
screen = _url_chat("map at meshterm.net/map, notes in file.txt", Session())
|
||||
screen.handle("up")
|
||||
assert "^U QR" in screen.footer_hint
|
||||
screen.handle("url_code")
|
||||
await asyncio.sleep(0)
|
||||
assert isinstance(opened[0], QrScreen) and opened[0].urls == ("https://meshterm.net/map",)
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -0,0 +1,60 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Finding links in a line of text — with a scheme, and without one where it is plain."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from meshterm.core.links import TLDS_FILE, tlds, urls
|
||||
|
||||
|
||||
def test_urls_are_found_whole_once_and_without_the_sentence_around_them() -> None:
|
||||
"""A URL ends where its sentence's punctuation begins, and keeps a bracket it opened."""
|
||||
body = (
|
||||
"map at https://meshterm.net. see (https://en.wikipedia.org/wiki/Foo_(bar)), "
|
||||
"'https://example.org/a?b=1' and meshcore://channel/add?name=Ops&secret=ab — "
|
||||
"again https://meshterm.net! nothing here: https:// or note:x"
|
||||
)
|
||||
assert urls(body) == [
|
||||
"https://meshterm.net",
|
||||
"https://en.wikipedia.org/wiki/Foo_(bar)",
|
||||
"https://example.org/a?b=1",
|
||||
"meshcore://channel/add?name=Ops&secret=ab",
|
||||
]
|
||||
|
||||
|
||||
def test_a_link_without_a_scheme_is_found_by_www_or_a_delegated_tld() -> None:
|
||||
"""``www.`` or a real TLD says *link*; each comes back as the https URL it opens."""
|
||||
body = (
|
||||
"map at meshterm.net/map, flasher at flasher.meshcore.co.uk. "
|
||||
"WWW.Example.org (or www.anything.local) and github.com/jpmartineau/MeshTerm?tab=1!"
|
||||
)
|
||||
assert urls(body) == [
|
||||
"https://meshterm.net/map",
|
||||
"https://flasher.meshcore.co.uk",
|
||||
"https://WWW.Example.org",
|
||||
"https://www.anything.local", # www. is enough on its own; .local is no TLD
|
||||
"https://github.com/jpmartineau/MeshTerm?tab=1",
|
||||
]
|
||||
|
||||
|
||||
def test_a_dotted_word_that_is_no_link_stays_text() -> None:
|
||||
"""A file, a number, an abbreviation, an address, a path: no TLD, or not a word's start."""
|
||||
body = (
|
||||
"see file.txt and notes.pdf, pi is 3.14 and v1.2.3, e.g. this, i.e. that, "
|
||||
"mail jp@meshterm.net, open C:/logs/meshterm.log or ./run.sh, @Alice.Smith"
|
||||
)
|
||||
assert urls(body) == []
|
||||
|
||||
|
||||
def test_the_same_link_written_two_ways_is_found_once() -> None:
|
||||
"""``meshterm.net`` and ``https://meshterm.net`` open the same page — one code, not two."""
|
||||
assert urls("meshterm.net or https://meshterm.net") == ["https://meshterm.net"]
|
||||
# A host inside a schemed link is part of it, never a second link of its own.
|
||||
assert urls("https://github.com/x/meshterm.net") == ["https://github.com/x/meshterm.net"]
|
||||
|
||||
|
||||
def test_the_tld_registry_is_ianas_list_read_whole() -> None:
|
||||
"""The file is IANA's, version line and all; the set is every TLD in it, lower case."""
|
||||
assert TLDS_FILE.read_text(encoding="ascii").startswith("# Version ")
|
||||
assert {"com", "net", "org", "dev", "io", "ca", "uk"} <= tlds()
|
||||
assert not {"txt", "pdf", "log", "local"} & tlds()
|
||||
assert len(tlds()) > 1000
|
||||
Reference in new issue
Block a user