@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,145 @@
|
|
|
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.WriterWorkbenchCheckpointHistory = api;
|
|
6
|
+
})(typeof globalThis !== "undefined" ? globalThis : this, function (pageHeader) {
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
const MESSAGE_MAX_LENGTH = 500;
|
|
10
|
+
const SOURCES = Object.freeze({
|
|
11
|
+
baseline: "기준 상태",
|
|
12
|
+
manual: "직접 저장",
|
|
13
|
+
cli: "CLI 자동 저장",
|
|
14
|
+
cli_guard: "CLI 적용 전 자동 보존",
|
|
15
|
+
restore: "복원",
|
|
16
|
+
restore_guard: "복원 전 자동 보존"
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function escapeHtml(value) {
|
|
20
|
+
return String(value ?? "").replace(/[&<>'"]/g, character => ({
|
|
21
|
+
"&": "&", "<": "<", ">": ">", "'": "'", '"': """
|
|
22
|
+
})[character]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeMessage(value, label = "체크포인트 메시지") {
|
|
26
|
+
const message = String(value || "").trim();
|
|
27
|
+
if (!message) throw new TypeError(`${label}를 입력해 주세요.`);
|
|
28
|
+
if (message.length > MESSAGE_MAX_LENGTH) throw new RangeError(`${label}는 ${MESSAGE_MAX_LENGTH}자 이하로 입력해 주세요.`);
|
|
29
|
+
return message;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function restoreMessage(checkpoint) {
|
|
33
|
+
const target = String(checkpoint?.message || "선택한 체크포인트").trim();
|
|
34
|
+
return `복원: ${target}`.slice(0, MESSAGE_MAX_LENGTH);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeChangedIds(value) {
|
|
38
|
+
if (!Array.isArray(value)) return [];
|
|
39
|
+
return [...new Set(value.map(item => String(item || "").trim()).filter(Boolean))];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeCheckpoint(row) {
|
|
43
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) return null;
|
|
44
|
+
const checkpointId = String(row.checkpoint_id || "").trim();
|
|
45
|
+
const message = String(row.message || "").trim();
|
|
46
|
+
if (!checkpointId || !message) return null;
|
|
47
|
+
const changedInstanceIds = normalizeChangedIds(row.changed_instance_ids);
|
|
48
|
+
const changedCountValue = Number(row.changed_count);
|
|
49
|
+
const checkpointNumberValue = Number(row.checkpoint_number);
|
|
50
|
+
const createdAt = new Date(row.created_at);
|
|
51
|
+
return Object.freeze({
|
|
52
|
+
checkpointId,
|
|
53
|
+
checkpointNumber: Number.isSafeInteger(checkpointNumberValue) && checkpointNumberValue > 0 ? checkpointNumberValue : 0,
|
|
54
|
+
message,
|
|
55
|
+
source: Object.hasOwn(SOURCES, row.source) ? String(row.source) : "manual",
|
|
56
|
+
createdByEmail: String(row.created_by_email || "").trim(),
|
|
57
|
+
createdAt: Number.isNaN(createdAt.getTime()) ? null : createdAt,
|
|
58
|
+
parentCheckpointId: String(row.parent_checkpoint_id || "").trim(),
|
|
59
|
+
restoredFromCheckpointId: String(row.restored_from_checkpoint_id || "").trim(),
|
|
60
|
+
componentSetRevision: Number(row.component_set_revision || 0),
|
|
61
|
+
componentChecksum: String(row.component_checksum || ""),
|
|
62
|
+
manifestHash: String(row.manifest_hash || ""),
|
|
63
|
+
changedInstanceIds,
|
|
64
|
+
changedCount: Number.isSafeInteger(changedCountValue) && changedCountValue >= 0 ? changedCountValue : changedInstanceIds.length,
|
|
65
|
+
isHead: row.is_head === true,
|
|
66
|
+
latestChanged: row.latest_changed === true
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeHistory(rows) {
|
|
71
|
+
const checkpoints = (Array.isArray(rows) ? rows : [])
|
|
72
|
+
.map(normalizeCheckpoint)
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.sort((left, right) => right.checkpointNumber - left.checkpointNumber || Number(right.createdAt || 0) - Number(left.createdAt || 0));
|
|
75
|
+
const head = checkpoints.find(checkpoint => checkpoint.isHead) || checkpoints[0] || null;
|
|
76
|
+
return Object.freeze({ checkpoints, head, latestChanged: Boolean(head?.latestChanged) });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizePage(rows, pageSize = 50) {
|
|
80
|
+
const size = Number(pageSize);
|
|
81
|
+
if (!Number.isSafeInteger(size) || size < 1 || size > 99) throw new TypeError("체크포인트 페이지 크기가 올바르지 않습니다.");
|
|
82
|
+
const normalized = normalizeHistory(rows);
|
|
83
|
+
const checkpoints = normalized.checkpoints.slice(0, size);
|
|
84
|
+
const cursor = checkpoints.at(-1)?.checkpointNumber || 0;
|
|
85
|
+
const hasMore = normalized.checkpoints.length > size && cursor > 0;
|
|
86
|
+
return Object.freeze({
|
|
87
|
+
checkpoints,
|
|
88
|
+
head: normalized.head,
|
|
89
|
+
latestChanged: normalized.latestChanged,
|
|
90
|
+
hasMore,
|
|
91
|
+
nextCursor: hasMore ? cursor : null
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sourceLabel(source) {
|
|
96
|
+
return SOURCES[source] || "체크포인트";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function formatCreatedAt(value) {
|
|
100
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
101
|
+
if (Number.isNaN(date.getTime())) return "시간 정보 없음";
|
|
102
|
+
return new Intl.DateTimeFormat("ko-KR", {
|
|
103
|
+
year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit"
|
|
104
|
+
}).format(date);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function changedListMarkup(checkpoint, labelForInstance) {
|
|
108
|
+
if (!checkpoint.changedInstanceIds.length) return `<p class="checkpoint-empty-diff">기준 상태 또는 변경 요약이 없는 체크포인트입니다.</p>`;
|
|
109
|
+
return `<ul class="checkpoint-change-list">${checkpoint.changedInstanceIds.map(instanceId => `<li><span>${escapeHtml(labelForInstance(instanceId))}</span><code>${escapeHtml(instanceId)}</code></li>`).join("")}</ul>`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function checkpointMarkup(checkpoint, state, labelForInstance, readOnly = false) {
|
|
113
|
+
const actor = checkpoint.createdByEmail || "작성자 정보 없음";
|
|
114
|
+
const restoreDisabled = Boolean(state.operation) || (checkpoint.isHead && !state.latestChanged);
|
|
115
|
+
const restoreLabel = restoreDisabled && checkpoint.isHead && !state.latestChanged ? "현재 체크포인트" : "이 상태로 복원";
|
|
116
|
+
return `<li class="checkpoint-entry" data-checkpoint-id="${escapeHtml(checkpoint.checkpointId)}"><div class="checkpoint-marker" aria-hidden="true"></div><article><header><div><div class="checkpoint-entry-meta"><span>${escapeHtml(sourceLabel(checkpoint.source))}</span>${checkpoint.isHead ? '<strong>HEAD</strong>' : ""}</div><h3>${escapeHtml(checkpoint.message)}</h3><p>${escapeHtml(actor)} · <time>${escapeHtml(formatCreatedAt(checkpoint.createdAt))}</time></p></div>${readOnly ? "" : `<button type="button" data-checkpoint-restore="${escapeHtml(checkpoint.checkpointId)}" ${restoreDisabled ? "disabled" : ""}>${restoreLabel}</button>`}</header><details><summary>변경된 Component ${checkpoint.changedCount}개</summary>${changedListMarkup(checkpoint, labelForInstance)}</details></article></li>`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function render(state = {}, options = {}) {
|
|
120
|
+
const labelForInstance = typeof options.labelForInstance === "function" ? options.labelForInstance : value => value;
|
|
121
|
+
const readOnly = options.readOnly === true;
|
|
122
|
+
const header = metaMarkup => pageHeader.render({ title: "데이터 기록", description: "체크포인트로 저장한 변경 내역을 확인합니다.", className: "checkpoint-history-heading", metaMarkup });
|
|
123
|
+
if (state.loading) return `<section class="checkpoint-history-panel" aria-busy="true">${header("")}<div class="checkpoint-loading" role="status">기록 불러오는 중…</div></section>`;
|
|
124
|
+
if (state.error) return `<section class="checkpoint-history-panel">${header("")}<p class="checkpoint-operation-error" role="alert">${escapeHtml(state.error.message || state.error)}</p><button type="button" data-checkpoint-retry>다시 시도</button></section>`;
|
|
125
|
+
const checkpoints = Array.isArray(state.checkpoints) ? state.checkpoints : [];
|
|
126
|
+
const pagination = state.hasMore || state.loadingMore || state.loadMoreError
|
|
127
|
+
? `<footer class="checkpoint-history-pagination"><button type="button" data-checkpoint-load-more ${state.loadingMore || state.operation ? "disabled" : ""}>${state.loadingMore ? "이전 기록 불러오는 중…" : "이전 기록 더 불러오기"}</button><p aria-live="polite">${state.loadMoreError ? escapeHtml(state.loadMoreError.message || state.loadMoreError) : ""}</p></footer>`
|
|
128
|
+
: "";
|
|
129
|
+
return `<section class="checkpoint-history-panel">${header(`<span class="checkpoint-history-count">불러온 기록 ${checkpoints.length}개</span>`)}${state.operationError ? `<p class="checkpoint-operation-error" role="alert">${escapeHtml(state.operationError.message || state.operationError)}</p>` : ""}${checkpoints.length ? `<ol class="checkpoint-timeline">${checkpoints.map(checkpoint => checkpointMarkup(checkpoint, state, labelForInstance, readOnly)).join("")}</ol>${pagination}` : `<div class="checkpoint-empty"><h3>아직 체크포인트가 없습니다</h3><p>${readOnly ? "저장된 체크포인트가 생기면 이곳에서 확인할 수 있습니다." : "메시지와 함께 현재 Latest를 첫 체크포인트로 저장해 보세요."}</p></div>`}</section>`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return Object.freeze({
|
|
133
|
+
MESSAGE_MAX_LENGTH,
|
|
134
|
+
SOURCES,
|
|
135
|
+
escapeHtml,
|
|
136
|
+
normalizeMessage,
|
|
137
|
+
restoreMessage,
|
|
138
|
+
normalizeCheckpoint,
|
|
139
|
+
normalizeHistory,
|
|
140
|
+
normalizePage,
|
|
141
|
+
sourceLabel,
|
|
142
|
+
formatCreatedAt,
|
|
143
|
+
render
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
const api = factory();
|
|
3
|
+
if (typeof module === "object" && module.exports) module.exports = api;
|
|
4
|
+
if (root) root.WriterWorkbenchComponentContract = api;
|
|
5
|
+
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
const WORKSPACE_MODES = Object.freeze({
|
|
9
|
+
COMPONENTS: "components",
|
|
10
|
+
AUTHORING: "authoring",
|
|
11
|
+
PREVIEW: "preview",
|
|
12
|
+
DESIGN: "design",
|
|
13
|
+
VALIDATION: "validation",
|
|
14
|
+
HISTORY: "history",
|
|
15
|
+
OUTPUT: "output"
|
|
16
|
+
});
|
|
17
|
+
const WORKSPACE_MODE_IDS = Object.freeze(Object.values(WORKSPACE_MODES));
|
|
18
|
+
const DEFAULT_WORKSPACE_MODE = WORKSPACE_MODES.AUTHORING;
|
|
19
|
+
const VARIANT_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
20
|
+
|
|
21
|
+
function isWorkspaceMode(value) {
|
|
22
|
+
return WORKSPACE_MODE_IDS.includes(String(value || "").trim().toLowerCase());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeWorkspaceMode(value, fallback = DEFAULT_WORKSPACE_MODE) {
|
|
26
|
+
const candidate = String(value || "").trim().toLowerCase();
|
|
27
|
+
if (isWorkspaceMode(candidate)) return candidate;
|
|
28
|
+
if (fallback === null) return null;
|
|
29
|
+
const normalizedFallback = String(fallback || "").trim().toLowerCase();
|
|
30
|
+
if (isWorkspaceMode(normalizedFallback)) return normalizedFallback;
|
|
31
|
+
throw new TypeError(`Unknown workspace mode fallback: ${fallback}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assertWorkspaceMode(value, label = "workspaceMode") {
|
|
35
|
+
if (!isWorkspaceMode(value)) {
|
|
36
|
+
throw new TypeError(`${label} must be one of: ${WORKSPACE_MODE_IDS.join(", ")}`);
|
|
37
|
+
}
|
|
38
|
+
return String(value).trim().toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isVariantId(value) {
|
|
42
|
+
return VARIANT_ID_PATTERN.test(String(value || "").trim());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function variantIdCandidate(value) {
|
|
46
|
+
return String(value || "")
|
|
47
|
+
.trim()
|
|
48
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.replace(/[_\s]+/g, "-")
|
|
51
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
52
|
+
.replace(/-+/g, "-")
|
|
53
|
+
.replace(/^-|-$/g, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeVariantId(value, fallback = "default") {
|
|
57
|
+
const candidate = variantIdCandidate(value);
|
|
58
|
+
if (isVariantId(candidate)) return candidate;
|
|
59
|
+
if (fallback === null) return null;
|
|
60
|
+
const normalizedFallback = variantIdCandidate(fallback);
|
|
61
|
+
if (isVariantId(normalizedFallback)) return normalizedFallback;
|
|
62
|
+
throw new TypeError(`Invalid view variant fallback: ${fallback}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function assertVariantId(value, label = "variantId") {
|
|
66
|
+
const candidate = String(value || "").trim();
|
|
67
|
+
if (!isVariantId(candidate)) {
|
|
68
|
+
throw new TypeError(`${label} must use lowercase kebab-case and start with a letter`);
|
|
69
|
+
}
|
|
70
|
+
return candidate;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeInstanceId(value, fallback = "component") {
|
|
74
|
+
return normalizeVariantId(value, fallback);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return Object.freeze({
|
|
78
|
+
WORKSPACE_MODES,
|
|
79
|
+
WORKSPACE_MODE_IDS,
|
|
80
|
+
DEFAULT_WORKSPACE_MODE,
|
|
81
|
+
VARIANT_ID_PATTERN,
|
|
82
|
+
isWorkspaceMode,
|
|
83
|
+
normalizeWorkspaceMode,
|
|
84
|
+
assertWorkspaceMode,
|
|
85
|
+
isVariantId,
|
|
86
|
+
normalizeVariantId,
|
|
87
|
+
assertVariantId,
|
|
88
|
+
normalizeInstanceId
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
const commonJs = typeof module === "object" && module.exports;
|
|
3
|
+
const idPolicy = commonJs ? require("./component-id-policy.js") : root?.WriterWorkbenchComponentIdPolicy;
|
|
4
|
+
const api = factory(idPolicy);
|
|
5
|
+
if (commonJs) module.exports = api;
|
|
6
|
+
if (root) root.WriterWorkbenchComponentDraftOperations = api;
|
|
7
|
+
})(typeof globalThis !== "undefined" ? globalThis : this, function (idPolicy) {
|
|
8
|
+
"use strict";
|
|
9
|
+
|
|
10
|
+
function create(options) {
|
|
11
|
+
if (!idPolicy) throw new Error("Component ID 정책 모듈을 초기화하지 못했습니다.");
|
|
12
|
+
if (typeof options?.draft !== "function" || typeof options?.setDraft !== "function" || typeof options?.scheduleSave !== "function") {
|
|
13
|
+
throw new Error("Component draft 작업 의존성이 완전하지 않습니다.");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const rerender = () => options.render?.();
|
|
17
|
+
const persistAndRender = instance => {
|
|
18
|
+
options.scheduleSave(instance);
|
|
19
|
+
rerender();
|
|
20
|
+
};
|
|
21
|
+
const technicalId = (instance, prefix, occupied) => idPolicy.technicalId(instance, prefix, occupied, {
|
|
22
|
+
now: options.now || Date.now,
|
|
23
|
+
randomUUID: options.randomUUID
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function markChange(instance, path, value, changeOptions = {}) {
|
|
27
|
+
if (options.saveState?.(instance)?.locked) return false;
|
|
28
|
+
idPolicy.setAt(options.draft(instance), path, value);
|
|
29
|
+
options.scheduleSave(instance);
|
|
30
|
+
options.syncJson?.(instance);
|
|
31
|
+
if (changeOptions.render) rerender();
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function replaceData(instance, nextData, changeOptions = {}) {
|
|
36
|
+
if (options.saveState?.(instance)?.locked) return false;
|
|
37
|
+
options.setDraft(instance, options.clone(nextData));
|
|
38
|
+
options.scheduleSave(instance);
|
|
39
|
+
if (changeOptions.render !== false) rerender();
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function arrayValue(instance, path) {
|
|
44
|
+
const value = idPolicy.getAt(options.draft(instance), path);
|
|
45
|
+
if (!Array.isArray(value)) throw new Error("Component 배열 경로가 올바르지 않습니다.");
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function arrayAdd(instance, path, item) {
|
|
50
|
+
arrayValue(instance, path).push(item);
|
|
51
|
+
persistAndRender(instance);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function arrayInsert(instance, path, index, item) {
|
|
55
|
+
const values = arrayValue(instance, path);
|
|
56
|
+
const target = Number.isSafeInteger(index) ? Math.max(0, Math.min(index, values.length)) : values.length;
|
|
57
|
+
values.splice(target, 0, item);
|
|
58
|
+
persistAndRender(instance);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function arrayRemove(instance, path, index) {
|
|
62
|
+
const values = arrayValue(instance, path);
|
|
63
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= values.length || !options.confirm?.()) return false;
|
|
64
|
+
values.splice(index, 1);
|
|
65
|
+
persistAndRender(instance);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function arrayMove(instance, path, index, direction) {
|
|
70
|
+
const values = arrayValue(instance, path);
|
|
71
|
+
const target = index + direction;
|
|
72
|
+
if (target < 0 || target >= values.length) return false;
|
|
73
|
+
[values[index], values[target]] = [values[target], values[index]];
|
|
74
|
+
persistAndRender(instance);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function arrayReorder(instance, path, fromIndex, toIndex) {
|
|
79
|
+
const values = arrayValue(instance, path);
|
|
80
|
+
if (!Number.isSafeInteger(fromIndex) || !Number.isSafeInteger(toIndex) || fromIndex < 0 || fromIndex >= values.length || toIndex < 0 || toIndex >= values.length || fromIndex === toIndex) return false;
|
|
81
|
+
const [item] = values.splice(fromIndex, 1);
|
|
82
|
+
values.splice(toIndex, 0, item);
|
|
83
|
+
persistAndRender(instance);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function characterAdd(instance) {
|
|
88
|
+
const data = options.draft(instance);
|
|
89
|
+
const id = technicalId(instance, "character", new Set(Object.keys(data.characters)));
|
|
90
|
+
const containerTemplates = [];
|
|
91
|
+
const seenContainers = new Set();
|
|
92
|
+
data.order.map(characterId => data.characters[characterId]).filter(record => Array.isArray(record?.containers)).forEach(record => {
|
|
93
|
+
record.containers.forEach((container, index) => {
|
|
94
|
+
const key = characterContainerKey(record, index);
|
|
95
|
+
if (!seenContainers.has(key)) { seenContainers.add(key); containerTemplates.push(container); }
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
if (!containerTemplates.length) containerTemplates.push({ role: "primary", title: "기본 정보" });
|
|
99
|
+
const occupiedContainers = new Set(Object.values(data.characters).flatMap(record => (record.containers || []).map(container => container.id)));
|
|
100
|
+
const containers = containerTemplates.map(container => {
|
|
101
|
+
const containerId = technicalId(instance, "container", occupiedContainers);
|
|
102
|
+
occupiedContainers.add(containerId);
|
|
103
|
+
return { id: containerId, role: container.role, title: container.title, sectionIds: [] };
|
|
104
|
+
});
|
|
105
|
+
data.characters[id] = {
|
|
106
|
+
isPlayable: true,
|
|
107
|
+
tag: "Player",
|
|
108
|
+
color: "#497fa8",
|
|
109
|
+
customSections: [],
|
|
110
|
+
containers,
|
|
111
|
+
authorNote: ""
|
|
112
|
+
};
|
|
113
|
+
data.names[id] = "새 캐릭터";
|
|
114
|
+
data.order.push(id);
|
|
115
|
+
data.customCharacters.push({ id });
|
|
116
|
+
persistAndRender(instance);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function characterRemove(instance, id) {
|
|
120
|
+
const data = options.draft(instance);
|
|
121
|
+
if (!data.order.includes(id) || !options.confirm?.("이 캐릭터를 삭제할까요?")) return false;
|
|
122
|
+
if (!data.deletedColumns.includes(id)) data.deletedColumns.push(id);
|
|
123
|
+
data.order = data.order.filter(value => value !== id);
|
|
124
|
+
data.customCharacters = data.customCharacters.filter(item => item.id !== id);
|
|
125
|
+
delete data.characters[id];
|
|
126
|
+
delete data.names[id];
|
|
127
|
+
persistAndRender(instance);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function characterMove(instance, id, direction) {
|
|
132
|
+
const order = options.draft(instance).order;
|
|
133
|
+
const index = order.indexOf(id);
|
|
134
|
+
const target = index + direction;
|
|
135
|
+
if (index < 0 || target < 0 || target >= order.length) return false;
|
|
136
|
+
[order[index], order[target]] = [order[target], order[index]];
|
|
137
|
+
persistAndRender(instance);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function characterRecords(data) {
|
|
142
|
+
return Object.values(data.characters || {}).filter(record => record && Array.isArray(record.containers));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function characterContainerKey(record, index) {
|
|
146
|
+
const container = record?.containers?.[index];
|
|
147
|
+
if (!container) return null;
|
|
148
|
+
if (index === 0 || container.role === "primary") return "primary";
|
|
149
|
+
const title = String(container.title || "");
|
|
150
|
+
const occurrence = record.containers.slice(1, index).filter(item => String(item.title || "") === title).length;
|
|
151
|
+
return `${title}\u0000${occurrence}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function characterContainerIndex(data, id, containerId) {
|
|
155
|
+
return data.characters?.[id]?.containers.findIndex(container => container.id === containerId) ?? -1;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function characterContainerIndexesByKey(records, key) {
|
|
159
|
+
return records.map(record => ({ record, index: record.containers.findIndex((container, index) => characterContainerKey(record, index) === key) }));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function characterContainerAdd(instance, id) {
|
|
163
|
+
const data = options.draft(instance);
|
|
164
|
+
const records = characterRecords(data);
|
|
165
|
+
if (!records.length || !data.characters[id]) return false;
|
|
166
|
+
const occupied = new Set(records.flatMap(record => record.containers.map(container => container.id)));
|
|
167
|
+
const usedTitles = new Set(records.flatMap(record => record.containers.slice(1).map(container => String(container.title || ""))));
|
|
168
|
+
let number = Math.max(2, ...records.map(record => record.containers.length + 1));
|
|
169
|
+
while (usedTitles.has(`Container ${number}`)) number += 1;
|
|
170
|
+
const title = `Container ${number}`;
|
|
171
|
+
records.forEach(record => {
|
|
172
|
+
const containerId = technicalId(instance, "container", occupied);
|
|
173
|
+
occupied.add(containerId);
|
|
174
|
+
record.containers.push({ id: containerId, role: "content", title, sectionIds: [] });
|
|
175
|
+
});
|
|
176
|
+
persistAndRender(instance);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function characterContainerRemove(instance, id, containerId) {
|
|
181
|
+
const data = options.draft(instance);
|
|
182
|
+
const records = characterRecords(data);
|
|
183
|
+
const index = characterContainerIndex(data, id, containerId);
|
|
184
|
+
const key = characterContainerKey(data.characters?.[id], index);
|
|
185
|
+
const matches = characterContainerIndexesByKey(records, key);
|
|
186
|
+
if (index <= 0 || !key
|
|
187
|
+
|| matches.some(match => match.index >= 0 && match.record.containers[match.index].sectionIds.length)
|
|
188
|
+
|| !options.confirm?.("모든 캐릭터에서 이 빈 Container를 삭제할까요?")) return false;
|
|
189
|
+
matches.forEach(match => { if (match.index >= 0) match.record.containers.splice(match.index, 1); });
|
|
190
|
+
persistAndRender(instance);
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function characterContainerMove(instance, id, containerId, direction) {
|
|
195
|
+
const data = options.draft(instance);
|
|
196
|
+
const records = characterRecords(data);
|
|
197
|
+
const index = characterContainerIndex(data, id, containerId);
|
|
198
|
+
const key = characterContainerKey(data.characters?.[id], index);
|
|
199
|
+
const matches = characterContainerIndexesByKey(records, key);
|
|
200
|
+
if (index <= 0 || !key || matches.some(match => match.index >= 0 && (match.index + direction <= 0 || match.index + direction >= match.record.containers.length))) return false;
|
|
201
|
+
matches.forEach(({ record, index: matchedIndex }) => {
|
|
202
|
+
if (matchedIndex < 0) return;
|
|
203
|
+
const target = matchedIndex + direction;
|
|
204
|
+
[record.containers[matchedIndex], record.containers[target]] = [record.containers[target], record.containers[matchedIndex]];
|
|
205
|
+
});
|
|
206
|
+
persistAndRender(instance);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function characterContainerRename(instance, id, containerId, title) {
|
|
211
|
+
const data = options.draft(instance);
|
|
212
|
+
const records = characterRecords(data);
|
|
213
|
+
const index = characterContainerIndex(data, id, containerId);
|
|
214
|
+
const key = characterContainerKey(data.characters?.[id], index);
|
|
215
|
+
if (index <= 0 || !key) return false;
|
|
216
|
+
characterContainerIndexesByKey(records, key).forEach(({ record, index: matchedIndex }) => {
|
|
217
|
+
if (matchedIndex >= 0) record.containers[matchedIndex].title = String(title);
|
|
218
|
+
});
|
|
219
|
+
options.scheduleSave(instance);
|
|
220
|
+
options.syncJson?.(instance);
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function characterSectionAdd(instance, id, containerId) {
|
|
225
|
+
const record = options.draft(instance).characters[id];
|
|
226
|
+
const container = record?.containers.find(item => item.id === containerId);
|
|
227
|
+
if (!record || !container) return false;
|
|
228
|
+
const section = {
|
|
229
|
+
id: technicalId(instance, "section", new Set(record.customSections.map(item => item.id))),
|
|
230
|
+
title: "새 항목",
|
|
231
|
+
body: ""
|
|
232
|
+
};
|
|
233
|
+
record.customSections.push(section);
|
|
234
|
+
container.sectionIds.push(section.id);
|
|
235
|
+
persistAndRender(instance);
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function characterSectionRemove(instance, id, sectionId) {
|
|
240
|
+
const record = options.draft(instance).characters[id];
|
|
241
|
+
const sectionIndex = record?.customSections.findIndex(section => section.id === sectionId) ?? -1;
|
|
242
|
+
if (sectionIndex < 0 || !options.confirm?.("이 문서 항목을 삭제할까요?")) return false;
|
|
243
|
+
record.customSections.splice(sectionIndex, 1);
|
|
244
|
+
record.containers.forEach(container => {
|
|
245
|
+
container.sectionIds = container.sectionIds.filter(value => value !== sectionId);
|
|
246
|
+
});
|
|
247
|
+
persistAndRender(instance);
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function characterSectionMove(instance, id, fromContainerId, toContainerId, fromIndex, toIndex) {
|
|
252
|
+
const record = options.draft(instance).characters[id];
|
|
253
|
+
const from = record?.containers.find(container => container.id === fromContainerId);
|
|
254
|
+
const to = record?.containers.find(container => container.id === toContainerId);
|
|
255
|
+
if (!from || !to || !Number.isSafeInteger(fromIndex) || !Number.isSafeInteger(toIndex) || fromIndex < 0 || fromIndex >= from.sectionIds.length) return false;
|
|
256
|
+
const [sectionId] = from.sectionIds.splice(fromIndex, 1);
|
|
257
|
+
const target = from === to && toIndex > fromIndex ? toIndex - 1 : toIndex;
|
|
258
|
+
to.sectionIds.splice(Math.max(0, Math.min(target, to.sectionIds.length)), 0, sectionId);
|
|
259
|
+
persistAndRender(instance);
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function commonAdd(instance) {
|
|
264
|
+
const data = options.draft(instance);
|
|
265
|
+
const id = technicalId(instance, "document", new Set(data.sectionOrder));
|
|
266
|
+
data.customSections.push({ id, title: instance.templateId === "ssobig.choice" ? "새 선택" : "새 문서", body: "" });
|
|
267
|
+
data.sectionOrder.push(id);
|
|
268
|
+
persistAndRender(instance);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function toggleComponentField(instance, fieldName, enabled) {
|
|
272
|
+
const definition = options.template?.(instance);
|
|
273
|
+
const field = definition?.fieldConfiguration?.find(item => item.key === fieldName);
|
|
274
|
+
if (!field || field.required) return false;
|
|
275
|
+
const data = options.draft(instance);
|
|
276
|
+
const selected = new Set(Array.isArray(data.enabledOptionalFields)
|
|
277
|
+
? data.enabledOptionalFields
|
|
278
|
+
: definition.fieldConfiguration.filter(item => !item.required).map(item => item.key));
|
|
279
|
+
if (enabled) selected.add(fieldName);
|
|
280
|
+
else selected.delete(fieldName);
|
|
281
|
+
data.enabledOptionalFields = definition.fieldConfiguration
|
|
282
|
+
.filter(item => !item.required && selected.has(item.key))
|
|
283
|
+
.map(item => item.key);
|
|
284
|
+
persistAndRender(instance);
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return Object.freeze({
|
|
289
|
+
markChange,
|
|
290
|
+
replaceData,
|
|
291
|
+
arrayValue,
|
|
292
|
+
arrayAdd,
|
|
293
|
+
arrayInsert,
|
|
294
|
+
arrayRemove,
|
|
295
|
+
arrayMove,
|
|
296
|
+
arrayReorder,
|
|
297
|
+
characterAdd,
|
|
298
|
+
characterRemove,
|
|
299
|
+
characterMove,
|
|
300
|
+
characterContainerAdd,
|
|
301
|
+
characterContainerRemove,
|
|
302
|
+
characterContainerMove,
|
|
303
|
+
characterContainerRename,
|
|
304
|
+
characterSectionAdd,
|
|
305
|
+
characterSectionRemove,
|
|
306
|
+
characterSectionMove,
|
|
307
|
+
commonAdd,
|
|
308
|
+
toggleComponentField
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return Object.freeze({ create });
|
|
313
|
+
});
|