@ssobig/writer-cli 0.3.2 → 0.3.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssobig/writer-cli",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Official agent CLI for SSOBIG WRITER",
5
5
  "type": "commonjs",
6
6
  "license": "UNLICENSED",
@@ -7,8 +7,31 @@
7
7
 
8
8
  const STORAGE_KEY = "somi-workbench-authoring-view";
9
9
  const DEFAULT_VIEW = "preview";
10
- const VIEWS = Object.freeze(["input", "preview"]);
10
+ const VIEWS = Object.freeze(["input", "preview", "list"]);
11
11
  const STORAGE_VERSION = 1;
12
+ const PANEL_WIDTH_KEY = "somi-workbench-panel-width";
13
+ const PANEL_TEMPLATES = new Set(["ssobig.basic", "ssobig.progress", "ssobig.common", "ssobig.character", "ssobig.ending", "ssobig.author-notes"]);
14
+
15
+ function normalizePanelWidth(value) { return value === "wide" ? "wide" : "default"; }
16
+ function supportsPanelWidth(templateId) { return PANEL_TEMPLATES.has(templateId); }
17
+ function readPanelWidth(storage = deviceStorage()) {
18
+ try { return normalizePanelWidth(storage?.getItem(PANEL_WIDTH_KEY)); }
19
+ catch (_) { return "default"; }
20
+ }
21
+ function writePanelWidth(value, storage = deviceStorage()) {
22
+ const width = normalizePanelWidth(value);
23
+ try { storage?.setItem(PANEL_WIDTH_KEY, width); }
24
+ catch (_) { /* 보기 설정을 저장할 수 없어도 현재 화면에는 적용한다. */ }
25
+ return width;
26
+ }
27
+ function panelWidthControl(value) {
28
+ const width = normalizePanelWidth(value);
29
+ const options = [
30
+ ["default", "기본", "M2 12h7m-4-4 4 4-4 4M22 12h-7m4-4-4 4 4 4"],
31
+ ["wide", "와이드", "M3 12h18M7 8l-4 4 4 4M17 8l4 4-4 4"]
32
+ ];
33
+ return `<div class="panel-width-buttons" role="group" aria-label="패널 폭">${options.map(([id, label, icon]) => `<button type="button" data-panel-width="${id}" aria-pressed="${width === id}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="${icon}"/></svg><span>${label}</span></button>`).join("")}</div>`;
34
+ }
12
35
 
13
36
  function normalize(value) {
14
37
  return VIEWS.includes(value) ? value : DEFAULT_VIEW;
@@ -47,5 +70,5 @@
47
70
  return normalized;
48
71
  }
49
72
 
50
- return Object.freeze({ STORAGE_KEY, STORAGE_VERSION, DEFAULT_VIEW, VIEWS, normalize, read, write });
73
+ return Object.freeze({ STORAGE_KEY, STORAGE_VERSION, DEFAULT_VIEW, VIEWS, normalize, read, write, PANEL_WIDTH_KEY, normalizePanelWidth, supportsPanelWidth, readPanelWidth, writePanelWidth, panelWidthControl });
51
74
  });
@@ -28,11 +28,12 @@
28
28
  const clueInstance = active.find(instance => instance.templateId === "ssobig.clues");
29
29
  const clueData = dataFor(clueInstance) || clueInstance?.data;
30
30
  for (const [index, clue] of (Array.isArray(clueData?.clues) ? clueData.clues : []).entries()) {
31
- const assetId = clueAssetId(clue);
32
- if (!assetId) continue;
33
- if (!result[assetId]) result[assetId] = [];
34
31
  const label = clue.name || clue.title || clue.id || index + 1;
35
- result[assetId].push(`단서 · ${label}`);
32
+ for (const [side, sideLabel] of [["back", "단서 이미지(앞면)"], ["front", "단서 이미지(뒷면)"]]) {
33
+ const assetId = clueAssetId(clue, side);
34
+ if (!assetId) continue;
35
+ (result[assetId] ||= []).push(`단서 · ${label} · ${sideLabel}`);
36
+ }
36
37
  }
