More draft patches for Offpunk from me that I haven’t particularly tested much yet.
Offpunk has no support for slash-containing list names β it just crashes on an attempt, because the names are neither supported by creating directories nor sanitized to not contain slashes. I wrote a patch for that. And another to make the all lists view, and other logic involving iterating through all lists like subscriptions and syncs, traverse them all, flatly for now.
This was related to imagined usage habit for a concept of subscribe-into-list functionality, where I optionally parameterized the #subscribe status marker as #subscribe(β¦), so that the parameter, a name of the target list, would get the updated feed lists instead of the tour, and the tour would get a link to that list instead if anything came up from the sync. That other patch is now published: Subscription into list rather than tour β Offpunk draft feature patch
From 595dffb5098a7af98d617cca23787a5baa51e27c Mon Sep 17 00:00:00 2001
Date: Thu, 29 Jan 2026 19:34:46 +0100
Subject: [PATCH 1/2] Fix/feature for slash-hierarchical list names
---
offpunk.py | 40 ++++++++++++++++++++++++++++------------
1 file changed, 28 insertions(+), 12 deletions(-)
diff --git a/offpunk.py b/offpunk.py
index eeadc42..690682b 100755
--- a/offpunk.py
+++ b/offpunk.py
@@ -1837,24 +1837,40 @@ Use "view XX" where XX is a number to view information about link XX.
display = not self.sync_only
self._go_to_url(url, handle=display)
+ @staticmethod
+ def _list_path(list_name: str) -> tuple[str | bytes,
+ str | bytes,
+ bool]:
+ """
+ :param list_name: name of the list, may be hierarchical
+ :return: 1. path of list file, 2. path of list dir, 3. file exists
+ """
+ listdir = os.path.join(xdg("data"), "lists")
+ list_path_components: list[str] = [listdir] + list_name.split("/")
+ list_filename = ("%s.gmi" % list_path_components[-1])
+ list_path_dir = os.path.join(*list_path_components[:-1])
+ list_path = os.path.join(list_path_dir, list_filename)
+ return list_path, list_path_dir, os.path.exists(list_path)
+
# return the path of the list file if list exists.
# return None if the list doesnβt exist.
- def list_path(self, list):
- listdir = os.path.join(xdg("data"), "lists")
- list_path = os.path.join(listdir, "%s.gmi" % list)
- if os.path.exists(list_path):
- return list_path
- else:
- return None
+ @staticmethod
+ def list_path(list_name: str) -> str | bytes | None:
+ """
+ :param list_name:
+ :return:
+ the path of the list file if list exists.
+ None if the list doesnβt exist.
+ """
+ list_path, _, exists = GeminiClient._list_path(list_name)
+ return list_path if exists else None
def list_create(self, list, title=None, quite=False):
- list_path = self.list_path(list)
+ list_path, dirs, exists = self._list_path(list)
if list in ["create", "edit", "delete", "help"]:
print(_("%s is not allowed as a name for a list") % list)
- elif not list_path:
- listdir = os.path.join(xdg("data"), "lists")
- os.makedirs(listdir, exist_ok=True)
- list_path = os.path.join(listdir, "%s.gmi" % list)
+ elif not exists:
+ os.makedirs(dirs, exist_ok=True)
with open(list_path, "a") as lfile:
if title:
lfile.write("# %s\n" % title)
--
2.52.0
From 524e12b75c02761faaf816eea1f185eeb023d750 Mon Sep 17 00:00:00 2001
Date: Thu, 29 Jan 2026 23:37:41 +0100
Subject: [PATCH 2/2] Reworked all-lists view to traverse slash-named
---
ansicat.py | 40 +++++++++++++++++++++++++++++-----------
netcache.py | 4 ++--
offpunk.py | 34 ++++++++++++++--------------------
3 files changed, 45 insertions(+), 33 deletions(-)
diff --git a/ansicat.py b/ansicat.py
index 6feba36..74b3abe 100755
--- a/ansicat.py
+++ b/ansicat.py
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
import argparse
import base64
+from collections.abc import Sequence, Generator
import fnmatch
import html
import mimetypes
@@ -914,6 +915,26 @@ class GopherRenderer(AbstractRenderer):
r.add_text(line)
return r.get_final(), links
+def list_lists(traverse=True,
+ sub: Sequence[str] = tuple(),
+ path_prefixing=True,
+ listdir=os.path.join(xdg("data"), "lists")) \
+ -> Generator[str]:
+ listdir: str | bytes = os.path.join(listdir, *sub)
+ slashname = "".join(i + "/" for i in sub) if path_prefixing else None
+ if os.path.exists(listdir):
+ lists = os.scandir(listdir)
+ for l in lists:
+ # Taking only files with .gmi
+ if l.name.endswith(".gmi"):
+ # removing the .gmi at the end of the name
+ yield slashname + l.name[:-4]
+ elif l.is_dir():
+ if traverse:
+ yield from list_lists(True, tuple(sub) + (l.name,), path_prefixing)
+ else:
+ yield slashname + l.name + "/"
+
class FolderRenderer(GemtextRenderer):
# it was initialized with:
@@ -922,12 +943,16 @@ class FolderRenderer(GemtextRenderer):
GemtextRenderer.__init__(self, content, url, center)
self.datadir = datadir
+ @property
+ def listdir(self):
+ return os.path.join(self.datadir, "lists")
+
def get_mime(self):
return "Directory"
def prepare(self, body, mode=None):
def get_first_line(l):
- path = os.path.join(listdir, l + ".gmi")
+ path = os.path.join(self.listdir, l + ".gmi")
with open(path) as f:
first_line = f.readline().strip()
f.close()
@@ -947,17 +972,8 @@ class FolderRenderer(GemtextRenderer):
body += "=> %s %s (%s items)\n" % (str(path), li, size)
return body
- listdir = os.path.join(self.datadir, "lists")
self.title = "My lists"
- lists = []
- if os.path.exists(listdir):
- listfiles = os.listdir(listdir)
- if len(listfiles) > 0:
- for l in listfiles:
- #We only take gmi files
- if l.endswith(".gmi"):
- # removing the .gmi at the end of the name
- lists.append(l[:-4])
+ lists = list(list_lists(listdir=self.listdir))
if len(lists) > 0:
body = ""
my_lists = []
@@ -990,6 +1006,8 @@ class FolderRenderer(GemtextRenderer):
body += _("\n## System Lists\n")
body += write_list(system_lists)
return [[body, None]]
+ else:
+ return [[_("\n## No lists found\n"), None]]
class FeedRenderer(GemtextRenderer):
diff --git a/netcache.py b/netcache.py
index c1880f5..dc6533e 100755
--- a/netcache.py
+++ b/netcache.py
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
import argparse
import codecs
+from collections.abc import Sequence, Generator
import datetime
import getpass
import glob
@@ -139,7 +140,6 @@ def is_cache_valid(url, validity=0):
# Thereβs not even a cache!
return False
-
def get_cache_path(url, add_index=True):
# Sometimes, cache_path became a folder! (which happens for index.html/index.gmi)
# In that case, we need to reconstruct it
@@ -166,7 +166,7 @@ def get_cache_path(url, add_index=True):
elif url.startswith("list://"):
listdir = os.path.join(xdg("data"), "lists")
listname = url[7:].lstrip("/")
- if listname in [""]:
+ if listname == "":
name = "My Lists"
path = listdir
else:
diff --git a/offpunk.py b/offpunk.py
index 690682b..f37a816 100755
--- a/offpunk.py
+++ b/offpunk.py
@@ -9,6 +9,8 @@ __version__ = "2.8"
# Initial imports and conditional imports {{{
import argparse
import cmd
+from collections.abc import Sequence, Generator
+from itertools import chain
import os
import os.path
import shutil
@@ -252,11 +254,11 @@ class GeminiClient(cmd.Cmd):
# We need to autocomplete listname for the first or second argument
# If the first one is a cmds
if words <= 1:
- allowed = lists + cmds
+ allowed = chain(lists, cmds)
elif words == 2:
# if text, the completing word is the second
if text:
- allowed = lists + cmds
+ allowed = chain(lists, cmds)
else:
current_cmd = line.split()[1]
if current_cmd in ["help", "create"]:
@@ -1565,8 +1567,8 @@ Use "view XX" where XX is a number to view information about link XX.
def get_list(self, list):
list_path = self.list_path(list)
if not list_path:
- old_file_gmi = os.path.join(xdg("config"), list + ".gmi")
- old_file_nogmi = os.path.join(xdg("config"), list)
+ old_file_gmi: str = os.path.join(xdg("config"), list + ".gmi")
+ old_file_nogmi: str = os.path.join(xdg("config"), list)
target = os.path.join(xdg("data"), "lists")
if os.path.exists(old_file_gmi):
shutil.move(old_file_gmi, target)
@@ -1896,8 +1898,7 @@ Use "view XX" where XX is a number to view information about link XX.
if not list_path:
print(_("%s is not a list, aborting the move") % args[0])
else:
- lists = self.list_lists()
- for l in lists:
+ for l in self.list_lists():
if l != args[0] and l not in ["archives", "history"]:
url = unmode_url(self.current_url)[0]
isremoved = self.list_rm_url(url, l)
@@ -1905,18 +1906,12 @@ Use "view XX" where XX is a number to view information about link XX.
print(_("Removed from %s") % l)
self.list_add_line(args[0])
- def list_lists(self):
- listdir = os.path.join(xdg("data"), "lists")
- to_return = []
- if os.path.exists(listdir):
- lists = os.listdir(listdir)
- if len(lists) > 0:
- for l in lists:
- # Taking only files with .gmi
- if l.endswith(".gmi"):
- # removing the .gmi at the end of the name
- to_return.append(l[:-4])
- return to_return
+ @staticmethod
+ def list_lists(traverse=True,
+ sub: Sequence[str] = tuple(),
+ path_prefixing=True)\
+ -> Generator[str]:
+ return ansicat.list_lists(traverse, sub, path_prefixing)
def list_has_status(self, list, status):
path = self.list_path(list)
@@ -1991,8 +1986,7 @@ Use "view XX" where XX is a number to view information about link XX.
listdir = os.path.join(xdg("data"), "lists")
os.makedirs(listdir, exist_ok=True)
if not arg:
- lists = self.list_lists()
- if len(lists) > 0:
+ if next(self.list_lists()):
lurl = "list:///"
self._go_to_url(lurl)
else:
--
2.52.0
Comments on “Slash-hierarchical list names β my draft implementation for Offpunk”