@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,85 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { config } from "./config.js";
|
|
4
|
+
import { getSetting, setSetting } from "../db/repos.js";
|
|
5
|
+
// Interview templates live as external JSON files (G-06: NOT hardcoded), so the
|
|
6
|
+
// community can add/edit document types without touching source. Loaded once at
|
|
7
|
+
// boot. Custom templates (edited in-app) are stored in the DB and overlaid on
|
|
8
|
+
// top of the file templates by id — so users can customize without file edits.
|
|
9
|
+
let fileCache = null;
|
|
10
|
+
function loadFileTemplates() {
|
|
11
|
+
if (fileCache)
|
|
12
|
+
return fileCache;
|
|
13
|
+
return reloadTemplates();
|
|
14
|
+
}
|
|
15
|
+
export function reloadTemplates() {
|
|
16
|
+
const map = new Map();
|
|
17
|
+
const dir = config.templatesDir;
|
|
18
|
+
if (fs.existsSync(dir)) {
|
|
19
|
+
for (const file of fs.readdirSync(dir)) {
|
|
20
|
+
if (!file.endsWith('.json'))
|
|
21
|
+
continue;
|
|
22
|
+
const raw = fs.readFileSync(path.join(dir, file), 'utf8');
|
|
23
|
+
try {
|
|
24
|
+
const tpl = JSON.parse(raw);
|
|
25
|
+
if (tpl.id && tpl.docType && Array.isArray(tpl.questions)) {
|
|
26
|
+
map.set(tpl.id, tpl);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// skip malformed template file
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
fileCache = map;
|
|
35
|
+
return map;
|
|
36
|
+
}
|
|
37
|
+
function customStore() {
|
|
38
|
+
return getSetting('custom_templates') ?? {};
|
|
39
|
+
}
|
|
40
|
+
/** File templates with DB custom templates overlaid (custom wins by id). */
|
|
41
|
+
function effectiveTemplates() {
|
|
42
|
+
const map = new Map(loadFileTemplates());
|
|
43
|
+
for (const [id, t] of Object.entries(customStore()))
|
|
44
|
+
map.set(id, t);
|
|
45
|
+
return map;
|
|
46
|
+
}
|
|
47
|
+
/** source of an id in the effective set — for the UI. */
|
|
48
|
+
export function templateSource(id) {
|
|
49
|
+
const isCustom = id in customStore();
|
|
50
|
+
const isFile = loadFileTemplates().has(id);
|
|
51
|
+
if (isCustom && isFile)
|
|
52
|
+
return 'override';
|
|
53
|
+
return isCustom ? 'custom' : 'file';
|
|
54
|
+
}
|
|
55
|
+
export function loadTemplates() {
|
|
56
|
+
return effectiveTemplates();
|
|
57
|
+
}
|
|
58
|
+
export function listTemplates() {
|
|
59
|
+
return [...effectiveTemplates().values()].sort((a, b) => a.docType.localeCompare(b.docType));
|
|
60
|
+
}
|
|
61
|
+
export function getTemplate(id) {
|
|
62
|
+
return effectiveTemplates().get(id) ?? null;
|
|
63
|
+
}
|
|
64
|
+
export function getTemplateForType(docType) {
|
|
65
|
+
for (const t of effectiveTemplates().values()) {
|
|
66
|
+
if (t.docType === docType)
|
|
67
|
+
return t;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
// ── custom template mutations (in-app editor) ────────────────────────────────
|
|
72
|
+
export function saveCustomTemplate(t) {
|
|
73
|
+
const store = customStore();
|
|
74
|
+
store[t.id] = t;
|
|
75
|
+
setSetting('custom_templates', store);
|
|
76
|
+
}
|
|
77
|
+
/** Delete a custom template. For an override, this reverts to the file version. */
|
|
78
|
+
export function deleteCustomTemplate(id) {
|
|
79
|
+
const store = customStore();
|
|
80
|
+
if (!(id in store))
|
|
81
|
+
return false;
|
|
82
|
+
delete store[id];
|
|
83
|
+
setSetting('custom_templates', store);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Derived wireframe render data (§5.1) — deterministic, AI-free. Input: accepted
|
|
2
|
+
// (and proposed, flagged) IA pages + their linked feature content + flow steps.
|
|
3
|
+
// Content is SEEDED from feature titles/bodies; hotspots (→ PG-nn) come from the
|
|
4
|
+
// flow. Same input → same output. The React <WireframeGrid> paints this shape.
|
|
5
|
+
import * as repo from "../db/repos.js";
|
|
6
|
+
import { lintProject } from "./lint.js";
|
|
7
|
+
function meta(i) {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(i.meta || '{}');
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function bodyLines(i) {
|
|
16
|
+
return i.body
|
|
17
|
+
.split('\n')
|
|
18
|
+
.map((l) => l.replace(/^[·\-*]\s*/, '').trim())
|
|
19
|
+
.filter(Boolean);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Build a flat, wireframe-ready list for a project. Pages that are 'rejected' are
|
|
23
|
+
* excluded; accepted + proposed pages are both included (proposed flagged so the
|
|
24
|
+
* UI can dash them — mirrors the IA sitemap proposal).
|
|
25
|
+
*/
|
|
26
|
+
export function deriveWireframes(projectId) {
|
|
27
|
+
const items = repo.listProjectItems(projectId);
|
|
28
|
+
const byRef = new Map();
|
|
29
|
+
for (const it of items)
|
|
30
|
+
byRef.set(it.ref_id, it);
|
|
31
|
+
// page ref -> outgoing hotspot (from consecutive page-bearing MAIN steps)
|
|
32
|
+
const outgoing = new Map();
|
|
33
|
+
const flowsForPage = new Map();
|
|
34
|
+
const flowItems = items.filter((i) => i.kind === 'flow');
|
|
35
|
+
for (const flow of flowItems) {
|
|
36
|
+
const steps = items
|
|
37
|
+
.filter((i) => i.kind === 'step' && i.parent_id === flow.id)
|
|
38
|
+
.sort((a, b) => a.position - b.position);
|
|
39
|
+
let prevPage = null;
|
|
40
|
+
for (const st of steps) {
|
|
41
|
+
const m = meta(st);
|
|
42
|
+
if (m.branch)
|
|
43
|
+
continue; // branch steps don't drive the main hotspot chain
|
|
44
|
+
if (!m.page)
|
|
45
|
+
continue;
|
|
46
|
+
if (!flowsForPage.has(m.page))
|
|
47
|
+
flowsForPage.set(m.page, new Set());
|
|
48
|
+
flowsForPage.get(m.page).add(flow.ref_id);
|
|
49
|
+
if (prevPage && prevPage !== m.page && !outgoing.has(prevPage)) {
|
|
50
|
+
outgoing.set(prevPage, m.page);
|
|
51
|
+
}
|
|
52
|
+
prevPage = m.page;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const reqIds = repo.reqIdsForProject(projectId).map((r) => r.id);
|
|
56
|
+
const violations = lintProject(items, reqIds);
|
|
57
|
+
const pageWarning = new Map();
|
|
58
|
+
for (const v of violations) {
|
|
59
|
+
if (v.code === 'W-UNREACHED-PAGE')
|
|
60
|
+
pageWarning.set(v.refs[0], '이 화면에 도달하는 플로우 없음');
|
|
61
|
+
else if (v.code === 'W-EMPTY-PAGE' && !pageWarning.has(v.refs[0]))
|
|
62
|
+
pageWarning.set(v.refs[0], '연결된 기능 없음');
|
|
63
|
+
}
|
|
64
|
+
const pages = items
|
|
65
|
+
.filter((i) => i.kind === 'page' && i.status !== 'rejected')
|
|
66
|
+
.sort((a, b) => a.position - b.position);
|
|
67
|
+
return pages.map((page) => {
|
|
68
|
+
const m = meta(page);
|
|
69
|
+
const featureRefs = m.links?.features ?? [];
|
|
70
|
+
const features = featureRefs.map((r) => byRef.get(r)).filter((x) => !!x);
|
|
71
|
+
const toPage = outgoing.get(page.ref_id) ?? null;
|
|
72
|
+
const hotspot = toPage ? { toPage, label: `→ ${toPage}` } : null;
|
|
73
|
+
return {
|
|
74
|
+
ref: page.ref_id,
|
|
75
|
+
itemId: page.id,
|
|
76
|
+
title: page.title,
|
|
77
|
+
pageType: (m.page_type ?? 'GENERIC'),
|
|
78
|
+
status: page.status === 'accepted' ? 'accepted' : 'proposed',
|
|
79
|
+
featureRefs,
|
|
80
|
+
flowRefs: [...(flowsForPage.get(page.ref_id) ?? [])],
|
|
81
|
+
hotspot,
|
|
82
|
+
lintWarning: pageWarning.get(page.ref_id) ?? null,
|
|
83
|
+
seed: seedFor((m.page_type ?? 'GENERIC'), page, features, hotspot),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function seedFor(type, page, features, hotspot) {
|
|
88
|
+
const labels = features.map((f) => f.title);
|
|
89
|
+
const lines = features.flatMap((f) => bodyLines(f));
|
|
90
|
+
const cta = hotspot ? '다음 단계로' : '확인';
|
|
91
|
+
switch (type) {
|
|
92
|
+
case 'LIST':
|
|
93
|
+
return {
|
|
94
|
+
search: `${page.title} 검색`,
|
|
95
|
+
rows: (labels.length ? labels : [page.title]).slice(0, 3).map((t, i) => ({
|
|
96
|
+
title: t,
|
|
97
|
+
meta: `${i + 1}번`,
|
|
98
|
+
action: i === 0 && hotspot ? '열기' : '보기',
|
|
99
|
+
hot: i === 0 && hotspot ? hotspot.label : undefined,
|
|
100
|
+
})),
|
|
101
|
+
};
|
|
102
|
+
case 'DETAIL':
|
|
103
|
+
return {
|
|
104
|
+
detailTitle: labels[0] ?? page.title,
|
|
105
|
+
slots: ['옵션 A', '옵션 B', '옵션 C'].map((label, i) => ({
|
|
106
|
+
label,
|
|
107
|
+
state: i === 1 ? 'on' : i === 2 ? 'dis' : 'idle',
|
|
108
|
+
})),
|
|
109
|
+
blocks: lines.slice(0, 2),
|
|
110
|
+
cta: hotspot ? `${cta}` : page.title,
|
|
111
|
+
};
|
|
112
|
+
case 'FORM':
|
|
113
|
+
return {
|
|
114
|
+
fields: (lines.length ? lines : labels).slice(0, 4).map((label, i) => ({
|
|
115
|
+
label,
|
|
116
|
+
value: i === 0 ? page.title : '입력값',
|
|
117
|
+
})),
|
|
118
|
+
cta: hotspot ? '제출' : '저장',
|
|
119
|
+
};
|
|
120
|
+
case 'DASH':
|
|
121
|
+
return {
|
|
122
|
+
stats: (labels.length ? labels : ['지표 A', '지표 B', '지표 C'])
|
|
123
|
+
.slice(0, 3)
|
|
124
|
+
.map((label, i) => ({ value: `${(i + 5) * 12}%`, label })),
|
|
125
|
+
bars: [40, 65, 80, 55, 90],
|
|
126
|
+
};
|
|
127
|
+
case 'SETTINGS':
|
|
128
|
+
return {
|
|
129
|
+
toggles: (lines.length ? lines : labels).slice(0, 3).map((label, i) => ({
|
|
130
|
+
label,
|
|
131
|
+
on: i < 2,
|
|
132
|
+
})),
|
|
133
|
+
};
|
|
134
|
+
default:
|
|
135
|
+
return { blocks: lines.length ? lines : labels.length ? labels : [page.title] };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { iterateSse } from "../sse.js";
|
|
2
|
+
const BASE = 'https://api.anthropic.com/v1';
|
|
3
|
+
export class AnthropicProvider {
|
|
4
|
+
id = 'anthropic';
|
|
5
|
+
apiKey;
|
|
6
|
+
constructor(apiKey) {
|
|
7
|
+
this.apiKey = apiKey;
|
|
8
|
+
}
|
|
9
|
+
headers() {
|
|
10
|
+
return {
|
|
11
|
+
'x-api-key': this.apiKey,
|
|
12
|
+
'anthropic-version': '2023-06-01',
|
|
13
|
+
'content-type': 'application/json',
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
async *streamChat(params) {
|
|
17
|
+
const system = params.messages
|
|
18
|
+
.filter((m) => m.role === 'system')
|
|
19
|
+
.map((m) => m.content)
|
|
20
|
+
.join('\n\n');
|
|
21
|
+
const messages = params.messages
|
|
22
|
+
.filter((m) => m.role !== 'system')
|
|
23
|
+
.map((m) => ({ role: m.role, content: m.content }));
|
|
24
|
+
const res = await fetch(`${BASE}/messages`, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: this.headers(),
|
|
27
|
+
signal: params.signal,
|
|
28
|
+
body: JSON.stringify({
|
|
29
|
+
model: params.model,
|
|
30
|
+
max_tokens: params.maxTokens ?? 4096,
|
|
31
|
+
system: system || undefined,
|
|
32
|
+
messages,
|
|
33
|
+
stream: true,
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
throw new Error(`Anthropic ${res.status}: ${await res.text()}`);
|
|
38
|
+
}
|
|
39
|
+
for await (const data of iterateSse(res)) {
|
|
40
|
+
if (data === '[DONE]')
|
|
41
|
+
break;
|
|
42
|
+
try {
|
|
43
|
+
const evt = JSON.parse(data);
|
|
44
|
+
if (evt.type === 'content_block_delta' && evt.delta?.type === 'text_delta') {
|
|
45
|
+
yield evt.delta.text;
|
|
46
|
+
}
|
|
47
|
+
else if (evt.type === 'error') {
|
|
48
|
+
throw new Error(evt.error?.message ?? 'anthropic stream error');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// non-JSON keep-alive line; ignore
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async testConnection(model) {
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetch(`${BASE}/messages`, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: this.headers(),
|
|
61
|
+
body: JSON.stringify({
|
|
62
|
+
model,
|
|
63
|
+
max_tokens: 1,
|
|
64
|
+
messages: [{ role: 'user', content: 'ping' }],
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
if (res.ok)
|
|
68
|
+
return { ok: true };
|
|
69
|
+
return { ok: false, detail: `${res.status}: ${(await res.text()).slice(0, 200)}` };
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
return { ok: false, detail: e.message };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { iterateSse } from "../sse.js";
|
|
2
|
+
import { humanizeProviderError } from "../../lib/provider-errors.js";
|
|
3
|
+
/**
|
|
4
|
+
* Shared implementation for OpenAI-compatible chat/completions endpoints.
|
|
5
|
+
* OpenAI and OpenRouter differ only in base URL + optional extra headers.
|
|
6
|
+
*/
|
|
7
|
+
export class OpenAICompatProvider {
|
|
8
|
+
id;
|
|
9
|
+
apiKey;
|
|
10
|
+
base;
|
|
11
|
+
extraHeaders;
|
|
12
|
+
constructor(id, apiKey, base, extraHeaders = {}) {
|
|
13
|
+
this.id = id;
|
|
14
|
+
this.apiKey = apiKey;
|
|
15
|
+
this.base = base;
|
|
16
|
+
this.extraHeaders = extraHeaders;
|
|
17
|
+
}
|
|
18
|
+
headers() {
|
|
19
|
+
return {
|
|
20
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
21
|
+
'content-type': 'application/json',
|
|
22
|
+
...this.extraHeaders,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
async *streamChat(params) {
|
|
26
|
+
const res = await fetch(`${this.base}/chat/completions`, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: this.headers(),
|
|
29
|
+
signal: params.signal,
|
|
30
|
+
body: JSON.stringify({
|
|
31
|
+
model: params.model,
|
|
32
|
+
max_tokens: params.maxTokens ?? 4096,
|
|
33
|
+
messages: params.messages.map((m) => ({ role: m.role, content: m.content })),
|
|
34
|
+
stream: true,
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
throw new Error(humanizeProviderError(res.status, await res.text()));
|
|
39
|
+
}
|
|
40
|
+
for await (const data of iterateSse(res)) {
|
|
41
|
+
if (data === '[DONE]')
|
|
42
|
+
break;
|
|
43
|
+
try {
|
|
44
|
+
const evt = JSON.parse(data);
|
|
45
|
+
const delta = evt.choices?.[0]?.delta?.content;
|
|
46
|
+
if (typeof delta === 'string' && delta.length)
|
|
47
|
+
yield delta;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// keep-alive / non-JSON line
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async testConnection(model) {
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetch(`${this.base}/chat/completions`, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: this.headers(),
|
|
59
|
+
body: JSON.stringify({
|
|
60
|
+
model,
|
|
61
|
+
max_tokens: 1,
|
|
62
|
+
messages: [{ role: 'user', content: 'ping' }],
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
if (res.ok)
|
|
66
|
+
return { ok: true };
|
|
67
|
+
return { ok: false, detail: `${res.status}: ${(await res.text()).slice(0, 200)}` };
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
return { ok: false, detail: e.message };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export class OpenAIProvider extends OpenAICompatProvider {
|
|
75
|
+
// base 를 넘기면 OpenAI 호환 게이트웨이(LiteLLM·Azure·사내 프록시)를 그대로 사용한다.
|
|
76
|
+
// 게이트웨이가 표준 Bearer 외 다른 헤더를 요구하면 extraHeaders 로 주입한다.
|
|
77
|
+
constructor(apiKey, base, extraHeaders = {}) {
|
|
78
|
+
super('openai', apiKey, normalizeBase(base) ?? 'https://api.openai.com/v1', extraHeaders);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** 사용자가 붙인 base 를 정규화 — 끝 슬래시 제거. 빈 값이면 null(기본값 사용). */
|
|
82
|
+
function normalizeBase(base) {
|
|
83
|
+
const b = (base ?? '').trim().replace(/\/+$/, '');
|
|
84
|
+
return b || null;
|
|
85
|
+
}
|
|
86
|
+
export class OpenRouterProvider extends OpenAICompatProvider {
|
|
87
|
+
// base 를 넘기면 OpenAI 호환 게이트웨이(LiteLLM 등)로 라우팅. 안 넘기면 openrouter.ai.
|
|
88
|
+
constructor(apiKey, base, extraHeaders = {}) {
|
|
89
|
+
super('openrouter', apiKey, normalizeBase(base) ?? 'https://openrouter.ai/api/v1', {
|
|
90
|
+
'HTTP-Referer': 'https://github.com/hanmariyang/drafting',
|
|
91
|
+
'X-Title': 'Drafting',
|
|
92
|
+
...extraHeaders,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|