37
38
  for (const instance of active) {
38
39
  if (instance.templateId === "ssobig.assets") continue;
@@ -56,7 +56,7 @@
56
56
  guide: guide("모든 참가자가 공통으로 읽는 규칙과 배경 정보를 작성합니다.", "프롤로그, 조사 규칙, 사건의 전말과 에필로그를 본문과 펼쳐보기로 구성합니다.", "문서의 제목·본문·노출 순서와 문서별 펼쳐보기 항목을 편집합니다.", "integrated", "공통 문서는 펼쳐보기 항목을 포함해 모든 캐릭터 관점의 통합 미리보기에 함께 표시됩니다.", ["ssobig.progress", "ssobig.character", "ssobig.ending"], "공통 문서 제작 화면 예시"),
57
57
  defaultData: { documents: [], deletedDocumentIds: [] },
58
58
  view: view("ssobig.view.common-workbench", "common-workbench", "공통정보 작업 화면", "ssobig.common", [
59
- binding("primary", "ssobig.common", "read_write")
59
+ binding("primary", "ssobig.common", "read_write"), binding("assets", "ssobig.assets", "read_write")
60
60
  ]),
61
61
  importProfiles: [IMPORT_PROFILE]
62
62
  },
@@ -103,7 +103,7 @@
103
103
  templateId: "ssobig.timeline", defaultInstanceId: "timeline", tabLabel: "타임라인",
104
104
  description: "사건이 일어난 시간과 장소, 캐릭터별 기억과 행동을 시간순으로 정리합니다.",
105
105
  guide: guide("사건의 객관적 흐름과 인물별 기억을 시간축으로 맞춥니다.", "22시 로비 사건과 각 캐릭터가 기억하는 행동을 나란히 기록합니다.", "시간·장소·진실과 캐릭터별 기억을 사건 단위로 편집합니다.", "context", "캐릭터 정보를 읽어 인물별 기억을 연결하지만 독립 플레이 화면은 제공하지 않습니다.", ["ssobig.character"], "타임라인 제작 화면 예시"),
106
- defaultData: { events: [] },
106
+ defaultData: { timeGroups: [], events: [], places: [] },
107
107
  view: view("ssobig.view.timeline-workbench", "timeline-workbench", "타임라인 작업 화면", "ssobig.timeline", [
108
108
  binding("primary", "ssobig.timeline", "read_write"), binding("character-context", "ssobig.character", "read", false)
109
109
  ]),
@@ -125,7 +125,7 @@
125
125
  guide: guide("플레이어에게 공개하지 않을 제작·운영 메모를 보관합니다.", "복선 점검, 진행자 주의사항과 다음 수정 목록을 기록합니다.", "제목과 본문으로 된 자유 메모를 추가하고 정리합니다.", "authoring", "작가 전용 Component로 통합 미리보기에 표시되지 않습니다.", [], "작가노트 제작 화면 예시"),
126
126
  defaultData: { notes: [] },
127
127
  view: view("ssobig.view.author-notes-workbench", "author-notes-workbench", "작가노트 작업 화면", "ssobig.author-notes", [
128
- binding("primary", "ssobig.author-notes", "read_write")
128
+ binding("primary", "ssobig.author-notes", "read_write"), binding("assets", "ssobig.assets", "read_write")
129
129
  ]),
130
130
  importProfiles: [IMPORT_PROFILE]
131
131
  },
@@ -312,6 +312,37 @@
312
312
  })
313
313
  ]);
314
314
 
315
+ const EVENT_TIMELINE_CONTRACT = component({
316
+ templateId: "ssobig.timeline",
317
+ root: shape({ timeGroups: field("array", { item: "group" }), events: field("array", { item: "event" }), places: field("array", { item: "place" }) }),
318
+ items: {
319
+ group: shape({ id: field("uuid"), name: field("non-empty-string"), type: field("enum", { values: ["text", "timeline"] }) }),
320
+ place: shape({ id: field("uuid"), name: field("non-empty-string") }),
321
+ event: shape({ id: field("uuid"), description: field("string"), timeGroupId: field("uuid"),
322
+ placeId: field("nullable-uuid"), characterId: field("nullable-stable-key")
323
+ }, { timeRange: field("object", { item: "range" }), included: field("boolean") }),
324
+ range: shape({ start: field("object", { item: "point" }), end: field("object", { item: "point" }) }),
325
+ point: shape({ day: field("integer", { minimum: 0, maximum: 999999 }), hour: field("integer", { minimum: 0, maximum: 23 }), minute: field("integer", { minimum: 0, maximum: 59 }) })
326
+ }
327
+ });
328
+ function eventTimelineError(data) {
329
+ const invalid = validateShape(data, EVENT_TIMELINE_CONTRACT.root, EVENT_TIMELINE_CONTRACT, "$");
330
+ if (invalid) return invalid;
331
+ const groups = new Map(data.timeGroups.map(group => [group.id, group]));
332
+ const places = new Set(data.places.map(place => place.id));
333
+ const minutes = point => point.day * 1440 + point.hour * 60 + point.minute;
334
+ for (const event of data.events) {
335
+ const group = groups.get(event.timeGroupId);
336
+ if (!group) return "사건의 시간 그룹이 존재하지 않습니다.";
337
+ if (event.placeId !== null && !places.has(event.placeId)) return "사건의 장소가 존재하지 않습니다.";
338
+ if (group.type === "timeline") {
339
+ if (!event.timeRange) return "타임라인 그룹의 사건에는 시작·종료 시간이 필수입니다.";
340
+ if (minutes(event.timeRange.end) <= minutes(event.timeRange.start)) return "종료는 시작 이후여야 합니다.";
341
+ } else if (Object.hasOwn(event, "timeRange")) return "텍스트 그룹의 사건에는 시간 범위를 저장하지 않습니다.";
342
+ }
343
+ return null;
344
+ }
345
+
315
346
  const BY_ID = new Map(CONTRACTS.map(contract => [contract.templateId, contract]));
