@rohal12/spindle 0.31.3 → 0.32.1

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": "@rohal12/spindle",
3
- "version": "0.31.3",
3
+ "version": "0.32.1",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -2,6 +2,7 @@ import type {
2
2
  SavePayload,
3
3
  SaveMeta,
4
4
  SaveRecord,
5
+ SaveInfo,
5
6
  PlaythroughRecord,
6
7
  SaveExport,
7
8
  } from './types';
@@ -134,6 +135,7 @@ export async function createSave(
134
135
  export async function overwriteSave(
135
136
  saveId: string,
136
137
  payload: SavePayload,
138
+ custom?: Record<string, unknown>,
137
139
  ): Promise<SaveRecord | undefined> {
138
140
  const existing = await getSave(saveId);
139
141
  if (!existing) return undefined;
@@ -149,6 +151,9 @@ export async function overwriteSave(
149
151
  ...existing.meta,
150
152
  updatedAt: new Date().toISOString(),
151
153
  passage: payload.passage,
154
+ ...(custom != null
155
+ ? { custom: { ...existing.meta.custom, ...custom } }
156
+ : {}),
152
157
  },
153
158
  payload: serializedPayload,
154
159
  };
@@ -275,12 +280,13 @@ export async function quickSave(
275
280
  playthroughId: string,
276
281
  payload: SavePayload,
277
282
  slot?: string,
283
+ custom?: Record<string, unknown>,
278
284
  ): Promise<SaveRecord> {
279
285
  const metaKey = slotMetaKey(ifid, slot);
280
286
  const existingId = await getMeta<string>(metaKey);
281
287
 
282
288
  if (existingId) {
283
- const updated = await overwriteSave(existingId, payload);
289
+ const updated = await overwriteSave(existingId, payload, custom);
284
290
  if (updated) return updated;
285
291
  }
286
292
 
@@ -288,6 +294,7 @@ export async function quickSave(
288
294
  const record = await createSave(ifid, playthroughId, payload, {
289
295
  isAutosave: !slot,
290
296
  ...(slot != null ? { slot } : {}),
297
+ ...custom,
291
298
  });
292
299
  await setMeta(metaKey, record.meta.id);
293
300
 
@@ -350,6 +357,73 @@ export async function populateKnownSaves(
350
357
  return result;
351
358
  }
352
359
 
360
+ /**
361
+ * Get metadata for a specific save slot.
362
+ * Returns null if no save exists for that slot.
363
+ */
364
+ export async function getSlotSaveInfo(
365
+ ifid: string,
366
+ slot?: string,
367
+ ): Promise<SaveInfo | null> {
368
+ const metaKey = slotMetaKey(ifid, slot);
369
+ const existingId = await getMeta<string>(metaKey);
370
+ if (!existingId) return null;
371
+ const record = await getSave(existingId);
372
+ if (!record) return null;
373
+ return {
374
+ slot: slot ?? '',
375
+ title: record.meta.title,
376
+ passage: record.meta.passage,
377
+ createdAt: record.meta.createdAt,
378
+ updatedAt: record.meta.updatedAt,
379
+ custom: record.meta.custom ?? {},
380
+ };
381
+ }
382
+
383
+ /**
384
+ * List metadata for all known save slots (default + named).
385
+ */
386
+ export async function listSlotSaves(ifid: string): Promise<SaveInfo[]> {
387
+ const result: SaveInfo[] = [];
388
+
389
+ // Check default autosave
390
+ const defaultInfo = await getSlotSaveInfo(ifid);
391
+ if (defaultInfo) result.push(defaultInfo);
392
+
393
+ // Check named slots from the index
394
+ const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
395
+ const slots = (await getMeta<string[]>(indexKey)) ?? [];
396
+ for (const slot of slots) {
397
+ const info = await getSlotSaveInfo(ifid, slot);
398
+ if (info) result.push(info);
399
+ }
400
+
401
+ return result;
402
+ }
403
+
404
+ /**
405
+ * Delete a save by slot name. Removes from slot index if named.
406
+ */
407
+ export async function deleteSlotSave(
408
+ ifid: string,
409
+ slot?: string,
410
+ ): Promise<void> {
411
+ const metaKey = slotMetaKey(ifid, slot);
412
+ const existingId = await getMeta<string>(metaKey);
413
+ if (!existingId) return;
414
+
415
+ await idbDeleteSave(existingId);
416
+ await setMeta(metaKey, undefined);
417
+
418
+ // Remove from slot index if named
419
+ if (slot != null) {
420
+ const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
421
+ const existing = (await getMeta<string[]>(indexKey)) ?? [];
422
+ const updated = existing.filter((s) => s !== slot);
423
+ await setMeta(indexKey, updated);
424
+ }
425
+ }
426
+
353
427
  // --- Session Persistence (survives F5, cleared on tab close) ---
354
428
 
355
429
  const SESSION_KEY_PREFIX = 'spindle.session.';
@@ -41,6 +41,16 @@ export interface PlaythroughRecord {
41
41
  label: string;
42
42
  }
43
43
 
44
+ /** Public-facing save metadata for the Story API. */
45
+ export interface SaveInfo {
46
+ slot: string;
47
+ title: string;
48
+ passage: string;
49
+ createdAt: string;
50
+ updatedAt: string;
51
+ custom: Record<string, unknown>;
52
+ }
53
+
44
54
  export interface SaveExport {
45
55
  version: 1;
46
56
  ifid: string;
package/src/store.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  } from 'immer';
9
9
  import type { StoryData } from './parser';
10
10
  import type { TransitionConfig } from './transition';
11
- import type { SavePayload, SaveHistoryMoment } from './saves/types';
11
+ import type { SavePayload, SaveHistoryMoment, SaveInfo } from './saves/types';
12
12
  import { executeStoryInit } from './story-init';
13
13
  import { resetTriggers } from './triggers';
14
14
  import {
@@ -18,6 +18,9 @@ import {
18
18
  quickSave,
19
19
  loadQuickSave,
20
20
  populateKnownSaves,
21
+ getSlotSaveInfo,
22
+ listSlotSaves,
23
+ deleteSlotSave,
21
24
  saveSession,
22
25
  clearSession,
23
26
  } from './saves/save-manager';
@@ -222,9 +225,12 @@ export interface StoryState {
222
225
  deleteTemporary: (name: string) => void;
223
226
  trackRender: (passageName: string) => void;
224
227
  restart: () => void;
225
- save: (slot?: string) => void;
228
+ save: (slot?: string, custom?: Record<string, unknown>) => void;
226
229
  load: (slot?: string) => void;
227
230
  hasSave: (slot?: string) => boolean;
231
+ getSaveInfo: (slot?: string) => Promise<SaveInfo | null>;
232
+ listSaves: () => Promise<SaveInfo[]>;
233
+ deleteSave: (slot?: string) => void;
228
234
  getSavePayload: () => SavePayload;
229
235
  loadFromPayload: (payload: SavePayload) => void;
230
236
  getHistoryVariables: (index: number) => Record<string, unknown>;
@@ -496,7 +502,7 @@ export const useStoryStore = create<StoryState>()(
496
502
  );
497
503
  },
498
504
 
499
- save: (slot?: string) => {
505
+ save: (slot?: string, custom?: Record<string, unknown>) => {
500
506
  const { storyData, playthroughId } = get();
501
507
  if (!storyData) return;
502
508
 
@@ -505,7 +511,7 @@ export const useStoryStore = create<StoryState>()(
505
511
  set((state) => {
506
512
  state.saveError = null;
507
513
  });
508
- quickSave(storyData.ifid, playthroughId, payload, slot)
514
+ quickSave(storyData.ifid, playthroughId, payload, slot, custom)
509
515
  .then(() => {
510
516
  set((state) => {
511
517
  state.knownSaves = {
@@ -550,6 +556,35 @@ export const useStoryStore = create<StoryState>()(
550
556
  return (slot ?? '') in knownSaves;
551
557
  },
552
558
 
559
+ getSaveInfo: async (slot?: string): Promise<SaveInfo | null> => {
560
+ const { storyData } = get();
561
+ if (!storyData) return null;
562
+ return getSlotSaveInfo(storyData.ifid, slot);
563
+ },
564
+
565
+ listSaves: async (): Promise<SaveInfo[]> => {
566
+ const { storyData } = get();
567
+ if (!storyData) return [];
568
+ return listSlotSaves(storyData.ifid);
569
+ },
570
+
571
+ deleteSave: (slot?: string) => {
572
+ const { storyData } = get();
573
+ if (!storyData) return;
574
+
575
+ deleteSlotSave(storyData.ifid, slot)
576
+ .then(() => {
577
+ set((state) => {
578
+ const key = slot ?? '';
579
+ const { [key]: _, ...rest } = state.knownSaves;
580
+ state.knownSaves = rest as Record<string, true>;
581
+ });
582
+ })
583
+ .catch((err) => {
584
+ console.error('spindle: failed to delete save', err);
585
+ });
586
+ },
587
+
553
588
  getSavePayload: (): SavePayload => {
554
589
  const {
555
590
  currentPassage,
package/src/story-api.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { useStoryStore } from './store';
2
2
  import type { Passage } from './parser';
3
3
  import { settings } from './settings';
4
- import type { SavePayload } from './saves/types';
4
+ import type { SavePayload, SaveInfo } from './saves/types';
5
5
  import { setTitleGenerator } from './saves/save-manager';
6
6
  import { registerClass } from './class-registry';
7
7
  import { defineMacro } from './define-macro';
@@ -48,9 +48,12 @@ export interface StoryAPI {
48
48
  back(): void;
49
49
  forward(): void;
50
50
  restart(): void;
51
- save(slot?: string): void;
51
+ save(slot?: string, custom?: Record<string, unknown>): void;
52
52
  load(slot?: string): void;
53
53
  hasSave(slot?: string): boolean;
54
+ getSaveInfo(slot?: string): Promise<SaveInfo | null>;
55
+ listSaves(): Promise<SaveInfo[]>;
56
+ deleteSave(slot?: string): void;
54
57
  visited(name?: string): number;
55
58
  hasVisited(name?: string): boolean;
56
59
  hasVisitedAny(...names: string[]): boolean;
@@ -137,8 +140,8 @@ function createStoryAPI(): StoryAPI {
137
140
  useStoryStore.getState().restart();
138
141
  },
139
142
 
140
- save(slot?: string): void {
141
- useStoryStore.getState().save(slot);
143
+ save(slot?: string, custom?: Record<string, unknown>): void {
144
+ useStoryStore.getState().save(slot, custom);
142
145
  },
143
146
 
144
147
  load(slot?: string): void {
@@ -149,6 +152,18 @@ function createStoryAPI(): StoryAPI {
149
152
  return useStoryStore.getState().hasSave(slot);
150
153
  },
151
154
 
155
+ getSaveInfo(slot?: string): Promise<SaveInfo | null> {
156
+ return useStoryStore.getState().getSaveInfo(slot);
157
+ },
158
+
159
+ listSaves(): Promise<SaveInfo[]> {
160
+ return useStoryStore.getState().listSaves();
161
+ },
162
+
163
+ deleteSave(slot?: string): void {
164
+ useStoryStore.getState().deleteSave(slot);
165
+ },
166
+
152
167
  visited(name?: string): number {
153
168
  const state = useStoryStore.getState();
154
169
  return state.visitCounts[name ?? state.currentPassage] ?? 0;
package/types/index.d.ts CHANGED
@@ -102,6 +102,25 @@ export interface Passage {
102
102
  content: string;
103
103
  }
104
104
 
105
+ /**
106
+ * Metadata about a save slot, returned by `getSaveInfo()` and `listSaves()`.
107
+ * @see {@link ../../src/saves/types.ts} for the implementation.
108
+ */
109
+ export interface SaveInfo {
110
+ /** Slot name (empty string for the default autosave slot). */
111
+ slot: string;
112
+ /** Save title (generated or custom). */
113
+ title: string;
114
+ /** Passage name at the time of saving. */
115
+ passage: string;
116
+ /** ISO 8601 timestamp when the save was first created. */
117
+ createdAt: string;
118
+ /** ISO 8601 timestamp when the save was last updated. */
119
+ updatedAt: string;
120
+ /** Custom metadata passed when saving. */
121
+ custom: Record<string, unknown>;
122
+ }
123
+
105
124
  /**
106
125
  * The main Story API available as `window.Story` at runtime.
107
126
  * Provides access to variables, navigation, save/load, and visit tracking.
@@ -128,8 +147,8 @@ export interface StoryAPI {
128
147
  /** Restart the story from the beginning. */
129
148
  restart(): void;
130
149
 
131
- /** Save the current state (quick save). */
132
- save(slot?: string): void;
150
+ /** Save the current state. Pass `slot` for a named save, `custom` for metadata. */
151
+ save(slot?: string, custom?: Record<string, unknown>): void;
133
152
 
134
153
  /** Load a saved state (quick load). */
135
154
  load(slot?: string): void;
@@ -137,6 +156,15 @@ export interface StoryAPI {
137
156
  /** Check whether a save exists. */
138
157
  hasSave(slot?: string): boolean;
139
158
 
159
+ /** Get metadata for a specific save slot. Returns null if no save exists. */
160
+ getSaveInfo(slot?: string): Promise<SaveInfo | null>;
161
+
162
+ /** List metadata for all known save slots. */
163
+ listSaves(): Promise<SaveInfo[]>;
164
+
165
+ /** Delete a save by slot name. */
166
+ deleteSave(slot?: string): void;
167
+
140
168
  /** Return the number of times a passage has been visited. */
141
169
  visited(name: string): number;
142
170