forge-openclaw-plugin 0.2.4 → 0.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +186 -6
- package/dist/assets/board-C_m78kvK.js +6 -0
- package/dist/assets/board-C_m78kvK.js.map +1 -0
- package/dist/assets/favicon-BCHm9dUV.ico +0 -0
- package/dist/assets/index-BWtLtXwb.js +36 -0
- package/dist/assets/index-BWtLtXwb.js.map +1 -0
- package/dist/assets/index-Dp5GXY_z.css +1 -0
- package/dist/assets/motion-CpZvZumD.js +10 -0
- package/dist/assets/motion-CpZvZumD.js.map +1 -0
- package/dist/assets/plus-jakarta-sans-latin-ext-wght-normal-DmpS2jIq.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-vietnamese-wght-normal-qRpaaN48.woff2 +0 -0
- package/dist/assets/sora-latin-ext-wght-normal-CawQDOvP.woff2 +0 -0
- package/dist/assets/sora-latin-wght-normal-DdqRvwsR.woff2 +0 -0
- package/dist/assets/space-grotesk-latin-500-normal-CNSSEhBt.woff +0 -0
- package/dist/assets/space-grotesk-latin-500-normal-lFbtlQH6.woff2 +0 -0
- package/dist/assets/space-grotesk-latin-700-normal-CwsQ-cCU.woff +0 -0
- package/dist/assets/space-grotesk-latin-700-normal-RjhwGPKo.woff2 +0 -0
- package/dist/assets/space-grotesk-latin-ext-500-normal-3dgZTiw9.woff +0 -0
- package/dist/assets/space-grotesk-latin-ext-500-normal-DUe3BAxM.woff2 +0 -0
- package/dist/assets/space-grotesk-latin-ext-700-normal-BQnZhY3m.woff2 +0 -0
- package/dist/assets/space-grotesk-latin-ext-700-normal-HVCqSBdx.woff +0 -0
- package/dist/assets/space-grotesk-vietnamese-500-normal-BTqKIpxg.woff +0 -0
- package/dist/assets/space-grotesk-vietnamese-500-normal-BmEvtly_.woff2 +0 -0
- package/dist/assets/space-grotesk-vietnamese-700-normal-DMty7AZE.woff2 +0 -0
- package/dist/assets/space-grotesk-vietnamese-700-normal-Duxec5Rn.woff +0 -0
- package/dist/assets/table-DtyXTw03.js +23 -0
- package/dist/assets/table-DtyXTw03.js.map +1 -0
- package/dist/assets/ui-BXbpiKyS.js +46 -0
- package/dist/assets/ui-BXbpiKyS.js.map +1 -0
- package/dist/assets/vendor-CRS-psbw.css +1 -0
- package/dist/assets/vendor-QBH6qVEe.js +433 -0
- package/dist/assets/vendor-QBH6qVEe.js.map +1 -0
- package/dist/assets/viz-w-IMeueL.js +34 -0
- package/dist/assets/viz-w-IMeueL.js.map +1 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.png +0 -0
- package/dist/index.html +29 -0
- package/dist/openclaw/api-client.d.ts +9 -0
- package/dist/openclaw/api-client.js +31 -4
- package/dist/openclaw/local-runtime.d.ts +3 -0
- package/dist/openclaw/local-runtime.js +136 -0
- package/dist/openclaw/parity.d.ts +4 -4
- package/dist/openclaw/parity.js +23 -33
- package/dist/openclaw/plugin-entry-shared.d.ts +4 -2
- package/dist/openclaw/plugin-entry-shared.js +63 -9
- package/dist/openclaw/routes.d.ts +12 -3
- package/dist/openclaw/routes.js +156 -924
- package/dist/openclaw/tools.js +242 -1100
- package/dist/server/app.js +2487 -0
- package/dist/server/db.js +313 -0
- package/dist/server/demo-data.js +49 -0
- package/dist/server/e2e-server.js +20 -0
- package/dist/server/errors.js +15 -0
- package/dist/server/index.js +16 -0
- package/dist/server/managers/base.js +17 -0
- package/dist/server/managers/contracts.js +47 -0
- package/dist/server/managers/platform/api-gateway-manager.js +11 -0
- package/dist/server/managers/platform/audit-manager.js +15 -0
- package/dist/server/managers/platform/authentication-manager.js +56 -0
- package/dist/server/managers/platform/authorization-manager.js +56 -0
- package/dist/server/managers/platform/background-job-manager.js +10 -0
- package/dist/server/managers/platform/configuration-manager.js +33 -0
- package/dist/server/managers/platform/database-manager.js +14 -0
- package/dist/server/managers/platform/event-bus-manager.js +7 -0
- package/dist/server/managers/platform/external-service-manager.js +11 -0
- package/dist/server/managers/platform/health-manager.js +7 -0
- package/dist/server/managers/platform/migration-manager.js +8 -0
- package/dist/server/managers/platform/search-index-manager.js +4 -0
- package/dist/server/managers/platform/secrets-manager.js +19 -0
- package/dist/server/managers/platform/session-manager.js +121 -0
- package/dist/server/managers/platform/storage-manager.js +16 -0
- package/dist/server/managers/platform/token-manager.js +37 -0
- package/dist/server/managers/platform/transaction-manager.js +8 -0
- package/dist/server/managers/platform/trusted-network.js +39 -0
- package/dist/server/managers/runtime.js +56 -0
- package/dist/server/managers/type-guards.js +4 -0
- package/dist/server/openapi.js +3553 -0
- package/dist/server/psyche-types.js +366 -0
- package/dist/server/repositories/activity-events.js +157 -0
- package/dist/server/repositories/collaboration.js +497 -0
- package/dist/server/repositories/deleted-entities.js +226 -0
- package/dist/server/repositories/domains.js +30 -0
- package/dist/server/repositories/event-log.js +64 -0
- package/dist/server/repositories/goals.js +156 -0
- package/dist/server/repositories/notes.js +359 -0
- package/dist/server/repositories/projects.js +211 -0
- package/dist/server/repositories/psyche.js +1353 -0
- package/dist/server/repositories/rewards.js +675 -0
- package/dist/server/repositories/settings.js +399 -0
- package/dist/server/repositories/tags.js +160 -0
- package/dist/server/repositories/task-runs.js +490 -0
- package/dist/server/repositories/tasks.js +424 -0
- package/dist/server/seed-demo.js +11 -0
- package/dist/server/services/context.js +214 -0
- package/dist/server/services/dashboard.js +173 -0
- package/dist/server/services/entity-crud.js +573 -0
- package/dist/server/services/gamification.js +215 -0
- package/dist/server/services/insights.js +91 -0
- package/dist/server/services/projects.js +77 -0
- package/dist/server/services/psyche.js +63 -0
- package/dist/server/services/relations.js +28 -0
- package/dist/server/services/reviews.js +88 -0
- package/dist/server/services/run-recovery.js +13 -0
- package/dist/server/services/tagging.js +49 -0
- package/dist/server/services/task-run-watchdog.js +92 -0
- package/dist/server/services/work-time.js +176 -0
- package/dist/server/types.js +1058 -0
- package/dist/server/web.js +91 -0
- package/openclaw.plugin.json +32 -9
- package/package.json +17 -4
- package/server/migrations/001_core.sql +411 -0
- package/server/migrations/002_psyche.sql +392 -0
- package/skills/forge-openclaw/SKILL.md +197 -271
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getDatabase } from "../db.js";
|
|
3
|
+
import { recordActivityEvent } from "./activity-events.js";
|
|
4
|
+
import { filterDeletedEntities, getDeletedEntityRecord, clearDeletedEntityRecord, isEntityDeleted, upsertDeletedEntityRecord } from "./deleted-entities.js";
|
|
5
|
+
import { recordEventLog } from "./event-log.js";
|
|
6
|
+
import { noteSchema, notesListQuerySchema, createNoteSchema, updateNoteSchema } from "../types.js";
|
|
7
|
+
function normalizeAnchorKey(anchorKey) {
|
|
8
|
+
return anchorKey.trim().length > 0 ? anchorKey : null;
|
|
9
|
+
}
|
|
10
|
+
function normalizeLinks(links) {
|
|
11
|
+
const seen = new Set();
|
|
12
|
+
return links.filter((link) => {
|
|
13
|
+
const key = `${link.entityType}:${link.entityId}:${link.anchorKey ?? ""}`;
|
|
14
|
+
if (seen.has(key)) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
seen.add(key);
|
|
18
|
+
return true;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function stripMarkdown(markdown) {
|
|
22
|
+
return markdown
|
|
23
|
+
.replace(/```[\s\S]*?```/g, (block) => block.replace(/```/g, "").trim())
|
|
24
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
25
|
+
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
|
|
26
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
27
|
+
.replace(/^>\s?/gm, "")
|
|
28
|
+
.replace(/^#{1,6}\s+/gm, "")
|
|
29
|
+
.replace(/^[-*+]\s+/gm, "")
|
|
30
|
+
.replace(/^\d+\.\s+/gm, "")
|
|
31
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
32
|
+
.replace(/\*([^*]+)\*/g, "$1")
|
|
33
|
+
.replace(/_([^_]+)_/g, "$1")
|
|
34
|
+
.replace(/~~([^~]+)~~/g, "$1")
|
|
35
|
+
.replace(/\r/g, "")
|
|
36
|
+
.trim();
|
|
37
|
+
}
|
|
38
|
+
function describeNote(note) {
|
|
39
|
+
const plain = note.contentPlain.trim() || stripMarkdown(note.contentMarkdown);
|
|
40
|
+
const compact = plain.replace(/\s+/g, " ").trim();
|
|
41
|
+
const title = compact.slice(0, 72) || "Note";
|
|
42
|
+
const subtitle = compact.length > 72 ? compact.slice(72, 168).trim() : "";
|
|
43
|
+
return { title, subtitle };
|
|
44
|
+
}
|
|
45
|
+
function buildFtsQuery(query) {
|
|
46
|
+
const tokens = query
|
|
47
|
+
.trim()
|
|
48
|
+
.split(/\s+/)
|
|
49
|
+
.map((token) => token.replace(/["*']/g, "").trim())
|
|
50
|
+
.filter(Boolean);
|
|
51
|
+
if (tokens.length === 0) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return tokens.map((token) => `${token}*`).join(" AND ");
|
|
55
|
+
}
|
|
56
|
+
function getNoteRow(noteId) {
|
|
57
|
+
return getDatabase()
|
|
58
|
+
.prepare(`SELECT id, content_markdown, content_plain, author, source, created_at, updated_at
|
|
59
|
+
FROM notes
|
|
60
|
+
WHERE id = ?`)
|
|
61
|
+
.get(noteId);
|
|
62
|
+
}
|
|
63
|
+
function listLinkRowsForNotes(noteIds) {
|
|
64
|
+
if (noteIds.length === 0) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
const placeholders = noteIds.map(() => "?").join(", ");
|
|
68
|
+
return getDatabase()
|
|
69
|
+
.prepare(`SELECT note_id, entity_type, entity_id, anchor_key, created_at
|
|
70
|
+
FROM note_links
|
|
71
|
+
WHERE note_id IN (${placeholders})
|
|
72
|
+
ORDER BY created_at ASC`)
|
|
73
|
+
.all(...noteIds);
|
|
74
|
+
}
|
|
75
|
+
function mapLinks(rows) {
|
|
76
|
+
return rows.map((row) => ({
|
|
77
|
+
entityType: row.entity_type,
|
|
78
|
+
entityId: row.entity_id,
|
|
79
|
+
anchorKey: normalizeAnchorKey(row.anchor_key)
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
function mapNote(row, linkRows) {
|
|
83
|
+
return noteSchema.parse({
|
|
84
|
+
id: row.id,
|
|
85
|
+
contentMarkdown: row.content_markdown,
|
|
86
|
+
contentPlain: row.content_plain,
|
|
87
|
+
author: row.author,
|
|
88
|
+
source: row.source,
|
|
89
|
+
createdAt: row.created_at,
|
|
90
|
+
updatedAt: row.updated_at,
|
|
91
|
+
links: mapLinks(linkRows)
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function upsertSearchRow(noteId, contentPlain, author) {
|
|
95
|
+
getDatabase().prepare(`DELETE FROM notes_fts WHERE note_id = ?`).run(noteId);
|
|
96
|
+
getDatabase()
|
|
97
|
+
.prepare(`INSERT INTO notes_fts (note_id, content_plain, author) VALUES (?, ?, ?)`)
|
|
98
|
+
.run(noteId, contentPlain, author ?? "");
|
|
99
|
+
}
|
|
100
|
+
function deleteSearchRow(noteId) {
|
|
101
|
+
getDatabase().prepare(`DELETE FROM notes_fts WHERE note_id = ?`).run(noteId);
|
|
102
|
+
}
|
|
103
|
+
function listAllNoteRows() {
|
|
104
|
+
return getDatabase()
|
|
105
|
+
.prepare(`SELECT id, content_markdown, content_plain, author, source, created_at, updated_at
|
|
106
|
+
FROM notes
|
|
107
|
+
ORDER BY created_at DESC`)
|
|
108
|
+
.all();
|
|
109
|
+
}
|
|
110
|
+
function findMatchingNoteIds(query) {
|
|
111
|
+
const ftsQuery = buildFtsQuery(query);
|
|
112
|
+
if (!ftsQuery) {
|
|
113
|
+
return new Set();
|
|
114
|
+
}
|
|
115
|
+
const rows = getDatabase()
|
|
116
|
+
.prepare(`SELECT note_id FROM notes_fts WHERE notes_fts MATCH ?`)
|
|
117
|
+
.all(ftsQuery);
|
|
118
|
+
return new Set(rows.map((row) => row.note_id));
|
|
119
|
+
}
|
|
120
|
+
function insertLinks(noteId, links, createdAt) {
|
|
121
|
+
const statement = getDatabase().prepare(`INSERT OR IGNORE INTO note_links (note_id, entity_type, entity_id, anchor_key, created_at)
|
|
122
|
+
VALUES (?, ?, ?, ?, ?)`);
|
|
123
|
+
for (const link of links) {
|
|
124
|
+
statement.run(noteId, link.entityType, link.entityId, link.anchorKey ?? "", createdAt);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function replaceLinks(noteId, links, createdAt) {
|
|
128
|
+
getDatabase().prepare(`DELETE FROM note_links WHERE note_id = ?`).run(noteId);
|
|
129
|
+
insertLinks(noteId, links, createdAt);
|
|
130
|
+
}
|
|
131
|
+
function listNoteLinks(noteId) {
|
|
132
|
+
return getDatabase()
|
|
133
|
+
.prepare(`SELECT note_id, entity_type, entity_id, anchor_key, created_at
|
|
134
|
+
FROM note_links
|
|
135
|
+
WHERE note_id = ?
|
|
136
|
+
ORDER BY created_at ASC`)
|
|
137
|
+
.all(noteId);
|
|
138
|
+
}
|
|
139
|
+
function recordNoteActivity(note, eventType, title, context) {
|
|
140
|
+
for (const link of note.links) {
|
|
141
|
+
recordActivityEvent({
|
|
142
|
+
entityType: link.entityType,
|
|
143
|
+
entityId: link.entityId,
|
|
144
|
+
eventType,
|
|
145
|
+
title,
|
|
146
|
+
description: note.contentPlain,
|
|
147
|
+
actor: note.author ?? context.actor ?? null,
|
|
148
|
+
source: context.source,
|
|
149
|
+
metadata: {
|
|
150
|
+
noteId: note.id,
|
|
151
|
+
anchorKey: link.anchorKey ?? ""
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
recordEventLog({
|
|
155
|
+
eventKind: eventType,
|
|
156
|
+
entityType: link.entityType,
|
|
157
|
+
entityId: link.entityId,
|
|
158
|
+
actor: note.author ?? context.actor ?? null,
|
|
159
|
+
source: context.source,
|
|
160
|
+
metadata: {
|
|
161
|
+
noteId: note.id,
|
|
162
|
+
anchorKey: link.anchorKey ?? ""
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export function getNoteById(noteId) {
|
|
168
|
+
if (isEntityDeleted("note", noteId)) {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
const row = getNoteRow(noteId);
|
|
172
|
+
if (!row) {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
return mapNote(row, listNoteLinks(noteId));
|
|
176
|
+
}
|
|
177
|
+
export function getNoteByIdIncludingDeleted(noteId) {
|
|
178
|
+
const row = getNoteRow(noteId);
|
|
179
|
+
if (!row) {
|
|
180
|
+
const deleted = getDeletedEntityRecord("note", noteId);
|
|
181
|
+
return deleted?.snapshot;
|
|
182
|
+
}
|
|
183
|
+
return mapNote(row, listNoteLinks(noteId));
|
|
184
|
+
}
|
|
185
|
+
export function listNotes(query = {}) {
|
|
186
|
+
const parsed = notesListQuerySchema.parse(query);
|
|
187
|
+
if (parsed.linkedEntityType &&
|
|
188
|
+
parsed.linkedEntityId &&
|
|
189
|
+
isEntityDeleted(parsed.linkedEntityType, parsed.linkedEntityId)) {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
const matchingIds = parsed.query ? findMatchingNoteIds(parsed.query) : null;
|
|
193
|
+
const rows = listAllNoteRows();
|
|
194
|
+
const linksByNoteId = new Map();
|
|
195
|
+
for (const link of listLinkRowsForNotes(rows.map((row) => row.id))) {
|
|
196
|
+
const current = linksByNoteId.get(link.note_id) ?? [];
|
|
197
|
+
current.push(link);
|
|
198
|
+
linksByNoteId.set(link.note_id, current);
|
|
199
|
+
}
|
|
200
|
+
return filterDeletedEntities("note", rows
|
|
201
|
+
.filter((row) => (matchingIds ? matchingIds.has(row.id) : true))
|
|
202
|
+
.filter((row) => (parsed.author ? (row.author ?? "").toLowerCase().includes(parsed.author.toLowerCase()) : true))
|
|
203
|
+
.map((row) => mapNote(row, linksByNoteId.get(row.id) ?? []))
|
|
204
|
+
.filter((note) => parsed.linkedEntityType && parsed.linkedEntityId
|
|
205
|
+
? note.links.some((link) => link.entityType === parsed.linkedEntityType &&
|
|
206
|
+
link.entityId === parsed.linkedEntityId &&
|
|
207
|
+
(parsed.anchorKey === undefined ? true : (link.anchorKey ?? null) === parsed.anchorKey))
|
|
208
|
+
: true)
|
|
209
|
+
.slice(0, parsed.limit ?? 100));
|
|
210
|
+
}
|
|
211
|
+
export function createNote(input, context) {
|
|
212
|
+
const parsed = createNoteSchema.parse({
|
|
213
|
+
...input,
|
|
214
|
+
links: normalizeLinks(input.links)
|
|
215
|
+
});
|
|
216
|
+
const now = new Date().toISOString();
|
|
217
|
+
const id = `note_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
218
|
+
const contentPlain = stripMarkdown(parsed.contentMarkdown);
|
|
219
|
+
getDatabase()
|
|
220
|
+
.prepare(`INSERT INTO notes (id, content_markdown, content_plain, author, source, created_at, updated_at)
|
|
221
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
222
|
+
.run(id, parsed.contentMarkdown, contentPlain, parsed.author ?? context.actor ?? null, context.source, now, now);
|
|
223
|
+
insertLinks(id, parsed.links, now);
|
|
224
|
+
clearDeletedEntityRecord("note", id);
|
|
225
|
+
upsertSearchRow(id, contentPlain, parsed.author ?? context.actor ?? null);
|
|
226
|
+
const note = getNoteById(id);
|
|
227
|
+
recordNoteActivity(note, "note.created", "Note added", context);
|
|
228
|
+
return note;
|
|
229
|
+
}
|
|
230
|
+
export function createLinkedNotes(notes, entityLink, context) {
|
|
231
|
+
if (!notes || notes.length === 0) {
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
return notes.map((note) => createNote({
|
|
235
|
+
contentMarkdown: note.contentMarkdown,
|
|
236
|
+
author: note.author,
|
|
237
|
+
links: [entityLink, ...note.links]
|
|
238
|
+
}, context));
|
|
239
|
+
}
|
|
240
|
+
export function updateNote(noteId, input, context) {
|
|
241
|
+
const existing = getNoteByIdIncludingDeleted(noteId);
|
|
242
|
+
if (!existing) {
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
const patch = updateNoteSchema.parse({
|
|
246
|
+
...input,
|
|
247
|
+
links: input.links ? normalizeLinks(input.links) : undefined
|
|
248
|
+
});
|
|
249
|
+
const nextMarkdown = patch.contentMarkdown ?? existing.contentMarkdown;
|
|
250
|
+
const nextPlain = stripMarkdown(nextMarkdown);
|
|
251
|
+
const nextAuthor = patch.author === undefined ? existing.author : patch.author;
|
|
252
|
+
const updatedAt = new Date().toISOString();
|
|
253
|
+
getDatabase()
|
|
254
|
+
.prepare(`UPDATE notes
|
|
255
|
+
SET content_markdown = ?, content_plain = ?, author = ?, updated_at = ?
|
|
256
|
+
WHERE id = ?`)
|
|
257
|
+
.run(nextMarkdown, nextPlain, nextAuthor, updatedAt, noteId);
|
|
258
|
+
if (patch.links) {
|
|
259
|
+
replaceLinks(noteId, patch.links, updatedAt);
|
|
260
|
+
}
|
|
261
|
+
const note = getNoteByIdIncludingDeleted(noteId);
|
|
262
|
+
if (note.links.length > 0) {
|
|
263
|
+
clearDeletedEntityRecord("note", noteId);
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
const details = describeNote(note);
|
|
267
|
+
upsertDeletedEntityRecord({
|
|
268
|
+
entityType: "note",
|
|
269
|
+
entityId: note.id,
|
|
270
|
+
title: details.title,
|
|
271
|
+
subtitle: details.subtitle,
|
|
272
|
+
snapshot: note,
|
|
273
|
+
deleteReason: "Note no longer has any linked entities.",
|
|
274
|
+
context
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
upsertSearchRow(noteId, nextPlain, nextAuthor);
|
|
278
|
+
recordNoteActivity(note, "note.updated", "Note updated", context);
|
|
279
|
+
return getNoteById(noteId);
|
|
280
|
+
}
|
|
281
|
+
export function deleteNote(noteId, context) {
|
|
282
|
+
const existing = getNoteByIdIncludingDeleted(noteId);
|
|
283
|
+
if (!existing) {
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
getDatabase().prepare(`DELETE FROM note_links WHERE note_id = ?`).run(noteId);
|
|
287
|
+
getDatabase().prepare(`DELETE FROM notes WHERE id = ?`).run(noteId);
|
|
288
|
+
deleteSearchRow(noteId);
|
|
289
|
+
recordNoteActivity(existing, "note.deleted", "Note deleted", context);
|
|
290
|
+
return existing;
|
|
291
|
+
}
|
|
292
|
+
export function buildNotesSummaryByEntity() {
|
|
293
|
+
const rows = getDatabase()
|
|
294
|
+
.prepare(`SELECT
|
|
295
|
+
note_links.entity_type AS entity_type,
|
|
296
|
+
note_links.entity_id AS entity_id,
|
|
297
|
+
notes.id AS note_id,
|
|
298
|
+
notes.created_at AS created_at
|
|
299
|
+
FROM note_links
|
|
300
|
+
INNER JOIN notes ON notes.id = note_links.note_id
|
|
301
|
+
LEFT JOIN deleted_entities
|
|
302
|
+
ON deleted_entities.entity_type = 'note'
|
|
303
|
+
AND deleted_entities.entity_id = notes.id
|
|
304
|
+
WHERE deleted_entities.entity_id IS NULL
|
|
305
|
+
ORDER BY notes.created_at DESC`)
|
|
306
|
+
.all();
|
|
307
|
+
return rows.reduce((acc, row) => {
|
|
308
|
+
const key = `${row.entity_type}:${row.entity_id}`;
|
|
309
|
+
const current = acc[key];
|
|
310
|
+
if (!current) {
|
|
311
|
+
acc[key] = {
|
|
312
|
+
count: 1,
|
|
313
|
+
latestNoteId: row.note_id,
|
|
314
|
+
latestCreatedAt: row.created_at
|
|
315
|
+
};
|
|
316
|
+
return acc;
|
|
317
|
+
}
|
|
318
|
+
current.count += 1;
|
|
319
|
+
if (!current.latestCreatedAt || row.created_at > current.latestCreatedAt) {
|
|
320
|
+
current.latestCreatedAt = row.created_at;
|
|
321
|
+
current.latestNoteId = row.note_id;
|
|
322
|
+
}
|
|
323
|
+
return acc;
|
|
324
|
+
}, {});
|
|
325
|
+
}
|
|
326
|
+
export function unlinkNotesForEntity(entityType, entityId, context) {
|
|
327
|
+
const noteIds = getDatabase()
|
|
328
|
+
.prepare(`SELECT DISTINCT note_id FROM note_links WHERE entity_type = ? AND entity_id = ?`)
|
|
329
|
+
.all(entityType, entityId);
|
|
330
|
+
if (noteIds.length === 0) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
getDatabase()
|
|
334
|
+
.prepare(`DELETE FROM note_links WHERE entity_type = ? AND entity_id = ?`)
|
|
335
|
+
.run(entityType, entityId);
|
|
336
|
+
for (const row of noteIds) {
|
|
337
|
+
const remaining = getDatabase()
|
|
338
|
+
.prepare(`SELECT COUNT(*) AS count FROM note_links WHERE note_id = ?`)
|
|
339
|
+
.get(row.note_id);
|
|
340
|
+
if (remaining.count > 0) {
|
|
341
|
+
clearDeletedEntityRecord("note", row.note_id);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const note = getNoteByIdIncludingDeleted(row.note_id);
|
|
345
|
+
if (!note) {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const details = describeNote(note);
|
|
349
|
+
upsertDeletedEntityRecord({
|
|
350
|
+
entityType: "note",
|
|
351
|
+
entityId: note.id,
|
|
352
|
+
title: details.title,
|
|
353
|
+
subtitle: details.subtitle,
|
|
354
|
+
snapshot: { ...note, links: [] },
|
|
355
|
+
deleteReason: `All links were removed when ${entityType} ${entityId} was deleted.`,
|
|
356
|
+
context
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getDatabase, runInTransaction } from "../db.js";
|
|
3
|
+
import { recordActivityEvent } from "./activity-events.js";
|
|
4
|
+
import { filterDeletedEntities, isEntityDeleted } from "./deleted-entities.js";
|
|
5
|
+
import { createLinkedNotes } from "./notes.js";
|
|
6
|
+
import { assertGoalExists } from "../services/relations.js";
|
|
7
|
+
import { getGoalById } from "./goals.js";
|
|
8
|
+
import { pruneLinkedEntityReferences } from "./psyche.js";
|
|
9
|
+
import { createProjectSchema, projectSchema, updateProjectSchema } from "../types.js";
|
|
10
|
+
function getDefaultProjectTemplate(goal) {
|
|
11
|
+
switch (goal.title) {
|
|
12
|
+
case "Build a durable body and calm energy":
|
|
13
|
+
return {
|
|
14
|
+
title: "Energy Foundation Sprint",
|
|
15
|
+
description: "Build the routines, scheduling, and recovery rhythm that make consistent physical energy possible."
|
|
16
|
+
};
|
|
17
|
+
case "Ship meaningful creative work every week":
|
|
18
|
+
return {
|
|
19
|
+
title: "Weekly Creative Shipping System",
|
|
20
|
+
description: "Create a repeatable system for deep work, reviews, and visible weekly output."
|
|
21
|
+
};
|
|
22
|
+
case "Strengthen shared life systems":
|
|
23
|
+
return {
|
|
24
|
+
title: "Shared Life Admin Reset",
|
|
25
|
+
description: "Reduce friction in logistics, planning, and recurring obligations that support shared life."
|
|
26
|
+
};
|
|
27
|
+
default:
|
|
28
|
+
return {
|
|
29
|
+
title: `${goal.title}: Active Project`,
|
|
30
|
+
description: "Concrete workstream under this life goal so tasks, evidence, and progress have a clear home."
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function mapProject(row) {
|
|
35
|
+
return projectSchema.parse({
|
|
36
|
+
id: row.id,
|
|
37
|
+
goalId: row.goal_id,
|
|
38
|
+
title: row.title,
|
|
39
|
+
description: row.description,
|
|
40
|
+
status: row.status,
|
|
41
|
+
themeColor: row.theme_color,
|
|
42
|
+
targetPoints: row.target_points,
|
|
43
|
+
createdAt: row.created_at,
|
|
44
|
+
updatedAt: row.updated_at
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
export function listProjects(filters = {}) {
|
|
48
|
+
const whereClauses = [];
|
|
49
|
+
const params = [];
|
|
50
|
+
if (filters.goalId) {
|
|
51
|
+
whereClauses.push("goal_id = ?");
|
|
52
|
+
params.push(filters.goalId);
|
|
53
|
+
}
|
|
54
|
+
if (filters.status) {
|
|
55
|
+
whereClauses.push("status = ?");
|
|
56
|
+
params.push(filters.status);
|
|
57
|
+
}
|
|
58
|
+
const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
|
59
|
+
const limitSql = filters.limit ? "LIMIT ?" : "";
|
|
60
|
+
if (filters.limit) {
|
|
61
|
+
params.push(filters.limit);
|
|
62
|
+
}
|
|
63
|
+
const rows = getDatabase()
|
|
64
|
+
.prepare(`SELECT id, goal_id, title, description, status, theme_color, target_points, created_at, updated_at
|
|
65
|
+
FROM projects
|
|
66
|
+
${whereSql}
|
|
67
|
+
ORDER BY created_at ASC
|
|
68
|
+
${limitSql}`)
|
|
69
|
+
.all(...params);
|
|
70
|
+
return filterDeletedEntities("project", rows.map(mapProject));
|
|
71
|
+
}
|
|
72
|
+
export function getProjectById(projectId) {
|
|
73
|
+
if (isEntityDeleted("project", projectId)) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const row = getDatabase()
|
|
77
|
+
.prepare(`SELECT id, goal_id, title, description, status, theme_color, target_points, created_at, updated_at
|
|
78
|
+
FROM projects
|
|
79
|
+
WHERE id = ?`)
|
|
80
|
+
.get(projectId);
|
|
81
|
+
return row ? mapProject(row) : undefined;
|
|
82
|
+
}
|
|
83
|
+
export function createProject(input, activity) {
|
|
84
|
+
return runInTransaction(() => {
|
|
85
|
+
const parsed = createProjectSchema.parse(input);
|
|
86
|
+
assertGoalExists(parsed.goalId);
|
|
87
|
+
const now = new Date().toISOString();
|
|
88
|
+
const id = `project_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
89
|
+
getDatabase()
|
|
90
|
+
.prepare(`INSERT INTO projects (id, goal_id, title, description, status, theme_color, target_points, created_at, updated_at)
|
|
91
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
92
|
+
.run(id, parsed.goalId, parsed.title, parsed.description, parsed.status, parsed.themeColor, parsed.targetPoints, now, now);
|
|
93
|
+
const project = getProjectById(id);
|
|
94
|
+
createLinkedNotes(parsed.notes, { entityType: "project", entityId: project.id, anchorKey: null }, activity ?? { source: "ui", actor: null });
|
|
95
|
+
if (activity) {
|
|
96
|
+
recordActivityEvent({
|
|
97
|
+
entityType: "project",
|
|
98
|
+
entityId: project.id,
|
|
99
|
+
eventType: "project_created",
|
|
100
|
+
title: `Project created: ${project.title}`,
|
|
101
|
+
description: "A new path was added under a life goal.",
|
|
102
|
+
actor: activity.actor ?? null,
|
|
103
|
+
source: activity.source,
|
|
104
|
+
metadata: {
|
|
105
|
+
goalId: project.goalId,
|
|
106
|
+
status: project.status,
|
|
107
|
+
targetPoints: project.targetPoints
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return project;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
export function updateProject(projectId, input, activity) {
|
|
115
|
+
const current = getProjectById(projectId);
|
|
116
|
+
if (!current) {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
return runInTransaction(() => {
|
|
120
|
+
const parsed = updateProjectSchema.parse(input);
|
|
121
|
+
const nextGoalId = parsed.goalId ?? current.goalId;
|
|
122
|
+
assertGoalExists(nextGoalId);
|
|
123
|
+
const next = {
|
|
124
|
+
goalId: nextGoalId,
|
|
125
|
+
title: parsed.title ?? current.title,
|
|
126
|
+
description: parsed.description ?? current.description,
|
|
127
|
+
status: parsed.status ?? current.status,
|
|
128
|
+
themeColor: parsed.themeColor ?? current.themeColor,
|
|
129
|
+
targetPoints: parsed.targetPoints ?? current.targetPoints,
|
|
130
|
+
updatedAt: new Date().toISOString()
|
|
131
|
+
};
|
|
132
|
+
getDatabase()
|
|
133
|
+
.prepare(`UPDATE projects
|
|
134
|
+
SET goal_id = ?, title = ?, description = ?, status = ?, theme_color = ?, target_points = ?, updated_at = ?
|
|
135
|
+
WHERE id = ?`)
|
|
136
|
+
.run(next.goalId, next.title, next.description, next.status, next.themeColor, next.targetPoints, next.updatedAt, projectId);
|
|
137
|
+
// Keep legacy task.goal_id aligned with the project's parent goal.
|
|
138
|
+
getDatabase()
|
|
139
|
+
.prepare(`UPDATE tasks SET goal_id = ?, updated_at = ? WHERE project_id = ?`)
|
|
140
|
+
.run(next.goalId, next.updatedAt, projectId);
|
|
141
|
+
const project = getProjectById(projectId);
|
|
142
|
+
if (project && activity) {
|
|
143
|
+
recordActivityEvent({
|
|
144
|
+
entityType: "project",
|
|
145
|
+
entityId: project.id,
|
|
146
|
+
eventType: current.status !== project.status ? "project_status_changed" : "project_updated",
|
|
147
|
+
title: current.status !== project.status ? `Project ${project.status}: ${project.title}` : `Project updated: ${project.title}`,
|
|
148
|
+
description: "Project details were updated.",
|
|
149
|
+
actor: activity.actor ?? null,
|
|
150
|
+
source: activity.source,
|
|
151
|
+
metadata: {
|
|
152
|
+
goalId: project.goalId,
|
|
153
|
+
previousGoalId: current.goalId,
|
|
154
|
+
status: project.status,
|
|
155
|
+
previousStatus: current.status
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return project;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
export function ensureDefaultProjectForGoal(goalId) {
|
|
163
|
+
assertGoalExists(goalId);
|
|
164
|
+
const existing = listProjects({ goalId, limit: 1 })[0];
|
|
165
|
+
if (existing) {
|
|
166
|
+
return existing;
|
|
167
|
+
}
|
|
168
|
+
return runInTransaction(() => {
|
|
169
|
+
const goal = getGoalById(goalId);
|
|
170
|
+
if (!goal) {
|
|
171
|
+
throw new Error(`Goal ${goalId} is missing`);
|
|
172
|
+
}
|
|
173
|
+
const template = getDefaultProjectTemplate(goal);
|
|
174
|
+
const now = new Date().toISOString();
|
|
175
|
+
const id = `project_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
176
|
+
getDatabase()
|
|
177
|
+
.prepare(`INSERT INTO projects (id, goal_id, title, description, status, theme_color, target_points, created_at, updated_at)
|
|
178
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
179
|
+
.run(id, goalId, template.title, template.description, goal.status === "completed" ? "completed" : goal.status === "paused" ? "paused" : "active", goal.themeColor, Math.max(100, Math.round(goal.targetPoints / 2)), now, now);
|
|
180
|
+
return getProjectById(id);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
export function deleteProject(projectId, activity) {
|
|
184
|
+
const current = getProjectById(projectId);
|
|
185
|
+
if (!current) {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
return runInTransaction(() => {
|
|
189
|
+
pruneLinkedEntityReferences("project", projectId);
|
|
190
|
+
getDatabase()
|
|
191
|
+
.prepare(`DELETE FROM projects WHERE id = ?`)
|
|
192
|
+
.run(projectId);
|
|
193
|
+
if (activity) {
|
|
194
|
+
recordActivityEvent({
|
|
195
|
+
entityType: "project",
|
|
196
|
+
entityId: current.id,
|
|
197
|
+
eventType: "project_deleted",
|
|
198
|
+
title: `Project deleted: ${current.title}`,
|
|
199
|
+
description: "Project removed from the system.",
|
|
200
|
+
actor: activity.actor ?? null,
|
|
201
|
+
source: activity.source,
|
|
202
|
+
metadata: {
|
|
203
|
+
goalId: current.goalId,
|
|
204
|
+
status: current.status,
|
|
205
|
+
targetPoints: current.targetPoints
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return current;
|
|
210
|
+
});
|
|
211
|
+
}
|