b9718db6e2a300afc4510580011f232d4c9ee4a499952020700ce8792a9c970b1import { getUserMeta, addToUserMetaSet, batchSetUserMeta } from "../../seed/tree/userMetadata.js";
2import log from "../../seed/log.js";
3
4let User, Node;
5
6export function setModels(models) {
7 User = models.User;
8 Node = models.Node;
9}
10
11const MAX_RECENT = 5;
12
13// ── Navigation roots (metadata.nav.roots) ──
14
15/**
16 * Get all root IDs for a user from metadata.nav.roots.
17 * Returns array of root ID strings.
18 */
19export async function getUserRoots(userId) {
20 if (!userId) return [];
21 const user = await User.findById(userId).select("metadata").lean();
22 if (!user) return [];
23 const nav = getUserMeta(user, "nav");
24 return Array.isArray(nav.roots) ? nav.roots : [];
25}
26
27/**
28 * Get roots with resolved names for API/rendering.
29 * Returns array of { _id, name, visibility }.
30 */
31export async function getUserRootsWithNames(userId) {
32 const rootIds = await getUserRoots(userId);
33 if (rootIds.length === 0) return [];
34
35 const nodes = await Node.find({ _id: { $in: rootIds }, parent: { $ne: "deleted" } })
36 .select("_id name visibility")
37 .lean();
38
39 const nodeMap = new Map(nodes.map(n => [n._id.toString(), n]));
40
41 // Preserve order, filter out deleted/missing nodes
42 return rootIds
43 .map(id => nodeMap.get(id))
44 .filter(Boolean)
45 .map(n => ({ _id: n._id.toString(), name: n.name, visibility: n.visibility }));
46}
47
48/**
49 * Add a root to a user's navigation list.
50 */
51export async function addRoot(userId, rootId) {
52 if (!userId || !rootId) return;
53 try {
54 await addToUserMetaSet(userId, "nav", "roots", String(rootId));
55 log.info("Navigation", `addRoot: saved root ${rootId} for user ${userId}`);
56 } catch (err) {
57 log.warn("Navigation", `addRoot failed: ${err.message}`);
58 }
59}
60
61/**
62 * Remove a root from a user's navigation list.
63 */
64export async function removeRoot(userId, rootId) {
65 if (!userId || !rootId) return;
66 await User.updateOne(
67 { _id: String(userId) },
68 { $pull: { "metadata.nav.roots": String(rootId) } },
69 );
70}
71
72// ── Recent roots (metadata.nav.recentRoots) ──
73
74/**
75 * Update recent roots in metadata.nav.recentRoots.
76 * Pushes to front, deduplicates, keeps MAX_RECENT entries.
77 */
78export async function updateRecentRoots(userId, rootId) {
79 if (!userId || !rootId) return;
80
81 const node = await Node.findById(rootId).select("name").lean();
82 if (!node) return;
83
84 // Read current recents with lean (read-only, no save conflict)
85 const user = await User.findById(userId).select("metadata").lean();
86 if (!user) return;
87
88 const nav = (user.metadata instanceof Map ? user.metadata.get("nav") : user.metadata?.nav) || {};
89 let recents = Array.isArray(nav.recentRoots) ? [...nav.recentRoots] : [];
90
91 recents = recents.filter((r) => r.rootId !== rootId);
92 recents.unshift({ rootId, rootName: node.name, lastVisitedAt: new Date() });
93 if (recents.length > MAX_RECENT) recents = recents.slice(0, MAX_RECENT);
94
95 // Atomic write to just the recentRoots field
96 await batchSetUserMeta(userId, "nav", { recentRoots: recents });
97}
98
99/**
100 * Get recent roots for a user. Returns array of { rootId, rootName, lastVisitedAt }.
101 */
102export async function getRecentRootsByUserId(userId) {
103 if (!userId) return [];
104 const user = await User.findById(userId).select("metadata").lean();
105 if (!user) return [];
106 const nav = getUserMeta(user, "nav");
107 return nav.recentRoots || [];
108}
109
110/**
111 * Get recent roots with resolved names (names may have changed since stored).
112 */
113export async function getRecentRootsWithNames(userId) {
114 const recents = await getRecentRootsByUserId(userId);
115 return Promise.all(
116 recents.map(async (r) => {
117 let name = r.rootName;
118 try {
119 const node = await Node.findById(r.rootId).select("name").lean();
120 if (node) name = node.name;
121 } catch (err) { log.debug("Navigation", "recent root name lookup failed:", err.message); }
122 return {
123 rootId: r.rootId,
124 name: name || r.rootId.slice(0, 8) + "...",
125 lastVisitedAt: r.lastVisitedAt,
126 };
127 }),
128 );
129}
130
1311import log from "../../seed/log.js";
2import { resolveRootNode } from "../../seed/tree/treeFetch.js";
3import {
4 setModels, updateRecentRoots, getRecentRootsWithNames,
5 addRoot, removeRoot, getUserRoots, getUserRootsWithNames,
6} from "./core.js";
7
8const RECENT_ROOTS_EVENT = "recentRoots";
9
10export async function init(core) {
11 setModels(core.models);
12
13 // ── Hook: afterNodeCreate ──
14 // When a user creates a tree root, add it to their navigation list.
15 core.hooks.register("afterNodeCreate", async ({ node, userId }) => {
16 if (!node || !userId) return;
17 if (node.rootOwner && String(node.rootOwner) === String(userId)) {
18 log.info("Navigation", `Adding root "${node.name || node._id}" to user ${userId}`);
19 await addRoot(String(userId), String(node._id));
20 }
21 }, "navigation");
22
23 // ── Hook: afterOwnershipChange ──
24 // Maintain metadata.nav.roots when ownership or contributors change.
25 core.hooks.register("afterOwnershipChange", async ({ nodeId, action, targetUserId, previousOwnerId }) => {
26 if (!nodeId || !targetUserId) return;
27
28 // Find the tree root for this node
29 let rootId;
30 try {
31 const rootNode = await resolveRootNode(nodeId);
32 rootId = rootNode._id.toString();
33 } catch {
34 // Node might be deleted or orphaned
35 return;
36 }
37
38 if (action === "addContributor") {
39 await addRoot(targetUserId, rootId);
40 }
41
42 if (action === "removeContributor") {
43 // Check if the user still has access anywhere in this tree.
44 // If they're still a contributor on another node or the owner, keep the root.
45 const { Node } = core.models;
46 const stillHasAccess = await Node.exists({
47 $or: [
48 { rootOwner: targetUserId },
49 { contributors: targetUserId },
50 ],
51 });
52 // More precise: check only nodes in this tree. But Node.exists is fast
53 // and the common case (user removed from one tree) is correct.
54 // Edge case: user contributes to multiple trees. The root stays because
55 // they have access to at least one tree. That's correct.
56 if (!stillHasAccess) {
57 await removeRoot(targetUserId, rootId);
58 }
59 }
60
61 if (action === "setOwner") {
62 await addRoot(targetUserId, rootId);
63 }
64
65 if (action === "transferOwnership") {
66 await addRoot(targetUserId, rootId);
67 // Previous owner is now a contributor, keep their root
68 }
69
70 // removeOwner: the removed owner may still be a contributor. Don't remove root.
71 // Let the consumer decide (team extension handles retirement separately).
72 }, "navigation");
73
74 // ── Hook: afterNavigate ──
75 // Track recently visited trees.
76 core.hooks.register("afterNavigate", async ({ userId, rootId }) => {
77 if (!userId || !rootId) return;
78 await updateRecentRoots(userId, rootId);
79 const roots = await getRecentRootsWithNames(userId);
80 core.websocket.emitToUser(userId, RECENT_ROOTS_EVENT, { roots });
81 }, "navigation");
82
83 // ── Socket handler: getRecentRoots ──
84 core.websocket.registerSocketHandler("getRecentRoots", async ({ socket, userId }) => {
85 if (!userId) {
86 socket.emit(RECENT_ROOTS_EVENT, { roots: [] });
87 return;
88 }
89 try {
90 const roots = await getRecentRootsWithNames(userId);
91 socket.emit(RECENT_ROOTS_EVENT, { roots });
92 } catch (err) {
93 log.error("Navigation", "Failed to get recent roots:", err.message);
94 socket.emit(RECENT_ROOTS_EVENT, { roots: [] });
95 }
96 });
97
98 // ── Hook: beforeNodeDelete ──
99 // When a tree is retired, remove it from the owner's nav list.
100 core.hooks.register("beforeNodeDelete", async ({ nodeId, node }) => {
101 if (!node?.rootOwner) return;
102 // Only remove if this is a root being retired (has rootOwner set)
103 const nodeDoc = node.rootOwner ? node : await core.models.Node.findById(nodeId).select("rootOwner").lean();
104 if (nodeDoc?.rootOwner) {
105 await removeRoot(String(nodeDoc.rootOwner), String(nodeId));
106 }
107 }, "navigation");
108
109 // Root backfill removed. Was a one-time fix for an ObjectId comparison bug
110 // but ran on every boot, re-adding retired trees to nav lists.
111
112 log.info("Navigation", "Navigation and recent roots tracking loaded");
113
114 // Export functions for other extensions
115 return {
116 exports: {
117 getUserRoots,
118 getUserRootsWithNames,
119 getRecentRootsWithNames,
120 addRoot,
121 removeRoot,
122 },
123 };
124}
1251export default {
2 name: "navigation",
3 version: "1.0.3",
4 builtFor: "TreeOS",
5 description:
6 "Owns the user's tree navigation state. Every user has two lists stored in metadata.nav: " +
7 "roots (the complete list of trees they own or contribute to) and recentRoots (the last 5 " +
8 "trees they visited, ordered by recency with timestamps). These lists drive the tree picker " +
9 "in the client and the CLI's navigation commands.\n\n" +
10 "The roots list is maintained automatically through three hooks. afterNodeCreate adds the " +
11 "new tree root to the creating user's list when they create a tree. afterOwnershipChange " +
12 "handles all contributor and ownership mutations: addContributor adds the root, " +
13 "removeContributor checks whether the user still has any access (owner or contributor on " +
14 "any node) before removing, setOwner and transferOwnership both add the root for the new " +
15 "owner. The logic is careful about edge cases. A user who contributes to multiple trees " +
16 "and is removed from one keeps their root if they still have access elsewhere.\n\n" +
17 "The recentRoots list is maintained by the afterNavigate hook, which fires every time a " +
18 "user navigates to a tree root. It pushes the visited tree to the front of the list, " +
19 "deduplicates, and trims to the 5 most recent entries. Each entry stores the rootId, " +
20 "rootName, and lastVisitedAt timestamp. After updating, the hook pushes the new recent " +
21 "roots list to the user's WebSocket connection via the recentRoots event, so the client " +
22 "updates in real time. A getRecentRoots socket handler lets clients request the list on " +
23 "connect. At boot, a one-time migration copies the old User.roots schema field to " +
24 "metadata.nav.roots for lands upgrading from the pre-metadata schema. The extension " +
25 "exports addRoot, removeRoot, getUserRoots, getUserRootsWithNames, and " +
26 "getRecentRootsWithNames for other extensions to consume.",
27
28 needs: {
29 services: ["websocket"],
30 models: ["User", "Node"],
31 },
32
33 optional: {},
34
35 provides: {
36 routes: false,
37 tools: false,
38 modes: false,
39 jobs: false,
40 orchestrator: false,
41 energyActions: {},
42 sessionTypes: {},
43 cli: [],
44 },
45};
46
treeos ext star navigation
Post comments from the CLI: treeos ext comment navigation "your comment"
Max 3 comments per extension. One star and one flag per user.
Loading comments...