@rohal12/spindle 0.31.2 → 0.32.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.31.2",
3
+ "version": "0.32.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -119,7 +119,7 @@ defineMenubarAction({
119
119
  setup: () => {
120
120
  const load = useStoryStore((s) => s.load);
121
121
  const hasSave = useStoryStore((s) => s.hasSave);
122
- useStoryStore((s) => s.saveVersion);
122
+ useStoryStore((s) => s.knownSaves);
123
123
  return { perform: () => load(), disabled: !hasSave() };
124
124
  },
125
125
  });
@@ -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
  };
@@ -258,33 +263,58 @@ export async function getSavesGrouped(
258
263
  return result;
259
264
  }
260
265
 
261
- // --- Quick Save ---
266
+ // --- Quick Save / Slot Save ---
262
267
 
263
268
  const AUTOSAVE_KEY_PREFIX = 'autosave.';
269
+ const SLOT_KEY_PREFIX = 'slot.';
270
+ const SLOT_INDEX_KEY_PREFIX = 'slotIndex.';
271
+
272
+ function slotMetaKey(ifid: string, slot?: string): string {
273
+ return slot != null
274
+ ? `${SLOT_KEY_PREFIX}${slot}.${ifid}`
275
+ : `${AUTOSAVE_KEY_PREFIX}${ifid}`;
276
+ }
264
277
 
265
278
  export async function quickSave(
266
279
  ifid: string,
267
280
  playthroughId: string,
268
281
  payload: SavePayload,
282
+ slot?: string,
283
+ custom?: Record<string, unknown>,
269
284
  ): Promise<SaveRecord> {
270
- const metaKey = `${AUTOSAVE_KEY_PREFIX}${ifid}`;
285
+ const metaKey = slotMetaKey(ifid, slot);
271
286
  const existingId = await getMeta<string>(metaKey);
272
287
 
273
288
  if (existingId) {
274
- const updated = await overwriteSave(existingId, payload);
289
+ const updated = await overwriteSave(existingId, payload, custom);
275
290
  if (updated) return updated;
276
291
  }
277
292
 
278
- // Create new autosave
293
+ // Create new save
279
294
  const record = await createSave(ifid, playthroughId, payload, {
280
- isAutosave: true,
295
+ isAutosave: !slot,
296
+ ...(slot != null ? { slot } : {}),
297
+ ...custom,
281
298
  });
282
299
  await setMeta(metaKey, record.meta.id);
300
+
301
+ // Track named slots in the index
302
+ if (slot != null) {
303
+ const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
304
+ const existing = (await getMeta<string[]>(indexKey)) ?? [];
305
+ if (!existing.includes(slot)) {
306
+ await setMeta(indexKey, [...existing, slot]);
307
+ }
308
+ }
309
+
283
310
  return record;
284
311
  }
285
312
 
