Use node identity in local helper UI
This commit is contained in:
@@ -10041,7 +10041,7 @@ def ui_tree_insert(root, node):
|
|||||||
cursor.setdefault("nodes", []).append(node)
|
cursor.setdefault("nodes", []).append(node)
|
||||||
|
|
||||||
|
|
||||||
def ui_render_tree_branch(branch, selected_path, mode, search, prefix=""):
|
def ui_render_tree_branch(branch, selected_path, selected_node_id, mode, search, prefix=""):
|
||||||
folders = branch.get("folders", {})
|
folders = branch.get("folders", {})
|
||||||
nodes = branch.get("nodes", [])
|
nodes = branch.get("nodes", [])
|
||||||
output = []
|
output = []
|
||||||
@@ -10052,14 +10052,15 @@ def ui_render_tree_branch(branch, selected_path, mode, search, prefix=""):
|
|||||||
output.append(
|
output.append(
|
||||||
f"<details class='folder' {'open' if should_open else ''}>"
|
f"<details class='folder' {'open' if should_open else ''}>"
|
||||||
f"<summary><span class='folder-icon'>dir</span>{html.escape(name)}</summary>"
|
f"<summary><span class='folder-icon'>dir</span>{html.escape(name)}</summary>"
|
||||||
f"{ui_render_tree_branch(child, selected_path, mode, search, folder_path)}"
|
f"{ui_render_tree_branch(child, selected_path, selected_node_id, mode, search, folder_path)}"
|
||||||
"</details>"
|
"</details>"
|
||||||
)
|
)
|
||||||
for node in sort_nodes_by_recency(nodes):
|
for node in sort_nodes_by_recency(nodes):
|
||||||
full_path = node.get("full_path") or ""
|
full_path = node.get("full_path") or ""
|
||||||
|
node_id = node.get("id") or ""
|
||||||
name = full_path.rstrip("/").split("/")[-1] or full_path
|
name = full_path.rstrip("/").split("/")[-1] or full_path
|
||||||
params = urllib.parse.urlencode({"mode": mode, "q": search, "path": full_path})
|
params = urllib.parse.urlencode({"mode": mode, "q": search, "node_id": node_id})
|
||||||
active = " active" if full_path == selected_path else ""
|
active = " active" if node_id and node_id == selected_node_id else ""
|
||||||
output.append(
|
output.append(
|
||||||
f"<a class='file{active}' href='/?{params}'><span class='file-icon'>node</span>"
|
f"<a class='file{active}' href='/?{params}'><span class='file-icon'>node</span>"
|
||||||
f"<span>{html.escape(name)}</span><small>{html.escape(node.get('type') or 'note')}</small></a>"
|
f"<span>{html.escape(name)}</span><small>{html.escape(node.get('type') or 'note')}</small></a>"
|
||||||
@@ -10241,6 +10242,7 @@ def start_local_ui(args):
|
|||||||
"feedback": [],
|
"feedback": [],
|
||||||
}
|
}
|
||||||
search = query.get("q", [""])[0]
|
search = query.get("q", [""])[0]
|
||||||
|
selected_node_id = query.get("node_id", [""])[0]
|
||||||
selected_path = query.get("path", [""])[0]
|
selected_path = query.get("path", [""])[0]
|
||||||
nodes_query = {"limit": 200}
|
nodes_query = {"limit": 200}
|
||||||
if search:
|
if search:
|
||||||
@@ -10257,14 +10259,21 @@ def start_local_ui(args):
|
|||||||
agent_nodes = [node for node in all_nodes if is_agent_namespace_path(node.get("full_path") or "")]
|
agent_nodes = [node for node in all_nodes if is_agent_namespace_path(node.get("full_path") or "")]
|
||||||
if is_agent_namespace_path(selected_path):
|
if is_agent_namespace_path(selected_path):
|
||||||
selected_path = ""
|
selected_path = ""
|
||||||
if not selected_path and nodes:
|
if not selected_node_id and selected_path:
|
||||||
selected_path = nodes[0].get("full_path") or ""
|
|
||||||
selected_node = None
|
|
||||||
if selected_path:
|
|
||||||
try:
|
try:
|
||||||
selected_node = read_node_by_path({"path": selected_path})
|
selected_node_id = (node_by_path({"path": selected_path}).get("identity") or {}).get("node_id") or ""
|
||||||
|
except Exception:
|
||||||
|
selected_node_id = ""
|
||||||
|
if not selected_node_id and nodes:
|
||||||
|
selected_node_id = nodes[0].get("id") or ""
|
||||||
|
selected_node = None
|
||||||
|
if selected_node_id:
|
||||||
|
try:
|
||||||
|
selected_node = get_node_by_id({"node_id": selected_node_id})
|
||||||
|
selected_path = selected_node.get("full_path") or ""
|
||||||
except Exception:
|
except Exception:
|
||||||
selected_node = None
|
selected_node = None
|
||||||
|
selected_path = ""
|
||||||
schedules = request_json("GET", f"/v1/projects/{project_id}/schedules").get("items", [])
|
schedules = request_json("GET", f"/v1/projects/{project_id}/schedules").get("items", [])
|
||||||
try:
|
try:
|
||||||
feedback = request_json("GET", "/v1/feedback?limit=50").get("items", [])
|
feedback = request_json("GET", "/v1/feedback?limit=50").get("items", [])
|
||||||
@@ -10320,6 +10329,7 @@ def start_local_ui(args):
|
|||||||
"archived_nodes": archived_nodes,
|
"archived_nodes": archived_nodes,
|
||||||
"agent_nodes": agent_nodes,
|
"agent_nodes": agent_nodes,
|
||||||
"connected_agents": connected_agents,
|
"connected_agents": connected_agents,
|
||||||
|
"selected_node_id": selected_node_id,
|
||||||
"selected_path": selected_path,
|
"selected_path": selected_path,
|
||||||
"selected_node": selected_node,
|
"selected_node": selected_node,
|
||||||
"schedules": schedules,
|
"schedules": schedules,
|
||||||
@@ -10453,6 +10463,7 @@ def start_local_ui(args):
|
|||||||
mode = query.get("mode", ["filesystem"])[0]
|
mode = query.get("mode", ["filesystem"])[0]
|
||||||
if mode not in {"filesystem", "path-list"}:
|
if mode not in {"filesystem", "path-list"}:
|
||||||
mode = "filesystem"
|
mode = "filesystem"
|
||||||
|
selected_node_id = query.get("node_id", [""])[0]
|
||||||
selected_path = query.get("path", [""])[0]
|
selected_path = query.get("path", [""])[0]
|
||||||
nodes_query = {"limit": 200}
|
nodes_query = {"limit": 200}
|
||||||
if search:
|
if search:
|
||||||
@@ -10460,32 +10471,40 @@ def start_local_ui(args):
|
|||||||
nodes_suffix = rest_query_string(nodes_query)
|
nodes_suffix = rest_query_string(nodes_query)
|
||||||
nodes = request_json("GET", f"/v1/projects/{project_id}/nodes?{nodes_suffix}").get("items", [])
|
nodes = request_json("GET", f"/v1/projects/{project_id}/nodes?{nodes_suffix}").get("items", [])
|
||||||
nodes = sort_nodes_by_recency(nodes)
|
nodes = sort_nodes_by_recency(nodes)
|
||||||
if not selected_path and nodes:
|
if not selected_node_id and selected_path:
|
||||||
selected_path = nodes[0].get("full_path") or ""
|
|
||||||
selected_node = None
|
|
||||||
if selected_path:
|
|
||||||
try:
|
try:
|
||||||
selected_node = read_node_by_path({"path": selected_path})
|
selected_node_id = (node_by_path({"path": selected_path}).get("identity") or {}).get("node_id") or ""
|
||||||
|
except Exception:
|
||||||
|
selected_node_id = ""
|
||||||
|
if not selected_node_id and nodes:
|
||||||
|
selected_node_id = nodes[0].get("id") or ""
|
||||||
|
selected_node = None
|
||||||
|
if selected_node_id:
|
||||||
|
try:
|
||||||
|
selected_node = get_node_by_id({"node_id": selected_node_id})
|
||||||
|
selected_path = selected_node.get("full_path") or ""
|
||||||
except Exception:
|
except Exception:
|
||||||
selected_node = None
|
selected_node = None
|
||||||
|
selected_path = ""
|
||||||
schedules = request_json("GET", f"/v1/projects/{project_id}/schedules").get("items", [])
|
schedules = request_json("GET", f"/v1/projects/{project_id}/schedules").get("items", [])
|
||||||
feedback = request_json("GET", "/v1/feedback?limit=50").get("items", [])
|
feedback = request_json("GET", "/v1/feedback?limit=50").get("items", [])
|
||||||
status = update_status()
|
status = update_status()
|
||||||
current_url = "/" + (f"?{parsed_path.query}" if parsed_path.query else "")
|
current_url = "/" + (f"?{parsed_path.query}" if parsed_path.query else "")
|
||||||
filesystem_url = "/?" + urllib.parse.urlencode({"mode": "filesystem", "q": search, "path": selected_path})
|
filesystem_url = "/?" + urllib.parse.urlencode({"mode": "filesystem", "q": search, "node_id": selected_node_id})
|
||||||
path_list_url = "/?" + urllib.parse.urlencode({"mode": "path-list", "q": search, "path": selected_path})
|
path_list_url = "/?" + urllib.parse.urlencode({"mode": "path-list", "q": search, "node_id": selected_node_id})
|
||||||
tree = ui_build_tree(nodes)
|
tree = ui_build_tree(nodes)
|
||||||
tree_html = ui_render_tree_branch(tree, selected_path, mode, search)
|
tree_html = ui_render_tree_branch(tree, selected_path, selected_node_id, mode, search)
|
||||||
selected_links = ui_json_list((selected_node or {}).get("links") or [])
|
selected_links = ui_json_list((selected_node or {}).get("links") or [])
|
||||||
selected_metadata = ui_json((selected_node or {}).get("metadata") or {})
|
selected_metadata = ui_json((selected_node or {}).get("metadata") or {})
|
||||||
selected_tags = ", ".join((selected_node or {}).get("tags") or [])
|
selected_tags = ", ".join((selected_node or {}).get("tags") or [])
|
||||||
selected_content = html.escape((selected_node or {}).get("content_text") or "")
|
selected_content = html.escape((selected_node or {}).get("content_text") or "")
|
||||||
selected_type = html.escape((selected_node or {}).get("type") or "note")
|
selected_type = html.escape((selected_node or {}).get("type") or "note")
|
||||||
selected_path_value = html.escape(selected_path or "")
|
selected_path_value = html.escape(selected_path or "")
|
||||||
|
selected_node_id_value = html.escape(selected_node_id or "")
|
||||||
selected_title = html.escape(selected_path or "New node")
|
selected_title = html.escape(selected_path or "New node")
|
||||||
selected_badges = ui_tags((selected_node or {}).get("tags") or [])
|
selected_badges = ui_tags((selected_node or {}).get("tags") or [])
|
||||||
node_rows = "".join(
|
node_rows = "".join(
|
||||||
f"<tr><td><a class='path-link' href='/?{rest_query_string({'mode': 'filesystem', 'path': item['full_path'], 'q': search})}'><code>{html.escape(item['full_path'])}</code></a></td><td>{ui_tags(item.get('tags') or [])}</td><td>{html.escape(item.get('type') or 'note')}</td><td><form method='post' action='/nodes/archive'><input type='hidden' name='return_to' value='/?{rest_query_string({'mode': mode, 'q': search})}'><input type='hidden' name='path' value='{html.escape(item['full_path'])}'><button class='ghost'>Archive</button></form></td></tr>"
|
f"<tr><td><a class='path-link' href='/?{rest_query_string({'mode': 'filesystem', 'node_id': item.get('id') or '', 'q': search})}'><code>{html.escape(item['full_path'])}</code></a></td><td>{ui_tags(item.get('tags') or [])}</td><td>{html.escape(item.get('type') or 'note')}</td><td><form method='post' action='/nodes/archive'><input type='hidden' name='return_to' value='/?{rest_query_string({'mode': mode, 'q': search})}'><input type='hidden' name='node_id' value='{html.escape(item.get('id') or '')}'><button class='ghost'>Archive</button></form></td></tr>"
|
||||||
for item in nodes
|
for item in nodes
|
||||||
)
|
)
|
||||||
schedule_rows = "".join(
|
schedule_rows = "".join(
|
||||||
@@ -10516,13 +10535,13 @@ def start_local_ui(args):
|
|||||||
{f'''
|
{f'''
|
||||||
<div class="workspace"><aside class="side"><div class="side-head"><strong>Files</strong><span class="muted">{len(nodes)} nodes</span></div><div class="tree">{tree_html or '<div class="empty">No nodes found.</div>'}</div></aside>
|
<div class="workspace"><aside class="side"><div class="side-head"><strong>Files</strong><span class="muted">{len(nodes)} nodes</span></div><div class="tree">{tree_html or '<div class="empty">No nodes found.</div>'}</div></aside>
|
||||||
<div class="detail"><div class="detail-head"><div class="selected-title"><strong>{selected_title}</strong></div><div>{selected_badges}</div></div><div class="detail-body">
|
<div class="detail"><div class="detail-head"><div class="selected-title"><strong>{selected_title}</strong></div><div>{selected_badges}</div></div><div class="detail-body">
|
||||||
<form method="post" action="/nodes/upsert"><input type="hidden" name="return_to" value="{html.escape(current_url)}"><h3>{'Edit node' if selected_node else 'Create node'}</h3>
|
<form method="post" action="/nodes/upsert"><input type="hidden" name="return_to" value="{html.escape(current_url)}"><input type="hidden" name="node_id" value="{selected_node_id_value}"><h3>{'Edit node' if selected_node else 'Create node'}</h3>
|
||||||
<label>Full path<input name="path" required value="{selected_path_value}" placeholder="/decisions/auth-model"></label>
|
<label>Full path<input name="path" required value="{selected_path_value}" placeholder="/decisions/auth-model"></label>
|
||||||
<div class="grid"><label>Type<input name="type" value="{selected_type}"></label><label>Tags CSV<input name="tags" value="{html.escape(selected_tags)}" placeholder="decision, auth"></label></div>
|
<div class="grid"><label>Type<input name="type" value="{selected_type}"></label><label>Tags CSV<input name="tags" value="{html.escape(selected_tags)}" placeholder="decision, auth"></label></div>
|
||||||
<label>Content<textarea name="content_text">{selected_content}</textarea></label>
|
<label>Content<textarea name="content_text">{selected_content}</textarea></label>
|
||||||
<div class="grid"><label>Links JSON<textarea class="mono" name="links">{selected_links}</textarea></label><label>Metadata JSON<textarea class="mono" name="metadata">{selected_metadata}</textarea></label></div>
|
<div class="grid"><label>Links JSON<textarea class="mono" name="links">{selected_links}</textarea></label><label>Metadata JSON<textarea class="mono" name="metadata">{selected_metadata}</textarea></label></div>
|
||||||
<div class="actions"><button>Save node</button><a class="button ghost" href="/?mode=filesystem&q={urllib.parse.quote(search)}">New node</a></div></form>
|
<div class="actions"><button>Save node</button><a class="button ghost" href="/?mode=filesystem&q={urllib.parse.quote(search)}">New node</a></div></form>
|
||||||
<form method="post" action="/nodes/link"><input type="hidden" name="return_to" value="{html.escape(current_url)}"><h3>Link nodes</h3><div class="grid"><label>Source path<input name="source_path" value="{selected_path_value}" required></label><label>Target path<input name="target_path" required></label><label>Relation<input name="relation" value="references"></label></div><button class="ghost">Create link</button></form>
|
<form method="post" action="/nodes/link"><input type="hidden" name="return_to" value="{html.escape(current_url)}"><h3>Link nodes</h3><div class="grid"><label>Source node id<input name="source_node_id" value="{selected_node_id_value}" required></label><label>Target node id<input name="target_node_id" required></label><label>Relation<input name="relation" value="references"></label></div><button class="ghost">Create link</button></form>
|
||||||
</div></div></div>''' if mode == 'filesystem' else f'''
|
</div></div></div>''' if mode == 'filesystem' else f'''
|
||||||
<div class="detail"><table><thead><tr><th>Path</th><th>Tags</th><th>Type</th><th>Action</th></tr></thead><tbody>{node_rows or '<tr><td colspan="4" class="muted">No nodes.</td></tr>'}</tbody></table></div>'''}
|
<div class="detail"><table><thead><tr><th>Path</th><th>Tags</th><th>Type</th><th>Action</th></tr></thead><tbody>{node_rows or '<tr><td colspan="4" class="muted">No nodes.</td></tr>'}</tbody></table></div>'''}
|
||||||
</section>
|
</section>
|
||||||
@@ -10600,20 +10619,24 @@ def start_local_ui(args):
|
|||||||
"payload": json.loads((form.get("payload", ["{}"])[0] or "{}")),
|
"payload": json.loads((form.get("payload", ["{}"])[0] or "{}")),
|
||||||
})
|
})
|
||||||
elif self.path == "/nodes/upsert":
|
elif self.path == "/nodes/upsert":
|
||||||
upsert_node({
|
node_id = form.get("node_id", [""])[0].strip()
|
||||||
|
payload = {
|
||||||
"path": form.get("path", [""])[0],
|
"path": form.get("path", [""])[0],
|
||||||
"type": form.get("type", ["note"])[0],
|
"type": form.get("type", ["note"])[0],
|
||||||
"tags": [tag.strip() for tag in form.get("tags", [""])[0].split(",") if tag.strip()],
|
"tags": [tag.strip() for tag in form.get("tags", [""])[0].split(",") if tag.strip()],
|
||||||
"links": json.loads(form.get("links", ["[]"])[0] or "[]"),
|
"links": json.loads(form.get("links", ["[]"])[0] or "[]"),
|
||||||
"metadata": json.loads(form.get("metadata", ["{}"])[0] or "{}"),
|
"metadata": json.loads(form.get("metadata", ["{}"])[0] or "{}"),
|
||||||
"content_text": form.get("content_text", [""])[0],
|
"content_text": form.get("content_text", [""])[0],
|
||||||
})
|
}
|
||||||
|
if node_id:
|
||||||
|
payload["node_id"] = node_id
|
||||||
|
upsert_node(payload)
|
||||||
elif self.path == "/nodes/archive":
|
elif self.path == "/nodes/archive":
|
||||||
archive_node_by_path({"path": form.get("path", [""])[0]})
|
archive_node_by_path({"node_id": form.get("node_id", [""])[0]})
|
||||||
elif self.path == "/nodes/link":
|
elif self.path == "/nodes/link":
|
||||||
link_nodes({
|
link_nodes({
|
||||||
"source_path": form.get("source_path", [""])[0],
|
"source_node_id": form.get("source_node_id", [""])[0],
|
||||||
"target_path": form.get("target_path", [""])[0],
|
"target_node_id": form.get("target_node_id", [""])[0],
|
||||||
"relation": form.get("relation", ["references"])[0],
|
"relation": form.get("relation", ["references"])[0],
|
||||||
})
|
})
|
||||||
elif self.path == "/auth/renew":
|
elif self.path == "/auth/renew":
|
||||||
|
|||||||
@@ -632,6 +632,18 @@ def test_context_and_related_use_identity_inputs(mod):
|
|||||||
mod.find_related_nodes({"path": "/notes/root"})
|
mod.find_related_nodes({"path": "/notes/root"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_helper_ui_forms_submit_identity_for_existing_node_actions():
|
||||||
|
source = (pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py").read_text()
|
||||||
|
assert "name='node_id' value='{html.escape(item.get('id') or '')}'" in source
|
||||||
|
assert 'name="source_node_id"' in source
|
||||||
|
assert 'name="target_node_id"' in source
|
||||||
|
assert 'archive_node_by_path({"node_id": form.get("node_id", [""])[0]})' in source
|
||||||
|
assert '"source_node_id": form.get("source_node_id", [""])[0]' in source
|
||||||
|
assert '"target_node_id": form.get("target_node_id", [""])[0]' in source
|
||||||
|
assert 'name="source_path"' not in source
|
||||||
|
assert 'name="target_path"' not in source
|
||||||
|
|
||||||
|
|
||||||
def test_discovery_rejects_full_content_view(mod):
|
def test_discovery_rejects_full_content_view(mod):
|
||||||
with pytest.raises(RuntimeError, match="full node bodies require nap_node_get"):
|
with pytest.raises(RuntimeError, match="full node bodies require nap_node_get"):
|
||||||
mod.list_project_nodes({"view": "full"})
|
mod.list_project_nodes({"view": "full"})
|
||||||
|
|||||||
Reference in New Issue
Block a user