@ssobig/writer-cli 0.3.3 → 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.3",
3
+ "version": "0.3.4",
4
4
  "description": "Official agent CLI for SSOBIG WRITER",
5
5
  "type": "commonjs",
6
6
  "license": "UNLICENSED",
@@ -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
  ]),
@@ -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, "$");
@@ -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;
@@ -16,6 +16,7 @@
16
16
  // events는 사건이 시간을 소유하는 legacy 모양, timeLevels/entries는 항목이 시간을 소유하는 v2 모양이다.
17
17
  function detectShape(data) {
18
18
  if (!isObject(data)) return "invalid";
19
+ if (Object.hasOwn(data, "timeGroups")) return Array.isArray(data.timeGroups) && Array.isArray(data.events) && Array.isArray(data.places) && !Object.hasOwn(data, "timeLevels") && !Object.hasOwn(data, "entries") ? "events" : "invalid";
19
20
  const events = Array.isArray(data.events);
20
21
  const levels = Array.isArray(data.timeLevels);
21
22
  const entries = Array.isArray(data.entries);
@@ -224,12 +225,72 @@
224
225
  }
225
226
 
226
227
  // 활성 런타임이 중립 이름으로 소비하는 정규화 진입점: 어떤 등록 모양이든 v2 data를 돌려준다.