316
347
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
317
348
  const STABLE_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
@@ -352,6 +383,8 @@
352
383
  case "non-empty-string": return { type: "string", minLength: 1, pattern: "\\S" };
353
384
  case "stable-key": return { type: "string", pattern: STABLE_KEY_PATTERN.source };
354
385
  case "uuid": return { type: "string", format: "uuid" };
386
+ case "nullable-uuid": return { anyOf: [{ type: "null" }, { type: "string", format: "uuid" }] };
387
+ case "nullable-stable-key": return { anyOf: [{ type: "null" }, { type: "string", pattern: STABLE_KEY_PATTERN.source }] };
355
388
  case "hex-color": return { type: "string", pattern: HEX_COLOR_PATTERN.source };
356
389
  case "hex-color-or-empty": return { type: "string", pattern: `^(?:|#[0-9a-fA-F]{6})$` };
357
390
  case "safe-storage-relative-path": return { type: "string", minLength: 1 };
@@ -397,6 +430,7 @@
397
430
  }
398
431
  function jsonSchemaFor(templateId) {
399
432
  const contract = getContract(templateId);
433
+ if (templateId === "ssobig.timeline") return { oneOf: [clone(schemaForShape(contract.root, contract)), clone(schemaForShape(EVENT_TIMELINE_CONTRACT.root, EVENT_TIMELINE_CONTRACT))] };
400
434
  return contract ? clone(schemaForShape(contract.root, contract)) : null;
401
435
  }
402
436
 
@@ -433,6 +467,8 @@
433
467
  }
434
468
  function validateField(value, definition, contract, path) {
435
469
  switch (definition.type) {
470
+ case "nullable-uuid": return value === null || (typeof value === "string" && UUID_PATTERN.test(value)) ? null : `${path} 값은 UUID 또는 null이어야 합니다.`;
471
+ case "nullable-stable-key": return value === null || (typeof value === "string" && STABLE_KEY_PATTERN.test(value)) ? null : `${path} 값은 캐릭터 ID 또는 null이어야 합니다.`;
436
472
  case "string": return typeof value === "string" ? null : `${path} 값은 문자열이어야 합니다.`;
437
473
  case "non-empty-string": return typeof value === "string" && value.trim() ? null : `${path} 값은 비어 있지 않은 문자열이어야 합니다.`;
438
474
  case "stable-key": return typeof value === "string" && STABLE_KEY_PATTERN.test(value) ? null : `${path} 값은 안정 식별자 형식이어야 합니다.`;
@@ -658,6 +694,7 @@
658
694
  }
659
695
  }
660
696
  function validateTargetData(templateId, data) {
697
+ if (templateId === "ssobig.timeline" && isObject(data) && Object.hasOwn(data, "timeGroups")) return eventTimelineError(data);
661
698
  const contract = getContract(templateId);
662
699
  if (!contract) return `지원하지 않는 Component 계약입니다: ${templateId}`;
663
700
  const invalid = validateShape(data, contract.root, contract, "$");
@@ -88,7 +88,7 @@
88
88
  }
89
89
  function applyRendererView(markup, view) {
90
90
  return String(markup)
91
- .replace(/data-renderer-view="(?:input|preview)"/, `data-renderer-view="${view}"`);
91
+ .replace(/data-renderer-view="(?:input|preview|list)"/, `data-renderer-view="${view}"`);
92
92
  }
