Implemented rngit work doc management

This commit is contained in:
Mark Qvist
2026-05-05 17:40:57 +02:00
parent 15320e4d2c
commit 7731e799f4
3 changed files with 1261 additions and 28 deletions
+231 -7
View File
@@ -58,6 +58,8 @@ class NomadNetworkNode():
PATH_STATS = "/page/stats.mu"
PATH_RELEASES = "/page/releases.mu"
PATH_RELEASE = "/page/release.mu"
PATH_WORK = "/page/work.mu"
PATH_WORK_DOC = "/page/work_doc.mu"
PATH_ARTIFACT = "/file/artifact"
PATH_DOWNLOAD = "/file/download"
@@ -78,6 +80,7 @@ class NomadNetworkNode():
U_ICON_STATS = "🗠"
U_ICON_HEART = ""
U_ICON_PACKAGE = ""
U_ICON_WORK = ""
NF_ICON_SEP = ""
NF_ICON_FOLDER = "󰉖"
@@ -88,6 +91,7 @@ class NomadNetworkNode():
NF_ICON_STATS = ""
NF_ICON_HEART = "󰋑"
NF_ICON_PACKAGE = "󰏗"
NF_ICON_WORK = "󱌣"
CLR_FOLDER = "`Ffe6"
CLR_FILE = "`F66d"
@@ -122,6 +126,8 @@ class NomadNetworkNode():
self.templates["commit"] = DEFAULT_COMMIT_TEMPLATE
self.templates["refs"] = DEFAULT_REFS_TEMPLATE
self.templates["stats"] = DEFAULT_STATS_TEMPLATE
self.templates["work"] = DEFAULT_WORK_TEMPLATE
self.templates["work_doc"] = DEFAULT_WORK_DOC_TEMPLATE
self.templatesdir = self.owner.configdir+"/templates"
self.use_nerdfonts = self.USE_NERDFONTS
self.highlight_syntax = True
@@ -160,6 +166,7 @@ class NomadNetworkNode():
elif name == "stats": return self.NF_ICON_STATS
elif name == "heart": return self.NF_ICON_HEART
elif name == "package": return self.NF_ICON_PACKAGE
elif name == "work": return self.NF_ICON_WORK
else: return ""
else:
@@ -172,6 +179,7 @@ class NomadNetworkNode():
elif name == "stats": return self.U_ICON_STATS
elif name == "heart": return self.U_ICON_HEART
elif name == "package": return self.U_ICON_PACKAGE
elif name == "work": return self.U_ICON_WORK
else: return ""
def jobs(self):
@@ -207,6 +215,8 @@ class NomadNetworkNode():
self.destination.register_request_handler(self.PATH_STATS, response_generator=self.serve_stats_page, allow=RNS.Destination.ALLOW_ALL)
self.destination.register_request_handler(self.PATH_RELEASES, response_generator=self.serve_releases_page, allow=RNS.Destination.ALLOW_ALL)
self.destination.register_request_handler(self.PATH_RELEASE, response_generator=self.serve_release_page, allow=RNS.Destination.ALLOW_ALL)
self.destination.register_request_handler(self.PATH_WORK, response_generator=self.serve_work_page, allow=RNS.Destination.ALLOW_ALL)
self.destination.register_request_handler(self.PATH_WORK_DOC, response_generator=self.serve_work_doc_page, allow=RNS.Destination.ALLOW_ALL)
self.destination.register_request_handler(self.PATH_ARTIFACT, response_generator=self.serve_artifact, allow=RNS.Destination.ALLOW_ALL)
self.destination.register_request_handler(self.PATH_DOWNLOAD, response_generator=self.serve_download, allow=RNS.Destination.ALLOW_ALL)
@@ -331,7 +341,7 @@ class NomadNetworkNode():
group_name = data.get("var_g", "") if data else ""
if not group_name:
content = self.m_heading("Error", 2) + "\nInvalid request.\n"
content = self.m_heading("Error", 2) + "\nInvalid request\n"
return self.render_template(content, st=st)
accessible_repos = self.get_accessible_repositories(remote_identity, group_name)
@@ -377,7 +387,7 @@ class NomadNetworkNode():
thanks = True if data.get("var_thanks", "") else False
if not group_name or not repo_name:
content = self.m_heading("Error", 2) + "\nInvalid request.\n"
content = self.m_heading("Error", 2) + "\nInvalid request\n"
return self.render_template(content, st=st)
content_parts = []
@@ -408,6 +418,11 @@ class NomadNetworkNode():
commits_count = self.get_commit_count(repo["path"], resolved_ref) if resolved_ref else 0
branch_count = len(refs.get("heads", [])) if refs else 0
tag_count = len(refs["tags"]) if refs else 0
active_work_dir = repo["path"]+".work/active"
if not os.path.isdir(active_work_dir): work_count = 0
else:
work_count = len([f for f in os.listdir(active_work_dir) if f.isdigit() and os.path.isdir(os.path.join(active_work_dir, f))])
# Get releases count
releases_path = f"{repo['path']}.releases"
@@ -418,6 +433,7 @@ class NomadNetworkNode():
sep = self.icon("sep")
content_parts.append(f"{self.m_link_r(self.icon("folder")+" Files", self.PATH_TREE, g=group_name, r=repo_name, ref='HEAD')} {sep} ")
if releases_count: content_parts.append(f"{self.m_link_r(self.icon("package")+f" Releases ({releases_count})", self.PATH_RELEASES, g=group_name, r=repo_name)} {sep} ")
content_parts.append(f"{self.m_link_r(self.icon("work")+f" Work ({work_count})", self.PATH_WORK, g=group_name, r=repo_name)} {sep} ")
content_parts.append(f"{self.m_link_r(self.icon("commits")+f" Commits ({commits_count})", self.PATH_COMMITS, g=group_name, r=repo_name, ref='HEAD')} {sep} ")
content_parts.append(f"{self.m_link_r(self.icon("branch")+f" Branches ({branch_count})", self.PATH_REFS, g=group_name, r=repo_name, type="heads")} {sep} ")
content_parts.append(f"{self.m_link_r(self.icon("tag")+f" Tags ({tag_count})", self.PATH_REFS, g=group_name, r=repo_name, type="tags")} {sep} ")
@@ -803,7 +819,7 @@ class NomadNetworkNode():
commit_hash = data.get("var_h", "") if data else ""
if not group_name or not repo_name:
content = self.m_heading("Error", 2) + "\nInvalid request.\n"
content = self.m_heading("Error", 2) + "\nInvalid request\n"
return self.render_template(content, st=st)
repo = self.get_accessible_repository(remote_identity, group_name, repo_name)
@@ -945,7 +961,7 @@ class NomadNetworkNode():
nav_content = "".join(nav_parts)
if not group_name or not repo_name:
content = self.m_heading("Error", 2) + "\nInvalid request.\n"
content = self.m_heading("Error", 2) + "\nInvalid request\n"
return self.render_template(content, st=st)
repo = self.get_accessible_repository(remote_identity, group_name, repo_name)
@@ -1039,7 +1055,7 @@ class NomadNetworkNode():
repo_name = data.get("var_r", "") if data else ""
if not group_name or not repo_name:
content = self.m_heading("Error", 2) + "\nInvalid request.\n"
content = self.m_heading("Error", 2) + "\nInvalid request\n"
return self.render_template(content, st=st)
content_parts = []
@@ -1123,7 +1139,7 @@ class NomadNetworkNode():
repo_name = data.get("var_r", "") if data else ""
if not group_name or not repo_name:
content = self.m_heading("Error", 2) + "\nInvalid request.\n"
content = self.m_heading("Error", 2) + "\nInvalid request\n"
return self.render_template(content, st=st)
repo = self.get_accessible_repository(remote_identity, group_name, repo_name)
@@ -1182,7 +1198,7 @@ class NomadNetworkNode():
tag = data.get("var_t", "") if data else ""
if not group_name or not repo_name or not tag:
content = self.m_heading("Error", 2) + "\nInvalid request.\n"
content = self.m_heading("Error", 2) + "\nInvalid request\n"
return self.render_template(content, st=st)
repo = self.get_accessible_repository(remote_identity, group_name, repo_name)
@@ -1270,6 +1286,209 @@ class NomadNetworkNode():
page_content = "".join(content_parts)
return self.render_template(page_content, nav_content=nav_content, template="release", st=st)
def serve_work_page(self, path, data, request_id, link_id, remote_identity, requested_at):
st = time.time()
RNS.log(f"Work page request from {remote_identity}", RNS.LOG_DEBUG)
if not data: data = {}
group_name = data.get("var_g", "") if data else ""
repo_name = data.get("var_r", "") if data else ""
scope = data.get("var_scope", "active") if data else "active"
if scope not in ["active", "completed", "all"]: scope = "active"
if not group_name or not repo_name:
content = self.m_heading("Error", 2) + "\nInvalid request\n"
return self.render_template(content, st=st)
repo = self.get_accessible_repository(remote_identity, group_name, repo_name)
if not repo:
content = self.m_heading("Error", 2) + "\nThe requested repository was not found.\n"
return self.render_template(content, st=st)
content_parts = []
nav_parts = []
# Breadcrumb navigation
breadcrumb = f">>\n{self.m_link("Node", self.PATH_INDEX)} / {self.m_link(group_name, self.PATH_GROUP, g=group_name)} / {self.m_link(repo_name, self.PATH_REPO, g=group_name, r=repo_name)} / work"
nav_parts.append(breadcrumb + "\n")
nav_content = "".join(nav_parts)
# Scope filter links
sep = self.icon("sep")
active_s = "`_" if scope == "active" else ""
cmplt_s = "`_" if scope == "completed" else ""
all_s = "`_" if scope == "all" else ""
filter_links = []
filter_links.append(active_s+self.m_link("Active", self.PATH_WORK, g=group_name, r=repo_name, scope="active")+active_s)
filter_links.append(cmplt_s+self.m_link("Completed", self.PATH_WORK, g=group_name, r=repo_name, scope="completed")+cmplt_s)
filter_links.append(all_s+self.m_link("All", self.PATH_WORK, g=group_name, r=repo_name, scope="all")+all_s)
content_parts.append(f" {sep} ".join(filter_links) + "\n\n")
# Load work documents
work_path = f"{repo['path']}.work"
scopes_to_show = ["active", "completed"] if scope == "all" else [scope]
for s in scopes_to_show:
folder_path = os.path.join(work_path, s)
docs = []
if os.path.isdir(folder_path):
for entry in os.listdir(folder_path):
doc_dir = os.path.join(folder_path, entry)
if not os.path.isdir(doc_dir): continue
try:
doc_id = int(entry)
root_path = os.path.join(doc_dir, "root")
if not os.path.isfile(root_path): continue
doc = self.owner._work_load_document(root_path)
if not doc: continue
meta = doc.get("meta", {})
comment_count = len([f for f in os.listdir(doc_dir) if f.isdigit() and os.path.isfile(os.path.join(doc_dir, f))])
docs.append({ "id": doc_id, "title": meta.get("title", "Untitled"),
"created": meta.get("created", 0), "author": meta.get("author", b""),
"comments": comment_count })
except: continue
docs.sort(key=lambda x: x["created"], reverse=True)
if not docs:
content_parts.append(self.m_heading(f"{s.capitalize()} ({len(docs)})", 2)+f"\n`*No {s} work documents`*\n")
content_parts.append("\n")
else:
content_parts.append(self.m_heading(f"{s.capitalize()} ({len(docs)})", 2))
content_parts.append("\n")
for doc in docs:
doc_title = str(doc.get('title', 'Untitled')[:92])
if len(doc_title) < len(doc.get('title', 'Untitled')): doc_title += ""
title_link = self.m_link(self.icon("file")+" "+doc_title, self.PATH_WORK_DOC, g=group_name, r=repo_name, id=doc["id"], scope=s)
author_str = RNS.prettyhexrep(doc["author"]) if doc["author"] else "unknown"
date_str = time.strftime("%Y-%m-%d", time.localtime(doc["created"])) if doc["created"] else ""
content_parts.append(f"{title_link} {self.CLR_DIM}#{doc['id']}`f\n")
content_parts.append(f"{self.CLR_DIM}{date_str} by {author_str}`f\n")
content_parts.append(f"{self.CLR_DIM}{doc['comments']} updates`f\n") if doc['comments'] else None
content_parts.append("\n")
if content_parts[-1] == "\n": content_parts[-1] = ""
self.owner.view_succeeded(group_name, repo_name, remote_identity)
page_content = "".join(content_parts)
return self.render_template(page_content, nav_content=nav_content, template="work", st=st)
def serve_work_doc_page(self, path, data, request_id, link_id, remote_identity, requested_at):
st = time.time()
RNS.log(f"Work document page request from {remote_identity}", RNS.LOG_DEBUG)
if not data: data = {}
group_name = data.get("var_g", "") if data else ""
repo_name = data.get("var_r", "") if data else ""
doc_id = data.get("var_id", "") if data else ""
scope = data.get("var_scope", "active") if data else "active"
if scope not in ["active", "completed", "all"]: scope = "active"
if not group_name or not repo_name or not doc_id:
content = self.m_heading("Error", 2) + "\nInvalid request\n"
return self.render_template(content, st=st)
try: doc_id = int(doc_id)
except:
content = self.m_heading("Error", 2) + "\nInvalid document ID\n"
return self.render_template(content, st=st)
repo = self.get_accessible_repository(remote_identity, group_name, repo_name)
if not repo:
content = self.m_heading("Error", 2) + "\nThe requested repository was not found\n"
return self.render_template(content, st=st)
work_path = f"{repo['path']}.work"
doc_dir = os.path.join(work_path, scope, str(doc_id))
root_path = os.path.join(doc_dir, "root")
if not os.path.isfile(root_path):
content = self.m_heading("Not Found", 2) + "\nThe requested work document was not found\n"
return self.render_template(content, st=st)
doc = self.owner._work_load_document(root_path)
if not doc:
content = self.m_heading("Error", 2) + "\nCould not load work document\n"
return self.render_template(content, st=st)
content_parts = []
nav_parts = []
# Breadcrumb navigation
breadcrumb = f">>\n{self.m_link("Node", self.PATH_INDEX)} / {self.m_link(group_name, self.PATH_GROUP, g=group_name)} / {self.m_link(repo_name, self.PATH_REPO, g=group_name, r=repo_name)} / {self.m_link('work', self.PATH_WORK, g=group_name, r=repo_name)} / #{doc_id}"
nav_parts.append(breadcrumb + "\n")
nav_content = "".join(nav_parts)
doc_title = doc['meta'].get('title', 'Untitled')[:64]
if len(doc_title) < len(doc['meta'].get('title', 'Untitled')): doc_title += ""
meta = doc.get("meta", {})
author = meta.get("author", b"")
author_str = RNS.prettyhexrep(author) if author else "Unknown"
created = meta.get("created", 0)
edited = meta.get("edited", 0)
fmt = meta.get("format", "markdown")
# Document header
content_parts.append(self.m_heading(f"{doc_title}", 2))
content_parts.append(f"\n{self.CLR_DIM}Author : {author_str}`f\n")
content_parts.append(f"{self.CLR_DIM}Created : {time.strftime('%Y-%m-%d %H:%M', time.localtime(created)) if created else 'unknown'}`f\n")
if edited and edited != created:
content_parts.append(f"{self.CLR_DIM}Edited : {time.strftime('%Y-%m-%d %H:%M', time.localtime(edited))}`f\n")
content_parts.append(f"{self.CLR_DIM}Status : {scope.capitalize()}`f\n\n")
# Document content
content = doc.get("content", "").strip()
if content:
if fmt == "micron": content_parts.append(content)
else: content_parts.append(self.mdc.format_block(content))
content_parts.append("\n")
# Load and display comments
comments = []
if os.path.isdir(doc_dir):
for entry in os.listdir(doc_dir):
if not entry.isdigit(): continue
comment_path = os.path.join(doc_dir, entry)
if not os.path.isfile(comment_path): continue
try:
comment_id = int(entry)
comment = self.owner._work_load_document(comment_path)
if not comment: continue
cmeta = comment.get("meta", {})
comments.append({
"id": comment_id,
"format": comment.get("format", "markdown"),
"content": comment.get("content", ""),
"created": cmeta.get("created", 0),
"author": cmeta.get("author", b"")
})
except: continue
comments.sort(key=lambda x: x["id"])
if comments:
content_parts.append("\n"+self.m_heading(f"Updates ({len(comments)})", 2))
for c in comments:
fmt = c["format"]
if fmt == "markdown": content = self.mdc.format_block(c["content"])
else: content = c["content"]
cauthor_str = RNS.prettyhexrep(c["author"]) if c["author"] else "Unknown"
cdate = time.strftime("%Y-%m-%d %H:%M", time.localtime(c["created"])) if c["created"] else "unknown"
content_parts.append(f"\n{self.CLR_DIM}#{c['id']} by {cauthor_str} on {cdate}`f\n")
content_parts.append(f"{content}\n")
self.owner.view_succeeded(group_name, repo_name, remote_identity)
page_content = "".join(content_parts)
return self.render_template(page_content, nav_content=nav_content, template="work_doc", st=st)
def serve_artifact(self, path, data, request_id, link_id, remote_identity, requested_at):
st = time.time()
RNS.log(f"Artifact file request from {remote_identity}", RNS.LOG_DEBUG)
@@ -2126,5 +2345,10 @@ DEFAULT_REFS_TEMPLATE = """{PAGE_CONTENT}"""
# Stats page template
DEFAULT_STATS_TEMPLATE = """{PAGE_CONTENT}"""
# Work page templates
DEFAULT_WORK_TEMPLATE = """{PAGE_CONTENT}"""
DEFAULT_WORK_DOC_TEMPLATE = """{PAGE_CONTENT}"""
# Fallback template
FALLBACK_TEMPLATE = """{PAGE_CONTENT}"""
+849 -9
View File
@@ -85,12 +85,28 @@ def program_setup(configdir, rnsconfigdir=None, verbosity=0, quietness=0, servic
elif operation == "delete": git_client.delete_release(remote=task["remote"], target=task["target"])
else: print("Invalid operation"); exit(1)
elif command == "work":
git_client = ReticulumGitClient(configdir=configdir, verbosity=targetverbosity, identitypath=identity)
scope = task.get("scope", "active")
doc_id = task.get("doc_id")
title = task.get("title")
if operation == "list": git_client.list_work(remote=task["remote"], scope=scope)
elif operation == "view": git_client.view_work(remote=task["remote"], doc_id=doc_id, scope=scope)
elif operation == "create": git_client.create_work(remote=task["remote"], title=title)
elif operation == "edit": git_client.edit_work(remote=task["remote"], doc_id=doc_id, scope=scope)
elif operation == "delete": git_client.delete_work(remote=task["remote"], doc_id=doc_id, scope=scope)
elif operation == "update": git_client.comment_work(remote=task["remote"], doc_id=doc_id, scope=scope)
elif operation == "complete": git_client.complete_work(remote=task["remote"], doc_id=doc_id)
elif operation == "activate": git_client.activate_work(remote=task["remote"], doc_id=doc_id)
else: print("Invalid operation"); exit(1)
else: print("Invalid command"); exit(1)
exit(0)
def main():
subcommands = ["node", "release"]
subcommands = ["node", "release", "work"]
try:
if len(sys.argv) < 2 or sys.argv[1] not in subcommands: subcommand = "node"
else: subcommand = sys.argv[1]; sys.argv.pop(1)
@@ -109,9 +125,20 @@ def main():
parser.add_argument("--config", action="store", default=None, help="path to alternative config directory", type=str)
parser.add_argument("--rnsconfig", action="store", default=None, help="path to alternative Reticulum config directory", type=str)
parser.add_argument("-i", "--identity", action="store", metavar="PATH", default=None, help="path to release identity", type=str)
parser.add_argument("operation", nargs="?", default=None, help="list, view, create or delete", type=str)
parser.add_argument("repository", nargs="?", default=None, help="URL of remote repository", type=str)
parser.add_argument("target", nargs="?", default=None, help="tag or path to release artifacts directory", type=str)
parser.add_argument("operation", nargs="?", default=None, help="list, view, create or delete", type=str)
parser.add_argument("target", nargs="?", default=None, help="tag and path to release artifacts directory", type=str)
elif subcommand == "work":
parser = argparse.ArgumentParser(description="Reticulum Git Work Document Manager")
parser.add_argument("--config", action="store", default=None, help="path to alternative config directory", type=str)
parser.add_argument("--rnsconfig", action="store", default=None, help="path to alternative Reticulum config directory", type=str)
parser.add_argument("-i", "--identity", action="store", metavar="PATH", default=None, help="path to identity", type=str)
parser.add_argument("--scope", action="store", default="active", help="document scope: active, completed or all", type=str)
parser.add_argument("-t", "--title", action="store", default=None, help="document title for create", type=str)
parser.add_argument("-d", "--id", action="store", default=None, help="document ID", type=int)
parser.add_argument("repository", nargs="?", default=None, help="URL of remote repository", type=str)
parser.add_argument("operation", nargs="?", default=None, help="list, view, create, edit, delete, update or complete", type=str)
parser.add_argument('-v', '--verbose', action='count', default=0)
parser.add_argument('-q', '--quiet', action='count', default=0)
@@ -134,6 +161,12 @@ def main():
program_setup(configdir = configarg, rnsconfigdir=rnsconfigarg, service=False, verbosity=args.verbose,
quietness=args.quiet, interactive=False, print_identity=False, task=task, identity=args.identity)
elif subcommand == "work":
task = {"command": subcommand, "operation": args.operation, "remote": args.repository,
"scope": args.scope, "doc_id": args.id, "title": args.title}
program_setup(configdir = configarg, rnsconfigdir=rnsconfigarg, service=False, verbosity=args.verbose,
quietness=args.quiet, interactive=False, print_identity=False, task=task, identity=args.identity)
except KeyboardInterrupt:
print("")
exit()
@@ -146,6 +179,7 @@ class ReticulumGitClient():
PATH_PUSH = "/git/push"
PATH_DELETE = "/git/delete"
PATH_RELEASE = "/mgmt/release"
PATH_WORK = "/mgmt/work"
RES_OK = 0x00
RES_DISALLOWED = 0x01
@@ -303,6 +337,10 @@ class ReticulumGitClient():
return self.request_response, self.response_metadata
######################
# Release Management #
######################
def _edit_release_notes(self, tag="this release"):
editor = os.environ.get("EDITOR", "")
if not editor:
@@ -593,6 +631,424 @@ class ReticulumGitClient():
finally:
if self.link: self.link.teardown()
########################
# Work Docs Management #
########################
def list_work(self, remote=None, scope="active"):
if not remote: print(f"No remote specified"); exit(1)
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(0.1)
timeout -= 1
if not self.link_ready: self.abort("Link establishment failed")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
request_data = {self.IDX_REPOSITORY: repo_path, "operation": "list", "scope": scope}
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=30)
print("\r \r", end="")
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Server error: {error_msg}")
if len(response) > 1: result = mp.unpackb(response[1:])
else: result = {"active": [], "completed": []}
scopes_to_show = ["active", "completed"] if scope == "all" else [scope]
for s in scopes_to_show:
docs = result.get(s, [])
if docs:
st = f"\n{s.capitalize()} documents"
print(st)
print("="*len(st)); print()
print(f"{'ID':<4} {'Title':<30} {'Author':<17} {'Created':<18} {'Comments'}")
print("-" * 80)
for doc in docs:
doc_id = doc.get("id", "?")
title = doc.get("title", "Untitled")
if len(title) > 29: title = f"{title[:29]}"
author = doc.get("author", "")[:16]+""
created_ts = doc.get("created", 0)
created = time.strftime("%Y-%m-%d %H:%M", time.localtime(created_ts)) if created_ts else "unknown"
comments = doc.get("comments", 0)
print(f"{doc_id:<4} {title:<30} {author:<17} {created:<18} {comments}")
print()
elif scope != "all": print(f"No {s} work documents found.")
if scope == "all" and not result.get("active") and not result.get("completed"): print("No work documents found.")
except Exception as e: self.abort(f"Error listing work documents: {e}")
finally:
if self.link: self.link.teardown()
def view_work(self, remote=None, doc_id=None, scope="active"):
if not remote: print(f"No remote specified"); exit(1)
if doc_id is None: print(f"No document ID specified"); exit(1)
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(0.5)
timeout -= 1
if not self.link_ready: self.abort("Link establishment failed")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
request_data = {self.IDX_REPOSITORY: repo_path, "operation": "view", "doc_id": doc_id, "scope": scope}
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=30)
print("\r \r", end="")
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
if len(response) <= 1: self.abort("Empty response from remote")
doc = mp.unpackb(response[1:])
dt = f"{doc['meta']['title']} (#{doc['id']})"
print(f"{dt}")
print("="*len(dt))
print(f"Author : {doc['meta']['author']}")
print(f"Status : {scope}")
print(f"Created : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(doc['meta']['created']))}")
print(f"Edited : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(doc['meta']['edited']))}")
print(f"Format : {doc['meta']['format']}")
print(f"Updates : {len(doc.get('comments', []))}")
print()
print(doc['content'])
comments = doc.get('comments', [])
if comments:
print("\nUpdates")
print("=======")
for c in comments:
ts = f"#{c['id']} by {c['author']} at {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(c['created']))}"
print(f"\n{ts}")
print("-"*len(ts))
print(c['content'])
print()
except Exception as e: self.abort(f"Error viewing work document: {e}")
finally:
if self.link: self.link.teardown()
def create_work(self, remote=None, title=None):
if not remote: print(f"No remote specified"); exit(1)
if not title: print(f"No title specified"); exit(1)
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(0.1)
timeout -= 1
if not self.link_ready: self.abort("Failed to establish link")
print("\r \r", end="")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
content = self._edit_work_content(title=title)
if content is None: print("Creation cancelled"); return
request_data = { self.IDX_REPOSITORY: repo_path,
"operation": "create", "title": title, "content": content, "format": "markdown" }
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=30)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Server error: {error_msg}")
if len(response) > 1:
result = mp.unpackb(response[1:])
print(f"Work document created as {result['scope']} #{result['id']}")
else: print("Work document created")
except Exception as e: self.abort(f"Error creating work document: {e}")
finally:
if self.link: self.link.teardown()
def edit_work(self, remote=None, doc_id=None, scope="active"):
if not remote: print(f"No remote specified"); exit(1)
if doc_id is None: print(f"No document ID specified"); exit(1)
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(0.2)
timeout -= 1
if not self.link_ready: self.abort("Failed to establish link")
print("\r \r", end="")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
request_data = {self.IDX_REPOSITORY: repo_path, "operation": "view", "doc_id": doc_id, "scope": scope}
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=30)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
doc = mp.unpackb(response[1:])
current_content = doc['content']
current_title = doc['meta']['title']
content = self._edit_work_content(title=current_title, content=current_content)
if content is None: print("Edit cancelled"); return
title = current_title
request_data = { self.IDX_REPOSITORY: repo_path,
"operation": "edit", "doc_id": doc_id, "scope": scope, "content": content }
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=30)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
print(f"Work document {scope} #{doc_id} updated")
except Exception as e: self.abort(f"Error editing work document: {e}")
finally:
if self.link: self.link.teardown()
def delete_work(self, remote=None, doc_id=None, scope="active"):
if not remote: print(f"No remote specified"); exit(1)
if doc_id is None: print(f"No document ID specified"); exit(1)
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(0.2)
timeout -= 1
if not self.link_ready: self.abort("Failed to establish link")
print("\r \r", end="")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
print(f"Are you sure you want to delete {scope} work document #{doc_id}? [y/N]: ", end="")
try: confirm = input().strip().lower()
except EOFError: confirm = "n"
if confirm != "y": print("Deletion cancelled"); return
request_data = { self.IDX_REPOSITORY: repo_path,
"operation": "delete", "doc_id": doc_id, "scope": scope }
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=30)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
print(f"Work document {scope} #{doc_id} deleted")
except Exception as e: self.abort(f"Error deleting work document: {e}")
finally:
if self.link: self.link.teardown()
def comment_work(self, remote=None, doc_id=None, scope="active"):
if not remote: print(f"No remote specified"); exit(1)
if doc_id is None: print(f"No document ID specified"); exit(1)
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(0.2)
timeout -= 1
if not self.link_ready: self.abort("Failed to establish link")
print("\r \r", end="")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
# Get content from editor
content = self._edit_work_content(title=f"Update on document #{doc_id}", is_comment=True)
if content is None: print("Update cancelled"); return
request_data = { self.IDX_REPOSITORY: repo_path,
"operation": "comment", "doc_id": doc_id, "scope": scope,
"content": content, "format": "markdown" }
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=30)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
if len(response) > 1:
result = mp.unpackb(response[1:])
print(f"Update #{result['id']} added to {scope} document #{doc_id}")
else: print("Update added")
except Exception as e: self.abort(f"Error adding comment: {e}")
finally:
if self.link: self.link.teardown()
def complete_work(self, remote=None, doc_id=None):
if not remote: print(f"No remote specified"); exit(1)
if doc_id is None: print(f"No document ID specified"); exit(1)
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(0.2)
timeout -= 1
if not self.link_ready: self.abort("Failed to establish link")
print("\r \r", end="")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
request_data = {self.IDX_REPOSITORY: repo_path,
"operation": "complete", "doc_id": doc_id}
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=30)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
if len(response) > 1:
result = mp.unpackb(response[1:])
print(f"Work document #{result['id']} completed")
else: print("Work document completed")
except Exception as e: self.abort(f"Error completing work document: {e}")
finally:
if self.link: self.link.teardown()
def activate_work(self, remote=None, doc_id=None):
if not remote: print(f"No remote specified"); exit(1)
if doc_id is None: print(f"No document ID specified"); exit(1)
self.connect_remote(remote)
timeout = self.link_timeout
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(0.2)
timeout -= 1
if not self.link_ready: self.abort("Failed to establish link")
print("\r \r", end="")
try:
destination_hash, group, repo = self.parse_remote_url(remote)
repo_path = f"{group}/{repo}"
request_data = {self.IDX_REPOSITORY: repo_path,
"operation": "activate", "doc_id": doc_id}
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=30)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
status_byte = response[0]
if status_byte != 0:
error_msg = response[1:].decode("utf-8", errors="ignore")
self.abort(f"Remote error: {error_msg}")
if len(response) > 1:
result = mp.unpackb(response[1:])
print(f"Work document #{result['id']} activated")
else: print("Work document activated")
except Exception as e: self.abort(f"Error activating work document: {e}")
finally:
if self.link: self.link.teardown()
def _edit_work_content(self, title="", content="", is_comment=False):
editor = os.environ.get("EDITOR", "")
if not editor:
for fallback in ["nano", "vim", "vi"]:
try:
subprocess.run(["which", fallback], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
editor = fallback
break
except subprocess.CalledProcessError: continue
if not editor:
print("No editor found. Please set $EDITOR environment variable.")
return None
if is_comment: template = COMMENT_TEMPLATE
else: template = CREATE_DOC_TEMPLATE
if content: template = content
try:
with NamedTemporaryFile(mode="w+", suffix=".md", delete=False) as tmp:
tmp_path = tmp.name
tmp.write(template)
result = subprocess.run([editor, tmp_path])
if result.returncode != 0:
print(f"Editor exited with error code {result.returncode}")
os.unlink(tmp_path)
return None
with open(tmp_path, "r") as f: edited = f.read()
os.unlink(tmp_path)
lines = [line for line in edited.split("\n") if not (line.strip().startswith(COMMENT_TEMPLATE) or line.strip().startswith(CREATE_DOC_TEMPLATE))]
result = "\n".join(lines).strip()
if not result: return None
return result
except Exception as e:
RNS.log(f"Error editing work content: {e}", RNS.LOG_ERROR)
return None
class ReticulumGitNode():
JOBS_INTERVAL = 5
@@ -621,6 +1077,7 @@ class ReticulumGitNode():
PATH_PUSH = "/git/push"
PATH_DELETE = "/git/delete"
PATH_RELEASE = "/mgmt/release"
PATH_WORK = "/mgmt/work"
RES_OK = 0x00
RES_DISALLOWED = 0x01
@@ -631,6 +1088,8 @@ class ReticulumGitNode():
IDX_REPOSITORY = 0x00
IDX_RESULT_CODE = 0x01
WORK_DOC_LIMIT = 256*1024*1024
def __init__(self, configdir=None, verbosity=None, print_identity=False):
self.identity = None
self.userdir = os.path.expanduser("~")
@@ -796,7 +1255,8 @@ class ReticulumGitNode():
perm, target = self.parse_permission(entry)
if not perm or not target: continue
else:
read = False; write = False; create = False; stats = False; release = False
read = False; write = False; create = False;
stats = False; release = False; interact = False
if perm == self.PERM_READ or perm == self.PERM_READWRITE: read = True
if perm == self.PERM_WRITE or perm == self.PERM_READWRITE: write = True
if perm == self.PERM_CREATE: create = True
@@ -938,7 +1398,8 @@ class ReticulumGitNode():
perm, target = self.parse_permission(perm_input)
if not perm or not target: continue
else:
read = False; write = False; create = False; stats = False; release = False
read = False; write = False; create = False
stats = False; release = False; interact = False
if perm == self.PERM_READ or perm == self.PERM_READWRITE: read = True
if perm == self.PERM_WRITE or perm == self.PERM_READWRITE: write = True
if perm == self.PERM_CREATE: create = True
@@ -1042,6 +1503,7 @@ class ReticulumGitNode():
self.destination.register_request_handler(self.PATH_PUSH, self.handle_push, allow=self.global_allow, allowed_list=ga_list)
self.destination.register_request_handler(self.PATH_DELETE, self.handle_delete, allow=self.global_allow, allowed_list=ga_list)
self.destination.register_request_handler(self.PATH_RELEASE, self.handle_release, allow=self.global_allow, allowed_list=ga_list)
self.destination.register_request_handler(self.PATH_WORK, self.handle_work, allow=self.global_allow, allowed_list=ga_list)
def remote_connected(self, link):
RNS.log(f"Peer connected to {self.destination}", RNS.LOG_DEBUG)
@@ -1607,6 +2069,371 @@ class ReticulumGitNode():
RNS.log(f"Error deleting release: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
#########################
# Work Document Methods #
#########################
def handle_work(self, path, data, request_id, remote_identity, requested_at):
RNS.log(f"Work request from remote {remote_identity}", RNS.LOG_DEBUG)
if not remote_identity: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified"
if not type(data) == dict: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if not self.IDX_REPOSITORY in data: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No repository specified"
operation = data.get("operation")
if not operation: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY])
read_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ)
write_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_WRITE)
interact_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_INTERACT)
access = False
if not read_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
comment_access = interact_access and (read_access or write_access)
manage_access = interact_access and write_access
if operation in ["list", "view"] and read_access: access = True
elif operation in ["comment"] and comment_access: access = True
elif operation in ["create", "edit", "delete"] and manage_access: access = True
elif operation in ["complete", "activate"] and manage_access: access = True
else: access = False
if not access: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
else:
repository_path = self.groups[group_name]["repositories"][repository_name]["path"]
work_path = f"{repository_path}.work"
try:
if operation == "list" and read_access: return self._work_list(work_path, data, remote_identity)
elif operation == "view" and read_access: return self._work_view(work_path, data, remote_identity)
elif operation == "comment" and comment_access: return self._work_comment(work_path, data, remote_identity)
elif operation == "create" and manage_access: return self._work_create(work_path, data, remote_identity)
elif operation == "edit" and manage_access: return self._work_edit(work_path, data, remote_identity)
elif operation == "delete" and manage_access: return self._work_delete(work_path, data, remote_identity)
elif operation == "complete" and manage_access: return self._work_complete(work_path, data, remote_identity)
elif operation == "activate" and manage_access: return self._work_activate(work_path, data, remote_identity)
else: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
except Exception as e:
RNS.log(f"Error while handling work request for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
def _work_get_next_id(self, base_path):
if not os.path.isdir(base_path): return 1
try:
entries = [int(d) for d in os.listdir(base_path) if d.isdigit()]
if not entries: return 1
return max(entries) + 1
except: return 1
def _work_load_document(self, doc_path):
try:
with open(doc_path, "rb") as f: return mp.unpackb(f.read())
except: return None
def _work_save_document(self, doc_path, document):
try:
dir_path = os.path.dirname(doc_path)
if not os.path.isdir(dir_path): os.makedirs(dir_path, mode=0o755)
tmp_path = doc_path + ".tmp"
with open(tmp_path, "wb") as f: f.write(mp.packb(document))
os.rename(tmp_path, doc_path)
return True
except Exception as e:
RNS.log(f"Error persisting work document: {e}", RNS.LOG_ERROR)
return False
def _work_list(self, work_path, data, remote_identity):
scope = data.get("scope", "active")
result = {"active": [], "completed": []}
for folder_name, key in [("active", "active"), ("completed", "completed")]:
if scope not in [folder_name, "all"]: continue
folder_path = os.path.join(work_path, folder_name)
if not os.path.isdir(folder_path): continue
for entry in os.listdir(folder_path):
doc_dir = os.path.join(folder_path, entry)
if not os.path.isdir(doc_dir): continue
try:
doc_id = int(entry)
root_path = os.path.join(doc_dir, "root")
if not os.path.isfile(root_path): continue
doc = self._work_load_document(root_path)
if not doc: continue
meta = doc.get("meta", {})
comment_count = len([f for f in os.listdir(doc_dir) if f.isdigit() and os.path.isfile(os.path.join(doc_dir, f))])
result[key].append({ "id": doc_id, "title": meta.get("title", "Untitled"),
"created": meta.get("created", 0), "edited": meta.get("edited", 0),
"author": RNS.hexrep(meta.get("author", b""), delimit=False) if meta.get("author") else "",
"format": meta.get("format", "markdown"), "comments": comment_count })
except: continue
for key in result: result[key].sort(key=lambda x: x["created"], reverse=True)
return b"\x00" + mp.packb(result)
def _work_view(self, work_path, data, remote_identity):
doc_id = data.get("doc_id")
scope = data.get("scope", "active")
if not scope in ["active", "completed", "all"]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified"
try: doc_id = int(doc_id)
except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID"
doc_dir = os.path.join(work_path, scope, str(doc_id))
root_path = os.path.join(doc_dir, "root")
if not os.path.isfile(root_path): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Document not found"
doc = self._work_load_document(root_path)
if not doc: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error loading document"
comments = []
if os.path.isdir(doc_dir):
for entry in os.listdir(doc_dir):
if not entry.isdigit(): continue
comment_path = os.path.join(doc_dir, entry)
if not os.path.isfile(comment_path): continue
try:
comment_id = int(entry)
comment = self._work_load_document(comment_path)
if not comment: continue
meta = comment.get("meta", {})
comments.append({ "id": comment_id, "content": comment.get("content", ""),
"created": meta.get("created", 0), "edited": meta.get("edited", 0),
"author": RNS.hexrep(meta.get("author", b""), delimit=False) if meta.get("author") else "",
"format": meta.get("format", "markdown") })
except: continue
comments.sort(key=lambda x: x["id"])
result = { "id": doc_id, "scope": scope,
"content": doc.get("content", ""), "comments": comments,
"meta": { "title": doc.get("meta", {}).get("title", "Untitled"),
"created": doc.get("meta", {}).get("created", 0),
"edited": doc.get("meta", {}).get("edited", 0),
"author": RNS.hexrep(doc.get("meta", {}).get("author", b""), delimit=False) if doc.get("meta", {}).get("author") else "",
"format": doc.get("meta", {}).get("format", "markdown") } }
return b"\x00" + mp.packb(result)
def _work_create(self, work_path, data, remote_identity):
title = data.get("title", "").strip()
content = data.get("content", "").strip()
format_type = data.get("format", "markdown")
signature = data.get("signature", None)
limit = self.WORK_DOC_LIMIT
if signature and not len(signature) == RNS.Identity.SIGLENGTH: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature"
if len(title)+len(content)+len(format_type) > limit: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content limit exceeded"
if not title: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Title is required"
if not content: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content is required"
try:
active_path = os.path.join(work_path, "active")
completed_path = os.path.join(work_path, "completed")
doc_id = max(self._work_get_next_id(active_path), self._work_get_next_id(completed_path))
doc_dir = os.path.join(active_path, str(doc_id))
now = time.time()
document = { "content": content,
"meta": { "format": format_type if format_type in ["markdown", "micron"] else "markdown",
"title": title, "created": now, "edited": now,
"signature": signature, "author": remote_identity.hash } }
root_path = os.path.join(doc_dir, "root")
if not self._work_save_document(root_path, document):
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error saving document"
RNS.log(f"Created work document {doc_id} by {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_DEBUG)
return b"\x00" + mp.packb({"id": doc_id, "scope": "active"})
except Exception as e:
RNS.log(f"Error creating work document: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
def _work_edit(self, work_path, data, remote_identity):
doc_id = data.get("doc_id")
scope = data.get("scope", "active")
content = data.get("content")
title = data.get("title")
signature = data.get("signature", None)
limit = self.WORK_DOC_LIMIT
size = 0
if title: size += len(title)
if content: size += len(content)
if not scope in ["active", "completed", "all"]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if signature and not len(signature) == RNS.Identity.SIGLENGTH: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature"
if size > limit: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content limit exceeded"
if content is None and title is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No changes specified"
if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified"
try: doc_id = int(doc_id)
except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID"
doc_dir = os.path.join(work_path, scope, str(doc_id))
root_path = os.path.join(doc_dir, "root")
if not os.path.isfile(root_path): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Document not found"
doc = self._work_load_document(root_path)
if not doc: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error loading document"
if doc.get("meta", {}).get("author") != remote_identity.hash: return self.RES_DISALLOWED.to_bytes(1, "big") + b"No access, not author"
try:
if title is not None: doc["meta"]["title"] = title.strip()
if content is not None: doc["content"] = content.strip()
doc["meta"]["edited"] = time.time()
doc["meta"]["signature"] = signature
if not self._work_save_document(root_path, doc): return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error saving document"
RNS.log(f"Edited work document {doc_id} by {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_DEBUG)
return b"\x00"
except Exception as e:
RNS.log(f"Error editing work document: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
def _work_delete(self, work_path, data, remote_identity):
doc_id = data.get("doc_id")
scope = data.get("scope", "active")
if not scope in ["active", "completed", "all"]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified"
try: doc_id = int(doc_id)
except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID"
doc_dir = os.path.join(work_path, scope, str(doc_id))
root_path = os.path.join(doc_dir, "root")
if not os.path.isfile(root_path): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Document not found"
doc = self._work_load_document(root_path)
if not doc: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error loading document"
if doc.get("meta", {}).get("author") != remote_identity.hash: return self.RES_DISALLOWED.to_bytes(1, "big") + b"No access, not author"
try:
shutil.rmtree(doc_dir)
RNS.log(f"Deleted work document {doc_id} by {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_DEBUG)
return b"\x00"
except Exception as e:
RNS.log(f"Error deleting work document: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
def _work_comment(self, work_path, data, remote_identity):
doc_id = data.get("doc_id")
scope = data.get("scope", "active")
content = data.get("content", "").strip()
signature = data.get("signature", None)
format_type = data.get("format", "markdown")
limit = self.WORK_DOC_LIMIT
size = len(content)
if not scope in ["active", "completed", "all"]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if size > limit: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content limit exceeded"
if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified"
try: doc_id = int(doc_id)
except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID"
if not content: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content is required"
doc_dir = os.path.join(work_path, scope, str(doc_id))
root_path = os.path.join(doc_dir, "root")
if not os.path.isfile(root_path): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Document not found"
try:
comment_id = self._work_get_next_id(doc_dir)
now = time.time()
comment = { "content": content,
"meta": { "format": format_type if format_type in ["markdown", "micron"] else "markdown",
"title": None, "created": now, "edited": now,
"signature": signature, "author": remote_identity.hash } }
comment_path = os.path.join(doc_dir, str(comment_id))
if not self._work_save_document(comment_path, comment): return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error saving comment"
RNS.log(f"Added comment {comment_id} to work document {doc_id} by {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_DEBUG)
return b"\x00" + mp.packb({"id": comment_id})
except Exception as e:
RNS.log(f"Error adding comment: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
def _work_complete(self, work_path, data, remote_identity):
doc_id = data.get("doc_id")
if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified"
try: doc_id = int(doc_id)
except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID"
active_dir = os.path.join(work_path, "active", str(doc_id))
completed_base = os.path.join(work_path, "completed")
if not os.path.isdir(active_dir): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Document not found"
root_path = os.path.join(active_dir, "root")
doc = self._work_load_document(root_path)
if not doc: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error loading document"
if doc.get("meta", {}).get("author") != remote_identity.hash: return self.RES_DISALLOWED.to_bytes(1, "big") + b"No access, not author"
try:
completed_dir = os.path.join(completed_base, str(doc_id))
shutil.move(active_dir, completed_dir)
RNS.log(f"Completed work document {doc_id} by {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_DEBUG)
return b"\x00" + mp.packb({"id": doc_id, "scope": "completed"})
except Exception as e:
RNS.log(f"Error completing work document: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
def _work_activate(self, work_path, data, remote_identity):
doc_id = data.get("doc_id")
if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified"
try: doc_id = int(doc_id)
except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID"
completed_dir = os.path.join(work_path, "completed", str(doc_id))
active_base = os.path.join(work_path, "active")
if not os.path.isdir(completed_dir): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Document not found"
root_path = os.path.join(completed_dir, "root")
doc = self._work_load_document(root_path)
if not doc: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error loading document"
if doc.get("meta", {}).get("author") != remote_identity.hash: return self.RES_DISALLOWED.to_bytes(1, "big") + b"No access, not author"
try:
active_dir = os.path.join(active_base, str(doc_id))
shutil.move(completed_dir, active_dir)
RNS.log(f"Activated work document {doc_id} by {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_DEBUG)
return b"\x00" + mp.packb({"id": doc_id, "scope": "active"})
except Exception as e:
RNS.log(f"Error activating work document: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
def repository_stats(self, remote_identity, group_name, repository_name, lookback_days=14):
if not self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_STATS): return None
else:
@@ -1845,6 +2672,15 @@ internal = rw:9710b86ba12c42d1d8f30f74fe509286
# figured repository collection paths have no permissions
# enabled, and will be neither readable nor writable.
#
# The following permissions are supported:
# r = read (clone, fetch, view)
# w = write (push, create and manage work documents)
# rw = read/write
# c = create (create new repositories in group)
# s = stats (view repository statistics)
# rel = release (create and manage releases)
# i = interact (comment on work documents)
#
# To configure permissions per repository, you must create
# an ".allowed" file matching the repository name. If the
# repository is in a folder called "my_project.git", create
@@ -1878,10 +2714,11 @@ internal = rw:9710b86ba12c42d1d8f30f74fe509286
# a corresponding "template_name.mu" file in the
# ~/.rngit/templates directory. The supported template
# names are "base", "front", "group", "repo", "tree",
# "blob", "commits", "commit", "refs" and "stats". You
# should include a {PAGE_CONTENT} variable somewhere in
# your templates, the rendered page content will be
# injected into this variable.
# "blob", "commits", "commit", "refs", "stats", "releases",
# "release", "work" and "work_doc". You should include a
# {PAGE_CONTENT} variable somewhere in your templates,
# the rendered page content will be injected into this
# variable.
# serve_nomadnet = no
@@ -1911,4 +2748,7 @@ RELEASE_NOTES_TEMPLATE = """# Enter release notes for {TAG}.
# Save and exit the editor when done, or exit without saving to abort.
"""
COMMENT_TEMPLATE = "# Remove this line and enter your update. Save and exit when done, or save an empty document to abort abort."
CREATE_DOC_TEMPLATE = "# Remove this line and enter your document content. Save and exit when done, or save an empty document to abort abort."
if __name__ == "__main__": main()
+181 -12
View File
@@ -284,7 +284,7 @@ To create a release, specify the tag name and path to artifacts:
.. code:: text
$ rngit release create rns://50824b711717f97c2fb1166ceddd5ea9/public/myrepo v1.2.0:./dist
$ rngit release rns://50824b711717f97c2fb1166ceddd5ea9/public/myrepo create v1.2.0:./dist
This will:
@@ -310,7 +310,7 @@ To view all releases for a repository:
.. code:: text
$ rngit release list rns://50824b711717f97c2fb1166ceddd5ea9/public/myrepo
$ rngit release rns://50824b711717f97c2fb1166ceddd5ea9/public/myrepo list
Tag Status Created Objs Notes
------------------------------------------------------------------
@@ -324,7 +324,7 @@ To see full information about a specific release:
.. code:: text
$ rngit release view rns://50824b711717f97c2fb1166ceddd5ea9/public/myrepo v1.2.0
$ rngit release rns://50824b711717f97c2fb1166ceddd5ea9/public/myrepo view v1.2.0
Release : 0.9.2
Status : published
@@ -348,7 +348,7 @@ To remove a release:
.. code:: text
$ rngit release delete rns://50824b711717f97c2fb1166ceddd5ea9/public/myrepo v1.2.0
$ rngit release rns://50824b711717f97c2fb1166ceddd5ea9/public/myrepo delete v1.2.0
Are you sure you want to delete release 'v1.2.0'? [y/N]: y
Release v1.2.0 deleted
@@ -382,23 +382,192 @@ Only releases with ``published`` status are visible through the Nomad Network in
.. code:: text
usage: rngit release [-h] [--config CONFIG] [--rnsconfig RNSCONFIG]
[-i IDENTITY] [-v] [-q] [--version]
operation repository [target]
[-i PATH] [-v] [-q] [--version]
[repository] [operation] [target]
Reticulum Git Release Manager
positional arguments:
repository URL of remote repository
operation list, view, create or delete
repository URL of remote repository (rns://hash/group/repo)
target tag or tag:path for create, tag for view/delete
target tag and path to release artifacts directory
options:
-h, --help show this help message and exit
--config CONFIG path to alternative config directory
--rnsconfig RNSCONFIG
path to alternative Reticulum config directory
-i IDENTITY, --identity IDENTITY
path to release identity
-v, --verbose increase verbosity
-q, --quiet decrease verbosity
-i, --identity PATH path to release identity
-v, --verbose
-q, --quiet
--version show program's version number and exit
Work Documents
==============
In addition to releases, ``rngit`` provides a work document management system for tracking tasks, investigations, issues and progress related to repositories. Work documents are stored as structured msgpack data and support threaded updates and comments.
**Listing Work Documents**
To view work documents for a repository:
.. code:: text
$ rngit work rns://50824b711717f97c2fb1166ceddd5ea9/public/myrepo list
Active documents
=================
ID Title Author Created Comments
---------------------------------------------------------------------------
1 Implemented new feature 9710b86ba12c4f2e… 2025-01-15 14:32 3
2 Fixed bug in parser 8f3a21c9d84e927b… 2025-01-14 09:15 1
Use ``--scope completed`` to view completed work documents, or ``--scope all`` to see both active and completed.
**Viewing a Work Document**
To view a specific work document with all its comments:
.. code:: text
$ rngit work rns://50824b711717f97c2fb1166ceddd5ea9/public/myrepo view -d 1
Implement new feature (active #1)
=================================
Author : 9710b86ba12c42d1d8f30f74fe509286
Status : active
Created : 2026-05-05 15:11:11
Edited : 2026-05-05 18:22:11
Format : markdown
Updates : 0
This work document tracks the implementation of the new feature...
Updates
=======
#1 by 9710b86ba12c42d1d8f30f74fe509286 at 2026-05-05 15:38:37
-------------------------------------------------------------
Initial analysis complete
**Creating Work Documents**
To create a new work document:
.. code:: text
$ rngit work rns://50824b711717f97c2fb1166ceddd5ea9/public/myrepo create --title "Investigate performance issue"
This will open your configured ``$EDITOR`` to compose the document content. Save and exit to create the document, or save an empty document to cancel.
**Editing Work Documents**
To edit an existing work document:
.. code:: text
$ rngit work rns://50824b711717f97c2fb1166ceddd5ea9/public/myrepo edit -d 1
This fetches the current content, opens it in your editor, and sends any changes back to the node.
**Adding Comments**
To add an update to a work document:
.. code:: text
$ rngit work rns://50824b711717f97c2fb1166ceddd5ea9/public/myrepo update -d 1
This opens your editor to compose the update.
**Completing Work Documents**
To mark a work document as completed (moving it from ``active`` to ``completed``):
.. code:: text
$ rngit work rns://50824b711717f97c2fb1166ceddd5ea9/public/myrepo complete -d 1
Work document #1 completed
**Activating Work Documents**
To mark a work document as active (moving it from ``completed`` to ``active``):
.. code:: text
$ rngit work rns://50824b711717f97c2fb1166ceddd5ea9/public/myrepo activate -d 1
Work document #1 activated
**Deleting Work Documents**
To delete a work document and all its comments:
.. code:: text
$ rngit work rns://50824b711717f97c2fb1166ceddd5ea9/public/myrepo delete -id 1
Are you sure you want to delete active work document #1? [y/N]: y
Work document #1 deleted
**Permissions**
Users can view work documents and updates if the have ``read`` permission for the repository. If users have ``read`` and ``interact``, they can also post updates/comments on existing work documents. Work document management requires having ``write`` and ``interact`` permission to the repository. These permissions are configured the same way as any other repository permissions. In the config file or ``.allowed`` files, use ``i:target`` to grant work document interaction rights:
.. code:: text
# In .allowed file or config
i:all # Allow everyone
i:9710b86... # Allow specific identity
i:none # Deny everyone
**Author Verification**
Users can only edit or delete work documents and updates they created. The author is cryptographically verified from the interacting link's ``remote_identity``.
**Storage Format**
Work documents are stored in a ``repo_name.work`` directory next to the repository, containing:
- ``active/`` - Active work documents
- ``completed/`` - Completed work documents
Each document is a numbered directory containing:
- ``root`` - The work document content and metadata (msgpack format)
- ``N`` - Numbered comment files (msgpack format)
**Nomad Network Interface**
When the Nomad Network page server is enabled, work documents are viewable through the web interface. The work page lists all documents with their status, and clicking a document shows its full content and updates.
**All Command-Line Options (rngit work)**
.. code:: text
usage: rngit work [-h] [--config CONFIG] [--rnsconfig RNSCONFIG]
[-i PATH] [--scope SCOPE] [-t TITLE] [-d ID] [-v]
[-q] [--version]
[repository] [operation]
Reticulum Git Work Document Manager
positional arguments:
repository URL of remote repository
operation list, view, create, edit, delete, update or complete
options:
-h, --help show this help message and exit
--config CONFIG path to alternative config directory
--rnsconfig RNSCONFIG
path to alternative Reticulum config directory
-i, --identity PATH path to identity
--scope SCOPE document scope: active, completed or all
-t, --title TITLE document title for create
-d, --id ID document ID
-v, --verbose
-q, --quiet
--version show program's version number and exit