@ssobig/writer-cli 0.2.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 +35 -0
- package/asset-repository.js +278 -0
- package/config.js +14 -0
- package/package.json +28 -0
- package/project-runtime.js +102 -0
- package/storage-path.js +110 -0
- package/templates/mystery-v1/authoring-view-preference.js +34 -0
- package/templates/mystery-v1/character-perspective-preview.js +61 -0
- package/templates/mystery-v1/component-asset-operations.js +103 -0
- package/templates/mystery-v1/component-autosave.js +121 -0
- package/templates/mystery-v1/component-catalog-contract.js +340 -0
- package/templates/mystery-v1/component-checkpoint-history.js +145 -0
- package/templates/mystery-v1/component-contract.js +90 -0
- package/templates/mystery-v1/component-draft-operations.js +313 -0
- package/templates/mystery-v1/component-field-contracts.js +595 -0
- package/templates/mystery-v1/component-id-policy.js +64 -0
- package/templates/mystery-v1/component-manager.js +396 -0
- package/templates/mystery-v1/component-navigation-counts.js +64 -0
- package/templates/mystery-v1/component-registry.js +205 -0
- package/templates/mystery-v1/component-renderers.js +139 -0
- package/templates/mystery-v1/component-storage-contract.js +237 -0
- package/templates/mystery-v1/external-update-coordinator.js +91 -0
- package/templates/mystery-v1/output-clue-card-layout.js +46 -0
- package/templates/mystery-v1/page-header.js +26 -0
- package/templates/mystery-v1/render-ui-state.js +76 -0
- package/templates/mystery-v1/runtime-snapshot-reconciler.js +40 -0
- package/templates/mystery-v1/tab-bar.js +87 -0
- package/templates/mystery-v1/view-component-contract.js +152 -0
- package/templates/mystery-v1/view-component-registry.js +44 -0
- package/templates/mystery-v1/view-component-runtime.js +95 -0
- package/tools/writer-cli/bin/ssobig-writer-daemon.cjs +34 -0
- package/tools/writer-cli/bin/ssobig-writer.cjs +12 -0
- package/tools/writer-cli/package-lock.json +121 -0
- package/tools/writer-cli/package.json +22 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/SKILL.md +38 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/agents/openai.yaml +4 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/assets-checkpoints.md +5 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/errors.md +10 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/install-auth.md +7 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +7 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/read-search.md +5 -0
- package/tools/writer-cli/src/agent-paths.cjs +114 -0
- package/tools/writer-cli/src/agent-service.cjs +496 -0
- package/tools/writer-cli/src/asset-policy.cjs +113 -0
- package/tools/writer-cli/src/auth.cjs +655 -0
- package/tools/writer-cli/src/checkpoint-diff.cjs +128 -0
- package/tools/writer-cli/src/command-registry.cjs +152 -0
- package/tools/writer-cli/src/commands.cjs +841 -0
- package/tools/writer-cli/src/corpus.cjs +83 -0
- package/tools/writer-cli/src/daemon-app.cjs +106 -0
- package/tools/writer-cli/src/daemon-client.cjs +187 -0
- package/tools/writer-cli/src/daemon-protocol.cjs +184 -0
- package/tools/writer-cli/src/daemon-runner.cjs +97 -0
- package/tools/writer-cli/src/daemon-server.cjs +378 -0
- package/tools/writer-cli/src/diagnostics.cjs +235 -0
- package/tools/writer-cli/src/domain.cjs +731 -0
- package/tools/writer-cli/src/errors.cjs +47 -0
- package/tools/writer-cli/src/gateway.cjs +357 -0
- package/tools/writer-cli/src/investigation-board-layout.cjs +328 -0
- package/tools/writer-cli/src/json-patch.cjs +98 -0
- package/tools/writer-cli/src/json.cjs +26 -0
- package/tools/writer-cli/src/local-index-cache.cjs +139 -0
- package/tools/writer-cli/src/local-index-lookup.cjs +98 -0
- package/tools/writer-cli/src/local-index-query.cjs +304 -0
- package/tools/writer-cli/src/local-index-snapshot.cjs +235 -0
- package/tools/writer-cli/src/local-index-storage.cjs +284 -0
- package/tools/writer-cli/src/local-index.cjs +199 -0
- package/tools/writer-cli/src/mutations.cjs +722 -0
- package/tools/writer-cli/src/platform-runner.cjs +55 -0
- package/tools/writer-cli/src/project-import.cjs +485 -0
- package/tools/writer-cli/src/skill-manager.cjs +255 -0
- package/tools/writer-cli/src/source-fingerprint.cjs +90 -0
- package/tools/writer-cli/src/update-gate.cjs +102 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
const pageHeader = typeof module === "object" && module.exports ? require("./page-header.js") : root?.WriterWorkbenchPageHeader;
|
|
3
|
+
const api = factory(pageHeader);
|
|
4
|
+
if (typeof module === "object" && module.exports) module.exports = api;
|
|
5
|
+
if (root) root.WriterWorkbenchComponentManager = api;
|
|
6
|
+
})(typeof globalThis !== "undefined" ? globalThis : this, function (pageHeader) {
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
function escapeHtml(value) {
|
|
10
|
+
return String(value ?? "")
|
|
11
|
+
.replaceAll("&", "&")
|
|
12
|
+
.replaceAll("<", "<")
|
|
13
|
+
.replaceAll(">", ">")
|
|
14
|
+
.replaceAll('"', """)
|
|
15
|
+
.replaceAll("'", "'");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function prettyJson(value) {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.stringify(value ?? null, null, 2);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return JSON.stringify({ error: "JSON으로 표시할 수 없습니다.", message: error.message }, null, 2);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function inferJsonSchema(value, depth = 0) {
|
|
27
|
+
if (depth > 5) return { type: "unknown" };
|
|
28
|
+
if (value === null) return { type: "null" };
|
|
29
|
+
if (Array.isArray(value)) return { type: "array", items: value.length ? inferJsonSchema(value[0], depth + 1) : { type: "unknown" } };
|
|
30
|
+
if (typeof value === "object") return { type: "object", properties: Object.fromEntries(Object.entries(value).map(([key, item]) => [key, inferJsonSchema(item, depth + 1)])) };
|
|
31
|
+
return { type: typeof value };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function dataTemplateName(template) {
|
|
35
|
+
return String(template?.name || template?.label || "알 수 없는 Data Component");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const IMAGE_ASSET_ACCEPT = "image/jpeg,image/png,image/webp,image/gif";
|
|
39
|
+
const GENERAL_ASSET_ACCEPT = `${IMAGE_ASSET_ACCEPT},application/json`;
|
|
40
|
+
const SUPPORTED_ASSET_MIME_TYPES = new Set(GENERAL_ASSET_ACCEPT.split(","));
|
|
41
|
+
const FUNCTION_DISPLAY_ORDER = new Map([
|
|
42
|
+
["ssobig.basic", 0], ["ssobig.progress", 1], ["ssobig.common", 2], ["ssobig.character", 3],
|
|
43
|
+
["ssobig.timeline", 4], ["ssobig.clues", 5], ["ssobig.clue-combinations", 6],
|
|
44
|
+
["ssobig.ai-response", 7], ["ssobig.choice", 8], ["ssobig.ending", 9], ["ssobig.postgame", 10],
|
|
45
|
+
["ssobig.author-notes", 11], ["ssobig.investigation-board", 12], ["ssobig.documents", 13]
|
|
46
|
+
]);
|
|
47
|
+
const DEFAULT_ASSET_SLOTS = Object.freeze([
|
|
48
|
+
Object.freeze({ id: "poster", label: "포스터 이미지", accept: IMAGE_ASSET_ACCEPT }),
|
|
49
|
+
Object.freeze({ id: "logo", label: "로고 이미지", accept: IMAGE_ASSET_ACCEPT }),
|
|
50
|
+
Object.freeze({ id: "workspace-background", label: "워크벤치 배경", accept: IMAGE_ASSET_ACCEPT })
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
function formatFileSize(value) {
|
|
54
|
+
const size = Number(value);
|
|
55
|
+
if (!Number.isFinite(size) || size < 0) return "크기 정보 없음";
|
|
56
|
+
if (size < 1024) return `${size} B`;
|
|
57
|
+
if (size < 1024 ** 2) return `${(size / 1024).toFixed(size < 10 * 1024 ? 1 : 0)} KB`;
|
|
58
|
+
if (size < 1024 ** 3) return `${(size / 1024 ** 2).toFixed(size < 10 * 1024 ** 2 ? 1 : 0)} MB`;
|
|
59
|
+
return `${(size / 1024 ** 3).toFixed(1)} GB`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function functionDisplayOrder(templateId) {
|
|
63
|
+
return FUNCTION_DISPLAY_ORDER.get(String(templateId || "")) ?? 1000;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function assetKind(entry) {
|
|
67
|
+
const type = String(entry?.type || "").toLowerCase();
|
|
68
|
+
if (["image/jpeg", "image/png", "image/webp", "image/gif"].includes(type)) return "image";
|
|
69
|
+
if (type === "application/json") return "json";
|
|
70
|
+
return entry ? "unsupported" : "empty";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assetFormatLabel(entry) {
|
|
74
|
+
const type = String(entry?.type || "").toLowerCase();
|
|
75
|
+
if (type === "image/jpeg") return "JPEG";
|
|
76
|
+
if (type === "image/png") return "PNG";
|
|
77
|
+
if (type === "image/webp") return "WEBP";
|
|
78
|
+
if (type === "image/gif") return "GIF";
|
|
79
|
+
if (type === "application/json") return "JSON";
|
|
80
|
+
return "FILE";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isReservedImageAssetId(assetId) {
|
|
84
|
+
const id = String(assetId || "");
|
|
85
|
+
return DEFAULT_ASSET_SLOTS.some(slot => slot.id === id) || id.startsWith("character-") || id.startsWith("clue-image-");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isSupportedAssetEntry(entry, assetId = "") {
|
|
89
|
+
const type = String(entry?.type || "").toLowerCase();
|
|
90
|
+
return Boolean(entry?.path && !entry.deleted && SUPPORTED_ASSET_MIME_TYPES.has(type) && (!isReservedImageAssetId(assetId) || assetKind(entry) === "image"));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function assetPreviewMarkup(assetId, entry) {
|
|
94
|
+
if (!entry) return `<span class="component-asset-placeholder">선택된 파일 없음</span>`;
|
|
95
|
+
const kind = assetKind(entry);
|
|
96
|
+
if (kind === "image") return `<img data-component-asset="${escapeHtml(assetId)}" alt="${escapeHtml(entry.name || assetId)} 미리보기">`;
|
|
97
|
+
return `<a class="component-asset-file-link" data-component-asset-link="${escapeHtml(assetId)}" target="_blank" rel="noopener"><span aria-hidden="true">↗</span><strong>파일 열기</strong></a>`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function assetUsageMarkup(usages) {
|
|
101
|
+
const items = Array.isArray(usages) ? usages.filter(Boolean) : [];
|
|
102
|
+
if (!items.length) return "";
|
|
103
|
+
return items.map(usage => `<span>${escapeHtml(usage)}</span>`).join("");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function assetCardMarkup(definition, entry, usages) {
|
|
107
|
+
const assetId = definition.id;
|
|
108
|
+
const uploaded = isSupportedAssetEntry(entry, assetId);
|
|
109
|
+
const imageSlot = isReservedImageAssetId(assetId);
|
|
110
|
+
const updatedAt = Number.isSafeInteger(entry?.updatedAt) ? new Date(entry.updatedAt).toLocaleString("ko-KR") : "";
|
|
111
|
+
const accept = definition.accept || (isReservedImageAssetId(assetId) ? IMAGE_ASSET_ACCEPT : GENERAL_ASSET_ACCEPT);
|
|
112
|
+
const usageMarkup = assetUsageMarkup(usages);
|
|
113
|
+
return `<article class="component-asset-card${uploaded ? " has-file" : " is-empty"}${imageSlot ? " is-image-slot" : ""}" data-component-asset-card="${escapeHtml(assetId)}">
|
|
114
|
+
<div class="component-asset-media">
|
|
115
|
+
<div class="component-asset-preview" data-component-asset-kind="${escapeHtml(assetKind(entry))}" data-component-asset-mime="${escapeHtml(uploaded ? entry.type : "")}">${assetPreviewMarkup(assetId, uploaded ? entry : null)}</div>
|
|
116
|
+
</div>
|
|
117
|
+
<div class="component-asset-info">
|
|
118
|
+
<div class="component-asset-title-row">
|
|
119
|
+
<div class="component-asset-title-tags">
|
|
120
|
+
${uploaded ? `<em class="component-asset-format">${escapeHtml(assetFormatLabel(entry))}</em>` : ""}
|
|
121
|
+
${usageMarkup ? `<div class="component-asset-usages" aria-label="사용처">${usageMarkup}</div>` : ""}
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
<strong class="component-asset-file-name" data-component-asset-name="${escapeHtml(assetId)}">${escapeHtml(uploaded ? entry.name : "선택된 파일 없음")}</strong>
|
|
125
|
+
<div class="component-asset-card-head">
|
|
126
|
+
<code>${escapeHtml(assetId)}</code>
|
|
127
|
+
</div>
|
|
128
|
+
<p class="component-asset-meta">${uploaded ? `${escapeHtml(entry.type)} · ${escapeHtml(formatFileSize(entry.size))}${updatedAt ? ` · ${escapeHtml(updatedAt)}` : ""}` : "이 슬롯에 파일을 연결할 수 있습니다."}</p>
|
|
129
|
+
<div class="component-asset-path"><span>Storage 경로</span><code>${escapeHtml(uploaded ? entry.path : "연결된 경로 없음")}</code></div>
|
|
130
|
+
</div>
|
|
131
|
+
<div class="component-asset-actions">
|
|
132
|
+
<label><span>${uploaded ? "파일 교체" : "파일 선택"}</span><input type="file" accept="${escapeHtml(accept)}" data-manager-asset-upload="${escapeHtml(assetId)}"></label>
|
|
133
|
+
<button type="button" data-manager-asset-delete="${escapeHtml(assetId)}"${uploaded ? "" : " disabled"}>연결 해제</button>
|
|
134
|
+
</div>
|
|
135
|
+
</article>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function assetLibraryMarkup(instance, options = {}) {
|
|
139
|
+
const manifest = options.assetManifest?.assets && typeof options.assetManifest.assets === "object" ? options.assetManifest : instance.data;
|
|
140
|
+
const entries = manifest?.assets && typeof manifest.assets === "object" ? manifest.assets : {};
|
|
141
|
+
const slotById = new Map(DEFAULT_ASSET_SLOTS.map(slot => [slot.id, slot]));
|
|
142
|
+
const activeEntries = Object.entries(entries).filter(([assetId, entry]) => isSupportedAssetEntry(entry, assetId));
|
|
143
|
+
const ids = [...DEFAULT_ASSET_SLOTS.map(slot => slot.id), ...activeEntries.map(([assetId]) => assetId).filter(assetId => !slotById.has(assetId)).sort()];
|
|
144
|
+
const usages = options.assetUsages && typeof options.assetUsages === "object" ? options.assetUsages : {};
|
|
145
|
+
const cards = ids.map(assetId => {
|
|
146
|
+
const entry = isSupportedAssetEntry(entries[assetId], assetId) ? entries[assetId] : null;
|
|
147
|
+
const definition = slotById.get(assetId) || { id: assetId, label: entry?.name || assetId };
|
|
148
|
+
return assetCardMarkup(definition, entry, usages[assetId]);
|
|
149
|
+
}).join("");
|
|
150
|
+
return `<section class="component-asset-library" data-component-asset-library>
|
|
151
|
+
<div class="component-asset-grid">${cards}</div>
|
|
152
|
+
</section>`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function definitionListMarkup(items) {
|
|
156
|
+
return `<dl class="component-manager-definition-list">${items.map(([label, value]) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`).join("")}</dl>`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function relatedViewMarkup(viewComponents) {
|
|
160
|
+
const labels = viewComponents.map(view => {
|
|
161
|
+
const compactLabel = String(view.label || "").replace(/\s*작업\s*화면\s*$/u, "").trim() || "연결 화면";
|
|
162
|
+
return compactLabel;
|
|
163
|
+
});
|
|
164
|
+
return `<span class="component-manager-related-list" title="${escapeHtml(labels.join(", ") || "연결된 화면 없음")}">${escapeHtml(labels.join(", ") || "연결된 화면 없음")}</span>`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function technicalDetailsMarkup(items) {
|
|
168
|
+
return `<dl class="component-manager-card-details">${items.map(([label, value]) => `<div><dt>${escapeHtml(label)} :</dt><dd title="${escapeHtml(value)}">${escapeHtml(value)}</dd></div>`).join("")}</dl>`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function primaryView(instance, viewComponents) {
|
|
172
|
+
return viewComponents.find(view => view.enabled && view.bindings.some(binding => binding.key === "primary" && binding.dataInstanceId === instance.instanceId)) || null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function fieldConfigurationMarkup(instance, template, busy) {
|
|
176
|
+
const fields = Array.isArray(template?.fieldConfiguration) ? template.fieldConfiguration : [];
|
|
177
|
+
if (!fields.length) return "";
|
|
178
|
+
const hasSelection = Array.isArray(instance.data?.enabledOptionalFields);
|
|
179
|
+
const enabled = new Set(hasSelection ? instance.data.enabledOptionalFields : []);
|
|
180
|
+
const controls = fields.map(field => {
|
|
181
|
+
const required = field.required === true;
|
|
182
|
+
const checked = required || !hasSelection || enabled.has(field.key);
|
|
183
|
+
return `<label class="component-field-option${required ? " is-required" : ""}">
|
|
184
|
+
<span class="component-field-option-copy"><strong>${escapeHtml(field.label)}</strong><small>${escapeHtml(field.description)}</small></span>
|
|
185
|
+
<span class="component-field-option-control">${required ? `<em>필수</em>` : `<input type="checkbox" data-component-field-toggle data-component-instance="${escapeHtml(instance.instanceId)}" data-component-field="${escapeHtml(field.key)}"${checked ? " checked" : ""}${busy ? " disabled" : ""}><span aria-hidden="true"></span><em>${checked ? "사용" : "사용 안 함"}</em>`}</span>
|
|
186
|
+
</label>`;
|
|
187
|
+
}).join("");
|
|
188
|
+
return `<section class="component-field-configuration" data-component-field-configuration="${escapeHtml(instance.instanceId)}">
|
|
189
|
+
<header><div><h4>속성 구성</h4><p>필수 속성은 항상 표시됩니다. 선택 속성을 끄면 기존 값은 보존한 채 제작하기와 미리보기에서 숨깁니다.</p></div><span>필수 ${fields.filter(field => field.required).length} · 선택 ${fields.filter(field => !field.required).length}</span></header>
|
|
190
|
+
<div class="component-field-option-list">${controls}</div>
|
|
191
|
+
<p class="component-field-status" data-component-field-status="${escapeHtml(instance.instanceId)}" data-state="idle" aria-live="polite"></p>
|
|
192
|
+
</section>`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function catalogEntryId(entry) {
|
|
196
|
+
return String(entry?.instance?.instanceId || entry?.template?.defaultInstanceId || entry?.template?.templateId || "");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function componentRow(instance, template, viewComponents, busy, selectedId) {
|
|
200
|
+
const required = instance.required || template?.required;
|
|
201
|
+
const selected = selectedId === instance.instanceId;
|
|
202
|
+
const view = primaryView(instance, viewComponents);
|
|
203
|
+
return `<article class="component-manager-row is-active${required ? " is-required" : ""}${selected ? " is-selected" : ""}" data-component-row="${escapeHtml(instance.instanceId)}" data-component-state="active" data-component-manager-select="${escapeHtml(instance.instanceId)}" data-component-kind="data" role="button" tabindex="0" aria-pressed="${selected}">
|
|
204
|
+
<div class="component-manager-card-body">
|
|
205
|
+
<span class="component-manager-row-title">
|
|
206
|
+
<strong>${escapeHtml(instance.tab || instance.instanceId)}</strong>
|
|
207
|
+
<span class="component-manager-card-status">
|
|
208
|
+
${required ? `<span class="component-manager-required-badge">필수</span>` : `<button class="component-manager-toggle is-on" type="button" role="switch" aria-checked="true" aria-label="${escapeHtml(instance.tab)} 사용 해제" data-component-action="archive" data-component-template="${escapeHtml(instance.templateId)}" data-component-instance="${escapeHtml(instance.instanceId)}" data-component-label="${escapeHtml(instance.tab)}"${busy ? " disabled" : ""}><em>사용 중</em><span aria-hidden="true"></span></button>`}
|
|
209
|
+
</span>
|
|
210
|
+
</span>
|
|
211
|
+
<span class="component-manager-row-template">${escapeHtml(template?.description || "작품에서 사용하는 기능입니다.")}</span>
|
|
212
|
+
</div>
|
|
213
|
+
${view ? `<div class="component-manager-row-actions"><button class="component-manager-open" type="button" data-component-action="open-authoring" data-component-instance="${escapeHtml(instance.instanceId)}"${busy ? " disabled" : ""}><span>작업 바로가기</span><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5 11 11 5M6 5h5v5"/></svg></button></div>` : ""}
|
|
214
|
+
</article>`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function inactiveComponentRow(instance, template, busy, selectedId) {
|
|
218
|
+
const isArchived = Boolean(instance);
|
|
219
|
+
const label = instance?.tab || template?.name || instance?.instanceId || "기능";
|
|
220
|
+
const action = isArchived ? "restore" : "add";
|
|
221
|
+
const instanceId = instance?.instanceId || template?.defaultInstanceId || template?.templateId;
|
|
222
|
+
const templateId = instance?.templateId || template?.templateId;
|
|
223
|
+
const selected = selectedId === instanceId;
|
|
224
|
+
return `<article class="component-manager-row is-inactive${selected ? " is-selected" : ""}" data-component-row="${escapeHtml(instanceId)}" data-component-state="inactive" data-component-manager-select="${escapeHtml(instanceId)}" data-component-kind="data" role="button" tabindex="0" aria-pressed="${selected}">
|
|
225
|
+
<div class="component-manager-card-body">
|
|
226
|
+
<span class="component-manager-row-title">
|
|
227
|
+
<strong>${escapeHtml(label)}</strong>
|
|
228
|
+
<span class="component-manager-card-status">
|
|
229
|
+
<button class="component-manager-toggle" type="button" role="switch" aria-checked="false" aria-label="${escapeHtml(label)} 사용" data-component-action="${action}" data-component-template="${escapeHtml(templateId)}" data-component-instance="${escapeHtml(instanceId)}" data-component-label="${escapeHtml(label)}"${busy ? " disabled" : ""}><em>사용 안 함</em><span aria-hidden="true"></span></button>
|
|
230
|
+
</span>
|
|
231
|
+
</span>
|
|
232
|
+
<span class="component-manager-row-template">${escapeHtml(template?.description || "작품에서 사용할 수 있는 기능입니다.")}</span>
|
|
233
|
+
</div>
|
|
234
|
+
</article>`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function componentInspector(entry, viewComponents, busy) {
|
|
238
|
+
if (!entry) return `<aside class="component-manager-inspector is-empty" data-component-manager-detail tabindex="-1">
|
|
239
|
+
<div class="component-manager-inspector-empty-guide">
|
|
240
|
+
<strong>세부 설정</strong>
|
|
241
|
+
<p>기능 카드를 클릭하면 세부 설정을 확인하고 변경할 수 있습니다.</p>
|
|
242
|
+
</div>
|
|
243
|
+
</aside>`;
|
|
244
|
+
const active = entry.kind === "active";
|
|
245
|
+
const instance = entry.instance;
|
|
246
|
+
const template = entry.template;
|
|
247
|
+
const instanceId = catalogEntryId(entry);
|
|
248
|
+
const templateId = instance?.templateId || template?.templateId || "";
|
|
249
|
+
const label = instance?.tab || template?.name || instanceId || "기능";
|
|
250
|
+
const description = template?.description || (active ? "작품에서 사용하는 기능입니다." : "작품에서 사용할 수 있는 기능입니다.");
|
|
251
|
+
const relatedViews = active ? viewComponents.filter(candidate => candidate.bindings.some(binding => binding.dataInstanceId === instanceId)) : [];
|
|
252
|
+
const view = active ? primaryView(instance, viewComponents) : null;
|
|
253
|
+
const required = Boolean(instance?.required || template?.required);
|
|
254
|
+
const technicalItems = active ? [
|
|
255
|
+
["Instance ID", instanceId],
|
|
256
|
+
["Data Template", templateId],
|
|
257
|
+
["Revision", instance.revision],
|
|
258
|
+
["DB 원본", "somi_project_component_instances.data"]
|
|
259
|
+
] : [
|
|
260
|
+
["Instance ID", instanceId],
|
|
261
|
+
["Data Template", templateId],
|
|
262
|
+
["상태", instance ? "작성한 내용 보관 중" : "아직 사용하지 않음"],
|
|
263
|
+
["DB 원본", instance ? "somi_project_component_instances.data" : "생성 전"]
|
|
264
|
+
];
|
|
265
|
+
return `<aside class="component-manager-inspector${active ? " is-active" : " is-inactive"}" data-component-manager-detail="${escapeHtml(instanceId)}" tabindex="-1">
|
|
266
|
+
<header class="component-manager-inspector-head">
|
|
267
|
+
<div><span class="component-kind-chip">${required ? "필수 기능" : active ? "사용 중" : "사용 안 함"}</span><h3>${escapeHtml(label)}</h3><p>${escapeHtml(description)}</p></div>
|
|
268
|
+
</header>
|
|
269
|
+
<section class="component-manager-inspector-related">
|
|
270
|
+
<strong>사용 위치</strong>
|
|
271
|
+
${relatedViewMarkup(relatedViews)}
|
|
272
|
+
</section>
|
|
273
|
+
<details class="component-manager-data-details">
|
|
274
|
+
<summary>데이터 정보</summary>
|
|
275
|
+
${technicalDetailsMarkup(technicalItems)}
|
|
276
|
+
</details>
|
|
277
|
+
${active ? fieldConfigurationMarkup(instance, template, busy) : `<p class="component-manager-inspector-note">이 기능을 사용으로 전환하면 속성 구성과 작업 화면을 확인할 수 있습니다.</p>`}
|
|
278
|
+
</aside>`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function addTemplateCard(template, busy) {
|
|
282
|
+
if (template.allowMultiple) return `<article class="component-add-card is-document">
|
|
283
|
+
<div><strong>${escapeHtml(template.name)}</strong><p>${escapeHtml(template.description)}</p></div>
|
|
284
|
+
<label><span>문서 이름</span><input type="text" data-component-new-label maxlength="120" placeholder="예: 범인 공개 문서"${busy ? " disabled" : ""}></label>
|
|
285
|
+
<label><span>식별자</span><input type="text" data-component-new-id pattern="[a-z][a-z0-9]*(?:-[a-z0-9]+)*" placeholder="자동 생성"${busy ? " disabled" : ""}></label>
|
|
286
|
+
<button type="button" data-component-action="add-document" data-component-template="${escapeHtml(template.templateId)}" data-component-instance="${escapeHtml(template.defaultInstanceId)}"${busy ? " disabled" : ""}>문서 추가</button>
|
|
287
|
+
</article>`;
|
|
288
|
+
return `<article class="component-add-card">
|
|
289
|
+
<div><strong>${escapeHtml(template.name)}</strong><p>${escapeHtml(template.description)}</p></div>
|
|
290
|
+
<button type="button" data-component-action="add" data-component-template="${escapeHtml(template.templateId)}" data-component-instance="${escapeHtml(template.defaultInstanceId)}" data-component-label="${escapeHtml(template.name)}"${busy ? " disabled" : ""}>추가</button>
|
|
291
|
+
</article>`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function restoreCard(instance, template, busy) {
|
|
295
|
+
return `<article class="component-add-card is-archived">
|
|
296
|
+
<div><span>보관됨</span><strong>${escapeHtml(instance.tab || template?.name || instance.instanceId)}</strong><p>기존 내용을 그대로 다시 사용할 수 있습니다.</p></div>
|
|
297
|
+
<button type="button" data-component-action="restore" data-component-template="${escapeHtml(instance.templateId)}" data-component-instance="${escapeHtml(instance.instanceId)}" data-component-label="${escapeHtml(instance.tab)}"${busy ? " disabled" : ""}>복원</button>
|
|
298
|
+
</article>`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function addDialogMarkup(dataComponents, archivedComponents, dataTemplates, busy) {
|
|
302
|
+
const activeSingletonTemplates = new Set(dataComponents.filter(instance => dataTemplates.find(template => template.templateId === instance.templateId)?.allowMultiple !== true).map(instance => instance.templateId));
|
|
303
|
+
const archivedSingletonTemplates = new Set(archivedComponents.filter(instance => dataTemplates.find(template => template.templateId === instance.templateId)?.allowMultiple !== true).map(instance => instance.templateId));
|
|
304
|
+
const optionalTemplates = dataTemplates.filter(template => !template.required && !template.internal);
|
|
305
|
+
const available = optionalTemplates.filter(template => template.allowMultiple || (!activeSingletonTemplates.has(template.templateId) && !archivedSingletonTemplates.has(template.templateId)));
|
|
306
|
+
const templateById = new Map(dataTemplates.map(template => [template.templateId, template]));
|
|
307
|
+
return `<dialog class="component-add-dialog" data-component-add-dialog>
|
|
308
|
+
<header><div><span>작품 구성</span><h3>기능 추가</h3><p>이 작품에서 사용할 기능을 선택하세요.</p></div><button type="button" data-component-action="close-add-dialog" aria-label="닫기">×</button></header>
|
|
309
|
+
<div class="component-add-dialog-body">
|
|
310
|
+
${archivedComponents.length ? `<section><h4>보관된 구성</h4><p>제외하기 전의 내용이 그대로 보관되어 있습니다.</p><div class="component-add-grid">${archivedComponents.map(instance => restoreCard(instance, templateById.get(instance.templateId), busy)).join("")}</div></section>` : ""}
|
|
311
|
+
<section><h4>추가 가능한 기능</h4><p>추가하면 제작하기와 미리보기에 해당 화면이 함께 생성됩니다.</p><div class="component-add-grid">${available.map(template => addTemplateCard(template, busy)).join("") || `<p class="component-manager-empty">추가할 수 있는 기능이 없습니다.</p>`}</div></section>
|
|
312
|
+
</div>
|
|
313
|
+
</dialog>`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function render(options = {}) {
|
|
317
|
+
const dataComponents = Array.isArray(options.dataComponents) ? [...options.dataComponents].sort((left, right) => left.order - right.order) : [];
|
|
318
|
+
const viewComponents = Array.isArray(options.viewComponents) ? [...options.viewComponents].sort((left, right) => left.order - right.order) : [];
|
|
319
|
+
const dataTemplates = Array.isArray(options.dataTemplates) ? options.dataTemplates : [];
|
|
320
|
+
const viewTemplates = Array.isArray(options.viewTemplates) ? options.viewTemplates : [];
|
|
321
|
+
const archivedComponents = Array.isArray(options.archivedComponents) ? [...options.archivedComponents].sort((left, right) => left.order - right.order) : [];
|
|
322
|
+
const dataTemplateByKey = new Map(dataTemplates.map(template => [template.templateId, template]));
|
|
323
|
+
void viewTemplates;
|
|
324
|
+
const compositionStatus = options.compositionStatus || { state: "idle", message: "" };
|
|
325
|
+
const busy = compositionStatus.state === "saving";
|
|
326
|
+
const publicComponents = dataComponents.filter(item => item.config?.hidden !== true);
|
|
327
|
+
const requiredComponents = publicComponents.filter(item => item.required || dataTemplateByKey.get(item.templateId)?.required);
|
|
328
|
+
const optionalDisplayOrder = new Map([
|
|
329
|
+
["ssobig.timeline", 0],
|
|
330
|
+
["ssobig.clues", 1],
|
|
331
|
+
["ssobig.clue-combinations", 2]
|
|
332
|
+
]);
|
|
333
|
+
const optionalComponents = publicComponents
|
|
334
|
+
.filter(item => !requiredComponents.includes(item))
|
|
335
|
+
.map((item, index) => ({ item, index }))
|
|
336
|
+
.sort((left, right) => {
|
|
337
|
+
const leftOrder = optionalDisplayOrder.get(left.item.templateId);
|
|
338
|
+
const rightOrder = optionalDisplayOrder.get(right.item.templateId);
|
|
339
|
+
if (leftOrder !== undefined || rightOrder !== undefined) {
|
|
340
|
+
return (leftOrder ?? Number.MAX_SAFE_INTEGER) - (rightOrder ?? Number.MAX_SAFE_INTEGER);
|
|
341
|
+
}
|
|
342
|
+
return left.index - right.index;
|
|
343
|
+
})
|
|
344
|
+
.map(entry => entry.item);
|
|
345
|
+
const activeComponents = [...requiredComponents, ...optionalComponents];
|
|
346
|
+
const activeTemplateIds = new Set(activeComponents.map(instance => instance.templateId));
|
|
347
|
+
const latestTemplateById = new Map();
|
|
348
|
+
dataTemplates.filter(template => !template.internal).forEach(template => {
|
|
349
|
+
const current = latestTemplateById.get(template.templateId);
|
|
350
|
+
if (!current || Number(template.version) > Number(current.version)) latestTemplateById.set(template.templateId, template);
|
|
351
|
+
});
|
|
352
|
+
const archivedByTemplateId = new Map(archivedComponents.filter(instance => !activeTemplateIds.has(instance.templateId)).map(instance => [instance.templateId, instance]));
|
|
353
|
+
const inactiveTemplates = [...latestTemplateById.values()].filter(template => !template.required && !activeTemplateIds.has(template.templateId) && !template.allowMultiple);
|
|
354
|
+
const catalogEntries = [
|
|
355
|
+
...activeComponents.map((instance, index) => ({ kind: "active", instance, template: dataTemplateByKey.get(instance.templateId), index })),
|
|
356
|
+
...inactiveTemplates.map((template, index) => ({ kind: "inactive", instance: archivedByTemplateId.get(template.templateId) || null, template, index: activeComponents.length + index }))
|
|
357
|
+
].sort((left, right) => functionDisplayOrder(left.template?.templateId || left.instance?.templateId) - functionDisplayOrder(right.template?.templateId || right.instance?.templateId) || left.index - right.index);
|
|
358
|
+
const requestedId = String(options.selectedId || "");
|
|
359
|
+
const selectedEntry = requestedId ? catalogEntries.find(entry => catalogEntryId(entry) === requestedId) || null : null;
|
|
360
|
+
const selectedId = catalogEntryId(selectedEntry);
|
|
361
|
+
const catalogRows = catalogEntries.map(entry => entry.kind === "active"
|
|
362
|
+
? componentRow(entry.instance, entry.template, viewComponents, busy, selectedId)
|
|
363
|
+
: inactiveComponentRow(entry.instance, entry.template, busy, selectedId)).join("");
|
|
364
|
+
const assetInstance = dataComponents.find(item => item.templateId === "ssobig.assets") || null;
|
|
365
|
+
const managerTab = options.managerTab === "assets" ? "assets" : "components";
|
|
366
|
+
const assetManifest = options.assetManifest?.assets && typeof options.assetManifest.assets === "object" ? options.assetManifest : assetInstance?.data;
|
|
367
|
+
const status = managerTab === "assets"
|
|
368
|
+
? options.assetStatus || { state: "idle", message: "" }
|
|
369
|
+
: compositionStatus;
|
|
370
|
+
const pageHeaderMarkup = pageHeader.render({
|
|
371
|
+
title: managerTab === "components" ? "기능" : "에셋",
|
|
372
|
+
description: managerTab === "components" ? "사용할 기능을 관리합니다. 기능 카드를 클릭하면 세부 설정을 확인하고 변경할 수 있습니다." : "작품에서 사용하는 이미지와 JSON 파일을 관리합니다. 변경 내용은 연결 화면에 즉시 반영됩니다.",
|
|
373
|
+
className: managerTab === "assets" ? "component-manager-hero component-manager-asset-hero" : "component-manager-hero",
|
|
374
|
+
metaMarkup: managerTab === "assets"
|
|
375
|
+
? `<label class="component-asset-create-button component-manager-asset-create-button"><span>새 파일 추가</span><input type="file" accept="${GENERAL_ASSET_ACCEPT}" data-manager-asset-create></label>`
|
|
376
|
+
: ""
|
|
377
|
+
});
|
|
378
|
+
return `<section class="pane component-manager-pane active" data-workspace-mode="components">
|
|
379
|
+
${pageHeaderMarkup}
|
|
380
|
+
<p class="component-manager-status" data-component-manager-status data-state="${escapeHtml(status.state || "idle")}" aria-live="polite">${escapeHtml(status.message || "")}</p>
|
|
381
|
+
${managerTab === "components" ? `<div class="component-manager-layout">
|
|
382
|
+
<section class="component-manager-catalog" data-ui-scroll-key="component-manager-catalog" aria-label="작품 기능 목록">
|
|
383
|
+
<div class="component-manager-card-grid">${catalogRows}</div>
|
|
384
|
+
</section>
|
|
385
|
+
${componentInspector(selectedEntry, viewComponents, busy)}
|
|
386
|
+
</div>${addDialogMarkup(dataComponents, archivedComponents, dataTemplates, busy)}` : assetInstance ? assetLibraryMarkup(assetInstance, options) : `<div class="component-manager-empty">에셋 구성을 찾을 수 없습니다.</div>`}
|
|
387
|
+
</section>`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function countSupportedAssets(manifest) {
|
|
391
|
+
const entries = manifest?.assets && typeof manifest.assets === "object" ? manifest.assets : {};
|
|
392
|
+
return Object.entries(entries).filter(([assetId, entry]) => isSupportedAssetEntry(entry, assetId)).length;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return Object.freeze({ render, prettyJson, inferJsonSchema, formatFileSize, assetKind, assetFormatLabel, functionDisplayOrder, countSupportedAssets });
|
|
396
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
const api = factory();
|
|
3
|
+
if (typeof module === "object" && module.exports) module.exports = api;
|
|
4
|
+
if (root) root.WriterWorkbenchComponentNavigationCounts = api;
|
|
5
|
+
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
function listLength(value) {
|
|
9
|
+
return Array.isArray(value) ? value.length : 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function commonCount(data) {
|
|
13
|
+
const sections = data?.sections && typeof data.sections === "object" ? data.sections : {};
|
|
14
|
+
const deleted = new Set(Array.isArray(data?.deletedBaseSections) ? data.deletedBaseSections : []);
|
|
15
|
+
const customIds = new Set((Array.isArray(data?.customSections) ? data.customSections : []).map(item => item?.id).filter(Boolean));
|
|
16
|
+
return (Array.isArray(data?.sectionOrder) ? data.sectionOrder : [])
|
|
17
|
+
.filter(id => !deleted.has(id) && (Object.hasOwn(sections, id) || customIds.has(id))).length;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function characterCount(data) {
|
|
21
|
+
const characters = data?.characters && typeof data.characters === "object" ? data.characters : {};
|
|
22
|
+
const deleted = new Set(Array.isArray(data?.deletedColumns) ? data.deletedColumns : []);
|
|
23
|
+
return (Array.isArray(data?.order) ? data.order : [])
|
|
24
|
+
.filter(id => !deleted.has(id) && Object.hasOwn(characters, id)).length;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function endingCount(data) {
|
|
28
|
+
const conditions = Array.isArray(data?.conditions) ? data.conditions : [];
|
|
29
|
+
const outcomes = Array.isArray(data?.outcomes) ? data.outcomes : [];
|
|
30
|
+
const matchedConditions = new Set();
|
|
31
|
+
outcomes.forEach(outcome => {
|
|
32
|
+
const index = conditions.findIndex(condition => condition?.id === outcome?.id);
|
|
33
|
+
if (index >= 0) matchedConditions.add(index);
|
|
34
|
+
});
|
|
35
|
+
return outcomes.length + conditions.length - matchedConditions.size;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function componentItemCount(instance, data = instance?.data) {
|
|
39
|
+
switch (String(instance?.templateId || "")) {
|
|
40
|
+
case "ssobig.progress": return listLength(data?.steps);
|
|
41
|
+
case "ssobig.common":
|
|
42
|
+
case "ssobig.choice": return commonCount(data);
|
|
43
|
+
case "ssobig.character": return characterCount(data);
|
|
44
|
+
case "ssobig.clues": return listLength(data?.clues);
|
|
45
|
+
case "ssobig.clue-combinations": return listLength(data?.combinations);
|
|
46
|
+
case "ssobig.ai-response": return listLength(data?.responses);
|
|
47
|
+
case "ssobig.timeline": return listLength(data?.events);
|
|
48
|
+
case "ssobig.investigation-board": return listLength(data?.nodes);
|
|
49
|
+
case "ssobig.ending": return endingCount(data);
|
|
50
|
+
case "ssobig.postgame": return listLength(data?.truth) + listLength(data?.epilogue);
|
|
51
|
+
case "ssobig.author-notes": return listLength(data?.notes);
|
|
52
|
+
case "ssobig.documents": return listLength(data?.blocks);
|
|
53
|
+
default: return 0;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function totalComponentCount(dataComponents) {
|
|
58
|
+
return Array.isArray(dataComponents)
|
|
59
|
+
? dataComponents.filter(instance => instance?.config?.hidden !== true).length
|
|
60
|
+
: 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return Object.freeze({ componentItemCount, totalComponentCount });
|
|
64
|
+
});
|