93
93
  function renderAuthoring(instance, data, extra) {
94
94
  const renderer = resolve(instance);
@@ -152,7 +152,9 @@
152
152
  // 타임라인의 모양 배타, 시간 레벨 참조, lane/when 종류별 규칙은 validateComponentData가 이미 검증한다.
153
153
  // 여기서는 다른 Component를 봐야 판정할 수 있는 character 참조만 확인한다.
154
154
  for (const timeline of instances.filter(instance => instance.templateId === "ssobig.timeline")) {
155
- const invalid = Array.isArray(timeline.data.events)
155
+ const invalid = Array.isArray(timeline.data.timeGroups)
156
+ ? (timeline.data.events.some(event => event.characterId !== null && !characterIds.has(event.characterId)) ? `${timeline.instanceId} 사건이 존재하지 않는 캐릭터를 가리킵니다.` : null)
157
+ : Array.isArray(timeline.data.events)
156
158
  ? legacyTimelineError(timeline, characterIds)
157
159
  : timelineLaneCharacterError(timeline, characterIds);
158
160
  if (invalid) return invalid;
@@ -34,6 +34,7 @@
34
34
 
35
35
  function parseLine(text, index = 0, from = 0) {
36
36
  const source = String(text ?? "");
37
+ const horizontalRule = /^ {0,3}-(?:[ \t]*-){2,}[ \t]*$/.test(source);
37
38
  const heading = source.match(/^( {0,3})(#{1,4})(?:[ \t]+)(.*)$/);
38
39
  const quote = source.match(/^( {0,3})>[ \t]?(.*)$/);
39
40
  const unordered = source.match(/^( {0,3})([-*])([ \t]+)(.*)$/);
@@ -46,6 +47,7 @@
46
47
  const body = unordered ? unordered[4] : ordered ? ordered[5] : "";
47
48
  const markerFrom = list ? indent.length : -1;
48
49
  const markerTo = list ? markerFrom + marker.length : -1;
50
+ const task = unordered ? body.match(/^\[([ xX])\](?:[ \t]+|$)/) : null;
49
51
 
50
52
  return Object.freeze({
51
53
  index,
@@ -53,6 +55,7 @@
53
55
  from,
54
56
  to: from + source.length,
55
57
  empty: !source.trim(),
58
+ horizontalRule,
56
59
  headingLevel: heading ? heading[2].length : 0,
57
60
  headingMarkerFrom: heading ? heading[1].length : -1,
58
61
  headingMarkerTo: heading ? source.length - heading[3].length : -1,
@@ -68,7 +71,10 @@
68
71
  listMarkerTo: markerTo,
69
72
  listPrefixTo: list ? markerTo + separator.length : -1,
70
73
  listSeparator: separator,
71
- listBody: body,
74
+ listBody: task ? body.slice(task[0].length) : body,
75
+ taskChecked: task ? task[1].toLowerCase() === "x" : null,
76
+ taskStateFrom: task ? markerTo + separator.length + 1 : -1,
77
+ taskMarkerTo: task ? markerTo + separator.length + 3 : -1,
72
78
  listNumberText: ordered ? ordered[2] : "",
73
79
  listNumber: ordered ? Number.parseInt(ordered[2], 10) : null,
74
80
  listDelimiter: ordered ? ordered[3] : unordered ? unordered[2] : ""
@@ -360,6 +366,13 @@
360
366
  continue;
361
367
  }
362
368
 
369
+ if (line.horizontalRule) {
370
+ closeAll();
371
+ blocks.push({ type: "horizontalRule", lineIndex: line.index });
372
+ lineIndex += 1;
373
+ continue;
374
+ }
375
+
363
376
  if (line.quoteMarkerFrom >= 0) {
364
377
  closeParagraph();
365
378
  if (list?.items.length) {
@@ -408,6 +421,7 @@
408
421
  indent: line.listIndent,
409
422
  separator: line.listSeparator,
410
423
  body: line.listBody,
424
+ taskChecked: line.taskChecked,
411
425
  numberText: line.listNumberText,
412
426
  lineIndex: line.index,
413
427
  children: []
@@ -18,6 +18,7 @@
18
18
  const doc = parent.ownerDocument;
19
19
  let view = null;
20
20
  let disposed = false;
21
+ let picker = null;
21
22
  let serial = Promise.resolve();
22
23
  const jobs = new Map();
23
24
  const fileInputs = new Set();
@@ -243,13 +244,24 @@
243
244
  input.click();
244
245
  }
245
246
  function chooseAsset() {
247
+ if (picker || disposed) return;
246
248
  const mark = { from: view.state.selection.main.head, to: view.state.selection.main.head };
247
249
  if (!allowed(mark)) return;
248
250
  const id = addBookmark(mark);
249
251
  const dialog = doc.createElement("dialog");
252
+ picker = dialog;
250
253
  dialog.className = "writer-md-image-picker";
251
- dialog.setAttribute("aria-label", "에셋 이미지 선택");
252
- const heading = doc.createElement("h3"); heading.textContent = "에셋에서 이미지 선택";
254
+ dialog.setAttribute("aria-label", "이미지 삽입");
255
+ if (assets?.upload) {
256
+ const find = button("이미지 찾기", () => {
257
+ const current = view.state.field(bookmarks).get(id);
258
+ if (current) chooseFiles(current);
259
+ dialog.close();
260
+ });
261
+ find.className = "writer-md-image-picker-upload";
262
+ dialog.append(find);
263
+ }
264
+ const heading = doc.createElement("h3"); heading.textContent = "에셋 리스트";
253
265
  dialog.append(heading);
254
266
  const list = doc.createElement("div"); list.className = "writer-md-image-picker-list";
255
267
  const entries = (assets.list?.() || []).filter(entry => IMAGE_TYPES.includes(entry.type));
@@ -262,9 +274,9 @@
262
274
  if (url) { const image = doc.createElement("img"); image.src = url; image.alt = ""; item.prepend(image); }
263
275
  list.append(item);
264
276
  });
265
- if (!entries.length) list.textContent = "등록된 이미지가 없습니다. 이미지 업로드를 이용해주세요.";
277
+ if (!entries.length) list.textContent = "등록된 이미지가 없습니다. 위의 이미지 찾기로 추가해주세요.";
266
278
  dialog.append(list, button("닫기", () => dialog.close()));
267
- dialog.addEventListener("close", () => { dialog.remove(); if (!disposed) { view.dispatch({ effects: bookmarkEffect.of({ id }) }); view.focus(); } }, { once: true });
279
+ dialog.addEventListener("close", () => { picker = null; dialog.remove(); if (!disposed) { view.dispatch({ effects: bookmarkEffect.of({ id }) }); view.focus(); onIdle?.(); } }, { once: true });
268
280
  parent.append(dialog);
269
281
  dialog.showModal();
270
282
  }
@@ -300,7 +312,9 @@
300
312
  };
301
313
  }
302
314
  return {
303
- hasPending: () => jobs.size > 0 || fileInputs.size > 0,
315
+ chooseFiles,
316
+ chooseAsset,
317
+ hasPending: () => jobs.size > 0 || fileInputs.size > 0 || picker !== null,
304
318
  extensions: [bookmarks, decorations, events, cm.keymap.of([
305
319
  { key: "Backspace", run: protectBoundary(true) },
306
320
  { key: "Delete", run: protectBoundary(false) }
@@ -308,12 +322,14 @@
308
322
  attach(editor) {
309
323
  view = editor;
310
324
  if (assets?.upload) {
311
- toolbar.append(button("이미지 업로드", () => chooseFiles()), button("에셋에서 선택", chooseAsset));
312
- parent.prepend(toolbar);
325
+ if (!doc.defaultView.WriterMarkdownToolbar) {
326
+ toolbar.append(button("이미지 업로드", () => chooseFiles()), button("에셋에서 선택", chooseAsset));
327
+ parent.prepend(toolbar);
328
+ }
313
329
  parent.append(feedback);
314
330
  }
315
331
  },
316
- destroy() { disposed = true; jobs.clear(); fileInputs.forEach(input => input.remove()); fileInputs.clear(); toolbar.remove(); feedback.remove(); parent.querySelectorAll(".writer-md-image-picker").forEach(dialog => dialog.remove()); }
332
+ destroy() { disposed = true; picker = null; jobs.clear(); fileInputs.forEach(input => input.remove()); fileInputs.clear(); toolbar.remove(); feedback.remove(); parent.querySelectorAll(".writer-md-image-picker").forEach(dialog => dialog.remove()); }
317
333
  };
318
334
  }
319
335
  return Object.freeze({ create, insertion });