@open330/kiwimu 0.8.0 → 1.1.0
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 +105 -27
- package/package.json +1 -1
- package/src/build/renderer.ts +272 -32
- package/src/build/static/dynamic-qa.js +423 -0
- package/src/build/static/edit-page.js +58 -0
- package/src/build/static/peek-panel.css +201 -0
- package/src/build/static/peek-panel.js +470 -0
- package/src/build/static/search.js +30 -15
- package/src/build/static/style.css +821 -6
- package/src/build/templates.ts +700 -48
- package/src/config.ts +41 -3
- package/src/demo/sample-data.ts +69 -2
- package/src/demo/setup.ts +25 -6
- package/src/expand/llm.ts +2 -2
- package/src/index.ts +467 -60
- package/src/ingest/docx.ts +1 -1
- package/src/ingest/markdown.ts +21 -0
- package/src/ingest/pdf.ts +4 -2
- package/src/llm-client.ts +63 -69
- package/src/pipeline/citations.ts +107 -0
- package/src/pipeline/llm-chunker.ts +277 -131
- package/src/pipeline/standardizer.ts +41 -0
- package/src/server.ts +465 -32
- package/src/services/dynamic-qa.ts +190 -0
- package/src/services/embedding.ts +122 -0
- package/src/services/index-generator.ts +185 -0
- package/src/services/ingest.ts +83 -25
- package/src/services/lint.ts +249 -0
- package/src/services/promote.ts +150 -0
- package/src/store.test.ts +11 -0
- package/src/store.ts +561 -28
- package/src/utils.ts +30 -0
package/src/build/templates.ts
CHANGED
|
@@ -1,25 +1,107 @@
|
|
|
1
|
+
import { escapeHtml } from "../utils";
|
|
2
|
+
|
|
1
3
|
interface PageLink {
|
|
2
4
|
slug: string;
|
|
3
5
|
title: string;
|
|
4
6
|
pageType?: string;
|
|
7
|
+
origin?: string;
|
|
8
|
+
sourceUri?: string;
|
|
5
9
|
}
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
|
|
11
|
+
/** Configurable category definition (matches SourceCategory in config.ts). */
|
|
12
|
+
interface CategorySpec {
|
|
13
|
+
name: string;
|
|
14
|
+
order: number;
|
|
15
|
+
patterns: string[];
|
|
9
16
|
}
|
|
10
17
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Convert a glob-like pattern (`*` wildcard) into a case-insensitive RegExp.
|
|
20
|
+
* Anchors are NOT applied — the result is meant to be tested against either
|
|
21
|
+
* the basename or the full URI, matching either substring is acceptable.
|
|
22
|
+
*/
|
|
23
|
+
function patternToRegex(pat: string): RegExp {
|
|
24
|
+
// Escape regex specials except *
|
|
25
|
+
const escaped = pat.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
26
|
+
return new RegExp(escaped, "i");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Categorize a source URI against user-supplied categories. Returns the first
|
|
31
|
+
* matching category; falls back to "기타" / "Other" with a high order if no match.
|
|
32
|
+
* If `categories` is empty/undefined the caller should treat the result as
|
|
33
|
+
* "no grouping" (caller decides — see `groupByCategory`).
|
|
34
|
+
*/
|
|
35
|
+
function categorize(
|
|
36
|
+
uri: string | undefined,
|
|
37
|
+
categories: CategorySpec[] | undefined
|
|
38
|
+
): { name: string; order: number; sortKey: string } {
|
|
39
|
+
const u = uri || "";
|
|
40
|
+
const base = u.split("/").pop()?.replace(/\.[a-z0-9]+$/i, "") || "";
|
|
41
|
+
if (categories && categories.length > 0) {
|
|
42
|
+
for (const cat of categories) {
|
|
43
|
+
for (const pat of cat.patterns) {
|
|
44
|
+
const re = patternToRegex(pat);
|
|
45
|
+
if (re.test(base) || re.test(u)) {
|
|
46
|
+
return { name: cat.name, order: cat.order, sortKey: base };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { name: "기타", order: 9999, sortKey: base };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function groupByCategory(
|
|
55
|
+
pages: PageLink[],
|
|
56
|
+
categories: CategorySpec[] | undefined
|
|
57
|
+
): { name: string; order: number; pages: PageLink[] }[] {
|
|
58
|
+
const groups = new Map<string, { name: string; order: number; pages: PageLink[] }>();
|
|
59
|
+
for (const p of pages) {
|
|
60
|
+
const c = categorize(p.sourceUri, categories);
|
|
61
|
+
if (!groups.has(c.name)) groups.set(c.name, { name: c.name, order: c.order, pages: [] });
|
|
62
|
+
groups.get(c.name)!.pages.push(p);
|
|
63
|
+
}
|
|
64
|
+
const sorted = [...groups.values()].sort((a, b) => a.order - b.order);
|
|
65
|
+
for (const g of sorted) {
|
|
66
|
+
g.pages.sort((a, b) => {
|
|
67
|
+
const ka = (a.sourceUri || "") + "|" + a.title;
|
|
68
|
+
const kb = (b.sourceUri || "") + "|" + b.title;
|
|
69
|
+
return ka.localeCompare(kb);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return sorted;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sidebarHtml(sourcePages: PageLink[], conceptPages: PageLink[], activeSlug?: string, categories?: CategorySpec[]): string {
|
|
76
|
+
// Group sources by category only when we have both sourceUri info AND user-defined categories.
|
|
77
|
+
const hasUri = sourcePages.some((p) => p.sourceUri);
|
|
78
|
+
const hasCats = !!(categories && categories.length > 0);
|
|
79
|
+
let sourceItems: string;
|
|
80
|
+
if (hasUri && hasCats) {
|
|
81
|
+
const groups = groupByCategory(sourcePages, categories);
|
|
82
|
+
sourceItems = groups
|
|
83
|
+
.map((g) => {
|
|
84
|
+
const items = g.pages
|
|
85
|
+
.map((p) => `<li><a href="/wiki/${p.slug}.html"${p.slug === activeSlug ? ' class="active"' : ""}>${escapeHtml(p.title)}</a></li>`)
|
|
86
|
+
.join("\n");
|
|
87
|
+
return `<details open class="sidebar-group"><summary>${escapeHtml(g.name)} <span class="sidebar-count">${g.pages.length}</span></summary><ul class="page-list">${items}</ul></details>`;
|
|
88
|
+
})
|
|
89
|
+
.join("\n");
|
|
90
|
+
} else {
|
|
91
|
+
sourceItems = sourcePages
|
|
92
|
+
.map(
|
|
93
|
+
(p) =>
|
|
94
|
+
`<li><a href="/wiki/${p.slug}.html"${p.slug === activeSlug ? ' class="active"' : ""}>${escapeHtml(p.title)}</a></li>`
|
|
95
|
+
)
|
|
96
|
+
.join("\n");
|
|
97
|
+
}
|
|
18
98
|
|
|
19
99
|
const conceptItems = conceptPages
|
|
20
100
|
.map(
|
|
21
|
-
(p) =>
|
|
22
|
-
|
|
101
|
+
(p) => {
|
|
102
|
+
const icon = p.origin === 'user' ? '💬' : '📝';
|
|
103
|
+
return `<li><a href="/wiki/${p.slug}.html"${p.slug === activeSlug ? ' class="active"' : ""}>${icon} ${escapeHtml(p.title)}</a></li>`;
|
|
104
|
+
}
|
|
23
105
|
)
|
|
24
106
|
.join("\n");
|
|
25
107
|
|
|
@@ -36,6 +118,15 @@ function sidebarHtml(sourcePages: PageLink[], conceptPages: PageLink[], activeSl
|
|
|
36
118
|
</div>
|
|
37
119
|
<div class="sidebar-panel${!activeIsSource && activeSlug ? " active" : ""}" id="tab-concept">
|
|
38
120
|
<ul class="page-list">${conceptItems}</ul>
|
|
121
|
+
</div>
|
|
122
|
+
<div class="sidebar-mobile-nav">
|
|
123
|
+
<a href="/catalog.html">📑 목록</a>
|
|
124
|
+
<a href="/wiki/random.html">🎲 임의 문서</a>
|
|
125
|
+
<a href="/quiz.html">📝 퀴즈</a>
|
|
126
|
+
<a href="/dashboard.html">📊 대시보드</a>
|
|
127
|
+
<a href="/graph.html">🔗 그래프</a>
|
|
128
|
+
<a href="/provenance">📚 출처</a>
|
|
129
|
+
<a href="/manage">⚙️ 관리</a>
|
|
39
130
|
</div>`;
|
|
40
131
|
}
|
|
41
132
|
|
|
@@ -45,25 +136,60 @@ function base(opts: {
|
|
|
45
136
|
sourcePages: PageLink[];
|
|
46
137
|
conceptPages: PageLink[];
|
|
47
138
|
activeSlug?: string;
|
|
139
|
+
description?: string;
|
|
48
140
|
content: string;
|
|
141
|
+
categories?: CategorySpec[];
|
|
49
142
|
}) {
|
|
143
|
+
const ogDescription = escapeHtml(opts.description || 'LLM으로 자동 생성된 학습 위키');
|
|
50
144
|
return `<!DOCTYPE html>
|
|
51
145
|
<html lang="ko">
|
|
52
146
|
<head>
|
|
53
147
|
<meta charset="UTF-8">
|
|
54
148
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
55
149
|
<title>${escapeHtml(opts.title)}</title>
|
|
150
|
+
<meta property="og:title" content="${escapeHtml(opts.title)}">
|
|
151
|
+
<meta property="og:description" content="${ogDescription}">
|
|
152
|
+
<meta property="og:type" content="article">
|
|
153
|
+
<meta name="twitter:card" content="summary">
|
|
154
|
+
<meta name="twitter:title" content="${escapeHtml(opts.title)}">
|
|
155
|
+
<meta name="description" content="${ogDescription}">
|
|
56
156
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
57
157
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
58
158
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;600;700;800&display=swap" rel="stylesheet">
|
|
59
159
|
<link rel="stylesheet" href="/static/style.css">
|
|
160
|
+
<link rel="stylesheet" href="/static/peek-panel.css">
|
|
60
161
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
|
61
|
-
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
|
62
|
-
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
|
162
|
+
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
|
163
|
+
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js" onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}]})"></script>
|
|
164
|
+
<script>
|
|
165
|
+
// Mermaid: lazy-load on first need, expose window.kiwiRenderMermaid(root)
|
|
166
|
+
// for static + dynamically injected content (peek panel, dynamic-qa).
|
|
167
|
+
(function(){
|
|
168
|
+
var loadPromise = null;
|
|
169
|
+
function load(){
|
|
170
|
+
if (!loadPromise) {
|
|
171
|
+
loadPromise = import('https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs').then(function(m){
|
|
172
|
+
m.default.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'strict' });
|
|
173
|
+
return m.default;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return loadPromise;
|
|
177
|
+
}
|
|
178
|
+
window.kiwiRenderMermaid = function(root){
|
|
179
|
+
var scope = root && root.querySelectorAll ? root : document;
|
|
180
|
+
var nodes = scope.querySelectorAll('.mermaid:not([data-processed="true"])');
|
|
181
|
+
if (!nodes.length) return Promise.resolve();
|
|
182
|
+
return load().then(function(mermaid){
|
|
183
|
+
return mermaid.run({ nodes: nodes }).catch(function(e){ console.warn('mermaid render failed', e); });
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
document.addEventListener('DOMContentLoaded', function(){ window.kiwiRenderMermaid(); });
|
|
187
|
+
})();
|
|
188
|
+
</script>
|
|
63
189
|
</head>
|
|
64
190
|
<body>
|
|
65
191
|
<nav class="topbar">
|
|
66
|
-
<button class="topbar-menu-btn" aria-label="메뉴">☰</button>
|
|
192
|
+
<button class="topbar-menu-btn" aria-label="메뉴 열기" aria-expanded="false">☰</button>
|
|
67
193
|
<a href="/index.html" class="topbar-brand">
|
|
68
194
|
<img src="/static/logo.png" alt="Kiwi Mu" class="topbar-logo">
|
|
69
195
|
${escapeHtml(opts.wikiName)}
|
|
@@ -73,22 +199,27 @@ function base(opts: {
|
|
|
73
199
|
<div id="search-results" class="search-dropdown"></div>
|
|
74
200
|
</div>
|
|
75
201
|
<div class="topbar-links">
|
|
76
|
-
<a href="/
|
|
202
|
+
<a href="/catalog.html" class="btn-graph">📑 목록</a>
|
|
203
|
+
<a href="/wiki/random.html" class="btn-graph">🎲 임의</a>
|
|
77
204
|
<a href="/quiz.html" class="btn-graph">📝 퀴즈</a>
|
|
78
|
-
<a href="/
|
|
79
|
-
<a href="/
|
|
205
|
+
<a href="/dashboard.html" class="btn-graph">📊 대시보드</a>
|
|
206
|
+
<a href="/graph.html" class="btn-graph">🔗 그래프</a>
|
|
207
|
+
<a href="/manage" class="btn-graph">⚙️ 관리</a>
|
|
80
208
|
</div>
|
|
81
209
|
</nav>
|
|
82
210
|
<div class="sidebar-overlay"></div>
|
|
83
211
|
<div class="layout">
|
|
84
212
|
<aside class="sidebar">
|
|
85
|
-
${sidebarHtml(opts.sourcePages, opts.conceptPages, opts.activeSlug)}
|
|
213
|
+
${sidebarHtml(opts.sourcePages, opts.conceptPages, opts.activeSlug, opts.categories)}
|
|
86
214
|
</aside>
|
|
87
215
|
<main class="content">
|
|
88
216
|
${opts.content}
|
|
89
217
|
</main>
|
|
90
218
|
</div>
|
|
91
219
|
<script src="/static/search.js"></script>
|
|
220
|
+
<script src="/static/dynamic-qa.js"></script>
|
|
221
|
+
<script src="/static/edit-page.js"></script>
|
|
222
|
+
<script src="/static/peek-panel.js"></script>
|
|
92
223
|
<script>
|
|
93
224
|
// Mobile hamburger menu
|
|
94
225
|
(function() {
|
|
@@ -97,8 +228,10 @@ function base(opts: {
|
|
|
97
228
|
const overlay = document.querySelector('.sidebar-overlay');
|
|
98
229
|
if (menuBtn && sidebar) {
|
|
99
230
|
menuBtn.addEventListener('click', () => {
|
|
100
|
-
sidebar.classList.toggle('open');
|
|
231
|
+
const isOpen = sidebar.classList.toggle('open');
|
|
101
232
|
overlay?.classList.toggle('active');
|
|
233
|
+
menuBtn.setAttribute('aria-expanded', isOpen);
|
|
234
|
+
menuBtn.setAttribute('aria-label', isOpen ? '메뉴 닫기' : '메뉴 열기');
|
|
102
235
|
});
|
|
103
236
|
overlay?.addEventListener('click', () => {
|
|
104
237
|
sidebar.classList.remove('open');
|
|
@@ -115,18 +248,10 @@ function base(opts: {
|
|
|
115
248
|
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
|
|
116
249
|
});
|
|
117
250
|
});
|
|
118
|
-
// KaTeX
|
|
119
|
-
document.addEventListener("DOMContentLoaded", function() {
|
|
120
|
-
renderMathInElement(document.body, {
|
|
121
|
-
delimiters: [
|
|
122
|
-
{left: "$$", right: "$$", display: true},
|
|
123
|
-
{left: "$", right: "$", display: false},
|
|
124
|
-
{left: "\\\\(", right: "\\\\)", display: false},
|
|
125
|
-
{left: "\\\\[", right: "\\\\]", display: true}
|
|
126
|
-
]
|
|
127
|
-
});
|
|
128
|
-
});
|
|
129
251
|
</script>
|
|
252
|
+
<footer class="kiwimu-badge" style="text-align:center;padding:16px;margin-top:32px;border-top:1px solid var(--border);font-size:12px;color:var(--text-muted);">
|
|
253
|
+
🥝 Built with <a href="https://github.com/Open330/kiwimu" target="_blank" rel="noopener" style="color:var(--namu-green);text-decoration:none;">Kiwi Mu</a> — 나만의 학습 위키 빌더
|
|
254
|
+
</footer>
|
|
130
255
|
</body>
|
|
131
256
|
</html>`;
|
|
132
257
|
}
|
|
@@ -136,15 +261,24 @@ export function renderPage(opts: {
|
|
|
136
261
|
pageTitle: string;
|
|
137
262
|
pageSlug: string;
|
|
138
263
|
pageType: string;
|
|
264
|
+
pageId: number;
|
|
265
|
+
origin?: string;
|
|
139
266
|
content: string;
|
|
140
267
|
externalRefs: string;
|
|
141
268
|
toc: string;
|
|
142
269
|
backlinks: PageLink[];
|
|
270
|
+
citationsHtml?: string;
|
|
143
271
|
sourcePages: PageLink[];
|
|
144
272
|
conceptPages: PageLink[];
|
|
273
|
+
categories?: CategorySpec[];
|
|
145
274
|
}): string {
|
|
146
|
-
|
|
147
|
-
|
|
275
|
+
let typeBadge: string;
|
|
276
|
+
if (opts.origin === 'user') {
|
|
277
|
+
typeBadge = `<span class="page-type-badge dynamic">💬 질문 생성</span>`;
|
|
278
|
+
} else {
|
|
279
|
+
const typeLabel = opts.pageType === "source" ? "📖 원본 문서" : "📝 개념 문서";
|
|
280
|
+
typeBadge = `<span class="page-type-badge ${opts.pageType}">${typeLabel}</span>`;
|
|
281
|
+
}
|
|
148
282
|
|
|
149
283
|
// Separate backlinks by type
|
|
150
284
|
const sourceBacklinks = opts.backlinks.filter((bl) => bl.pageType === "source");
|
|
@@ -174,25 +308,44 @@ export function renderPage(opts: {
|
|
|
174
308
|
? `<details class="toc-box" open><summary>목차</summary>${opts.toc}</details>`
|
|
175
309
|
: "";
|
|
176
310
|
|
|
311
|
+
const citationsHtml = opts.citationsHtml || "";
|
|
312
|
+
|
|
177
313
|
const content = `
|
|
178
|
-
<article class="wiki-page">
|
|
314
|
+
<article class="wiki-page" data-page-slug="${opts.pageSlug}" data-page-id="${opts.pageId}">
|
|
179
315
|
<header class="page-header">
|
|
180
316
|
${typeBadge}
|
|
181
|
-
<h1>${escapeHtml(opts.pageTitle)}
|
|
317
|
+
<h1>${escapeHtml(opts.pageTitle)} <button class="edit-btn" data-slug="${opts.pageSlug}" title="편집">✎</button></h1>
|
|
182
318
|
</header>
|
|
183
319
|
${tocHtml}
|
|
184
320
|
<div class="page-body">${opts.content}</div>
|
|
321
|
+
${citationsHtml}
|
|
185
322
|
${externalRefsHtml}
|
|
186
323
|
${backlinksHtml}
|
|
187
|
-
</article
|
|
324
|
+
</article>
|
|
325
|
+
<div class="edit-modal" id="edit-modal" style="display:none">
|
|
326
|
+
<div class="edit-modal-inner">
|
|
327
|
+
<div class="edit-modal-header">
|
|
328
|
+
<span>페이지 편집</span>
|
|
329
|
+
<button class="edit-modal-close">×</button>
|
|
330
|
+
</div>
|
|
331
|
+
<textarea class="edit-textarea" id="edit-textarea"></textarea>
|
|
332
|
+
<div class="edit-modal-footer">
|
|
333
|
+
<button class="edit-cancel">취소</button>
|
|
334
|
+
<button class="edit-save">저장</button>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
</div>`;
|
|
188
338
|
|
|
189
339
|
return base({
|
|
190
340
|
title: `${opts.pageTitle} - ${opts.wikiName}`,
|
|
191
341
|
wikiName: opts.wikiName,
|
|
192
342
|
sourcePages: opts.sourcePages,
|
|
193
343
|
conceptPages: opts.conceptPages,
|
|
344
|
+
categories: opts.categories,
|
|
194
345
|
activeSlug: opts.pageSlug,
|
|
346
|
+
description: `${opts.pageTitle} - ${opts.wikiName} 학습 위키`,
|
|
195
347
|
content,
|
|
348
|
+
categories: opts.categories,
|
|
196
349
|
});
|
|
197
350
|
}
|
|
198
351
|
|
|
@@ -201,13 +354,30 @@ export function renderIndex(opts: {
|
|
|
201
354
|
sourcePages: PageLink[];
|
|
202
355
|
conceptPages: PageLink[];
|
|
203
356
|
sourceCount: number;
|
|
357
|
+
categories?: CategorySpec[];
|
|
204
358
|
}): string {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
.
|
|
359
|
+
// Group source cards only when both sourceUri AND user categories are present.
|
|
360
|
+
const sourceHasUri = opts.sourcePages.some((p) => p.sourceUri);
|
|
361
|
+
const hasCats = !!(opts.categories && opts.categories.length > 0);
|
|
362
|
+
const shouldGroup = sourceHasUri && hasCats;
|
|
363
|
+
const sourceCards = shouldGroup
|
|
364
|
+
? groupByCategory(opts.sourcePages, opts.categories)
|
|
365
|
+
.map((g) => {
|
|
366
|
+
const cards = g.pages
|
|
367
|
+
.map(
|
|
368
|
+
(p) =>
|
|
369
|
+
`<a href="/wiki/${p.slug}.html" class="page-card source"><span class="card-title">${escapeHtml(p.title)}</span></a>`
|
|
370
|
+
)
|
|
371
|
+
.join("\n");
|
|
372
|
+
return `<div class="page-group"><h3 class="page-group-title">${escapeHtml(g.name)} <span class="page-group-count">${g.pages.length}</span></h3><div class="page-cards">${cards}</div></div>`;
|
|
373
|
+
})
|
|
374
|
+
.join("\n")
|
|
375
|
+
: opts.sourcePages
|
|
376
|
+
.map(
|
|
377
|
+
(p) =>
|
|
378
|
+
`<a href="/wiki/${p.slug}.html" class="page-card source"><span class="card-title">${escapeHtml(p.title)}</span></a>`
|
|
379
|
+
)
|
|
380
|
+
.join("\n");
|
|
211
381
|
|
|
212
382
|
const conceptCards = opts.conceptPages
|
|
213
383
|
.map(
|
|
@@ -230,12 +400,12 @@ export function renderIndex(opts: {
|
|
|
230
400
|
<!-- Add document link -->
|
|
231
401
|
<section class="index-section add-section">
|
|
232
402
|
<h2>➕ 문서 추가</h2>
|
|
233
|
-
<p>문서를 추가하려면 <a href="/
|
|
403
|
+
<p>문서를 추가하려면 <a href="/manage">관리 페이지</a>에서 문서를 추가하세요.</p>
|
|
234
404
|
</section>
|
|
235
405
|
|
|
236
406
|
<section class="index-section">
|
|
237
407
|
<h2>📖 원본 문서</h2>
|
|
238
|
-
|
|
408
|
+
${sourceCards.length > 0 ? (shouldGroup ? sourceCards : `<div class="page-cards">${sourceCards}</div>`) : '<div class="page-cards"><div class="empty-state">아직 원본 문서가 없습니다. URL이나 파일을 추가해보세요!</div></div>'}
|
|
239
409
|
</section>
|
|
240
410
|
<section class="index-section">
|
|
241
411
|
<h2>📝 개념 문서</h2>
|
|
@@ -243,6 +413,7 @@ export function renderIndex(opts: {
|
|
|
243
413
|
</section>
|
|
244
414
|
<section class="index-section">
|
|
245
415
|
<div class="quick-links">
|
|
416
|
+
<a href="/catalog.html" class="quick-link">📑 문서 목록</a>
|
|
246
417
|
<a href="/quiz.html" class="quick-link">📝 학습 퀴즈</a>
|
|
247
418
|
<a href="/graph.html" class="quick-link">📊 지식 그래프 보기</a>
|
|
248
419
|
</div>
|
|
@@ -256,7 +427,10 @@ export function renderIndex(opts: {
|
|
|
256
427
|
wikiName: opts.wikiName,
|
|
257
428
|
sourcePages: opts.sourcePages,
|
|
258
429
|
conceptPages: opts.conceptPages,
|
|
430
|
+
categories: opts.categories,
|
|
431
|
+
description: `${opts.wikiName} — LLM으로 자동 생성된 학습 위키`,
|
|
259
432
|
content,
|
|
433
|
+
categories: opts.categories,
|
|
260
434
|
});
|
|
261
435
|
}
|
|
262
436
|
|
|
@@ -264,6 +438,7 @@ export function renderGraph(opts: {
|
|
|
264
438
|
wikiName: string;
|
|
265
439
|
sourcePages: PageLink[];
|
|
266
440
|
conceptPages: PageLink[];
|
|
441
|
+
categories?: CategorySpec[];
|
|
267
442
|
}): string {
|
|
268
443
|
const content = `
|
|
269
444
|
<div class="graph-page">
|
|
@@ -284,6 +459,7 @@ export function renderGraph(opts: {
|
|
|
284
459
|
sourcePages: opts.sourcePages,
|
|
285
460
|
conceptPages: opts.conceptPages,
|
|
286
461
|
content,
|
|
462
|
+
categories: opts.categories,
|
|
287
463
|
});
|
|
288
464
|
}
|
|
289
465
|
|
|
@@ -292,6 +468,7 @@ export function renderQuizPage(opts: {
|
|
|
292
468
|
quizzes: Array<{ id: number; question: string; answer: string; explanation?: string; quiz_type: string; page_title?: string; page_slug?: string }>;
|
|
293
469
|
sourcePages: PageLink[];
|
|
294
470
|
conceptPages: PageLink[];
|
|
471
|
+
categories?: CategorySpec[];
|
|
295
472
|
}): string {
|
|
296
473
|
const quizzesJson = JSON.stringify(opts.quizzes).replace(/</g, "\\u003c");
|
|
297
474
|
|
|
@@ -331,6 +508,7 @@ export function renderQuizPage(opts: {
|
|
|
331
508
|
<p id="quiz-explanation-text" class="explanation-text"></p>
|
|
332
509
|
</div>
|
|
333
510
|
<p class="quiz-source" id="quiz-source"></p>
|
|
511
|
+
<p class="quiz-review-info" id="quiz-review-info" style="display:none;"></p>
|
|
334
512
|
<button id="quiz-next-btn" class="quiz-btn primary">다음 문제 →</button>
|
|
335
513
|
</div>
|
|
336
514
|
</div>
|
|
@@ -408,6 +586,7 @@ export function renderQuizPage(opts: {
|
|
|
408
586
|
.quiz-stats { background: var(--bg-alt, #f5f5f5); border: 1px solid var(--border); border-radius: 8px; padding: 16px; margin-bottom: 20px; text-align: left; }
|
|
409
587
|
.quiz-stats h3 { font-size: 15px; margin: 0 0 8px; }
|
|
410
588
|
.quiz-stats p { font-size: 14px; color: var(--text-muted); margin: 4px 0; }
|
|
589
|
+
.quiz-review-info { font-size: 13px; color: var(--text-muted); margin-bottom: 12px; padding: 6px 12px; background: var(--accent-light, #e8f5e9); border-radius: 6px; display: inline-block; }
|
|
411
590
|
</style>
|
|
412
591
|
<script>
|
|
413
592
|
(function() {
|
|
@@ -478,19 +657,47 @@ export function renderQuizPage(opts: {
|
|
|
478
657
|
|
|
479
658
|
function checkAnswer(userAnswer) {
|
|
480
659
|
const q = quizzes[current];
|
|
481
|
-
const isCorrect = normalize(userAnswer) === normalize(q.answer)
|
|
660
|
+
const isCorrect = normalize(userAnswer) === normalize(q.answer);
|
|
482
661
|
|
|
483
662
|
if (isCorrect) score++;
|
|
484
663
|
|
|
664
|
+
// SM-2 spaced repetition in localStorage
|
|
665
|
+
var quality = isCorrect ? 4 : 1;
|
|
666
|
+
var srsData = JSON.parse(localStorage.getItem('kiwimu-srs') || '{}');
|
|
667
|
+
var srs = srsData[q.id] || { ef: 2.5, interval: 0 };
|
|
668
|
+
if (quality >= 3) {
|
|
669
|
+
if (srs.interval === 0) srs.interval = 1;
|
|
670
|
+
else if (srs.interval === 1) srs.interval = 6;
|
|
671
|
+
else srs.interval = Math.round(srs.interval * srs.ef);
|
|
672
|
+
srs.ef = srs.ef + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
|
|
673
|
+
} else {
|
|
674
|
+
srs.interval = 0;
|
|
675
|
+
}
|
|
676
|
+
if (srs.ef < 1.3) srs.ef = 1.3;
|
|
677
|
+
var nextDate = new Date();
|
|
678
|
+
nextDate.setDate(nextDate.getDate() + srs.interval);
|
|
679
|
+
srs.nextReview = nextDate.toISOString();
|
|
680
|
+
srsData[q.id] = srs;
|
|
681
|
+
localStorage.setItem('kiwimu-srs', JSON.stringify(srsData));
|
|
682
|
+
|
|
485
683
|
// Record attempt in localStorage
|
|
486
684
|
var attempts = JSON.parse(localStorage.getItem('kiwimu-quiz-attempts') || '[]');
|
|
487
|
-
attempts.push({ quizId: q.id, isCorrect: isCorrect, timestamp: new Date().toISOString() });
|
|
685
|
+
attempts.push({ quizId: q.id, isCorrect: isCorrect, quality: quality, timestamp: new Date().toISOString() });
|
|
488
686
|
localStorage.setItem('kiwimu-quiz-attempts', JSON.stringify(attempts));
|
|
489
687
|
|
|
490
688
|
document.getElementById('quiz-result-icon').textContent = isCorrect ? '🎉' : '😅';
|
|
491
689
|
document.getElementById('quiz-answer-text').innerHTML = esc(q.answer);
|
|
492
690
|
document.getElementById('quiz-answer-text').style.color = isCorrect ? 'var(--accent, #4caf50)' : '#e53935';
|
|
493
691
|
|
|
692
|
+
// Show next review info
|
|
693
|
+
var reviewInfoEl = document.getElementById('quiz-review-info');
|
|
694
|
+
if (srs.interval === 0) {
|
|
695
|
+
reviewInfoEl.textContent = '🔄 다음 복습: 오늘';
|
|
696
|
+
} else {
|
|
697
|
+
reviewInfoEl.textContent = '📅 다음 복습: ' + srs.interval + '일 후';
|
|
698
|
+
}
|
|
699
|
+
reviewInfoEl.style.display = 'block';
|
|
700
|
+
|
|
494
701
|
// Show explanation if available
|
|
495
702
|
var explanationEl = document.getElementById('quiz-explanation');
|
|
496
703
|
if (q.explanation) {
|
|
@@ -597,6 +804,132 @@ export function renderQuizPage(opts: {
|
|
|
597
804
|
});
|
|
598
805
|
}
|
|
599
806
|
|
|
807
|
+
export function renderDashboardPage(opts: {
|
|
808
|
+
wikiName: string;
|
|
809
|
+
stats: { total: number; mastered: number; learning: number; new: number; dueToday: number };
|
|
810
|
+
weakConcepts: Array<{ title: string; slug: string; wrongCount: number }>;
|
|
811
|
+
recentAttempts: Array<{ quiz_id: number; question: string; is_correct: boolean; attempted_at: string }>;
|
|
812
|
+
sourcePages: PageLink[];
|
|
813
|
+
conceptPages: PageLink[];
|
|
814
|
+
categories?: CategorySpec[];
|
|
815
|
+
}): string {
|
|
816
|
+
const { stats } = opts;
|
|
817
|
+
const progressPct = stats.total > 0 ? Math.round((stats.mastered / stats.total) * 100) : 0;
|
|
818
|
+
|
|
819
|
+
const weakConceptsHtml = opts.weakConcepts.length > 0
|
|
820
|
+
? opts.weakConcepts.map(c =>
|
|
821
|
+
`<li><a href="/wiki/${c.slug}.html">${escapeHtml(c.title)}</a> <span class="dash-weak-count">오답 ${c.wrongCount}회</span></li>`
|
|
822
|
+
).join("")
|
|
823
|
+
: `<li class="dash-empty">아직 데이터가 없습니다.</li>`;
|
|
824
|
+
|
|
825
|
+
const recentHtml = opts.recentAttempts.length > 0
|
|
826
|
+
? opts.recentAttempts.map(a => {
|
|
827
|
+
const icon = a.is_correct ? '✅' : '❌';
|
|
828
|
+
const date = a.attempted_at ? a.attempted_at.slice(0, 10) : '';
|
|
829
|
+
return `<li>${icon} <span class="dash-q">${escapeHtml(a.question.length > 60 ? a.question.slice(0, 57) + '...' : a.question)}</span> <span class="dash-date">${date}</span></li>`;
|
|
830
|
+
}).join("")
|
|
831
|
+
: `<li class="dash-empty">아직 시도한 퀴즈가 없습니다.</li>`;
|
|
832
|
+
|
|
833
|
+
const content = `
|
|
834
|
+
<div class="dash-page">
|
|
835
|
+
<h1>📊 학습 대시보드</h1>
|
|
836
|
+
<p class="dash-desc">스페이스드 리피티션(SM-2) 기반 학습 현황을 확인하세요.</p>
|
|
837
|
+
|
|
838
|
+
<div class="dash-cards">
|
|
839
|
+
<div class="dash-card">
|
|
840
|
+
<div class="dash-card-value">${stats.total}</div>
|
|
841
|
+
<div class="dash-card-label">전체 문제</div>
|
|
842
|
+
</div>
|
|
843
|
+
<div class="dash-card dash-card-mastered">
|
|
844
|
+
<div class="dash-card-value">${stats.mastered}</div>
|
|
845
|
+
<div class="dash-card-label">숙달</div>
|
|
846
|
+
</div>
|
|
847
|
+
<div class="dash-card dash-card-learning">
|
|
848
|
+
<div class="dash-card-value">${stats.learning}</div>
|
|
849
|
+
<div class="dash-card-label">학습중</div>
|
|
850
|
+
</div>
|
|
851
|
+
<div class="dash-card dash-card-new">
|
|
852
|
+
<div class="dash-card-value">${stats.new}</div>
|
|
853
|
+
<div class="dash-card-label">새 문제</div>
|
|
854
|
+
</div>
|
|
855
|
+
<div class="dash-card dash-card-due">
|
|
856
|
+
<div class="dash-card-value">${stats.dueToday}</div>
|
|
857
|
+
<div class="dash-card-label">오늘 복습</div>
|
|
858
|
+
</div>
|
|
859
|
+
</div>
|
|
860
|
+
|
|
861
|
+
<div class="dash-progress-section">
|
|
862
|
+
<h2>📈 숙달 진행률</h2>
|
|
863
|
+
<div class="dash-progress-bar-container">
|
|
864
|
+
<div class="dash-progress-bar" style="width:${progressPct}%"></div>
|
|
865
|
+
</div>
|
|
866
|
+
<p class="dash-progress-text">${stats.mastered} / ${stats.total} 문제 숙달 (${progressPct}%)</p>
|
|
867
|
+
</div>
|
|
868
|
+
|
|
869
|
+
<div class="dash-columns">
|
|
870
|
+
<div class="dash-section">
|
|
871
|
+
<h2>💪 약한 개념</h2>
|
|
872
|
+
<ul class="dash-list">${weakConceptsHtml}</ul>
|
|
873
|
+
</div>
|
|
874
|
+
<div class="dash-section">
|
|
875
|
+
<h2>🕐 최근 시도</h2>
|
|
876
|
+
<ul class="dash-list">${recentHtml}</ul>
|
|
877
|
+
</div>
|
|
878
|
+
</div>
|
|
879
|
+
|
|
880
|
+
<div class="dash-action">
|
|
881
|
+
<a href="/quiz.html" class="dash-review-btn">📝 복습 시작</a>
|
|
882
|
+
</div>
|
|
883
|
+
</div>
|
|
884
|
+
<style>
|
|
885
|
+
.dash-page { max-width: 800px; margin: 0 auto; padding: 24px 16px; }
|
|
886
|
+
.dash-page h1 { font-size: 24px; margin-bottom: 8px; }
|
|
887
|
+
.dash-page h2 { font-size: 18px; margin-bottom: 12px; }
|
|
888
|
+
.dash-desc { color: var(--text-muted); font-size: 14px; margin-bottom: 24px; }
|
|
889
|
+
.dash-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 12px; margin-bottom: 28px; }
|
|
890
|
+
.dash-card {
|
|
891
|
+
background: var(--bg-alt, #fff); border: 1px solid var(--border); border-radius: 10px;
|
|
892
|
+
padding: 16px; text-align: center;
|
|
893
|
+
}
|
|
894
|
+
.dash-card-value { font-size: 28px; font-weight: 800; color: var(--text); }
|
|
895
|
+
.dash-card-label { font-size: 13px; color: var(--text-muted); margin-top: 4px; }
|
|
896
|
+
.dash-card-mastered .dash-card-value { color: #2e7d32; }
|
|
897
|
+
.dash-card-learning .dash-card-value { color: #f9a825; }
|
|
898
|
+
.dash-card-new .dash-card-value { color: #1565c0; }
|
|
899
|
+
.dash-card-due .dash-card-value { color: #e53935; }
|
|
900
|
+
.dash-progress-section { margin-bottom: 28px; }
|
|
901
|
+
.dash-progress-bar-container { height: 10px; background: var(--border); border-radius: 5px; overflow: hidden; margin: 8px 0; }
|
|
902
|
+
.dash-progress-bar { height: 100%; background: #2e7d32; border-radius: 5px; transition: width 0.5s ease; }
|
|
903
|
+
.dash-progress-text { font-size: 14px; color: var(--text-muted); }
|
|
904
|
+
.dash-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 28px; }
|
|
905
|
+
@media (max-width: 600px) { .dash-columns { grid-template-columns: 1fr; } }
|
|
906
|
+
.dash-section { background: var(--bg-alt, #fff); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
|
|
907
|
+
.dash-list { list-style: none; padding: 0; margin: 0; }
|
|
908
|
+
.dash-list li { padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 14px; display: flex; align-items: center; gap: 8px; }
|
|
909
|
+
.dash-list li:last-child { border-bottom: none; }
|
|
910
|
+
.dash-list a { color: var(--accent, #4caf50); text-decoration: none; }
|
|
911
|
+
.dash-list a:hover { text-decoration: underline; }
|
|
912
|
+
.dash-weak-count { font-size: 12px; color: #e53935; margin-left: auto; white-space: nowrap; }
|
|
913
|
+
.dash-date { font-size: 12px; color: var(--text-muted); margin-left: auto; white-space: nowrap; }
|
|
914
|
+
.dash-q { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
915
|
+
.dash-empty { color: var(--text-muted); font-style: italic; }
|
|
916
|
+
.dash-action { text-align: center; margin-top: 8px; }
|
|
917
|
+
.dash-review-btn {
|
|
918
|
+
display: inline-block; padding: 12px 32px; background: var(--accent, #4caf50); color: white;
|
|
919
|
+
border-radius: 8px; font-size: 16px; font-weight: 600; text-decoration: none; transition: opacity 0.2s;
|
|
920
|
+
}
|
|
921
|
+
.dash-review-btn:hover { opacity: 0.9; }
|
|
922
|
+
</style>`;
|
|
923
|
+
|
|
924
|
+
return base({
|
|
925
|
+
title: `📊 학습 대시보드 — ${opts.wikiName}`,
|
|
926
|
+
wikiName: opts.wikiName,
|
|
927
|
+
sourcePages: opts.sourcePages,
|
|
928
|
+
conceptPages: opts.conceptPages,
|
|
929
|
+
content,
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
600
933
|
export function renderAdmin(opts: {
|
|
601
934
|
wikiName: string;
|
|
602
935
|
sources: Array<{ id: number; uri: string; type: string; title: string; fetched_at: string }>;
|
|
@@ -658,7 +991,7 @@ export function renderAdmin(opts: {
|
|
|
658
991
|
<div class="topbar-links">
|
|
659
992
|
<a href="/index.html" class="btn-graph">🏠 홈</a>
|
|
660
993
|
<a href="/graph.html" class="btn-graph">📊 그래프</a>
|
|
661
|
-
<a href="/
|
|
994
|
+
<a href="/manage" class="btn-graph" style="border-color: var(--accent);">⚙️ 관리</a>
|
|
662
995
|
</div>
|
|
663
996
|
</nav>
|
|
664
997
|
<div class="admin-page">
|
|
@@ -690,7 +1023,7 @@ export function renderAdmin(opts: {
|
|
|
690
1023
|
</div>
|
|
691
1024
|
<div class="config-row">
|
|
692
1025
|
<span class="config-key">모델</span>
|
|
693
|
-
<input id="llm-model" class="config-input" value="${escapeHtml(opts.llmConfig.model)}" placeholder="gemini-
|
|
1026
|
+
<input id="llm-model" class="config-input" value="${escapeHtml(opts.llmConfig.model)}" placeholder="gemini-3.1-flash-lite-preview">
|
|
694
1027
|
</div>
|
|
695
1028
|
<div class="config-row">
|
|
696
1029
|
<span class="config-key">API Key</span>
|
|
@@ -837,7 +1170,7 @@ export function renderAdmin(opts: {
|
|
|
837
1170
|
|
|
838
1171
|
document.getElementById('llm-provider').addEventListener('change', (e) => {
|
|
839
1172
|
document.getElementById('endpoint-row').style.display = e.target.value === 'azure-openai' ? '' : 'none';
|
|
840
|
-
const models = { gemini: 'gemini-
|
|
1173
|
+
const models = { gemini: 'gemini-3.1-flash-lite-preview', 'azure-openai': 'gpt-5.4-nano', openai: 'gpt-5.4-nano', anthropic: 'claude-sonnet-4-6' };
|
|
841
1174
|
document.getElementById('llm-model').placeholder = models[e.target.value] || '';
|
|
842
1175
|
});
|
|
843
1176
|
document.getElementById('llm-form').addEventListener('submit', async (e) => {
|
|
@@ -923,3 +1256,322 @@ export function renderAdmin(opts: {
|
|
|
923
1256
|
</body>
|
|
924
1257
|
</html>`;
|
|
925
1258
|
}
|
|
1259
|
+
|
|
1260
|
+
export function renderCatalogPage(opts: {
|
|
1261
|
+
wikiName: string;
|
|
1262
|
+
categories: Array<{
|
|
1263
|
+
name: string;
|
|
1264
|
+
slug: string;
|
|
1265
|
+
description?: string;
|
|
1266
|
+
pages: Array<{ id: number; title: string; slug: string; type: string; linkCount: number }>;
|
|
1267
|
+
}>;
|
|
1268
|
+
totalPages: number;
|
|
1269
|
+
totalLinks: number;
|
|
1270
|
+
generatedAt: string;
|
|
1271
|
+
sourcePages: PageLink[];
|
|
1272
|
+
conceptPages: PageLink[];
|
|
1273
|
+
}): string {
|
|
1274
|
+
const categoriesHtml = opts.categories.map((cat) => {
|
|
1275
|
+
const pagesHtml = cat.pages.map((p) => {
|
|
1276
|
+
const typeBadge = p.type === 'source'
|
|
1277
|
+
? '<span class="catalog-badge source">📖 원본</span>'
|
|
1278
|
+
: '<span class="catalog-badge concept">📝 개념</span>';
|
|
1279
|
+
const linkBadge = p.linkCount > 0
|
|
1280
|
+
? `<span class="catalog-link-count" title="연결된 문서 수">🔗 ${p.linkCount}</span>`
|
|
1281
|
+
: '';
|
|
1282
|
+
return `<li class="catalog-item" data-title="${escapeHtml(p.title.toLowerCase())}">
|
|
1283
|
+
<a href="/wiki/${p.slug}.html">${escapeHtml(p.title)}</a>
|
|
1284
|
+
${typeBadge}
|
|
1285
|
+
${linkBadge}
|
|
1286
|
+
</li>`;
|
|
1287
|
+
}).join("\n");
|
|
1288
|
+
|
|
1289
|
+
return `
|
|
1290
|
+
<details class="catalog-category" open>
|
|
1291
|
+
<summary class="catalog-category-header">
|
|
1292
|
+
<span class="catalog-category-name">${escapeHtml(cat.name)}</span>
|
|
1293
|
+
<span class="catalog-category-count">${cat.pages.length}개 문서</span>
|
|
1294
|
+
</summary>
|
|
1295
|
+
${cat.description ? `<p class="catalog-category-desc">${escapeHtml(cat.description)}</p>` : ''}
|
|
1296
|
+
<ul class="catalog-list">${pagesHtml}</ul>
|
|
1297
|
+
</details>`;
|
|
1298
|
+
}).join("\n");
|
|
1299
|
+
|
|
1300
|
+
const content = `
|
|
1301
|
+
<div class="catalog-page">
|
|
1302
|
+
<h1>📑 문서 목록</h1>
|
|
1303
|
+
<p class="catalog-desc">전체 ${opts.totalPages}개 문서 · ${opts.totalLinks}개 링크 · ${opts.categories.length}개 카테고리</p>
|
|
1304
|
+
|
|
1305
|
+
<div class="catalog-filter">
|
|
1306
|
+
<input type="text" id="catalog-search" placeholder="문서 이름으로 검색..." autocomplete="off">
|
|
1307
|
+
</div>
|
|
1308
|
+
|
|
1309
|
+
<div id="catalog-categories">
|
|
1310
|
+
${categoriesHtml || '<p class="catalog-empty">아직 문서가 없습니다. 소스를 추가하면 자동으로 목록이 생성됩니다.</p>'}
|
|
1311
|
+
</div>
|
|
1312
|
+
</div>
|
|
1313
|
+
<script>
|
|
1314
|
+
(function() {
|
|
1315
|
+
const input = document.getElementById('catalog-search');
|
|
1316
|
+
if (!input) return;
|
|
1317
|
+
input.addEventListener('input', function() {
|
|
1318
|
+
const q = this.value.toLowerCase().trim();
|
|
1319
|
+
document.querySelectorAll('.catalog-item').forEach(function(item) {
|
|
1320
|
+
const title = item.getAttribute('data-title') || '';
|
|
1321
|
+
item.style.display = (!q || title.includes(q)) ? '' : 'none';
|
|
1322
|
+
});
|
|
1323
|
+
// Hide empty categories
|
|
1324
|
+
document.querySelectorAll('.catalog-category').forEach(function(cat) {
|
|
1325
|
+
const visible = cat.querySelectorAll('.catalog-item[style=""], .catalog-item:not([style])');
|
|
1326
|
+
const allItems = cat.querySelectorAll('.catalog-item');
|
|
1327
|
+
let visibleCount = 0;
|
|
1328
|
+
allItems.forEach(function(item) { if (item.style.display !== 'none') visibleCount++; });
|
|
1329
|
+
cat.style.display = (q && visibleCount === 0) ? 'none' : '';
|
|
1330
|
+
});
|
|
1331
|
+
});
|
|
1332
|
+
})();
|
|
1333
|
+
</script>`;
|
|
1334
|
+
|
|
1335
|
+
return base({
|
|
1336
|
+
title: `문서 목록 - ${opts.wikiName}`,
|
|
1337
|
+
wikiName: opts.wikiName,
|
|
1338
|
+
sourcePages: opts.sourcePages,
|
|
1339
|
+
conceptPages: opts.conceptPages,
|
|
1340
|
+
// NOTE: renderCatalogPage's `opts.categories` has its own shape
|
|
1341
|
+
// (catalog UI categories with `.pages`), distinct from SourceCategory.
|
|
1342
|
+
// We deliberately don't forward it to `base()` to avoid the name clash;
|
|
1343
|
+
// catalog page's sidebar therefore uses the flat fallback rendering.
|
|
1344
|
+
description: `${opts.wikiName} 전체 문서 목록 — ${opts.totalPages}개 문서`,
|
|
1345
|
+
content,
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
export function renderProvenancePage(opts: {
|
|
1350
|
+
wikiName: string;
|
|
1351
|
+
coverage: Array<{
|
|
1352
|
+
sourceId: number;
|
|
1353
|
+
sourceTitle: string;
|
|
1354
|
+
citationCount: number;
|
|
1355
|
+
pageCount: number;
|
|
1356
|
+
pages: Array<{ title: string; slug: string }>;
|
|
1357
|
+
}>;
|
|
1358
|
+
sourcePages: PageLink[];
|
|
1359
|
+
conceptPages: PageLink[];
|
|
1360
|
+
categories?: CategorySpec[];
|
|
1361
|
+
}): string {
|
|
1362
|
+
const totalCitations = opts.coverage.reduce((s, c) => s + c.citationCount, 0);
|
|
1363
|
+
const totalSourcesCited = opts.coverage.filter(c => c.citationCount > 0).length;
|
|
1364
|
+
|
|
1365
|
+
const rows = opts.coverage.map(c => {
|
|
1366
|
+
const pageLinks = c.pages.map(p =>
|
|
1367
|
+
`<a href="/wiki/${p.slug}.html" class="provenance-page-link">${escapeHtml(p.title)}</a>`
|
|
1368
|
+
).join(", ") || '<span class="text-muted">-</span>';
|
|
1369
|
+
|
|
1370
|
+
const barWidth = totalCitations > 0 ? Math.max(2, Math.round((c.citationCount / totalCitations) * 100)) : 0;
|
|
1371
|
+
const barColor = c.citationCount === 0 ? '#e0e0e0' : c.citationCount < 3 ? '#ffc107' : '#28a745';
|
|
1372
|
+
|
|
1373
|
+
return `<tr>
|
|
1374
|
+
<td>${escapeHtml(c.sourceTitle || 'Untitled')}</td>
|
|
1375
|
+
<td class="text-center">${c.citationCount}</td>
|
|
1376
|
+
<td class="text-center">${c.pageCount}</td>
|
|
1377
|
+
<td><div class="provenance-bar" style="width:${barWidth}%;background:${barColor}"></div></td>
|
|
1378
|
+
<td class="provenance-pages">${pageLinks}</td>
|
|
1379
|
+
</tr>`;
|
|
1380
|
+
}).join("\n");
|
|
1381
|
+
|
|
1382
|
+
const content = `
|
|
1383
|
+
<div class="provenance-page">
|
|
1384
|
+
<h1>Source Provenance</h1>
|
|
1385
|
+
<p class="provenance-summary">
|
|
1386
|
+
${totalCitations} citations across ${totalSourcesCited}/${opts.coverage.length} sources
|
|
1387
|
+
</p>
|
|
1388
|
+
|
|
1389
|
+
<table class="provenance-table">
|
|
1390
|
+
<thead>
|
|
1391
|
+
<tr>
|
|
1392
|
+
<th>Source</th>
|
|
1393
|
+
<th class="text-center">Citations</th>
|
|
1394
|
+
<th class="text-center">Pages</th>
|
|
1395
|
+
<th>Coverage</th>
|
|
1396
|
+
<th>Citing Pages</th>
|
|
1397
|
+
</tr>
|
|
1398
|
+
</thead>
|
|
1399
|
+
<tbody>
|
|
1400
|
+
${rows}
|
|
1401
|
+
</tbody>
|
|
1402
|
+
</table>
|
|
1403
|
+
|
|
1404
|
+
${opts.coverage.some(c => c.citationCount === 0) ? `
|
|
1405
|
+
<div class="provenance-warning">
|
|
1406
|
+
<strong>Uncited sources:</strong>
|
|
1407
|
+
${opts.coverage.filter(c => c.citationCount === 0).map(c => escapeHtml(c.sourceTitle || 'Untitled')).join(", ")}
|
|
1408
|
+
<br><small>Run <code>kiwimu cite</code> to retroactively generate citations for existing content.</small>
|
|
1409
|
+
</div>` : ''}
|
|
1410
|
+
</div>
|
|
1411
|
+
|
|
1412
|
+
<style>
|
|
1413
|
+
.provenance-page { max-width: 960px; margin: 0 auto; }
|
|
1414
|
+
.provenance-page h1 { margin-bottom: 8px; }
|
|
1415
|
+
.provenance-summary { color: var(--text-muted); margin-bottom: 24px; }
|
|
1416
|
+
.provenance-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
|
1417
|
+
.provenance-table th, .provenance-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); text-align: left; }
|
|
1418
|
+
.provenance-table th { font-weight: 600; background: var(--bg-secondary, #f8f9fa); }
|
|
1419
|
+
.text-center { text-align: center !important; }
|
|
1420
|
+
.text-muted { color: var(--text-muted, #999); }
|
|
1421
|
+
.provenance-bar { height: 8px; border-radius: 4px; min-width: 2px; }
|
|
1422
|
+
.provenance-pages { font-size: 12px; }
|
|
1423
|
+
.provenance-page-link { display: inline-block; margin: 2px 4px 2px 0; padding: 1px 6px; background: var(--bg-secondary, #f0f0f0); border-radius: 3px; text-decoration: none; color: var(--namu-green, #2e7d32); }
|
|
1424
|
+
.provenance-page-link:hover { background: var(--namu-green, #2e7d32); color: white; }
|
|
1425
|
+
.provenance-warning { margin-top: 24px; padding: 12px 16px; background: #fff3cd; border: 1px solid #ffc107; border-radius: 6px; font-size: 13px; }
|
|
1426
|
+
</style>`;
|
|
1427
|
+
|
|
1428
|
+
return base({
|
|
1429
|
+
title: `Source Provenance - ${opts.wikiName}`,
|
|
1430
|
+
wikiName: opts.wikiName,
|
|
1431
|
+
sourcePages: opts.sourcePages,
|
|
1432
|
+
conceptPages: opts.conceptPages,
|
|
1433
|
+
content,
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
export function renderActivityPage(
|
|
1438
|
+
authToken: string,
|
|
1439
|
+
wikiName: string,
|
|
1440
|
+
stats: { total: number; byAction: Record<string, number>; recentDays: { date: string; count: number }[] }
|
|
1441
|
+
): string {
|
|
1442
|
+
const actionIcons: Record<string, string> = {
|
|
1443
|
+
ingest: "\u{1F4E5}", page_created: "\u{1F4C4}", page_updated: "\u270F\uFE0F", quiz_generated: "\u{1F9E9}",
|
|
1444
|
+
quiz_attempted: "\u{1F4DD}", query: "\u2753", build: "\u{1F528}", deploy: "\u{1F680}", expand: "\u{1F9E0}",
|
|
1445
|
+
};
|
|
1446
|
+
const actionLabels: Record<string, string> = {
|
|
1447
|
+
ingest: "Ingest", page_created: "Page Created", page_updated: "Page Updated",
|
|
1448
|
+
quiz_generated: "Quiz Generated", quiz_attempted: "Quiz Attempted", query: "Q&A",
|
|
1449
|
+
build: "Build", deploy: "Deploy", expand: "Expand",
|
|
1450
|
+
};
|
|
1451
|
+
const filterButtons = Object.entries(stats.byAction)
|
|
1452
|
+
.map(([action, count]) => `<button class="filter-btn" data-action="${action}">${actionIcons[action] || "\u{1F4CC}"} ${actionLabels[action] || action} <span class="count">(${count})</span></button>`)
|
|
1453
|
+
.join("\n ");
|
|
1454
|
+
|
|
1455
|
+
return `<!DOCTYPE html>
|
|
1456
|
+
<html lang="ko">
|
|
1457
|
+
<head>
|
|
1458
|
+
<meta charset="UTF-8">
|
|
1459
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1460
|
+
<meta name="kiwi-auth" content="${authToken}">
|
|
1461
|
+
<title>Activity Log - ${wikiName}</title>
|
|
1462
|
+
<style>
|
|
1463
|
+
:root { --bg: #fff; --fg: #1a1a2e; --card-bg: #f8f9fa; --border: #e0e0e0; --accent: #4a90d9; --muted: #6c757d; --badge-bg: #e8f0fe; --badge-fg: #1a73e8; }
|
|
1464
|
+
@media (prefers-color-scheme: dark) {
|
|
1465
|
+
:root { --bg: #1a1a2e; --fg: #e0e0e0; --card-bg: #16213e; --border: #2a2a4a; --accent: #64b5f6; --muted: #9e9e9e; --badge-bg: #1e3a5f; --badge-fg: #90caf9; }
|
|
1466
|
+
}
|
|
1467
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1468
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--fg); line-height: 1.6; }
|
|
1469
|
+
.container { max-width: 860px; margin: 0 auto; padding: 2rem 1rem; }
|
|
1470
|
+
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
|
1471
|
+
.subtitle { color: var(--muted); margin-bottom: 1.5rem; }
|
|
1472
|
+
.filters { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.5rem; }
|
|
1473
|
+
.filter-btn { background: var(--card-bg); border: 1px solid var(--border); border-radius: 1rem; padding: 0.3rem 0.8rem; cursor: pointer; font-size: 0.85rem; color: var(--fg); transition: all 0.15s; }
|
|
1474
|
+
.filter-btn:hover, .filter-btn.active { background: var(--badge-bg); color: var(--badge-fg); border-color: var(--accent); }
|
|
1475
|
+
.filter-btn .count { color: var(--muted); font-size: 0.75rem; }
|
|
1476
|
+
.timeline { list-style: none; border-left: 2px solid var(--border); padding-left: 1.5rem; }
|
|
1477
|
+
.timeline-item { position: relative; padding: 0.75rem 0; }
|
|
1478
|
+
.timeline-item::before { content: ""; position: absolute; left: -1.75rem; top: 1.1rem; width: 10px; height: 10px; border-radius: 50%; background: var(--accent); border: 2px solid var(--bg); }
|
|
1479
|
+
.timeline-item .time { font-size: 0.75rem; color: var(--muted); }
|
|
1480
|
+
.timeline-item .badge { display: inline-block; background: var(--badge-bg); color: var(--badge-fg); font-size: 0.75rem; padding: 0.1rem 0.5rem; border-radius: 0.75rem; margin-left: 0.5rem; }
|
|
1481
|
+
.timeline-item .title { font-weight: 500; margin-top: 0.15rem; }
|
|
1482
|
+
.timeline-item .details { font-size: 0.8rem; color: var(--muted); margin-top: 0.15rem; }
|
|
1483
|
+
.load-more { display: block; width: 100%; padding: 0.6rem; margin-top: 1rem; background: var(--card-bg); border: 1px solid var(--border); border-radius: 0.5rem; cursor: pointer; color: var(--fg); font-size: 0.9rem; text-align: center; }
|
|
1484
|
+
.load-more:hover { background: var(--badge-bg); }
|
|
1485
|
+
.empty { text-align: center; color: var(--muted); padding: 3rem; }
|
|
1486
|
+
a.back { color: var(--accent); text-decoration: none; font-size: 0.9rem; }
|
|
1487
|
+
a.back:hover { text-decoration: underline; }
|
|
1488
|
+
</style>
|
|
1489
|
+
</head>
|
|
1490
|
+
<body>
|
|
1491
|
+
<div class="container">
|
|
1492
|
+
<a class="back" href="/">← Back to Wiki</a>
|
|
1493
|
+
<h1>Activity Log</h1>
|
|
1494
|
+
<p class="subtitle">${stats.total} total events</p>
|
|
1495
|
+
<div class="filters">
|
|
1496
|
+
<button class="filter-btn active" data-action="">All (${stats.total})</button>
|
|
1497
|
+
${filterButtons}
|
|
1498
|
+
</div>
|
|
1499
|
+
<ul class="timeline" id="timeline"></ul>
|
|
1500
|
+
<button class="load-more" id="load-more">Load more</button>
|
|
1501
|
+
<div class="empty" id="empty" style="display:none;">No activity yet.</div>
|
|
1502
|
+
</div>
|
|
1503
|
+
<script>
|
|
1504
|
+
const authToken = document.querySelector('meta[name="kiwi-auth"]')?.content || '';
|
|
1505
|
+
const icons = ${JSON.stringify(actionIcons)};
|
|
1506
|
+
const labels = ${JSON.stringify(actionLabels)};
|
|
1507
|
+
let currentAction = '';
|
|
1508
|
+
let offset = 0;
|
|
1509
|
+
const limit = 50;
|
|
1510
|
+
|
|
1511
|
+
function formatTime(iso) {
|
|
1512
|
+
const d = new Date(iso + 'Z');
|
|
1513
|
+
const now = new Date();
|
|
1514
|
+
const diff = now - d;
|
|
1515
|
+
if (diff < 60000) return 'just now';
|
|
1516
|
+
if (diff < 3600000) return Math.floor(diff/60000) + 'm ago';
|
|
1517
|
+
if (diff < 86400000) return Math.floor(diff/3600000) + 'h ago';
|
|
1518
|
+
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function esc(s) { return s ? s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') : ''; }
|
|
1522
|
+
|
|
1523
|
+
function renderEntry(e) {
|
|
1524
|
+
const icon = icons[e.action] || '\u{1F4CC}';
|
|
1525
|
+
const label = labels[e.action] || e.action;
|
|
1526
|
+
let detailsHtml = '';
|
|
1527
|
+
if (e.details) {
|
|
1528
|
+
try {
|
|
1529
|
+
const d = JSON.parse(e.details);
|
|
1530
|
+
detailsHtml = '<span class="details">' + Object.entries(d).map(([k,v]) => esc(k) + ': ' + esc(String(v).slice(0,60))).join(' | ') + '</span>';
|
|
1531
|
+
} catch {}
|
|
1532
|
+
}
|
|
1533
|
+
return '<li class="timeline-item" data-action="' + esc(e.action) + '">' +
|
|
1534
|
+
'<span class="time">' + formatTime(e.created_at) + '</span>' +
|
|
1535
|
+
'<span class="badge">' + icon + ' ' + esc(label) + '</span>' +
|
|
1536
|
+
'<div class="title">' + esc(e.title || '') + '</div>' +
|
|
1537
|
+
detailsHtml + '</li>';
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
async function loadEntries(append) {
|
|
1541
|
+
const params = new URLSearchParams({ limit: String(limit), offset: String(offset), token: authToken });
|
|
1542
|
+
if (currentAction) params.set('action', currentAction);
|
|
1543
|
+
const res = await fetch('/api/activity?' + params);
|
|
1544
|
+
const data = await res.json();
|
|
1545
|
+
const entries = data.entries;
|
|
1546
|
+
const tl = document.getElementById('timeline');
|
|
1547
|
+
if (!append) tl.innerHTML = '';
|
|
1548
|
+
if (entries.length === 0 && offset === 0) {
|
|
1549
|
+
document.getElementById('empty').style.display = '';
|
|
1550
|
+
document.getElementById('load-more').style.display = 'none';
|
|
1551
|
+
} else {
|
|
1552
|
+
document.getElementById('empty').style.display = 'none';
|
|
1553
|
+
document.getElementById('load-more').style.display = entries.length < limit ? 'none' : '';
|
|
1554
|
+
tl.innerHTML += entries.map(renderEntry).join('');
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
document.querySelectorAll('.filter-btn').forEach(btn => {
|
|
1559
|
+
btn.addEventListener('click', () => {
|
|
1560
|
+
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
|
1561
|
+
btn.classList.add('active');
|
|
1562
|
+
currentAction = btn.dataset.action;
|
|
1563
|
+
offset = 0;
|
|
1564
|
+
loadEntries(false);
|
|
1565
|
+
});
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
document.getElementById('load-more').addEventListener('click', () => {
|
|
1569
|
+
offset += limit;
|
|
1570
|
+
loadEntries(true);
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
loadEntries(false);
|
|
1574
|
+
</script>
|
|
1575
|
+
</body>
|
|
1576
|
+
</html>`;
|
|
1577
|
+
}
|