EXTENSION for TreeOS
deleted-revive
Deletion in TreeOS is permanent by default. When a branch is deleted, its nodes are marked with a special parent value that removes them from the live tree. The data stays in the database but becomes invisible and inaccessible. This extension makes that recoverable. The deleted list endpoint finds all nodes owned by a user whose parent is set to the DELETED sentinel value. These are the root nodes of deleted branches. Each entry shows the node's name, when it was created, and its ID. The user sees everything they've deleted and can decide what to bring back. Two revival paths are available. Revive as branch reattaches the deleted subtree under a specified parent node in an existing tree. The user picks where it goes. The entire branch, with all its children, notes, and metadata, comes back exactly as it was. Revive as root promotes the deleted branch to a standalone tree. The node becomes a new root owned by the user. Useful when a branch outgrew its original tree or when the parent tree no longer exists. Both operations call kernel functions (reviveNodeBranch and reviveNodeBranchAsRoot) that handle parent pointer updates, contributor lists, and tree integrity. The extension only provides the user-facing routes and authorization checks. If html-rendering is installed, the deleted list renders as a browsable HTML page with revival actions. The CLI exposes a 'deleted' command that lists soft-deleted branches for the current user.
v1.0.1 by TreeOS Site 0 downloads 4 files 512 lines 14.2 KB published 38d ago
treeos ext install deleted-revive
View changelog

Manifest

Provides

  • routes
  • 2 CLI commands

Requires

  • models: Node

Optional

  • extensions: html-rendering, treeos-base
SHA256: 3a1326369c8c5bec70871935339886058ccf8b2329e08d335416e9abb79640ce

CLI Commands

CommandMethodDescription
deletedGETList soft-deleted branches
revive <deletedId> <target>POSTRevive a deleted branch under a target node

Source Code

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

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star deleted-revive

Comments

Loading comments...

Post comments from the CLI: treeos ext comment deleted-revive "your comment"
Max 3 comments per extension. One star and one flag per user.