1import createRouter from "./routes.js";
2import { getExtension } from "../loader.js";
3
4export async function init(core) {
5 // Register quick link on user profile
6 try {
7 const treeos = getExtension("treeos-base");
8 treeos?.exports?.registerSlot?.("user-quick-links", "deleted-revive", ({ userId, queryString }) =>
9 `<li><a href="/api/v1/user/${userId}/deleted${queryString}">Deleted</a></li>`,
10 { priority: 45 }
11 );
12 } catch {}
13
14 return {
15 router: createRouter(core),
16 };
17}
18
1export default {
2 name: "deleted-revive",
3 version: "1.0.1",
4 builtFor: "TreeOS",
5 description:
6 "Deletion in TreeOS is permanent by default. When a branch is deleted, its nodes are " +
7 "marked with a special parent value that removes them from the live tree. The data " +
8 "stays in the database but becomes invisible and inaccessible. This extension makes " +
9 "that recoverable. " +
10 "\n\n" +
11 "The deleted list endpoint finds all nodes owned by a user whose parent is set to the " +
12 "DELETED sentinel value. These are the root nodes of deleted branches. Each entry shows " +
13 "the node's name, when it was created, and its ID. The user sees everything they've " +
14 "deleted and can decide what to bring back. " +
15 "\n\n" +
16 "Two revival paths are available. Revive as branch reattaches the deleted subtree under " +
17 "a specified parent node in an existing tree. The user picks where it goes. The entire " +
18 "branch, with all its children, notes, and metadata, comes back exactly as it was. " +
19 "Revive as root promotes the deleted branch to a standalone tree. The node becomes a " +
20 "new root owned by the user. Useful when a branch outgrew its original tree or when " +
21 "the parent tree no longer exists. " +
22 "\n\n" +
23 "Both operations call kernel functions (reviveNodeBranch and reviveNodeBranchAsRoot) " +
24 "that handle parent pointer updates, contributor lists, and tree integrity. The " +
25 "extension only provides the user-facing routes and authorization checks. If " +
26 "html-rendering is installed, the deleted list renders as a browsable HTML page with " +
27 "revival actions. The CLI exposes a 'deleted' command that lists soft-deleted branches " +
28 "for the current user.",
29
30 needs: {
31 models: ["Node"],
32 },
33
34 optional: {
35 extensions: ["html-rendering", "treeos-base"],
36 },
37
38 provides: {
39 models: {},
40 routes: "./routes.js",
41 tools: false,
42 jobs: false,
43 orchestrator: false,
44 energyActions: {},
45 sessionTypes: {},
46 cli: [
47 { command: "deleted", scope: ["home"], description: "List soft-deleted branches", method: "GET", endpoint: "/user/:userId/deleted" },
48 { command: "revive <deletedId> <target>", scope: ["home"], description: "Revive a deleted branch under a target node", method: "POST", endpoint: "/user/:userId/deleted/:deletedId/revive", bodyMap: { targetNodeId: 1 } },
49 ],
50 },
51};
52
1import { page } from "../../html-rendering/html/layout.js";
2import { escapeHtml } from "../../html-rendering/html/utils.js";
3
4export function renderDeletedBranches({ userId, user, deleted, token }) {
5 const tokenQS = token ? `?token=${encodeURIComponent(token)}&html` : `?html`;
6
7 const css = `
8.header-subtitle {
9 margin-bottom: 0;
10}
11
12
13@keyframes waterDrift {
14 0% { transform: translateY(-1px); }
15 100% { transform: translateY(1px); }
16}
17
18/* Glass Deleted List */
19.deleted-list {
20 list-style: none;
21 display: flex;
22 flex-direction: column;
23 gap: 16px;
24}
25
26.deleted-card {
27 position: relative;
28 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
29 backdrop-filter: blur(22px) saturate(140%);
30 -webkit-backdrop-filter: blur(22px) saturate(140%);
31 border-radius: 16px;
32 padding: 24px;
33 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
34 inset 0 1px 0 rgba(255, 255, 255, 0.25);
35 border: 1px solid rgba(255, 255, 255, 0.28);
36 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
37 color: white;
38 overflow: hidden;
39
40 /* Start hidden for lazy loading */
41 opacity: 0;
42 transform: translateY(30px);
43}
44
45/* When item becomes visible */
46.deleted-card.visible {
47 animation: fadeInUp 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
48}
49
50.deleted-card::before {
51 content: "";
52 position: absolute;
53 inset: -40%;
54 background: radial-gradient(
55 120% 60% at 0% 0%,
56 rgba(255, 255, 255, 0.35),
57 transparent 60%
58 );
59 opacity: 0;
60 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
61 pointer-events: none;
62}
63
64.deleted-card:hover {
65 background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
66 transform: translateY(-2px);
67 box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
68}
69
70.deleted-card:hover::before {
71 opacity: 1;
72 transform: translateX(30%) translateY(10%);
73}
74
75.deleted-info {
76 margin-bottom: 16px;
77}
78
79.deleted-name {
80 font-size: 18px;
81 font-weight: 600;
82 margin-bottom: 6px;
83}
84
85.deleted-name a {
86 color: white;
87 text-decoration: none;
88 transition: all 0.2s;
89}
90
91.deleted-name a:hover {
92 text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
93}
94
95.deleted-id {
96 font-size: 12px;
97 color: rgba(255, 255, 255, 0.75);
98 font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
99 letter-spacing: -0.3px;
100}
101
102/* Revival Forms */
103.revival-section {
104 display: flex;
105 flex-direction: column;
106 gap: 10px;
107 padding-top: 16px;
108 border-top: 1px solid rgba(255, 255, 255, 0.15);
109}
110
111.revive-as-root-form button {
112 position: relative;
113 overflow: hidden;
114 padding: 12px 24px;
115 border-radius: 980px;
116 border: 1px solid rgba(255, 255, 255, 0.3);
117 background: rgba(255, 255, 255, 0.3);
118 backdrop-filter: blur(10px);
119 color: white;
120 cursor: pointer;
121 font-weight: 600;
122 font-size: 14px;
123 transition: all 0.3s;
124 font-family: inherit;
125 width: 100%;
126 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
127}
128
129.revive-as-root-form button::before {
130 content: "";
131 position: absolute;
132 inset: -40%;
133 background: radial-gradient(
134 120% 60% at 0% 0%,
135 rgba(255, 255, 255, 0.35),
136 transparent 60%
137 );
138 opacity: 0;
139 transform: translateX(-30%) translateY(-10%);
140 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
141 pointer-events: none;
142}
143
144.revive-as-root-form button:hover {
145 background: rgba(255, 255, 255, 0.4);
146 transform: translateY(-2px);
147 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
148}
149
150.revive-as-root-form button:hover::before {
151 opacity: 1;
152 transform: translateX(30%) translateY(10%);
153}
154
155.revive-into-branch-form {
156 display: flex;
157 gap: 8px;
158 flex-wrap: wrap;
159 align-items: center;
160}
161
162.revive-into-branch-form input[type="text"] {
163 flex: 1;
164 min-width: 180px;
165 padding: 10px 14px;
166 font-size: 14px;
167 border-radius: 10px;
168 border: 1px solid rgba(255, 255, 255, 0.25);
169 background: rgba(255, 255, 255, 0.15);
170 backdrop-filter: blur(10px);
171 -webkit-backdrop-filter: blur(10px);
172 font-family: inherit;
173 color: white;
174 font-weight: 500;
175 transition: all 0.3s;
176 box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
177}
178
179.revive-into-branch-form input[type="text"]::placeholder {
180 color: rgba(255, 255, 255, 0.5);
181 font-size: 13px;
182}
183
184.revive-into-branch-form input[type="text"]:focus {
185 outline: none;
186 border-color: rgba(255, 255, 255, 0.4);
187 background: rgba(255, 255, 255, 0.2);
188 box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1),
189 inset 0 1px 0 rgba(255, 255, 255, 0.2);
190}
191
192.revive-into-branch-form button {
193 padding: 10px 18px;
194 font-size: 13px;
195 font-weight: 600;
196 border-radius: 980px;
197 border: 1px solid rgba(255, 255, 255, 0.25);
198 background: rgba(255, 255, 255, 0.15);
199 backdrop-filter: blur(10px);
200 color: white;
201 cursor: pointer;
202 transition: all 0.3s;
203 font-family: inherit;
204 white-space: nowrap;
205 opacity: 0.85;
206 box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
207}
208
209.revive-into-branch-form button:hover {
210 background: rgba(255, 255, 255, 0.25);
211 opacity: 1;
212 transform: translateY(-1px);
213 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1),
214 inset 0 1px 0 rgba(255, 255, 255, 0.2);
215}
216
217/* Responsive Design */
218`;
219
220 const body = `
221 <div class="container">
222 <!-- Back Navigation -->
223 <div class="back-nav">
224 <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">
225 \u2190 Back to Profile
226 </a>
227 </div>
228
229 <!-- Header Section -->
230 <div class="header">
231 <h1>
232 Deleted Branches for
233 <a href="/api/v1/user/${userId}${tokenQS}">${user.username}</a>
234 </h1>
235 <div class="header-subtitle">
236 Recover deleted trees and branches as new trees or merge them into existing ones.
237 </div>
238 </div>
239
240 <!-- Deleted Items List -->
241 ${
242 deleted.length > 0
243 ? `
244 <ul class="deleted-list">
245 ${deleted
246 .map(
247 ({ _id, name }) => `
248 <li class="deleted-card">
249 <div class="deleted-info">
250 <div class="deleted-name">
251 <a href="/api/v1/root/${_id}${tokenQS}">
252 ${name || "Untitled"}
253 </a>
254 </div>
255 <div class="deleted-id">${_id}</div>
256 </div>
257
258 <div class="revival-section">
259 <!-- Revive as Root -->
260 <form
261 method="POST"
262 action="/api/v1/user/${userId}/deleted/${_id}/reviveAsRoot?token=${encodeURIComponent(token)}&html"
263 class="revive-as-root-form"
264 >
265 <button type="submit">Revive as Root</button>
266 </form>
267
268 <!-- Revive into Branch -->
269 <form
270 method="POST"
271 action="/api/v1/user/${userId}/deleted/${_id}/revive?token=${encodeURIComponent(token)}&html"
272 class="revive-into-branch-form"
273 >
274 <input
275 type="text"
276 name="targetParentId"
277 placeholder="Target parent node ID"
278 required
279 />
280 <button type="submit">Revive into Branch</button>
281 </form>
282 </div>
283 </li>
284 `,
285 )
286 .join("")}
287 </ul>
288 `
289 : `
290 <div class="empty-state">
291 <div class="empty-state-icon">\uD83D\uDDD1\uFE0F</div>
292 <div class="empty-state-text">No deleted branches</div>
293 <div class="empty-state-subtext">
294 Deleted branches will appear here and can be revived
295 </div>
296 </div>
297 `
298 }
299 </div>`;
300
301 const js = `
302 // Intersection Observer for lazy loading animations
303 const observerOptions = {
304 root: null,
305 rootMargin: '50px',
306 threshold: 0.1
307 };
308
309 const observer = new IntersectionObserver((entries) => {
310 entries.forEach((entry, index) => {
311 if (entry.isIntersecting) {
312 // Add a small stagger delay based on order
313 setTimeout(() => {
314 entry.target.classList.add('visible');
315 }, index * 50); // 50ms stagger between items
316
317 // Stop observing once animated
318 observer.unobserve(entry.target);
319 }
320 });
321 }, observerOptions);
322
323 // Observe all deleted cards
324 document.querySelectorAll('.deleted-card').forEach(card => {
325 observer.observe(card);
326 });`;
327
328 return page({
329 title: `${user.username} \u2014 Deleted Branches`,
330 css,
331 body,
332 js,
333 });
334}
335
1import log from "../../seed/log.js";
2import express from "express";
3import authenticate from "../../seed/middleware/authenticate.js";
4import { sendOk, sendError, ERR } from "../../seed/protocol.js";
5import { getExtension } from "../loader.js";
6import { renderDeletedBranches } from "./pages/deleted.js";
7import {
8 getDeletedBranchesForUser,
9} from "../../seed/tree/treeFetch.js";
10import {
11 reviveNodeBranch,
12 reviveNodeBranchAsRoot,
13} from "../../seed/tree/treeManagement.js";
14import User from "../../seed/models/user.js";
15
16export default function createRouter(core) {
17 const htmlExt = getExtension("html-rendering");
18 const htmlAuth = htmlExt?.exports?.urlAuth || authenticate;
19
20 const router = express.Router();
21
22 router.get("/user/:userId/deleted", htmlAuth, async (req, res) => {
23 try {
24 const { userId } = req.params;
25 const wantHtml = Object.prototype.hasOwnProperty.call(req.query, "html");
26 const deleted = await getDeletedBranchesForUser(userId);
27
28 if (!wantHtml || !getExtension("html-rendering")) {
29 return sendOk(res, { userId, deleted });
30 }
31
32 const user = await User.findById(userId).lean();
33 const token = req.query.token ?? "";
34
35 // renderDeletedBranches imported directly from pages/deleted.js
36 if (!renderDeletedBranches) {
37 return sendOk(res, { userId, deleted });
38 }
39
40 return res.send(renderDeletedBranches({ userId, user, deleted, token }));
41 } catch (err) {
42 log.error("Deleted Revive", "Error in /user/:userId/deleted:", err);
43 sendError(res, 500, ERR.INTERNAL, err.message);
44 }
45 });
46
47 router.post("/user/:userId/deleted/:nodeId/revive", authenticate, async (req, res) => {
48 try {
49 const { userId, nodeId } = req.params;
50 const { targetParentId } = req.body;
51
52 if (!req.userId || req.userId.toString() !== userId.toString()) {
53 return sendError(res, 403, ERR.FORBIDDEN, "Not authorized");
54 }
55
56 if (!targetParentId) {
57 return sendError(res, 400, ERR.INVALID_INPUT, "targetParentId is required");
58 }
59
60 const result = await reviveNodeBranch({
61 deletedNodeId: nodeId,
62 targetParentId,
63 userId: req.userId,
64 });
65
66 if ("html" in req.query) {
67 return res.redirect(
68 `/api/v1/root/${nodeId}?token=${req.query.token ?? ""}&html`,
69 );
70 }
71
72 return sendOk(res, result);
73 } catch (err) {
74 log.error("Deleted Revive", "revive branch error:", err);
75 return sendError(res, 400, ERR.INVALID_INPUT, err.message);
76 }
77 });
78
79 router.post("/user/:userId/deleted/:nodeId/reviveAsRoot", authenticate, async (req, res) => {
80 try {
81 const { userId, nodeId } = req.params;
82
83 if (!req.userId || req.userId.toString() !== userId.toString()) {
84 return sendError(res, 403, ERR.FORBIDDEN, "Not authorized");
85 }
86
87 const result = await reviveNodeBranchAsRoot({
88 deletedNodeId: nodeId,
89 userId: req.userId,
90 });
91
92 if ("html" in req.query) {
93 return res.redirect(
94 `/api/v1/root/${nodeId}?token=${req.query.token ?? ""}&html`,
95 );
96 }
97
98 return sendOk(res, result);
99 } catch (err) {
100 log.error("Deleted Revive", "revive root error:", err);
101 return sendError(res, 400, ERR.INVALID_INPUT, err.message);
102 }
103 });
104
105 return router;
106}
107
Loading comments...