286
- export async function hasQuickSave(ifid: string): Promise<boolean> {
287
- const metaKey = `${AUTOSAVE_KEY_PREFIX}${ifid}`;
313
+ export async function hasQuickSave(
314
+ ifid: string,
315
+ slot?: string,
316
+ ): Promise<boolean> {
317
+ const metaKey = slotMetaKey(ifid, slot);
288
318
  const existingId = await getMeta<string>(metaKey);
289
319
  if (!existingId) return false;
290
320
  const record = await getSave(existingId);
@@ -293,13 +323,107 @@ export async function hasQuickSave(ifid: string): Promise<boolean> {
293
323
 
294
324
  export async function loadQuickSave(
295
325
  ifid: string,
326
+ slot?: string,
296
327
  ): Promise<SavePayload | undefined> {
297
- const metaKey = `${AUTOSAVE_KEY_PREFIX}${ifid}`;
328
+ const metaKey = slotMetaKey(ifid, slot);
298
329
  const existingId = await getMeta<string>(metaKey);
299
330
  if (!existingId) return undefined;
300
331
  return loadSave(existingId);
301
332
  }
302
333
 
334
+ /**
335
+ * Check IDB for all known saves and return a map of slot keys to true.
336
+ * The default (autosave) slot uses empty string as key.
337
+ */
338
+ export async function populateKnownSaves(
339
+ ifid: string,
340
+ ): Promise<Record<string, true>> {
341
+ const result: Record<string, true> = {};
342
+
343
+ // Check default autosave
344
+ if (await hasQuickSave(ifid)) {
345
+ result[''] = true;
346
+ }
347
+
348
+ // Check named slots from the index
349
+ const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
350
+ const slots = (await getMeta<string[]>(indexKey)) ?? [];
351
+ for (const slot of slots) {
352
+ if (await hasQuickSave(ifid, slot)) {
353
+ result[slot] = true;
354
+ }
355
+ }
356
+
357
+ return result;
358
+ }
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
+
303
427
  // --- Session Persistence (survives F5, cleared on tab close) ---
304
428
 
305
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 {
@@ -17,6 +17,10 @@ import {
17
17
  getCurrentPlaythroughId,
18
18
  quickSave,
19
19
  loadQuickSave,
20
+ populateKnownSaves,
21
+ getSlotSaveInfo,
22
+ listSlotSaves,
23
+ deleteSlotSave,
20
24
  saveSession,
21
25
  clearSession,
22
26
  } from './saves/save-manager';
@@ -198,7 +202,7 @@ export interface StoryState {
198
202
  historyIndex: number;
199
203
  visitCounts: Record<string, number>;
200
204
  renderCounts: Record<string, number>;
201
- saveVersion: number;
205
+ knownSaves: Record<string, true>;
202
206
  playthroughId: string;
203
207
  maxHistory: number;
204
208
  saveError: string | null;
@@ -221,9 +225,12 @@ export interface StoryState {
221
225
  deleteTemporary: (name: string) => void;
222
226
  trackRender: (passageName: string) => void;
223
227
  restart: () => void;
224
- save: (slot?: string) => void;
228
+ save: (slot?: string, custom?: Record<string, unknown>) => void;
225
229
  load: (slot?: string) => void;
226
230
  hasSave: (slot?: string) => boolean;
231
+ getSaveInfo: (slot?: string) => Promise<SaveInfo | null>;
232
+ listSaves: () => Promise<SaveInfo[]>;
233
+ deleteSave: (slot?: string) => void;
227
234
  getSavePayload: () => SavePayload;
228
235
  loadFromPayload: (payload: SavePayload) => void;
229
236
  getHistoryVariables: (index: number) => Record<string, unknown>;
@@ -243,7 +250,7 @@ export const useStoryStore = create<StoryState>()(
243
250
  historyIndex: -1,
244
251
  visitCounts: {},
245
252
  renderCounts: {},
246
- saveVersion: 0,
253
+ knownSaves: {},
247
254
  playthroughId: '',
248
255
  maxHistory: 40,
249
256
  saveError: null,
@@ -307,6 +314,14 @@ export const useStoryStore = create<StoryState>()(
307
314
  state.playthroughId = newId;
308
315
  });
309
316
  }
317
+
318
+ // Populate knownSaves from IDB so hasSave() works after reload
319
+ const saves = await populateKnownSaves(ifid);
320
+ if (Object.keys(saves).length > 0) {
321
+ set((state) => {
322
+ state.knownSaves = saves;
323
+ });
324
+ }
310
325
  })
311
326
  .catch((err) =>
312
327
  console.error('spindle: failed to init save system', err),
@@ -487,7 +502,7 @@ export const useStoryStore = create<StoryState>()(
487
502
  );
488
503
  },
489
504
 
490
- save: () => {
505
+ save: (slot?: string, custom?: Record<string, unknown>) => {
491
506
  const { storyData, playthroughId } = get();
492
507
  if (!storyData) return;
493
508
 
@@ -496,14 +511,17 @@ export const useStoryStore = create<StoryState>()(
496
511
  set((state) => {
497
512
  state.saveError = null;
498
513
  });
499
- quickSave(storyData.ifid, playthroughId, payload)
514
+ quickSave(storyData.ifid, playthroughId, payload, slot, custom)
500
515
  .then(() => {
501
516
  set((state) => {
502
- state.saveVersion++;
517
+ state.knownSaves = {
518
+ ...state.knownSaves,
519
+ [slot ?? '']: true,
520
+ };
503
521
  });
504
522
  })
505
523
  .catch((err) => {
506
- console.error('spindle: failed to quick save', err);
524
+ console.error('spindle: failed to save', err);
507
525
  set((state) => {
508
526
  state.saveError =
509
527
  err instanceof Error ? err.message : 'Failed to save';
@@ -511,20 +529,20 @@ export const useStoryStore = create<StoryState>()(
511
529
  });
512
530
  },
513
531
 
514
- load: () => {
532
+ load: (slot?: string) => {
515
533
  const { storyData } = get();
516
534
  if (!storyData) return;
517
535
 
518
536
  set((state) => {
519
537
  state.loadError = null;
520
538
  });
521
- loadQuickSave(storyData.ifid)
539
+ loadQuickSave(storyData.ifid, slot)
522
540
  .then((payload) => {
523
541
  if (!payload) return;
524
542
  get().loadFromPayload(payload);
525
543
  })
526
544
  .catch((err) => {
527
- console.error('spindle: failed to load quick save', err);
545
+ console.error('spindle: failed to load save', err);
528
546
  set((state) => {
529
547
  state.loadError =
530
548
  err instanceof Error ? err.message : 'Failed to load';
@@ -532,11 +550,39 @@ export const useStoryStore = create<StoryState>()(
532
550
  });
533
551
  },
534
552
 
535
- hasSave: () => {
536
- // Synchronous: return true if a save has been made this session
537
- const { storyData, saveVersion } = get();
553
+ hasSave: (slot?: string) => {
554
+ const { storyData, knownSaves } = get();
538
555
  if (!storyData) return false;
539
- return saveVersion > 0;
556
+ return (slot ?? '') in knownSaves;
557
+ },
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
+ });
540
586
  },
541
587
 
542
588
  getSavePayload: (): SavePayload => {
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