Compare commits
22 Commits
Author | SHA1 | Date |
---|---|---|
nervuri | b216653887 | |
nervuri | d31b41ad1f | |
nervuri | 65160e927a | |
nervuri | c186417856 | |
nervuri | c55563113a | |
nervuri | 07291e828a | |
nervuri | 71ff278c59 | |
nervuri | 5ce221ae8b | |
nervuri | 7d82c27b05 | |
nervuri | 733540e2eb | |
nervuri | a469ce515a | |
nervuri | 60613529b0 | |
nervuri | 5ccd2a57da | |
nervuri | dc2503c89d | |
nervuri | 548f984e0d | |
nervuri | 74da61846f | |
nervuri | dc15d27991 | |
nervuri | 54bb392684 | |
nervuri | 07bd86f935 | |
nervuri | dda21ff72d | |
nervuri | aa9693f478 | |
nervuri | 996dc9f081 |
11
README.md
11
README.md
|
@ -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
297
av98.py
|
@ -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):
|
||||
|
|
Loading…
Reference in New Issue