Compare commits

...

22 Commits

Author SHA1 Message Date
nervuri b216653887 Begin README with fork description. 2022-05-23 00:00:00 +00:00
nervuri d31b41ad1f Update README. 2021-12-26 20:54:48 +02:00
nervuri 65160e927a Exclude "\n" character from fragment highlight. 2021-12-26 20:37:33 +02:00
nervuri c186417856 Refactor code, for clarity. 2021-12-25 20:45:43 +02:00
nervuri c55563113a Fix fragment highlighting bug.
Fix fragment highlighting when multiple matches are found on the same line.
2021-12-25 20:38:01 +02:00
nervuri 07291e828a Fix display bug in `less raw` 2021-12-25 20:34:53 +02:00
nervuri 71ff278c59 Add more polish to fragment generation commands.
1. Use heading numbers instead of line numbers in `fragment -h`.
2. Open `fr -h` and `fr -l` output in `less`, if it doesn't fit in the
terminal.
3. A bit of refactoring, for clarity.
2021-12-25 13:54:24 +02:00
nervuri 5ce221ae8b Remove -G option in do_less. 2021-12-21 12:20:21 +02:00
nervuri 7d82c27b05 Change fragment match prefix to U+0016 x 3.
Thanks to John Cowan for suggesting it:

https://gitlab.com/gemini-specification/gemini-text/-/issues/3#note_790663541
2021-12-21 12:16:37 +02:00
nervuri 733540e2eb Add polish to fragment generation commands. 2021-12-21 01:33:11 +02:00
nervuri a469ce515a Fix fragment-related display bug. 2021-12-20 21:25:25 +02:00
nervuri 60613529b0 Generate fragment by line or heading, to a specific occurrence of repeated text. 2021-12-20 21:17:26 +02:00
nervuri 5ccd2a57da Search for specific occurrence of fragment text. 2021-12-20 21:14:36 +02:00
nervuri dc2503c89d Remove fragment type prefixes. 2021-12-20 18:27:42 +02:00
nervuri 548f984e0d Generate heading fragments by index. 2021-12-19 22:32:42 +02:00
nervuri 74da61846f Improve handling of URI fragments. 2021-12-19 22:26:45 +02:00
nervuri dc15d27991 Move gemtext parsing to a separate function. 2021-12-19 22:21:28 +02:00
nervuri 54bb392684 View raw gemtext with `cat raw` & `less raw`. 2021-12-19 22:19:54 +02:00
nervuri 07bd86f935 Remove regex library import. 2021-12-19 22:18:05 +02:00
nervuri dda21ff72d Fragment types: remove "regex", add "heading". 2021-12-19 15:17:54 +02:00
nervuri aa9693f478 Add fork explanation. 2021-12-17 20:42:58 +02:00
nervuri 996dc9f081 Add support for URI fragments. 2021-12-17 18:50:55 +02:00
2 changed files with 270 additions and 38 deletions

View File

