@hanmariyang/drafting 1.6.2
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/LICENSE +21 -0
- package/README.md +217 -0
- package/api/dist/db/index.js +107 -0
- package/api/dist/db/repos.js +670 -0
- package/api/dist/index.js +89 -0
- package/api/dist/lib/ai.js +314 -0
- package/api/dist/lib/config.js +57 -0
- package/api/dist/lib/crypto.js +71 -0
- package/api/dist/lib/design-system-gen.js +332 -0
- package/api/dist/lib/fixtures.js +150 -0
- package/api/dist/lib/gateway.js +55 -0
- package/api/dist/lib/handoff.js +283 -0
- package/api/dist/lib/items-gen.js +211 -0
- package/api/dist/lib/lint-service.js +118 -0
- package/api/dist/lib/lint.js +141 -0
- package/api/dist/lib/mockup-gen.js +136 -0
- package/api/dist/lib/numbering.js +75 -0
- package/api/dist/lib/provider-errors.js +31 -0
- package/api/dist/lib/render.js +154 -0
- package/api/dist/lib/style-guide.js +47 -0
- package/api/dist/lib/templates.js +85 -0
- package/api/dist/lib/types.js +1 -0
- package/api/dist/lib/wireframes.js +137 -0
- package/api/dist/providers/byok/anthropic.js +75 -0
- package/api/dist/providers/byok/openai-compat.js +95 -0
- package/api/dist/providers/cli.js +391 -0
- package/api/dist/providers/index.js +68 -0
- package/api/dist/providers/managed.js +22 -0
- package/api/dist/providers/sse.js +37 -0
- package/api/dist/providers/stub.js +55 -0
- package/api/dist/providers/types.js +1 -0
- package/api/dist/routes/backup.js +24 -0
- package/api/dist/routes/deliverables.js +588 -0
- package/api/dist/routes/documents.js +205 -0
- package/api/dist/routes/helpers.js +43 -0
- package/api/dist/routes/interview.js +141 -0
- package/api/dist/routes/keys.js +70 -0
- package/api/dist/routes/projects.js +103 -0
- package/api/dist/routes/settings.js +134 -0
- package/api/dist/routes/share.js +39 -0
- package/api/dist/routes/suggestions.js +144 -0
- package/api/templates/design-system.json +17 -0
- package/api/templates/feature-spec.json +73 -0
- package/api/templates/ia.json +52 -0
- package/api/templates/prd.json +60 -0
- package/api/templates/user-flow.json +61 -0
- package/bin/drafting.mjs +79 -0
- package/db/schema.sql +154 -0
- package/package.json +62 -0
- package/web/dist/assets/index-CS06cWP3.js +125 -0
- package/web/dist/assets/index-DWoYeaZU.css +1 -0
- package/web/dist/index.html +14 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid';
|
|
2
|
+
import { getDb, nowIso } from "./index.js";
|
|
3
|
+
import { seal, open } from "../lib/crypto.js";
|
|
4
|
+
import { nextRefId, toRefRows } from "../lib/numbering.js";
|
|
5
|
+
function db() {
|
|
6
|
+
return getDb();
|
|
7
|
+
}
|
|
8
|
+
// ─── Projects ──────────────────────────────────────────────────────────────
|
|
9
|
+
export function createProject(name, description = '') {
|
|
10
|
+
const id = nanoid();
|
|
11
|
+
const ts = nowIso();
|
|
12
|
+
db()
|
|
13
|
+
.prepare(`INSERT INTO projects (id, name, description, created_at, updated_at)
|
|
14
|
+
VALUES (?, ?, ?, ?, ?)`)
|
|
15
|
+
.run(id, name, description, ts, ts);
|
|
16
|
+
return getProject(id);
|
|
17
|
+
}
|
|
18
|
+
export function listProjects() {
|
|
19
|
+
return db()
|
|
20
|
+
.prepare('SELECT * FROM projects ORDER BY updated_at DESC')
|
|
21
|
+
.all();
|
|
22
|
+
}
|
|
23
|
+
export function getProject(id) {
|
|
24
|
+
return db().prepare('SELECT * FROM projects WHERE id = ?').get(id) ?? null;
|
|
25
|
+
}
|
|
26
|
+
export function updateProject(id, patch) {
|
|
27
|
+
const cur = getProject(id);
|
|
28
|
+
if (!cur)
|
|
29
|
+
return null;
|
|
30
|
+
db()
|
|
31
|
+
.prepare('UPDATE projects SET name = ?, description = ?, updated_at = ? WHERE id = ?')
|
|
32
|
+
.run(patch.name ?? cur.name, patch.description ?? cur.description, nowIso(), id);
|
|
33
|
+
return getProject(id);
|
|
34
|
+
}
|
|
35
|
+
export function deleteProject(id) {
|
|
36
|
+
db().prepare('DELETE FROM projects WHERE id = ?').run(id);
|
|
37
|
+
}
|
|
38
|
+
// ─── Documents ───────────────────────────────────────────────────────────────
|
|
39
|
+
export function createDocument(input) {
|
|
40
|
+
const id = nanoid();
|
|
41
|
+
const ts = nowIso();
|
|
42
|
+
// inherit source version from parent at creation time (SPEC-04)
|
|
43
|
+
let sourceVersion = null;
|
|
44
|
+
if (input.parentDocumentId) {
|
|
45
|
+
const parent = getDocument(input.parentDocumentId);
|
|
46
|
+
sourceVersion = parent ? parent.version : null;
|
|
47
|
+
}
|
|
48
|
+
db()
|
|
49
|
+
.prepare(`INSERT INTO documents
|
|
50
|
+
(id, project_id, type, title, status, parent_document_id, version,
|
|
51
|
+
context_stale, context_source_version, context_pending_version,
|
|
52
|
+
created_at, updated_at)
|
|
53
|
+
VALUES (?, ?, ?, ?, 'draft', ?, 0, 0, ?, NULL, ?, ?)`)
|
|
54
|
+
.run(id, input.projectId, input.type, input.title, input.parentDocumentId ?? null, sourceVersion, ts, ts);
|
|
55
|
+
return getDocument(id);
|
|
56
|
+
}
|
|
57
|
+
export function getDocument(id) {
|
|
58
|
+
return db().prepare('SELECT * FROM documents WHERE id = ?').get(id) ?? null;
|
|
59
|
+
}
|
|
60
|
+
export function listDocuments(projectId) {
|
|
61
|
+
return db()
|
|
62
|
+
.prepare('SELECT * FROM documents WHERE project_id = ? ORDER BY created_at ASC')
|
|
63
|
+
.all(projectId);
|
|
64
|
+
}
|
|
65
|
+
export function updateDocumentTitle(id, title) {
|
|
66
|
+
if (!getDocument(id))
|
|
67
|
+
return null;
|
|
68
|
+
db()
|
|
69
|
+
.prepare('UPDATE documents SET title = ?, updated_at = ? WHERE id = ?')
|
|
70
|
+
.run(title, nowIso(), id);
|
|
71
|
+
return getDocument(id);
|
|
72
|
+
}
|
|
73
|
+
export function setDocumentStatus(id, status) {
|
|
74
|
+
db()
|
|
75
|
+
.prepare('UPDATE documents SET status = ?, updated_at = ? WHERE id = ?')
|
|
76
|
+
.run(status, nowIso(), id);
|
|
77
|
+
}
|
|
78
|
+
export function deleteDocument(id) {
|
|
79
|
+
db().prepare('DELETE FROM documents WHERE id = ?').run(id);
|
|
80
|
+
}
|
|
81
|
+
// ─── Sections ────────────────────────────────────────────────────────────────
|
|
82
|
+
export function listSections(documentId) {
|
|
83
|
+
return db()
|
|
84
|
+
.prepare('SELECT * FROM sections WHERE document_id = ? ORDER BY position ASC')
|
|
85
|
+
.all(documentId);
|
|
86
|
+
}
|
|
87
|
+
export function getSection(id) {
|
|
88
|
+
return db().prepare('SELECT * FROM sections WHERE id = ?').get(id) ?? null;
|
|
89
|
+
}
|
|
90
|
+
export function createSection(documentId, heading, body, position, status = 'accepted') {
|
|
91
|
+
const id = nanoid();
|
|
92
|
+
const ts = nowIso();
|
|
93
|
+
const pos = position ??
|
|
94
|
+
(db()
|
|
95
|
+
.prepare('SELECT COALESCE(MAX(position), -1) + 1 AS p FROM sections WHERE document_id = ?')
|
|
96
|
+
.get(documentId).p);
|
|
97
|
+
db()
|
|
98
|
+
.prepare(`INSERT INTO sections (id, document_id, position, heading, body, status, created_at, updated_at)
|
|
99
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
100
|
+
.run(id, documentId, pos, heading, body, status, ts, ts);
|
|
101
|
+
return getSection(id);
|
|
102
|
+
}
|
|
103
|
+
export function setSectionStatus(id, status) {
|
|
104
|
+
if (!getSection(id))
|
|
105
|
+
return null;
|
|
106
|
+
db()
|
|
107
|
+
.prepare('UPDATE sections SET status = ?, updated_at = ? WHERE id = ?')
|
|
108
|
+
.run(status, nowIso(), id);
|
|
109
|
+
return getSection(id);
|
|
110
|
+
}
|
|
111
|
+
/** Accepted sections only — the actual document (SYSTEM.md §0.2). Used by export/share. */
|
|
112
|
+
export function listAcceptedSections(documentId) {
|
|
113
|
+
return db()
|
|
114
|
+
.prepare("SELECT * FROM sections WHERE document_id = ? AND status = 'accepted' ORDER BY position ASC")
|
|
115
|
+
.all(documentId);
|
|
116
|
+
}
|
|
117
|
+
/** Count of sections excluded from export (proposed or rejected). */
|
|
118
|
+
export function countExcludedSections(documentId) {
|
|
119
|
+
return db()
|
|
120
|
+
.prepare("SELECT COUNT(*) AS n FROM sections WHERE document_id = ? AND status != 'accepted'")
|
|
121
|
+
.get(documentId).n;
|
|
122
|
+
}
|
|
123
|
+
export function updateSection(id, patch) {
|
|
124
|
+
const cur = getSection(id);
|
|
125
|
+
if (!cur)
|
|
126
|
+
return null;
|
|
127
|
+
db()
|
|
128
|
+
.prepare('UPDATE sections SET heading = ?, body = ?, updated_at = ? WHERE id = ?')
|
|
129
|
+
.run(patch.heading ?? cur.heading, patch.body ?? cur.body, nowIso(), id);
|
|
130
|
+
return getSection(id);
|
|
131
|
+
}
|
|
132
|
+
export function deleteSection(id) {
|
|
133
|
+
db().prepare('DELETE FROM sections WHERE id = ?').run(id);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Replace all sections of a document. `status` sets the state of the new
|
|
137
|
+
* sections — 'accepted' for direct/restore writes (default, preserves prior
|
|
138
|
+
* behaviour), 'proposed' when the AI drafts them (SYSTEM.md §0.1).
|
|
139
|
+
*/
|
|
140
|
+
export function replaceSections(documentId, sections, status = 'accepted') {
|
|
141
|
+
db().prepare('DELETE FROM sections WHERE document_id = ?').run(documentId);
|
|
142
|
+
sections.forEach((s, i) => createSection(documentId, s.heading, s.body, i, status));
|
|
143
|
+
return listSections(documentId);
|
|
144
|
+
}
|
|
145
|
+
/** Reorder sections by an explicit id ordering. Missing ids are appended. */
|
|
146
|
+
export function reorderSections(documentId, orderedIds) {
|
|
147
|
+
const existing = listSections(documentId);
|
|
148
|
+
const byId = new Map(existing.map((s) => [s.id, s]));
|
|
149
|
+
let pos = 0;
|
|
150
|
+
const ts = nowIso();
|
|
151
|
+
for (const id of orderedIds) {
|
|
152
|
+
if (byId.has(id)) {
|
|
153
|
+
db()
|
|
154
|
+
.prepare('UPDATE sections SET position = ?, updated_at = ? WHERE id = ?')
|
|
155
|
+
.run(pos++, ts, id);
|
|
156
|
+
byId.delete(id);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// append any not mentioned, preserving old order
|
|
160
|
+
for (const s of byId.values()) {
|
|
161
|
+
db().prepare('UPDATE sections SET position = ? WHERE id = ?').run(pos++, s.id);
|
|
162
|
+
}
|
|
163
|
+
return listSections(documentId);
|
|
164
|
+
}
|
|
165
|
+
// ─── Suggestions / AI proposal queue (SYSTEM.md §0) ──────────────────────────
|
|
166
|
+
export function createSuggestion(input) {
|
|
167
|
+
const id = nanoid();
|
|
168
|
+
const ts = nowIso();
|
|
169
|
+
db()
|
|
170
|
+
.prepare(`INSERT INTO suggestions
|
|
171
|
+
(id, document_id, section_id, target_item_id, kind, title, body,
|
|
172
|
+
quote_before, quote_after, source, status, created_at, resolved_at)
|
|
173
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, NULL)`)
|
|
174
|
+
.run(id, input.documentId, input.sectionId ?? null, input.targetItemId ?? null, input.kind, input.title, input.body ?? '', input.quoteBefore ?? '', input.quoteAfter ?? '', input.source, ts);
|
|
175
|
+
return getSuggestion(id);
|
|
176
|
+
}
|
|
177
|
+
export function getSuggestion(id) {
|
|
178
|
+
return db().prepare('SELECT * FROM suggestions WHERE id = ?').get(id) ?? null;
|
|
179
|
+
}
|
|
180
|
+
export function listSuggestions(documentId, status) {
|
|
181
|
+
if (status) {
|
|
182
|
+
return db()
|
|
183
|
+
.prepare('SELECT * FROM suggestions WHERE document_id = ? AND status = ? ORDER BY created_at ASC')
|
|
184
|
+
.all(documentId, status);
|
|
185
|
+
}
|
|
186
|
+
return db()
|
|
187
|
+
.prepare('SELECT * FROM suggestions WHERE document_id = ? ORDER BY created_at ASC')
|
|
188
|
+
.all(documentId);
|
|
189
|
+
}
|
|
190
|
+
export function countOpenSuggestions(documentId) {
|
|
191
|
+
return db()
|
|
192
|
+
.prepare("SELECT COUNT(*) AS n FROM suggestions WHERE document_id = ? AND status = 'open'")
|
|
193
|
+
.get(documentId).n;
|
|
194
|
+
}
|
|
195
|
+
export function resolveSuggestion(id, status) {
|
|
196
|
+
if (!getSuggestion(id))
|
|
197
|
+
return null;
|
|
198
|
+
db()
|
|
199
|
+
.prepare('UPDATE suggestions SET status = ?, resolved_at = ? WHERE id = ?')
|
|
200
|
+
.run(status, nowIso(), id);
|
|
201
|
+
return getSuggestion(id);
|
|
202
|
+
}
|
|
203
|
+
/** Open suggestions targeting a specific plan item (structure-doc rows). */
|
|
204
|
+
export function listItemSuggestions(itemId, status = 'open') {
|
|
205
|
+
return db()
|
|
206
|
+
.prepare('SELECT * FROM suggestions WHERE target_item_id = ? AND status = ? ORDER BY created_at ASC')
|
|
207
|
+
.all(itemId, status);
|
|
208
|
+
}
|
|
209
|
+
/** All lint suggestions for a document by status (waive判정에 사용). */
|
|
210
|
+
export function listLintSuggestions(documentId, status) {
|
|
211
|
+
if (status) {
|
|
212
|
+
return db()
|
|
213
|
+
.prepare("SELECT * FROM suggestions WHERE document_id = ? AND kind = 'lint' AND status = ? ORDER BY created_at ASC")
|
|
214
|
+
.all(documentId, status);
|
|
215
|
+
}
|
|
216
|
+
return db()
|
|
217
|
+
.prepare("SELECT * FROM suggestions WHERE document_id = ? AND kind = 'lint' ORDER BY created_at ASC")
|
|
218
|
+
.all(documentId);
|
|
219
|
+
}
|
|
220
|
+
// ─── Plan items (structure docs: feature-spec · IA · user-flow) ──────────────
|
|
221
|
+
export function parsePlanItemMeta(item) {
|
|
222
|
+
try {
|
|
223
|
+
return JSON.parse(item.meta || '{}');
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return {};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
export function listItems(documentId) {
|
|
230
|
+
return db()
|
|
231
|
+
.prepare('SELECT * FROM plan_items WHERE document_id = ? ORDER BY position ASC')
|
|
232
|
+
.all(documentId);
|
|
233
|
+
}
|
|
234
|
+
export function getItem(id) {
|
|
235
|
+
return db().prepare('SELECT * FROM plan_items WHERE id = ?').get(id) ?? null;
|
|
236
|
+
}
|
|
237
|
+
/** All plan items across every document in a project (lint/hub/wireframe input). */
|
|
238
|
+
export function listProjectItems(projectId) {
|
|
239
|
+
const docs = listDocuments(projectId);
|
|
240
|
+
const out = [];
|
|
241
|
+
for (const d of docs)
|
|
242
|
+
out.push(...listItems(d.id));
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Create a plan item. `refId` is ALWAYS assigned here (server-numbered §1.2) —
|
|
247
|
+
* any id-like field in an LLM response is ignored. Numbering scope = the whole
|
|
248
|
+
* document for that kind (and the parent's children for feature/step).
|
|
249
|
+
*/
|
|
250
|
+
export function createItem(input) {
|
|
251
|
+
const id = nanoid();
|
|
252
|
+
const ts = nowIso();
|
|
253
|
+
const existing = listItems(input.documentId);
|
|
254
|
+
const parent = input.parentId ? getItem(input.parentId) : null;
|
|
255
|
+
const refId = nextRefId(toRefRows(existing), input.kind, parent?.ref_id ?? null);
|
|
256
|
+
const pos = input.position ??
|
|
257
|
+
(db()
|
|
258
|
+
.prepare('SELECT COALESCE(MAX(position), -1) + 1 AS p FROM plan_items WHERE document_id = ?')
|
|
259
|
+
.get(input.documentId).p);
|
|
260
|
+
db()
|
|
261
|
+
.prepare(`INSERT INTO plan_items
|
|
262
|
+
(id, document_id, parent_id, kind, ref_id, position, title, body, meta, status,
|
|
263
|
+
created_at, updated_at)
|
|
264
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
265
|
+
.run(id, input.documentId, input.parentId ?? null, input.kind, refId, pos, input.title, input.body ?? '', JSON.stringify(input.meta ?? {}), input.status ?? 'proposed', ts, ts);
|
|
266
|
+
return getItem(id);
|
|
267
|
+
}
|
|
268
|
+
export function updateItem(id, patch) {
|
|
269
|
+
const cur = getItem(id);
|
|
270
|
+
if (!cur)
|
|
271
|
+
return null;
|
|
272
|
+
db()
|
|
273
|
+
.prepare('UPDATE plan_items SET title = ?, body = ?, meta = ?, position = ?, updated_at = ? WHERE id = ?')
|
|
274
|
+
.run(patch.title ?? cur.title, patch.body ?? cur.body, patch.meta ? JSON.stringify(patch.meta) : cur.meta, patch.position ?? cur.position, nowIso(), id);
|
|
275
|
+
return getItem(id);
|
|
276
|
+
}
|
|
277
|
+
export function setItemStatus(id, status) {
|
|
278
|
+
if (!getItem(id))
|
|
279
|
+
return null;
|
|
280
|
+
db()
|
|
281
|
+
.prepare('UPDATE plan_items SET status = ?, updated_at = ? WHERE id = ?')
|
|
282
|
+
.run(status, nowIso(), id);
|
|
283
|
+
return getItem(id);
|
|
284
|
+
}
|
|
285
|
+
export function deleteItem(id) {
|
|
286
|
+
db().prepare('DELETE FROM plan_items WHERE id = ?').run(id);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* REQ-nn ids derived from a project's PRD accepted sections in position order
|
|
290
|
+
* (§1.2). REQ ids are NOT stored — this is the single derivation point.
|
|
291
|
+
*/
|
|
292
|
+
export function reqIdsForProject(projectId) {
|
|
293
|
+
const prd = listDocuments(projectId).find((d) => d.type === 'prd');
|
|
294
|
+
if (!prd)
|
|
295
|
+
return [];
|
|
296
|
+
const sections = listAcceptedSections(prd.id);
|
|
297
|
+
return sections.map((s, i) => ({
|
|
298
|
+
id: `REQ-${String(i + 1).padStart(2, '0')}`,
|
|
299
|
+
heading: s.heading,
|
|
300
|
+
sectionId: s.id,
|
|
301
|
+
}));
|
|
302
|
+
}
|
|
303
|
+
// ─── Version history & context chain (P-01, SPEC-12) ─────────────────────────
|
|
304
|
+
export function snapshotDocument(documentId, event, meta = {}, note = '') {
|
|
305
|
+
const doc = getDocument(documentId);
|
|
306
|
+
if (!doc)
|
|
307
|
+
throw new Error('document not found');
|
|
308
|
+
const sections = listSections(documentId);
|
|
309
|
+
const snapshot = {
|
|
310
|
+
title: doc.title,
|
|
311
|
+
sections: sections.map((s) => ({
|
|
312
|
+
heading: s.heading,
|
|
313
|
+
body: s.body,
|
|
314
|
+
position: s.position,
|
|
315
|
+
})),
|
|
316
|
+
};
|
|
317
|
+
const newVersion = doc.version + 1;
|
|
318
|
+
db()
|
|
319
|
+
.prepare('UPDATE documents SET version = ?, updated_at = ? WHERE id = ?')
|
|
320
|
+
.run(newVersion, nowIso(), documentId);
|
|
321
|
+
db()
|
|
322
|
+
.prepare(`INSERT INTO document_versions
|
|
323
|
+
(id, document_id, version, event_type, snapshot, meta, note, created_at)
|
|
324
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
325
|
+
.run(nanoid(), documentId, newVersion, event, JSON.stringify(snapshot), JSON.stringify(meta), note, nowIso());
|
|
326
|
+
// Any structural change to this doc invalidates the inherited context of its
|
|
327
|
+
// children (P-01 §2). We only ever mark them stale — never auto-overwrite.
|
|
328
|
+
markChildrenStale(documentId, newVersion);
|
|
329
|
+
return newVersion;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Mark every direct child whose inherited version is out of date as stale.
|
|
333
|
+
* Per SYSTEM.md §0.5, a parent change sends the affected child's accepted
|
|
334
|
+
* sections back to 'proposed' and opens a kind='stale' suggestion so the editor
|
|
335
|
+
* re-reviews them. We never rewrite bodies here (G-02) — only flip status.
|
|
336
|
+
*/
|
|
337
|
+
export function markChildrenStale(parentId, parentVersion) {
|
|
338
|
+
const parent = getDocument(parentId);
|
|
339
|
+
const children = db()
|
|
340
|
+
.prepare('SELECT * FROM documents WHERE parent_document_id = ?')
|
|
341
|
+
.all(parentId);
|
|
342
|
+
for (const child of children) {
|
|
343
|
+
// Only act on a fresh transition to avoid re-flipping already-stale children.
|
|
344
|
+
const wasStale = child.context_stale === 1;
|
|
345
|
+
if (child.context_source_version !== parentVersion) {
|
|
346
|
+
db()
|
|
347
|
+
.prepare(`UPDATE documents
|
|
348
|
+
SET context_stale = 1, context_pending_version = ?, updated_at = ?
|
|
349
|
+
WHERE id = ?`)
|
|
350
|
+
.run(parentVersion, nowIso(), child.id);
|
|
351
|
+
if (!wasStale) {
|
|
352
|
+
db()
|
|
353
|
+
.prepare("UPDATE sections SET status = 'proposed', updated_at = ? WHERE document_id = ? AND status = 'accepted'")
|
|
354
|
+
.run(nowIso(), child.id);
|
|
355
|
+
createSuggestion({
|
|
356
|
+
documentId: child.id,
|
|
357
|
+
sectionId: null,
|
|
358
|
+
kind: 'stale',
|
|
359
|
+
title: '상위 문서 변경으로 재검토 필요',
|
|
360
|
+
body: '상위 문서가 갱신되어 이 문서의 섹션이 다시 제안 상태로 돌아왔습니다. 컨텍스트를 갱신하고 각 섹션을 다시 수락하세요.',
|
|
361
|
+
source: parent
|
|
362
|
+
? `${parent.type} "${parent.title}" v${parentVersion}`
|
|
363
|
+
: `상위 문서 v${parentVersion}`,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
export function getParentContext(documentId) {
|
|
370
|
+
const doc = getDocument(documentId);
|
|
371
|
+
if (!doc?.parent_document_id)
|
|
372
|
+
return null;
|
|
373
|
+
const parent = getDocument(doc.parent_document_id);
|
|
374
|
+
if (!parent)
|
|
375
|
+
return null;
|
|
376
|
+
return {
|
|
377
|
+
parentId: parent.id,
|
|
378
|
+
parentType: parent.type,
|
|
379
|
+
parentTitle: parent.title,
|
|
380
|
+
parentVersion: parent.version,
|
|
381
|
+
sections: listSections(parent.id).map((s) => ({
|
|
382
|
+
heading: s.heading,
|
|
383
|
+
body: s.body,
|
|
384
|
+
})),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Flow (A) from P-01: refresh inherited context ONLY. Child section bodies are
|
|
389
|
+
* left byte-for-byte unchanged. Records a `context_inherit` version event.
|
|
390
|
+
*/
|
|
391
|
+
export function refreshContext(documentId) {
|
|
392
|
+
const doc = getDocument(documentId);
|
|
393
|
+
if (!doc?.parent_document_id)
|
|
394
|
+
return doc;
|
|
395
|
+
const parent = getDocument(doc.parent_document_id);
|
|
396
|
+
if (!parent)
|
|
397
|
+
return doc;
|
|
398
|
+
db()
|
|
399
|
+
.prepare(`UPDATE documents
|
|
400
|
+
SET context_stale = 0, context_source_version = ?, context_pending_version = NULL,
|
|
401
|
+
updated_at = ?
|
|
402
|
+
WHERE id = ?`)
|
|
403
|
+
.run(parent.version, nowIso(), documentId);
|
|
404
|
+
snapshotDocument(documentId, 'context_inherit', {
|
|
405
|
+
inherited_from: { parent_document_id: parent.id, parent_version: parent.version },
|
|
406
|
+
});
|
|
407
|
+
return getDocument(documentId);
|
|
408
|
+
}
|
|
409
|
+
export function listVersions(documentId) {
|
|
410
|
+
return db()
|
|
411
|
+
.prepare(`SELECT id, document_id, version, event_type, meta, note, created_at
|
|
412
|
+
FROM document_versions WHERE document_id = ? ORDER BY version DESC`)
|
|
413
|
+
.all(documentId);
|
|
414
|
+
}
|
|
415
|
+
export function getVersion(versionRowId) {
|
|
416
|
+
return db()
|
|
417
|
+
.prepare('SELECT * FROM document_versions WHERE id = ?')
|
|
418
|
+
.get(versionRowId) ?? null;
|
|
419
|
+
}
|
|
420
|
+
/** Restore a document's sections from a version snapshot. Records `restore`. */
|
|
421
|
+
export function restoreVersion(documentId, versionRowId) {
|
|
422
|
+
const v = getVersion(versionRowId);
|
|
423
|
+
if (!v || v.document_id !== documentId)
|
|
424
|
+
return null;
|
|
425
|
+
const snap = JSON.parse(v.snapshot);
|
|
426
|
+
const ordered = [...snap.sections].sort((a, b) => a.position - b.position);
|
|
427
|
+
replaceSections(documentId, ordered.map((s) => ({ heading: s.heading, body: s.body })));
|
|
428
|
+
updateDocumentTitle(documentId, snap.title);
|
|
429
|
+
snapshotDocument(documentId, 'restore', { restored_from_version: v.version });
|
|
430
|
+
return getDocument(documentId);
|
|
431
|
+
}
|
|
432
|
+
function hydrateSession(row) {
|
|
433
|
+
return { ...row, answers: JSON.parse(row.answers) };
|
|
434
|
+
}
|
|
435
|
+
export function createSession(documentId, templateId) {
|
|
436
|
+
const id = nanoid();
|
|
437
|
+
const ts = nowIso();
|
|
438
|
+
db()
|
|
439
|
+
.prepare(`INSERT INTO interview_sessions
|
|
440
|
+
(id, document_id, template_id, status, current_index, answers, created_at, updated_at)
|
|
441
|
+
VALUES (?, ?, ?, 'active', 0, '[]', ?, ?)`)
|
|
442
|
+
.run(id, documentId, templateId, ts, ts);
|
|
443
|
+
return getSession(id);
|
|
444
|
+
}
|
|
445
|
+
export function getSession(id) {
|
|
446
|
+
const row = db().prepare('SELECT * FROM interview_sessions WHERE id = ?').get(id);
|
|
447
|
+
return row ? hydrateSession(row) : null;
|
|
448
|
+
}
|
|
449
|
+
export function getSessionByDocument(documentId) {
|
|
450
|
+
const row = db()
|
|
451
|
+
.prepare('SELECT * FROM interview_sessions WHERE document_id = ? ORDER BY created_at DESC LIMIT 1')
|
|
452
|
+
.get(documentId);
|
|
453
|
+
return row ? hydrateSession(row) : null;
|
|
454
|
+
}
|
|
455
|
+
export function updateSession(id, patch) {
|
|
456
|
+
const cur = getSession(id);
|
|
457
|
+
if (!cur)
|
|
458
|
+
return null;
|
|
459
|
+
db()
|
|
460
|
+
.prepare(`UPDATE interview_sessions
|
|
461
|
+
SET status = ?, current_index = ?, answers = ?, updated_at = ?
|
|
462
|
+
WHERE id = ?`)
|
|
463
|
+
.run(patch.status ?? cur.status, patch.current_index ?? cur.current_index, JSON.stringify(patch.answers ?? cur.answers), nowIso(), id);
|
|
464
|
+
return getSession(id);
|
|
465
|
+
}
|
|
466
|
+
export function upsertApiKey(provider, plaintextKey, label = '') {
|
|
467
|
+
const sealed = seal(plaintextKey);
|
|
468
|
+
const last4 = plaintextKey.slice(-4);
|
|
469
|
+
const ts = nowIso();
|
|
470
|
+
const existing = db()
|
|
471
|
+
.prepare('SELECT id FROM api_keys WHERE provider = ?')
|
|
472
|
+
.get(provider);
|
|
473
|
+
if (existing) {
|
|
474
|
+
db()
|
|
475
|
+
.prepare(`UPDATE api_keys
|
|
476
|
+
SET label = ?, ciphertext = ?, iv = ?, auth_tag = ?, last4 = ?, updated_at = ?
|
|
477
|
+
WHERE provider = ?`)
|
|
478
|
+
.run(label, sealed.ciphertext, sealed.iv, sealed.authTag, last4, ts, provider);
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
db()
|
|
482
|
+
.prepare(`INSERT INTO api_keys
|
|
483
|
+
(id, provider, label, ciphertext, iv, auth_tag, last4, created_at, updated_at)
|
|
484
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
485
|
+
.run(nanoid(), provider, label, sealed.ciphertext, sealed.iv, sealed.authTag, last4, ts, ts);
|
|
486
|
+
}
|
|
487
|
+
return getKeyMeta(provider);
|
|
488
|
+
}
|
|
489
|
+
/** 터미널/CLI 모드: 표준 env 이름으로 BYOK 키 주입(dev 도구처럼). 마법사·암호화DB 불필요. */
|
|
490
|
+
export function envKey(provider) {
|
|
491
|
+
const e = process.env;
|
|
492
|
+
if (provider === 'anthropic')
|
|
493
|
+
return e.ANTHROPIC_API_KEY || null;
|
|
494
|
+
if (provider === 'openrouter')
|
|
495
|
+
return e.OPENROUTER_API_KEY || null;
|
|
496
|
+
if (provider === 'openai')
|
|
497
|
+
return e.OPENAI_API_KEY || e.LITELLM_API_KEY || null;
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
function envKeyMeta(provider) {
|
|
501
|
+
const k = envKey(provider);
|
|
502
|
+
if (!k)
|
|
503
|
+
return null;
|
|
504
|
+
return { id: 'env', provider, label: 'env', last4: k.slice(-4), created_at: '', updated_at: '' };
|
|
505
|
+
}
|
|
506
|
+
export function getKeyMeta(provider) {
|
|
507
|
+
const row = db()
|
|
508
|
+
.prepare('SELECT id, provider, label, last4, created_at, updated_at FROM api_keys WHERE provider = ?')
|
|
509
|
+
.get(provider);
|
|
510
|
+
return row ?? envKeyMeta(provider); // DB 없으면 env 폴백
|
|
511
|
+
}
|
|
512
|
+
export function listKeyMeta() {
|
|
513
|
+
const rows = db()
|
|
514
|
+
.prepare('SELECT id, provider, label, last4, created_at, updated_at FROM api_keys ORDER BY provider')
|
|
515
|
+
.all();
|
|
516
|
+
const have = new Set(rows.map((r) => r.provider));
|
|
517
|
+
const fromEnv = ['anthropic', 'openai', 'openrouter']
|
|
518
|
+
.filter((p) => !have.has(p))
|
|
519
|
+
.map(envKeyMeta)
|
|
520
|
+
.filter((x) => !!x);
|
|
521
|
+
return [...rows, ...fromEnv];
|
|
522
|
+
}
|
|
523
|
+
/** Decrypt and return the raw key for a provider (server-internal use only). */
|
|
524
|
+
export function getDecryptedKey(provider) {
|
|
525
|
+
const row = db()
|
|
526
|
+
.prepare('SELECT ciphertext, iv, auth_tag FROM api_keys WHERE provider = ?')
|
|
527
|
+
.get(provider);
|
|
528
|
+
if (row)
|
|
529
|
+
return open({ ciphertext: row.ciphertext, iv: row.iv, authTag: row.auth_tag });
|
|
530
|
+
return envKey(provider); // DB 없으면 env-var 폴백 (터미널/CLI 모드)
|
|
531
|
+
}
|
|
532
|
+
export function deleteApiKey(provider) {
|
|
533
|
+
db().prepare('DELETE FROM api_keys WHERE provider = ?').run(provider);
|
|
534
|
+
}
|
|
535
|
+
export function createShareLink(documentId, expiresAt) {
|
|
536
|
+
const id = nanoid();
|
|
537
|
+
const token = nanoid(24);
|
|
538
|
+
db()
|
|
539
|
+
.prepare(`INSERT INTO share_links (id, document_id, token, expires_at, revoked, created_at)
|
|
540
|
+
VALUES (?, ?, ?, ?, 0, ?)`)
|
|
541
|
+
.run(id, documentId, token, expiresAt, nowIso());
|
|
542
|
+
return db().prepare('SELECT * FROM share_links WHERE id = ?').get(id);
|
|
543
|
+
}
|
|
544
|
+
export function getShareByToken(token) {
|
|
545
|
+
return db().prepare('SELECT * FROM share_links WHERE token = ?').get(token) ?? null;
|
|
546
|
+
}
|
|
547
|
+
export function listShareLinks(documentId) {
|
|
548
|
+
return db()
|
|
549
|
+
.prepare('SELECT * FROM share_links WHERE document_id = ? ORDER BY created_at DESC')
|
|
550
|
+
.all(documentId);
|
|
551
|
+
}
|
|
552
|
+
export function revokeShareLink(id) {
|
|
553
|
+
db().prepare('UPDATE share_links SET revoked = 1 WHERE id = ?').run(id);
|
|
554
|
+
}
|
|
555
|
+
export function exportProjectBundle(projectId) {
|
|
556
|
+
const d = db();
|
|
557
|
+
const project = d.prepare('SELECT * FROM projects WHERE id = ?').get(projectId);
|
|
558
|
+
if (!project)
|
|
559
|
+
return null;
|
|
560
|
+
const docs = d.prepare('SELECT * FROM documents WHERE project_id = ?').all(projectId);
|
|
561
|
+
const docIds = docs.map((x) => x.id);
|
|
562
|
+
const inq = docIds.map(() => '?').join(',');
|
|
563
|
+
const byDocs = (t) => docIds.length ? d.prepare(`SELECT * FROM ${t} WHERE document_id IN (${inq})`).all(...docIds) : [];
|
|
564
|
+
return {
|
|
565
|
+
format: 'drafting-project',
|
|
566
|
+
version: 1,
|
|
567
|
+
project,
|
|
568
|
+
documents: docs,
|
|
569
|
+
sections: byDocs('sections'),
|
|
570
|
+
planItems: byDocs('plan_items'),
|
|
571
|
+
sessions: byDocs('interview_sessions'),
|
|
572
|
+
suggestions: byDocs('suggestions'),
|
|
573
|
+
versions: byDocs('document_versions'),
|
|
574
|
+
mockups: d.prepare('SELECT * FROM mockups WHERE project_id = ?').all(projectId),
|
|
575
|
+
styleGuide: getSetting(`style_guide:${projectId}`),
|
|
576
|
+
designSystem: getSetting(`design_system:${projectId}`),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
const byCreated = (a, b) => String(a.created_at ?? '').localeCompare(String(b.created_at ?? ''));
|
|
580
|
+
export function importProjectBundle(bundle) {
|
|
581
|
+
if (bundle?.format !== 'drafting-project')
|
|
582
|
+
throw new Error('유효한 Drafting 프로젝트 번들이 아닙니다');
|
|
583
|
+
const d = db();
|
|
584
|
+
const ts = nowIso();
|
|
585
|
+
const newPid = nanoid();
|
|
586
|
+
const docMap = new Map();
|
|
587
|
+
const secMap = new Map();
|
|
588
|
+
const itemMap = new Map();
|
|
589
|
+
const sessMap = new Map();
|
|
590
|
+
for (const x of bundle.documents ?? [])
|
|
591
|
+
docMap.set(x.id, nanoid());
|
|
592
|
+
for (const x of bundle.sections ?? [])
|
|
593
|
+
secMap.set(x.id, nanoid());
|
|
594
|
+
for (const x of bundle.planItems ?? [])
|
|
595
|
+
itemMap.set(x.id, nanoid());
|
|
596
|
+
for (const x of bundle.sessions ?? [])
|
|
597
|
+
sessMap.set(x.id, nanoid());
|
|
598
|
+
const rid = (m, v) => v ? m.get(v) ?? null : null;
|
|
599
|
+
const run = (sql, ...args) => d.prepare(sql).run(...args);
|
|
600
|
+
run('INSERT INTO projects (id,name,description,created_at,updated_at) VALUES (?,?,?,?,?)', newPid, `${bundle.project.name} (가져옴)`, bundle.project.description ?? '', ts, ts);
|
|
601
|
+
for (const x of [...(bundle.documents ?? [])].sort(byCreated)) {
|
|
602
|
+
run(`INSERT INTO documents (id,project_id,type,title,status,parent_document_id,version,context_stale,context_source_version,context_pending_version,created_at,updated_at)
|
|
603
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, docMap.get(x.id), newPid, x.type, x.title, x.status, rid(docMap, x.parent_document_id), x.version ?? 0, x.context_stale ?? 0, x.context_source_version ?? null, x.context_pending_version ?? null, x.created_at ?? ts, x.updated_at ?? ts);
|
|
604
|
+
}
|
|
605
|
+
for (const x of bundle.sections ?? []) {
|
|
606
|
+
run('INSERT INTO sections (id,document_id,position,heading,body,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)', secMap.get(x.id), docMap.get(x.document_id), x.position, x.heading, x.body, x.status, x.created_at ?? ts, x.updated_at ?? ts);
|
|
607
|
+
}
|
|
608
|
+
for (const x of [...(bundle.planItems ?? [])].sort(byCreated)) {
|
|
609
|
+
run('INSERT INTO plan_items (id,document_id,parent_id,kind,ref_id,position,title,body,meta,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)', itemMap.get(x.id), docMap.get(x.document_id), rid(itemMap, x.parent_id), x.kind, x.ref_id, x.position, x.title, x.body ?? '', x.meta ?? '{}', x.status, x.created_at ?? ts, x.updated_at ?? ts);
|
|
610
|
+
}
|
|
611
|
+
for (const x of bundle.sessions ?? []) {
|
|
612
|
+
run('INSERT INTO interview_sessions (id,document_id,template_id,status,current_index,answers,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)', sessMap.get(x.id), docMap.get(x.document_id), x.template_id, x.status, x.current_index ?? 0, x.answers ?? '[]', x.created_at ?? ts, x.updated_at ?? ts);
|
|
613
|
+
}
|
|
614
|
+
for (const x of bundle.suggestions ?? []) {
|
|
615
|
+
run('INSERT INTO suggestions (id,document_id,section_id,target_item_id,kind,title,body,quote_before,quote_after,source,status,created_at,resolved_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)', nanoid(), docMap.get(x.document_id), rid(secMap, x.section_id), rid(itemMap, x.target_item_id), x.kind, x.title ?? '', x.body ?? '', x.quote_before ?? '', x.quote_after ?? '', x.source ?? '', x.status ?? 'open', x.created_at ?? ts, x.resolved_at ?? null);
|
|
616
|
+
}
|
|
617
|
+
for (const x of bundle.versions ?? []) {
|
|
618
|
+
run('INSERT INTO document_versions (id,document_id,version,event_type,snapshot,meta,note,created_at) VALUES (?,?,?,?,?,?,?,?)', nanoid(), docMap.get(x.document_id), x.version, x.event_type, x.snapshot, x.meta ?? '{}', x.note ?? '', x.created_at ?? ts);
|
|
619
|
+
}
|
|
620
|
+
for (const x of bundle.mockups ?? []) {
|
|
621
|
+
run('INSERT INTO mockups (id,project_id,page_ref,html,status,style_key,created_at) VALUES (?,?,?,?,?,?,?)', nanoid(), newPid, x.page_ref, x.html, x.status ?? 'proposed', x.style_key ?? null, x.created_at ?? ts);
|
|
622
|
+
}
|
|
623
|
+
if (bundle.styleGuide)
|
|
624
|
+
setSetting(`style_guide:${newPid}`, bundle.styleGuide);
|
|
625
|
+
if (bundle.designSystem)
|
|
626
|
+
setSetting(`design_system:${newPid}`, bundle.designSystem);
|
|
627
|
+
return newPid;
|
|
628
|
+
}
|
|
629
|
+
export function getMockup(projectId, pageRef) {
|
|
630
|
+
return (db()
|
|
631
|
+
.prepare('SELECT * FROM mockups WHERE project_id = ? AND page_ref = ?')
|
|
632
|
+
.get(projectId, pageRef) ?? null);
|
|
633
|
+
}
|
|
634
|
+
export function listMockups(projectId) {
|
|
635
|
+
return db()
|
|
636
|
+
.prepare('SELECT * FROM mockups WHERE project_id = ?')
|
|
637
|
+
.all(projectId);
|
|
638
|
+
}
|
|
639
|
+
export function upsertMockup(projectId, pageRef, html, styleKey) {
|
|
640
|
+
const existing = getMockup(projectId, pageRef);
|
|
641
|
+
const ts = nowIso();
|
|
642
|
+
if (existing) {
|
|
643
|
+
db()
|
|
644
|
+
.prepare(`UPDATE mockups SET html = ?, status = 'proposed', style_key = ?, created_at = ? WHERE id = ?`)
|
|
645
|
+
.run(html, styleKey, ts, existing.id);
|
|
646
|
+
return getMockup(projectId, pageRef);
|
|
647
|
+
}
|
|
648
|
+
db()
|
|
649
|
+
.prepare(`INSERT INTO mockups (id, project_id, page_ref, html, status, style_key, created_at)
|
|
650
|
+
VALUES (?, ?, ?, ?, 'proposed', ?, ?)`)
|
|
651
|
+
.run(nanoid(), projectId, pageRef, html, styleKey, ts);
|
|
652
|
+
return getMockup(projectId, pageRef);
|
|
653
|
+
}
|
|
654
|
+
export function setMockupStatus(projectId, pageRef, status) {
|
|
655
|
+
db().prepare('UPDATE mockups SET status = ? WHERE project_id = ? AND page_ref = ?').run(status, projectId, pageRef);
|
|
656
|
+
return getMockup(projectId, pageRef);
|
|
657
|
+
}
|
|
658
|
+
export function deleteMockup(projectId, pageRef) {
|
|
659
|
+
db().prepare('DELETE FROM mockups WHERE project_id = ? AND page_ref = ?').run(projectId, pageRef);
|
|
660
|
+
}
|
|
661
|
+
export function getSetting(key) {
|
|
662
|
+
const row = db().prepare('SELECT value FROM settings WHERE key = ?').get(key);
|
|
663
|
+
return row ? JSON.parse(row.value) : null;
|
|
664
|
+
}
|
|
665
|
+
export function setSetting(key, value) {
|
|
666
|
+
db()
|
|
667
|
+
.prepare(`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
|
|
668
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
|
|
669
|
+
.run(key, JSON.stringify(value), nowIso());
|
|
670
|
+
}
|