228
+ // Explicit, reviewed migration only. Rendering and ordinary saves never call this.
229
+ function convertToEventTimeline(data) {
230
+ if (detectShape(data) !== "v2") throw new Error("시간 그룹 이관은 timeLevels/entries 원본만 지원합니다.");
231
+ if (data.entries.some(entry => entry.lane.kind === "unresolved")) throw new Error("미연결 캐릭터를 먼저 해결해 주세요.");
232
+ const places = JSON.parse(JSON.stringify(data.places || []));
233
+ const groups = data.timeLevels.map(level => ({ id: level.id, name: level.name, type: level.mode === "clock" ? "timeline" : "text" }));
234
+ const events = data.entries.map(entry => {
235
+ const group = groups.find(group => group.id === entry.timeLevelId);
236
+ if (!group) throw new Error("존재하지 않는 시간 그룹입니다.");
237
+ const ids = entry.placeIds || [];
238
+ const matched = places.find(place => place.name.trim() === text(entry.location).trim());
239
+ const placeId = ids.at(-1) || matched?.id || null;
240
+ const annotations = [];
241
+ const location = text(entry.location).trim();
242
+ if (ids.length > 1) annotations.push(`기존 이동 경로: ${entryPlaceNames(data, entry).join(" → ")}`);
243
+ if (location && ![places.find(place => place.id === placeId)?.name, entryPlaceNames(data, entry).join(" → ")].includes(location)) annotations.push(`기존 장소 메모: ${location}`);
244
+ if (!ids.length && !matched && location && !annotations.some(value => value.endsWith(location))) annotations.push(`기존 장소 메모: ${location}`);
245
+ const event = { id: entry.id, timeGroupId: entry.timeLevelId, description: entry.text, characterId: entry.lane.kind === "character" ? entry.lane.characterId : null, placeId };
246
+ if (entry.included === false) event.included = false;
247
+ if (group.type === "timeline") {
248
+ if (entry.when.kind !== "clock") throw new Error("시각이 없는 사건의 시간 범위를 먼저 지정해 주세요.");
249
+ const start = (entry.when.dayOffset || 0) * 1440 + entry.when.startMinute;
250
+ const end = entry.when.endMinute === undefined ? start + 30 : (entry.when.dayOffset || 0) * 1440 + entry.when.endMinute;
251
+ if (end <= start) throw new Error("시작 이후의 종료 시간을 지정해 주세요.");
252
+ event.timeRange = { start: minutePoint(start), end: minutePoint(end) };
253
+ const clockLabel = minute => `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`;
254
+ const canonical = clockLabel(entry.when.startMinute) + (entry.when.endMinute === undefined ? "" : `~${clockLabel(entry.when.endMinute)}`);
255
+ const normalized = text(entry.timeLabel).replace(/[–—−-]/g, "~").replace(/\s/g, "");
256
+ if (normalized && normalized !== canonical) annotations.push(`기존 시간 표현: ${entry.timeLabel}`);
257
+ } else if (text(entry.timeLabel).trim()) annotations.push(`기존 시간 표현: ${entry.timeLabel}`);
258
+ if (annotations.length) event.description += `${event.description ? "\n\n" : ""}${annotations.join("\n")}`;
259
+ return event;
260
+ });
261
+ return { timeGroups: groups, events, places };
262
+ }
263
+
264
+ function pointMinutes(point) { return point.day * 1440 + point.hour * 60 + point.minute; }
265
+ function minutePoint(value) { return { day: Math.floor(value / 1440), hour: Math.floor(value % 1440 / 60), minute: value % 60 }; }
266
+ function defaultRange(start = { day: 0, hour: 9, minute: 0 }) { return { start: { ...start }, end: minutePoint(pointMinutes(start) + 30) }; }
267
+ function pointLabel(point) { return `${point.day + 1}일차 ${String(point.hour).padStart(2, "0")}:${String(point.minute).padStart(2, "0")}`; }
268
+ function rangeLabel(range) { return range ? `${pointLabel(range.start)}–${pointLabel(range.end)}` : ""; }
269
+ function orderedEvents(data) {
270
+ const groups = new Map(data.timeGroups.map((group, index) => [group.id, { ...group, index }]));
271
+ return data.events.map((event, index) => ({ event, index })).sort((a, b) => {
272
+ const ga = groups.get(a.event.timeGroupId), gb = groups.get(b.event.timeGroupId);
273
+ return ga.index - gb.index || (ga.type === "timeline" ? pointMinutes(a.event.timeRange.start) - pointMinutes(b.event.timeRange.start) : 0) || a.index - b.index;
274
+ }).map(item => item.event);
275
+ }
276
+ // Projection only: old board/output consumers never own this derived representation.
277
+ function eventTimelineProjection(data) {
278
+ return { timeLevels: data.timeGroups.map(group => ({ id: group.id, name: group.name, mode: group.type === "timeline" ? "clock" : "manual" })), places: data.places,
279
+ entries: orderedEvents(data).map(event => ({ id: event.id, timeLevelId: event.timeGroupId,
280
+ lane: event.characterId === null ? { kind: "truth" } : { kind: "character", characterId: event.characterId },
281
+ when: event.timeRange ? { kind: "clock", dayOffset: event.timeRange.start.day, startMinute: event.timeRange.start.hour * 60 + event.timeRange.start.minute,
282
+ endMinute: pointMinutes(event.timeRange.end) - event.timeRange.start.day * 1440 } : { kind: "unspecified" },
283
+ timeLabel: rangeLabel(event.timeRange), text: event.description, included: event.included !== false,
284
+ placeIds: event.placeId === null ? [] : [event.placeId], location: "" })) };
285
+ }
286
+
227
287
  function normalizedTimelineData(data, options = {}) {
288
+ if (detectShape(data) === "events") return eventTimelineProjection(data);
228
289
  const shape = detectShape(data);
229
290
  if (shape === "v2") return data;
230
291
  if (shape === "legacy") return convertLegacyTimeline(data, options);
231
292
  throw new Error("타임라인 data가 등록된 모양이 아닙니다.");
232
293
  }
233
294
 
234
- return Object.freeze({ detectShape, convertLegacyTimeline, compareLegacyToV2, normalizedTimelineData, entryPlaceNames, orderedEntries, overlapGroups, deriveClusters });
295
+ return Object.freeze({ convertToEventTimeline, pointMinutes, minutePoint, defaultRange, pointLabel, rangeLabel, orderedEvents, eventTimelineProjection, detectShape, convertLegacyTimeline, compareLegacyToV2, normalizedTimelineData, entryPlaceNames, orderedEntries, overlapGroups, deriveClusters });
235
296
  });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ssobig/writer-cli",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@ssobig/writer-cli",
9
- "version": "0.3.3",
9
+ "version": "0.3.4",
10
10
  "dependencies": {
11
11
  "@supabase/supabase-js": "2.110.9"
12
12
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssobig/writer-cli",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "private": true,
5
5
  "description": "Internal agent-safe CLI for SSOBIG WRITER manuscript and asset operations",
6
6
  "type": "commonjs",