@ -1,3 +1,14 @@
This is a fork of [AV-98](https://tildegit.org/solderpunk/AV-98) with
experimental URI fragment support. It introduces the `fragment` (`fr`)
command to generate URIs with fragments, which are used as search
queries in the page (like Ctrl+F in modern web browsers).
* Demo: https://asciinema.org/a/458615
* Old demo: https://asciinema.org/a/456941
* Spec discussion: https://gitlab.com/gemini-specification/gemini-text/-/issues/3
---
# AV-98
AV-98 is an experimental client for the

297
av98.py
View File

@ -66,6 +66,7 @@ _ABBREVS = {
"book": "bookmarks",
"f": "fold",
"fo": "forward",
"fr": "fragment",
"g": "go",
"h": "history",
"hist": "history",
@ -136,6 +137,7 @@ class GeminiItem():
self.host = parsed.hostname
self.port = parsed.port or standard_ports.get(self.scheme, 0)
self.path = parsed.path
self.fragment = parsed.fragment
def root(self):
return GeminiItem(self._derive_url("/"))
@ -745,12 +747,10 @@ you'll be able to transparently follow links to Gopherspace!""")
def _validate_cert(self, address, host, cert):
"""
Validate a TLS certificate in TOFU mode.
If the cryptography module is installed:
- Check the certificate Common Name or SAN matches `host`
- Check the certificate's not valid before date is in the past
- Check the certificate's not valid after date is in the future
Whether the cryptography module is installed or not, check the
certificate's fingerprint against the TOFU database to see if we've
previously encountered a different certificate for this IP address and
@ -884,6 +884,40 @@ you'll be able to transparently follow links to Gopherspace!""")
self._debug("Using handler: %s" % cmd_str)
return cmd_str
def _parse_gemtext_line(self, line, menu_gi, preformatted = False):
rendered_line = ''
if line.startswith("```"):
preformatted = not preformatted
elif preformatted:
rendered_line = line + "\n"
elif line.startswith("=>"):
try:
gi = GeminiItem.from_map_line(line, menu_gi)
self.index.append(gi)
rendered_line = self._format_geminiitem(len(self.index), gi) + "\n"
except:
self._debug("Skipping possible link: %s" % line)
elif line.startswith("* "):
rendered_line = line[1:].lstrip("\t ")
rendered_line = textwrap.fill(rendered_line, self.options["width"],
initial_indent = "", subsequent_indent=" ") + "\n"
elif line.startswith(">"):
rendered_line = line[1:].lstrip("\t ")
rendered_line = textwrap.fill(rendered_line, self.options["width"],
initial_indent = "> ", subsequent_indent="> ") + "\n"
elif line.startswith("###"):
rendered_line = line[3:].lstrip("\t ")
rendered_line = "\x1b[4m" + rendered_line + "\x1b[0m""\n"
elif line.startswith("##"):
rendered_line = line[2:].lstrip("\t ")
rendered_line = "\x1b[1m" + rendered_line + "\x1b[0m""\n"
elif line.startswith("#"):
rendered_line = line[1:].lstrip("\t ")
rendered_line = "\x1b[1m\x1b[4m" + rendered_line + "\x1b[0m""\n"
else:
rendered_line = textwrap.fill(line, self.options["width"]) + "\n"
return (rendered_line, preformatted)
def _handle_gemtext(self, body, menu_gi, display=True):
self.index = []
preformatted = False
@ -891,37 +925,59 @@ you'll be able to transparently follow links to Gopherspace!""")
os.unlink(self.idx_filename)
tmpf = tempfile.NamedTemporaryFile("w", encoding="UTF-8", delete=False)
self.idx_filename = tmpf.name
# Percent-decode URI fragment (<text>[:<occurrence>]).
fragment_text = ''
fragment_occurrence_number = 0 # 0 -> all occurrences
if menu_gi.fragment:
fragment_text = urllib.parse.unquote(menu_gi.fragment.split(':')[0])
try:
fragment_occurrence_number = int(menu_gi.fragment.split(':')[1])
except:
pass
match_all_fragment_occurrences = fragment_occurrence_number == 0
# Invisible chars for `less` to search on:
fragment_match_prefix = '\x16\x16\x16'
# Number of times fragment was found in the raw gemtext:
occurrence_count = 0
for line in body.splitlines():
if line.startswith("```"):
preformatted = not preformatted
elif preformatted:
tmpf.write(line + "\n")
elif line.startswith("=>"):
try:
gi = GeminiItem.from_map_line(line, menu_gi)
self.index.append(gi)
tmpf.write(self._format_geminiitem(len(self.index), gi) + "\n")
except:
self._debug("Skipping possible link: %s" % line)
elif line.startswith("* "):
line = line[1:].lstrip("\t ")
tmpf.write(textwrap.fill(line, self.options["width"],
initial_indent = "", subsequent_indent=" ") + "\n")
elif line.startswith(">"):
line = line[1:].lstrip("\t ")
tmpf.write(textwrap.fill(line, self.options["width"],
initial_indent = "> ", subsequent_indent="> ") + "\n")
elif line.startswith("###"):
line = line[3:].lstrip("\t ")
tmpf.write("\x1b[4m" + line + "\x1b[0m""\n")
elif line.startswith("##"):
line = line[2:].lstrip("\t ")
tmpf.write("\x1b[1m" + line + "\x1b[0m""\n")
elif line.startswith("#"):
line = line[1:].lstrip("\t ")
tmpf.write("\x1b[1m\x1b[4m" + line + "\x1b[0m""\n")
else:
tmpf.write(textwrap.fill(line, self.options["width"]) + "\n")
# Parse gemtext.
rendered_line, preformatted = self._parse_gemtext_line(
line, menu_gi, preformatted)
# Highlight fragment matches.
if fragment_text and fragment_text in line:
# Count how many times fragment_text is found on this
# line and add this count to the total occurrence_count.
# If the occurrence number from the fragment is between
# previous count and current count, then we have a match.
previous_occurrence_count = occurrence_count
occurrence_count += line.count(fragment_text)
if match_all_fragment_occurrences\
or previous_occurrence_count < fragment_occurrence_number <= occurrence_count:
# Found one or more matches on this line of raw gemtext.
# In order to highlight it/them on the rendered gemtext,
# check if the fragment still matches the same number of
# times. If not, highlight the entire line.
if line.count(fragment_text) == rendered_line.count(fragment_text)\
and (match_all_fragment_occurrences
or fragment_occurrence_number == occurrence_count):
# Highlight the exact match.
rendered_line = rendered_line.replace(fragment_text,
"\x1b[7m" + fragment_match_prefix\
+ fragment_text + "\x1b[0m")
else:
# Highlight the entire line.
rendered_line = "\x1b[7m" + fragment_match_prefix\
+ rendered_line.rstrip("\n") + "\x1b[0m" + "\n"
# Write to file.
if rendered_line:
tmpf.write(rendered_line)
tmpf.close()
self.lookup = self.index
@ -932,6 +988,17 @@ you'll be able to transparently follow links to Gopherspace!""")
cmd_str = self._get_handler_cmd("text/gemini")
subprocess.call(shlex.split(cmd_str % self.idx_filename))
# If URI fragment is present, open the page in Less and
# search for the fragment string.
if fragment_text:
# cat temp_file | less -rGp "\x12%s"
# Prefix the search string with ^R (ascii code 12),
# so that Less does not interpret it as regex.
process1 = subprocess.Popen(('cat', self.idx_filename),
stdout=subprocess.PIPE)
subprocess.call(('less', '-rGp', '\x12%s' % fragment_match_prefix),
stdin=process1.stdout)
def _format_geminiitem(self, index, gi, url=False):
protocol = "" if gi.scheme == "gemini" else " %s" % gi.scheme
line = "[%d%s] %s" % (index, protocol, gi.name or gi.url)
@ -1275,7 +1342,6 @@ you'll be able to transparently follow links to Gopherspace!""")
def do_tour(self, line):
"""Add index items as waypoints on a tour, which is basically a FIFO
queue of gemini items.
Items can be added with `tour 1 2 3 4` or ranges like `tour 1-4`.
All items in current menu can be added with `tour *`.
Current tour can be listed with `tour ls` and scrubbed with `tour clear`."""
@ -1379,15 +1445,170 @@ Use 'ls -l' to see URLs."""
### Stuff that does something to most recently viewed item
@needs_gi
def do_cat(self, *args):
"""Run most recently visited item through "cat" command."""
subprocess.call(shlex.split("cat %s" % self._get_active_tmpfile()))
"""Run most recently visited item through "cat" command.
'cat raw' displays the raw version of the item."""
tmp_file = self.tmp_filename if args[0] == 'raw'\
else self._get_active_tmpfile()
subprocess.call(shlex.split("cat %s" % tmp_file))
@needs_gi
def do_less(self, *args):
"""Run most recently visited item through "less" command."""
"""Run most recently visited item through "less" command.
'less raw' displays the raw version of the item."""
if args[0] == 'raw':
tmp_file = self.tmp_filename
less_opt = '-R'
else:
tmp_file = self._get_active_tmpfile()
less_opt = '-r'
cmd_str = self._get_handler_cmd(self.mime)
cmd_str = cmd_str % self._get_active_tmpfile()
subprocess.call("%s | less -R" % cmd_str, shell=True)
cmd_str = cmd_str % tmp_file
subprocess.call("%s | less %s" % (cmd_str, less_opt), shell=True)
@needs_gi
def do_fragment(self, *args):
"""Add search fragment to the current URI.
Options: -t (text), -h (heading), -l (line).
Select Nth occurence of text: -tN."""
err = """Please provide text to include in the fragment.
Options: -t (text), -h (heading), -l (line).
Select Nth occurence of text: -tN."""
if not self.mime == 'text/gemini':
print("Can't generate fragment for %s." % self.mime)
return
# Get fragment text and occurrence number.
fragment_text = args[0]
occurrence = 0
if args[0][0:2] in ('-t', '-h', '-l'):
split = args[0][2:].split(' ')
if split[0].isdigit():
occurrence = int(split[0])
if len(split) > 1 or not occurrence:
fragment_text = ' '.join(split[1:]) # all but the first element
else:
fragment_text = ''
# Select heading.
if args[0].startswith('-h'):
if not fragment_text:
# Display all headings, numbered.
heading_number = 0
numbered_headings = ''
with open(self.tmp_filename, 'r') as raw_gemtext_file:
for line in raw_gemtext_file.read().splitlines():
if line.startswith('#'):
heading_number += 1
numbered_headings += str(heading_number)\
.rjust(4) + ' ' + line + '\n'
# Remove trailing line break.
numbered_headings = numbered_headings.rstrip()
print(numbered_headings)
# If output does not fit in terminal, open it in Less.
if shutil.get_terminal_size()[1] < heading_number:
process1 = subprocess.Popen(('echo', numbered_headings),
stdout=subprocess.PIPE)
subprocess.call('less', stdin=process1.stdout)
return
elif fragment_text.isdigit():
# Heading number provided.
# Generate fragment for heading N.
occurrence = 0
selected_heading_number = int(fragment_text)
with open(self.tmp_filename, 'r') as raw_gemtext_file:
raw_gemtext = raw_gemtext_file.read()
# 1st pass - get line text
match_found = False
heading_number = 0
for line in raw_gemtext.splitlines():
if line.startswith('#'):
heading_number += 1
if heading_number == selected_heading_number:
match_found = True
fragment_text = line
break
if not match_found:
print("Heading not found. This page has "\
+ str(heading_number) + " headings.")
return
# 2nd pass - get occurrence number, if more than 1 match.
if raw_gemtext.count(fragment_text) > 1:
heading_number = 0
for line in raw_gemtext.splitlines():
occurrence += line.count(fragment_text)
if line.startswith('#'):
heading_number += 1
if heading_number == selected_heading_number:
break
elif not fragment_text.isdigit():
print("Please provide heading line number (run `fr -h` to list them).")
return
# Select line.
elif args[0].startswith('-l'):
if not fragment_text:
# Display file with line numbers.
line_number = 0
numbered_lines = ''
with open(self.tmp_filename, 'r') as raw_gemtext_file:
for line in raw_gemtext_file.read().splitlines():
line_number += 1
numbered_lines += str(line_number)\
.rjust(4) + ' ' + line + '\n'
# Remove trailing line break.
numbered_lines = numbered_lines.rstrip()
print(numbered_lines)
# If output does not fit in terminal, open it in Less.
if shutil.get_terminal_size()[1] < line_number:
cmd_str = self._get_handler_cmd(self.mime)
cmd_str = cmd_str % self.tmp_filename
subprocess.call("%s | less -N" % cmd_str, shell=True)
return
elif fragment_text.isdigit():
# Line number provided.
# Generate fragment for line N.
occurrence = 0
selected_line_number = int(fragment_text)
with open(self.tmp_filename, 'r') as raw_gemtext_file:
raw_gemtext = raw_gemtext_file.read()
# 1st pass - get line text
match_found = False
line_number = 0
for line in raw_gemtext.splitlines():
line_number += 1
if line_number == selected_line_number:
match_found = True
fragment_text = line
break
if not match_found:
print("Line not found. This page has "\
+ str(line_number) + " lines.")
return
if not fragment_text:
print("This line is empty.")
return
# 2nd pass - get occurrence number, if more than 1 match.
if raw_gemtext.count(fragment_text) > 1:
line_number = 0
for line in raw_gemtext.splitlines():
line_number += 1
occurrence += line.count(fragment_text)
if line_number == selected_line_number:
break
elif not fragment_text.isdigit():
print("Please provide line number (run `fr -l` to list them).")
return
if not fragment_text:
print(err)
return
# Convert occurrence number to string.
occurrence = ':%d' % occurrence if occurrence else ''
# Percent-encode input, append as fragment and visit the new URI.
uri_without_fragment = self.gi.url.split('#')[0]
self.do_go(uri_without_fragment\
+ '#' + urllib.parse.quote(fragment_text) + occurrence)
@needs_gi
def do_fold(self, *args):