mirror of
https://github.com/markqvist/Reticulum.git
synced 2026-06-08 14:11:53 -07:00
Implemented rngit work doc management
This commit is contained in:
@@ -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}"""
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user