forge-openclaw-plugin 0.2.15 → 0.2.19
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 +39 -4
- package/dist/assets/{board-C_m78kvK.js → board-8L3uX7_O.js} +2 -2
- package/dist/assets/{board-C_m78kvK.js.map → board-8L3uX7_O.js.map} +1 -1
- package/dist/assets/index-Cj1IBH_w.js +36 -0
- package/dist/assets/index-Cj1IBH_w.js.map +1 -0
- package/dist/assets/index-DQT6EbuS.css +1 -0
- package/dist/assets/{motion-CpZvZumD.js → motion-1GAqqi8M.js} +2 -2
- package/dist/assets/{motion-CpZvZumD.js.map → motion-1GAqqi8M.js.map} +1 -1
- package/dist/assets/{table-DtyXTw03.js → table-DBGlgRjk.js} +2 -2
- package/dist/assets/{table-DtyXTw03.js.map → table-DBGlgRjk.js.map} +1 -1
- package/dist/assets/{ui-BXbpiKyS.js → ui-iTluWjC4.js} +2 -2
- package/dist/assets/{ui-BXbpiKyS.js.map → ui-iTluWjC4.js.map} +1 -1
- package/dist/assets/{vendor-QBH6qVEe.js → vendor-BvM2F9Dp.js} +151 -81
- package/dist/assets/vendor-BvM2F9Dp.js.map +1 -0
- package/dist/assets/{viz-w-IMeueL.js → viz-CNeunkfu.js} +2 -2
- package/dist/assets/{viz-w-IMeueL.js.map → viz-CNeunkfu.js.map} +1 -1
- package/dist/index.html +8 -8
- package/dist/openclaw/local-runtime.js +142 -9
- package/dist/openclaw/parity.js +1 -0
- package/dist/openclaw/plugin-entry-shared.js +7 -1
- package/dist/openclaw/routes.js +7 -0
- package/dist/openclaw/tools.js +198 -16
- package/dist/server/app.js +2615 -251
- package/dist/server/managers/platform/secrets-manager.js +44 -1
- package/dist/server/managers/runtime.js +3 -1
- package/dist/server/openapi.js +2212 -170
- package/dist/server/repositories/calendar.js +1101 -0
- package/dist/server/repositories/deleted-entities.js +10 -2
- package/dist/server/repositories/habits.js +358 -0
- package/dist/server/repositories/notes.js +161 -28
- package/dist/server/repositories/projects.js +45 -13
- package/dist/server/repositories/rewards.js +176 -6
- package/dist/server/repositories/settings.js +47 -5
- package/dist/server/repositories/task-runs.js +46 -10
- package/dist/server/repositories/tasks.js +25 -9
- package/dist/server/repositories/weekly-reviews.js +109 -0
- package/dist/server/repositories/work-adjustments.js +105 -0
- package/dist/server/services/calendar-runtime.js +1301 -0
- package/dist/server/services/context.js +16 -6
- package/dist/server/services/dashboard.js +6 -3
- package/dist/server/services/entity-crud.js +116 -3
- package/dist/server/services/gamification.js +66 -18
- package/dist/server/services/insights.js +2 -1
- package/dist/server/services/projects.js +32 -8
- package/dist/server/services/reviews.js +17 -2
- package/dist/server/services/work-time.js +27 -0
- package/dist/server/types.js +1069 -45
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server/migrations/003_habits.sql +30 -0
- package/server/migrations/004_habit_links.sql +8 -0
- package/server/migrations/005_habit_psyche_links.sql +24 -0
- package/server/migrations/006_work_adjustments.sql +14 -0
- package/server/migrations/007_weekly_review_closures.sql +17 -0
- package/server/migrations/008_calendar_execution.sql +147 -0
- package/server/migrations/009_true_calendar_events.sql +195 -0
- package/server/migrations/010_calendar_selection_state.sql +6 -0
- package/server/migrations/011_calendar_timezone_backfill.sql +11 -0
- package/server/migrations/012_work_block_ranges.sql +7 -0
- package/server/migrations/013_microsoft_local_auth_settings.sql +8 -0
- package/server/migrations/014_note_tags_and_ephemeral.sql +8 -0
- package/skills/forge-openclaw/SKILL.md +130 -10
- package/skills/forge-openclaw/cron_jobs.md +395 -0
- package/dist/assets/index-BWtLtXwb.js +0 -36
- package/dist/assets/index-BWtLtXwb.js.map +0 -1
- package/dist/assets/index-Dp5GXY_z.css +0 -1
- package/dist/assets/vendor-QBH6qVEe.js.map +0 -1
|
@@ -18,6 +18,53 @@ function normalizeLinks(links) {
|
|
|
18
18
|
return true;
|
|
19
19
|
});
|
|
20
20
|
}
|
|
21
|
+
function normalizeTags(tags) {
|
|
22
|
+
if (!tags) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
return tags
|
|
27
|
+
.map((tag) => tag.trim())
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
.filter((tag) => {
|
|
30
|
+
const normalized = tag.toLowerCase();
|
|
31
|
+
if (seen.has(normalized)) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
seen.add(normalized);
|
|
35
|
+
return true;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function parseTagsJson(raw) {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(raw);
|
|
41
|
+
return Array.isArray(parsed)
|
|
42
|
+
? normalizeTags(parsed.filter((value) => typeof value === "string"))
|
|
43
|
+
: [];
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function noteMatchesTextTerm(note, term) {
|
|
50
|
+
const normalized = term.trim().toLowerCase();
|
|
51
|
+
if (!normalized) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return note.tags.some((tag) => tag.toLowerCase().includes(normalized));
|
|
55
|
+
}
|
|
56
|
+
function cleanupExpiredNotes() {
|
|
57
|
+
const expiredRows = getDatabase()
|
|
58
|
+
.prepare(`SELECT id
|
|
59
|
+
FROM notes
|
|
60
|
+
WHERE destroy_at IS NOT NULL
|
|
61
|
+
AND destroy_at != ''
|
|
62
|
+
AND destroy_at <= ?`)
|
|
63
|
+
.all(new Date().toISOString());
|
|
64
|
+
for (const row of expiredRows) {
|
|
65
|
+
deleteNoteInternal(row.id, { source: "system", actor: null }, "Ephemeral note expired");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
21
68
|
function stripMarkdown(markdown) {
|
|
22
69
|
return markdown
|
|
23
70
|
.replace(/```[\s\S]*?```/g, (block) => block.replace(/```/g, "").trim())
|
|
@@ -55,7 +102,7 @@ function buildFtsQuery(query) {
|
|
|
55
102
|
}
|
|
56
103
|
function getNoteRow(noteId) {
|
|
57
104
|
return getDatabase()
|
|
58
|
-
.prepare(`SELECT id, content_markdown, content_plain, author, source, created_at, updated_at
|
|
105
|
+
.prepare(`SELECT id, content_markdown, content_plain, author, source, tags_json, destroy_at, created_at, updated_at
|
|
59
106
|
FROM notes
|
|
60
107
|
WHERE id = ?`)
|
|
61
108
|
.get(noteId);
|
|
@@ -86,6 +133,8 @@ function mapNote(row, linkRows) {
|
|
|
86
133
|
contentPlain: row.content_plain,
|
|
87
134
|
author: row.author,
|
|
88
135
|
source: row.source,
|
|
136
|
+
tags: parseTagsJson(row.tags_json),
|
|
137
|
+
destroyAt: row.destroy_at,
|
|
89
138
|
createdAt: row.created_at,
|
|
90
139
|
updatedAt: row.updated_at,
|
|
91
140
|
links: mapLinks(linkRows)
|
|
@@ -102,7 +151,7 @@ function deleteSearchRow(noteId) {
|
|
|
102
151
|
}
|
|
103
152
|
function listAllNoteRows() {
|
|
104
153
|
return getDatabase()
|
|
105
|
-
.prepare(`SELECT id, content_markdown, content_plain, author, source, created_at, updated_at
|
|
154
|
+
.prepare(`SELECT id, content_markdown, content_plain, author, source, tags_json, destroy_at, created_at, updated_at
|
|
106
155
|
FROM notes
|
|
107
156
|
ORDER BY created_at DESC`)
|
|
108
157
|
.all();
|
|
@@ -117,6 +166,19 @@ function findMatchingNoteIds(query) {
|
|
|
117
166
|
.all(ftsQuery);
|
|
118
167
|
return new Set(rows.map((row) => row.note_id));
|
|
119
168
|
}
|
|
169
|
+
function findMatchingNoteIdsForTextTerms(terms) {
|
|
170
|
+
const normalizedTerms = terms.map((term) => term.trim()).filter(Boolean);
|
|
171
|
+
if (normalizedTerms.length === 0) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const matches = new Set();
|
|
175
|
+
for (const term of normalizedTerms) {
|
|
176
|
+
for (const noteId of findMatchingNoteIds(term)) {
|
|
177
|
+
matches.add(noteId);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return matches;
|
|
181
|
+
}
|
|
120
182
|
function insertLinks(noteId, links, createdAt) {
|
|
121
183
|
const statement = getDatabase().prepare(`INSERT OR IGNORE INTO note_links (note_id, entity_type, entity_id, anchor_key, created_at)
|
|
122
184
|
VALUES (?, ?, ?, ?, ?)`);
|
|
@@ -164,7 +226,10 @@ function recordNoteActivity(note, eventType, title, context) {
|
|
|
164
226
|
});
|
|
165
227
|
}
|
|
166
228
|
}
|
|
167
|
-
export function getNoteById(noteId) {
|
|
229
|
+
export function getNoteById(noteId, options = {}) {
|
|
230
|
+
if (!options.skipCleanup) {
|
|
231
|
+
cleanupExpiredNotes();
|
|
232
|
+
}
|
|
168
233
|
if (isEntityDeleted("note", noteId)) {
|
|
169
234
|
return undefined;
|
|
170
235
|
}
|
|
@@ -174,7 +239,10 @@ export function getNoteById(noteId) {
|
|
|
174
239
|
}
|
|
175
240
|
return mapNote(row, listNoteLinks(noteId));
|
|
176
241
|
}
|
|
177
|
-
export function getNoteByIdIncludingDeleted(noteId) {
|
|
242
|
+
export function getNoteByIdIncludingDeleted(noteId, options = {}) {
|
|
243
|
+
if (!options.skipCleanup) {
|
|
244
|
+
cleanupExpiredNotes();
|
|
245
|
+
}
|
|
178
246
|
const row = getNoteRow(noteId);
|
|
179
247
|
if (!row) {
|
|
180
248
|
const deleted = getDeletedEntityRecord("note", noteId);
|
|
@@ -183,13 +251,26 @@ export function getNoteByIdIncludingDeleted(noteId) {
|
|
|
183
251
|
return mapNote(row, listNoteLinks(noteId));
|
|
184
252
|
}
|
|
185
253
|
export function listNotes(query = {}) {
|
|
254
|
+
cleanupExpiredNotes();
|
|
186
255
|
const parsed = notesListQuerySchema.parse(query);
|
|
187
|
-
|
|
188
|
-
parsed.
|
|
189
|
-
|
|
256
|
+
const linkedFilters = [
|
|
257
|
+
...(parsed.linkedEntityType && parsed.linkedEntityId
|
|
258
|
+
? [
|
|
259
|
+
{
|
|
260
|
+
entityType: parsed.linkedEntityType,
|
|
261
|
+
entityId: parsed.linkedEntityId
|
|
262
|
+
}
|
|
263
|
+
]
|
|
264
|
+
: []),
|
|
265
|
+
...parsed.linkedTo
|
|
266
|
+
];
|
|
267
|
+
if (linkedFilters.some((filter) => isEntityDeleted(filter.entityType, filter.entityId))) {
|
|
190
268
|
return [];
|
|
191
269
|
}
|
|
192
|
-
const matchingIds =
|
|
270
|
+
const matchingIds = findMatchingNoteIdsForTextTerms([
|
|
271
|
+
...(parsed.query ? [parsed.query] : []),
|
|
272
|
+
...parsed.textTerms
|
|
273
|
+
]);
|
|
193
274
|
const rows = listAllNoteRows();
|
|
194
275
|
const linksByNoteId = new Map();
|
|
195
276
|
for (const link of listLinkRowsForNotes(rows.map((row) => row.id))) {
|
|
@@ -198,32 +279,63 @@ export function listNotes(query = {}) {
|
|
|
198
279
|
linksByNoteId.set(link.note_id, current);
|
|
199
280
|
}
|
|
200
281
|
return filterDeletedEntities("note", rows
|
|
201
|
-
.filter((row) =>
|
|
202
|
-
|
|
282
|
+
.filter((row) => parsed.author
|
|
283
|
+
? (row.author ?? "")
|
|
284
|
+
.toLowerCase()
|
|
285
|
+
.includes(parsed.author.toLowerCase())
|
|
286
|
+
: true)
|
|
203
287
|
.map((row) => mapNote(row, linksByNoteId.get(row.id) ?? []))
|
|
204
|
-
.filter((note) =>
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
288
|
+
.filter((note) => {
|
|
289
|
+
if (!matchingIds) {
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
return (matchingIds.has(note.id) ||
|
|
293
|
+
parsed.textTerms.some((term) => noteMatchesTextTerm(note, term)) ||
|
|
294
|
+
(parsed.query ? noteMatchesTextTerm(note, parsed.query) : false));
|
|
295
|
+
})
|
|
296
|
+
.filter((note) => linkedFilters.length > 0
|
|
297
|
+
? note.links.some((link) => linkedFilters.some((filter) => link.entityType === filter.entityType &&
|
|
298
|
+
link.entityId === filter.entityId &&
|
|
299
|
+
(parsed.anchorKey === undefined
|
|
300
|
+
? true
|
|
301
|
+
: (link.anchorKey ?? null) === parsed.anchorKey)))
|
|
302
|
+
: true)
|
|
303
|
+
.filter((note) => parsed.tags.length > 0
|
|
304
|
+
? parsed.tags.every((filterTag) => note.tags.some((noteTag) => noteTag.toLowerCase() === filterTag.toLowerCase()))
|
|
208
305
|
: true)
|
|
306
|
+
.filter((note) => {
|
|
307
|
+
if (!parsed.updatedFrom && !parsed.updatedTo) {
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
const updatedDate = note.updatedAt.slice(0, 10);
|
|
311
|
+
if (parsed.updatedFrom && updatedDate < parsed.updatedFrom) {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
if (parsed.updatedTo && updatedDate > parsed.updatedTo) {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
return true;
|
|
318
|
+
})
|
|
209
319
|
.slice(0, parsed.limit ?? 100));
|
|
210
320
|
}
|
|
211
321
|
export function createNote(input, context) {
|
|
322
|
+
cleanupExpiredNotes();
|
|
212
323
|
const parsed = createNoteSchema.parse({
|
|
213
324
|
...input,
|
|
214
|
-
links: normalizeLinks(input.links)
|
|
325
|
+
links: normalizeLinks(input.links),
|
|
326
|
+
tags: normalizeTags(input.tags)
|
|
215
327
|
});
|
|
216
328
|
const now = new Date().toISOString();
|
|
217
329
|
const id = `note_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
218
330
|
const contentPlain = stripMarkdown(parsed.contentMarkdown);
|
|
219
331
|
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);
|
|
332
|
+
.prepare(`INSERT INTO notes (id, content_markdown, content_plain, author, source, tags_json, destroy_at, created_at, updated_at)
|
|
333
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
334
|
+
.run(id, parsed.contentMarkdown, contentPlain, parsed.author ?? context.actor ?? null, context.source, JSON.stringify(parsed.tags), parsed.destroyAt, now, now);
|
|
223
335
|
insertLinks(id, parsed.links, now);
|
|
224
336
|
clearDeletedEntityRecord("note", id);
|
|
225
337
|
upsertSearchRow(id, contentPlain, parsed.author ?? context.actor ?? null);
|
|
226
|
-
const note = getNoteById(id);
|
|
338
|
+
const note = getNoteById(id, { skipCleanup: true });
|
|
227
339
|
recordNoteActivity(note, "note.created", "Note added", context);
|
|
228
340
|
return note;
|
|
229
341
|
}
|
|
@@ -234,31 +346,37 @@ export function createLinkedNotes(notes, entityLink, context) {
|
|
|
234
346
|
return notes.map((note) => createNote({
|
|
235
347
|
contentMarkdown: note.contentMarkdown,
|
|
236
348
|
author: note.author,
|
|
349
|
+
tags: note.tags,
|
|
350
|
+
destroyAt: note.destroyAt,
|
|
237
351
|
links: [entityLink, ...note.links]
|
|
238
352
|
}, context));
|
|
239
353
|
}
|
|
240
354
|
export function updateNote(noteId, input, context) {
|
|
241
|
-
|
|
355
|
+
cleanupExpiredNotes();
|
|
356
|
+
const existing = getNoteByIdIncludingDeleted(noteId, { skipCleanup: true });
|
|
242
357
|
if (!existing) {
|
|
243
358
|
return undefined;
|
|
244
359
|
}
|
|
245
360
|
const patch = updateNoteSchema.parse({
|
|
246
361
|
...input,
|
|
247
|
-
links: input.links ? normalizeLinks(input.links) : undefined
|
|
362
|
+
links: input.links ? normalizeLinks(input.links) : undefined,
|
|
363
|
+
tags: input.tags ? normalizeTags(input.tags) : undefined
|
|
248
364
|
});
|
|
249
365
|
const nextMarkdown = patch.contentMarkdown ?? existing.contentMarkdown;
|
|
250
366
|
const nextPlain = stripMarkdown(nextMarkdown);
|
|
251
367
|
const nextAuthor = patch.author === undefined ? existing.author : patch.author;
|
|
368
|
+
const nextTags = patch.tags ?? existing.tags;
|
|
369
|
+
const nextDestroyAt = patch.destroyAt === undefined ? existing.destroyAt : patch.destroyAt;
|
|
252
370
|
const updatedAt = new Date().toISOString();
|
|
253
371
|
getDatabase()
|
|
254
372
|
.prepare(`UPDATE notes
|
|
255
|
-
SET content_markdown = ?, content_plain = ?, author = ?, updated_at = ?
|
|
373
|
+
SET content_markdown = ?, content_plain = ?, author = ?, tags_json = ?, destroy_at = ?, updated_at = ?
|
|
256
374
|
WHERE id = ?`)
|
|
257
|
-
.run(nextMarkdown, nextPlain, nextAuthor, updatedAt, noteId);
|
|
375
|
+
.run(nextMarkdown, nextPlain, nextAuthor, JSON.stringify(nextTags), nextDestroyAt, updatedAt, noteId);
|
|
258
376
|
if (patch.links) {
|
|
259
377
|
replaceLinks(noteId, patch.links, updatedAt);
|
|
260
378
|
}
|
|
261
|
-
const note = getNoteByIdIncludingDeleted(noteId);
|
|
379
|
+
const note = getNoteByIdIncludingDeleted(noteId, { skipCleanup: true });
|
|
262
380
|
if (note.links.length > 0) {
|
|
263
381
|
clearDeletedEntityRecord("note", noteId);
|
|
264
382
|
}
|
|
@@ -275,21 +393,34 @@ export function updateNote(noteId, input, context) {
|
|
|
275
393
|
});
|
|
276
394
|
}
|
|
277
395
|
upsertSearchRow(noteId, nextPlain, nextAuthor);
|
|
396
|
+
if (nextDestroyAt && Date.parse(nextDestroyAt) <= Date.now()) {
|
|
397
|
+
deleteNoteInternal(noteId, { source: "system", actor: null }, "Ephemeral note expired");
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
278
400
|
recordNoteActivity(note, "note.updated", "Note updated", context);
|
|
279
401
|
return getNoteById(noteId);
|
|
280
402
|
}
|
|
281
|
-
|
|
282
|
-
const existing =
|
|
403
|
+
function deleteNoteInternal(noteId, context, title) {
|
|
404
|
+
const existing = getNoteRow(noteId)
|
|
405
|
+
? mapNote(getNoteRow(noteId), listNoteLinks(noteId))
|
|
406
|
+
: getDeletedEntityRecord("note", noteId)?.snapshot;
|
|
283
407
|
if (!existing) {
|
|
284
408
|
return undefined;
|
|
285
409
|
}
|
|
410
|
+
clearDeletedEntityRecord("note", noteId);
|
|
286
411
|
getDatabase().prepare(`DELETE FROM note_links WHERE note_id = ?`).run(noteId);
|
|
287
412
|
getDatabase().prepare(`DELETE FROM notes WHERE id = ?`).run(noteId);
|
|
288
413
|
deleteSearchRow(noteId);
|
|
289
|
-
|
|
414
|
+
clearDeletedEntityRecord("note", noteId);
|
|
415
|
+
recordNoteActivity(existing, "note.deleted", title, context);
|
|
290
416
|
return existing;
|
|
291
417
|
}
|
|
418
|
+
export function deleteNote(noteId, context) {
|
|
419
|
+
cleanupExpiredNotes();
|
|
420
|
+
return deleteNoteInternal(noteId, context, "Note deleted");
|
|
421
|
+
}
|
|
292
422
|
export function buildNotesSummaryByEntity() {
|
|
423
|
+
cleanupExpiredNotes();
|
|
293
424
|
const rows = getDatabase()
|
|
294
425
|
.prepare(`SELECT
|
|
295
426
|
note_links.entity_type AS entity_type,
|
|
@@ -302,8 +433,9 @@ export function buildNotesSummaryByEntity() {
|
|
|
302
433
|
ON deleted_entities.entity_type = 'note'
|
|
303
434
|
AND deleted_entities.entity_id = notes.id
|
|
304
435
|
WHERE deleted_entities.entity_id IS NULL
|
|
436
|
+
AND (notes.destroy_at IS NULL OR notes.destroy_at = '' OR notes.destroy_at > ?)
|
|
305
437
|
ORDER BY notes.created_at DESC`)
|
|
306
|
-
.all();
|
|
438
|
+
.all(new Date().toISOString());
|
|
307
439
|
return rows.reduce((acc, row) => {
|
|
308
440
|
const key = `${row.entity_type}:${row.entity_id}`;
|
|
309
441
|
const current = acc[key];
|
|
@@ -324,6 +456,7 @@ export function buildNotesSummaryByEntity() {
|
|
|
324
456
|
}, {});
|
|
325
457
|
}
|
|
326
458
|
export function unlinkNotesForEntity(entityType, entityId, context) {
|
|
459
|
+
cleanupExpiredNotes();
|
|
327
460
|
const noteIds = getDatabase()
|
|
328
461
|
.prepare(`SELECT DISTINCT note_id FROM note_links WHERE entity_type = ? AND entity_id = ?`)
|
|
329
462
|
.all(entityType, entityId);
|
|
@@ -6,7 +6,8 @@ import { createLinkedNotes } from "./notes.js";
|
|
|
6
6
|
import { assertGoalExists } from "../services/relations.js";
|
|
7
7
|
import { getGoalById } from "./goals.js";
|
|
8
8
|
import { pruneLinkedEntityReferences } from "./psyche.js";
|
|
9
|
-
import {
|
|
9
|
+
import { listTasks, updateTaskInTransaction } from "./tasks.js";
|
|
10
|
+
import { calendarSchedulingRulesSchema, createProjectSchema, projectSchema, updateProjectSchema } from "../types.js";
|
|
10
11
|
function getDefaultProjectTemplate(goal) {
|
|
11
12
|
switch (goal.title) {
|
|
12
13
|
case "Build a durable body and calm energy":
|
|
@@ -40,10 +41,18 @@ function mapProject(row) {
|
|
|
40
41
|
status: row.status,
|
|
41
42
|
themeColor: row.theme_color,
|
|
42
43
|
targetPoints: row.target_points,
|
|
44
|
+
schedulingRules: calendarSchedulingRulesSchema.parse(JSON.parse(row.scheduling_rules_json || "{}")),
|
|
43
45
|
createdAt: row.created_at,
|
|
44
46
|
updatedAt: row.updated_at
|
|
45
47
|
});
|
|
46
48
|
}
|
|
49
|
+
function completeLinkedProjectTasks(projectId, activity) {
|
|
50
|
+
const openTasks = listTasks({ projectId }).filter((task) => task.status !== "done");
|
|
51
|
+
for (const task of openTasks) {
|
|
52
|
+
updateTaskInTransaction(task.id, { status: "done" }, activity);
|
|
53
|
+
}
|
|
54
|
+
return openTasks.length;
|
|
55
|
+
}
|
|
47
56
|
export function listProjects(filters = {}) {
|
|
48
57
|
const whereClauses = [];
|
|
49
58
|
const params = [];
|
|
@@ -62,6 +71,7 @@ export function listProjects(filters = {}) {
|
|
|
62
71
|
}
|
|
63
72
|
const rows = getDatabase()
|
|
64
73
|
.prepare(`SELECT id, goal_id, title, description, status, theme_color, target_points, created_at, updated_at
|
|
74
|
+
, scheduling_rules_json
|
|
65
75
|
FROM projects
|
|
66
76
|
${whereSql}
|
|
67
77
|
ORDER BY created_at ASC
|
|
@@ -75,6 +85,7 @@ export function getProjectById(projectId) {
|
|
|
75
85
|
}
|
|
76
86
|
const row = getDatabase()
|
|
77
87
|
.prepare(`SELECT id, goal_id, title, description, status, theme_color, target_points, created_at, updated_at
|
|
88
|
+
, scheduling_rules_json
|
|
78
89
|
FROM projects
|
|
79
90
|
WHERE id = ?`)
|
|
80
91
|
.get(projectId);
|
|
@@ -87,9 +98,9 @@ export function createProject(input, activity) {
|
|
|
87
98
|
const now = new Date().toISOString();
|
|
88
99
|
const id = `project_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
89
100
|
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);
|
|
101
|
+
.prepare(`INSERT INTO projects (id, goal_id, title, description, status, theme_color, target_points, scheduling_rules_json, created_at, updated_at)
|
|
102
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
103
|
+
.run(id, parsed.goalId, parsed.title, parsed.description, parsed.status, parsed.themeColor, parsed.targetPoints, JSON.stringify(parsed.schedulingRules), now, now);
|
|
93
104
|
const project = getProjectById(id);
|
|
94
105
|
createLinkedNotes(parsed.notes, { entityType: "project", entityId: project.id, anchorKey: null }, activity ?? { source: "ui", actor: null });
|
|
95
106
|
if (activity) {
|
|
@@ -127,32 +138,42 @@ export function updateProject(projectId, input, activity) {
|
|
|
127
138
|
status: parsed.status ?? current.status,
|
|
128
139
|
themeColor: parsed.themeColor ?? current.themeColor,
|
|
129
140
|
targetPoints: parsed.targetPoints ?? current.targetPoints,
|
|
141
|
+
schedulingRules: parsed.schedulingRules ?? current.schedulingRules,
|
|
130
142
|
updatedAt: new Date().toISOString()
|
|
131
143
|
};
|
|
132
144
|
getDatabase()
|
|
133
145
|
.prepare(`UPDATE projects
|
|
134
|
-
SET goal_id = ?, title = ?, description = ?, status = ?, theme_color = ?, target_points = ?, updated_at = ?
|
|
146
|
+
SET goal_id = ?, title = ?, description = ?, status = ?, theme_color = ?, target_points = ?, scheduling_rules_json = ?, updated_at = ?
|
|
135
147
|
WHERE id = ?`)
|
|
136
|
-
.run(next.goalId, next.title, next.description, next.status, next.themeColor, next.targetPoints, next.updatedAt, projectId);
|
|
148
|
+
.run(next.goalId, next.title, next.description, next.status, next.themeColor, next.targetPoints, JSON.stringify(next.schedulingRules), next.updatedAt, projectId);
|
|
137
149
|
// Keep legacy task.goal_id aligned with the project's parent goal.
|
|
138
150
|
getDatabase()
|
|
139
151
|
.prepare(`UPDATE tasks SET goal_id = ?, updated_at = ? WHERE project_id = ?`)
|
|
140
152
|
.run(next.goalId, next.updatedAt, projectId);
|
|
153
|
+
const completedLinkedTaskCount = current.status !== "completed" && next.status === "completed"
|
|
154
|
+
? completeLinkedProjectTasks(projectId, activity)
|
|
155
|
+
: 0;
|
|
141
156
|
const project = getProjectById(projectId);
|
|
142
157
|
if (project && activity) {
|
|
158
|
+
const statusChanged = current.status !== project.status;
|
|
143
159
|
recordActivityEvent({
|
|
144
160
|
entityType: "project",
|
|
145
161
|
entityId: project.id,
|
|
146
|
-
eventType:
|
|
147
|
-
title:
|
|
148
|
-
description:
|
|
162
|
+
eventType: statusChanged ? "project_status_changed" : "project_updated",
|
|
163
|
+
title: statusChanged ? `Project ${project.status}: ${project.title}` : `Project updated: ${project.title}`,
|
|
164
|
+
description: statusChanged && project.status === "completed"
|
|
165
|
+
? `Project finished and auto-completed ${completedLinkedTaskCount} linked unfinished task${completedLinkedTaskCount === 1 ? "" : "s"}.`
|
|
166
|
+
: statusChanged
|
|
167
|
+
? `Project status changed from ${current.status} to ${project.status}.`
|
|
168
|
+
: "Project details were updated.",
|
|
149
169
|
actor: activity.actor ?? null,
|
|
150
170
|
source: activity.source,
|
|
151
171
|
metadata: {
|
|
152
172
|
goalId: project.goalId,
|
|
153
173
|
previousGoalId: current.goalId,
|
|
154
174
|
status: project.status,
|
|
155
|
-
previousStatus: current.status
|
|
175
|
+
previousStatus: current.status,
|
|
176
|
+
completedLinkedTaskCount
|
|
156
177
|
}
|
|
157
178
|
});
|
|
158
179
|
}
|
|
@@ -174,9 +195,20 @@ export function ensureDefaultProjectForGoal(goalId) {
|
|
|
174
195
|
const now = new Date().toISOString();
|
|
175
196
|
const id = `project_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
176
197
|
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)),
|
|
198
|
+
.prepare(`INSERT INTO projects (id, goal_id, title, description, status, theme_color, target_points, scheduling_rules_json, created_at, updated_at)
|
|
199
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
200
|
+
.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)), JSON.stringify({
|
|
201
|
+
allowWorkBlockKinds: [],
|
|
202
|
+
blockWorkBlockKinds: [],
|
|
203
|
+
allowCalendarIds: [],
|
|
204
|
+
blockCalendarIds: [],
|
|
205
|
+
allowEventTypes: [],
|
|
206
|
+
blockEventTypes: [],
|
|
207
|
+
allowEventKeywords: [],
|
|
208
|
+
blockEventKeywords: [],
|
|
209
|
+
allowAvailability: [],
|
|
210
|
+
blockAvailability: []
|
|
211
|
+
}), now, now);
|
|
180
212
|
return getProjectById(id);
|
|
181
213
|
});
|
|
182
214
|
}
|