@rohal12/spindle 0.1.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 +66 -0
- package/dist/pkg/format.js +1 -0
- package/dist/pkg/index.js +12 -0
- package/dist/pkg/types/globals.d.ts +18 -0
- package/dist/pkg/types/index.d.ts +158 -0
- package/package.json +71 -0
- package/src/components/App.tsx +53 -0
- package/src/components/Passage.tsx +36 -0
- package/src/components/PassageLink.tsx +35 -0
- package/src/components/SaveLoadDialog.tsx +403 -0
- package/src/components/SettingsDialog.tsx +106 -0
- package/src/components/StoryInterface.tsx +31 -0
- package/src/components/macros/Back.tsx +23 -0
- package/src/components/macros/Button.tsx +49 -0
- package/src/components/macros/Checkbox.tsx +41 -0
- package/src/components/macros/Computed.tsx +100 -0
- package/src/components/macros/Cycle.tsx +39 -0
- package/src/components/macros/Do.tsx +46 -0
- package/src/components/macros/For.tsx +113 -0
- package/src/components/macros/Forward.tsx +25 -0
- package/src/components/macros/Goto.tsx +23 -0
- package/src/components/macros/If.tsx +63 -0
- package/src/components/macros/Include.tsx +52 -0
- package/src/components/macros/Listbox.tsx +42 -0
- package/src/components/macros/MacroLink.tsx +107 -0
- package/src/components/macros/Numberbox.tsx +43 -0
- package/src/components/macros/Print.tsx +48 -0
- package/src/components/macros/QuickLoad.tsx +33 -0
- package/src/components/macros/QuickSave.tsx +22 -0
- package/src/components/macros/Radiobutton.tsx +59 -0
- package/src/components/macros/Repeat.tsx +53 -0
- package/src/components/macros/Restart.tsx +27 -0
- package/src/components/macros/Saves.tsx +25 -0
- package/src/components/macros/Set.tsx +36 -0
- package/src/components/macros/SettingsButton.tsx +29 -0
- package/src/components/macros/Stop.tsx +12 -0
- package/src/components/macros/StoryTitle.tsx +20 -0
- package/src/components/macros/Switch.tsx +69 -0
- package/src/components/macros/Textarea.tsx +41 -0
- package/src/components/macros/Textbox.tsx +40 -0
- package/src/components/macros/Timed.tsx +63 -0
- package/src/components/macros/Type.tsx +83 -0
- package/src/components/macros/Unset.tsx +25 -0
- package/src/components/macros/VarDisplay.tsx +44 -0
- package/src/components/macros/Widget.tsx +18 -0
- package/src/components/macros/option-utils.ts +14 -0
- package/src/expression.ts +93 -0
- package/src/index.tsx +120 -0
- package/src/markup/ast.ts +284 -0
- package/src/markup/markdown.ts +21 -0
- package/src/markup/render.tsx +537 -0
- package/src/markup/tokenizer.ts +581 -0
- package/src/parser.ts +72 -0
- package/src/registry.ts +21 -0
- package/src/saves/idb.ts +165 -0
- package/src/saves/save-manager.ts +317 -0
- package/src/saves/types.ts +40 -0
- package/src/settings.ts +96 -0
- package/src/store.ts +317 -0
- package/src/story-api.ts +129 -0
- package/src/story-init.ts +67 -0
- package/src/story-variables.ts +166 -0
- package/src/styles.css +780 -0
- package/src/utils/parse-delay.ts +14 -0
- package/src/widgets/widget-registry.ts +15 -0
package/src/saves/idb.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { SaveRecord, PlaythroughRecord } from './types';
|
|
2
|
+
|
|
3
|
+
const DB_NAME = 'spindle';
|
|
4
|
+
const DB_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
let dbPromise: Promise<IDBDatabase> | null = null;
|
|
7
|
+
let fallbackStore: {
|
|
8
|
+
saves: Map<string, SaveRecord>;
|
|
9
|
+
playthroughs: Map<string, PlaythroughRecord>;
|
|
10
|
+
meta: Map<string, unknown>;
|
|
11
|
+
} | null = null;
|
|
12
|
+
|
|
13
|
+
function useFallback() {
|
|
14
|
+
if (!fallbackStore) {
|
|
15
|
+
fallbackStore = {
|
|
16
|
+
saves: new Map(),
|
|
17
|
+
playthroughs: new Map(),
|
|
18
|
+
meta: new Map(),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return fallbackStore;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function openDB(): Promise<IDBDatabase> {
|
|
25
|
+
if (dbPromise) return dbPromise;
|
|
26
|
+
|
|
27
|
+
if (typeof indexedDB === 'undefined') {
|
|
28
|
+
return Promise.reject(new Error('IndexedDB unavailable'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
dbPromise = new Promise((resolve, reject) => {
|
|
32
|
+
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
|
33
|
+
|
|
34
|
+
req.onupgradeneeded = () => {
|
|
35
|
+
const db = req.result;
|
|
36
|
+
|
|
37
|
+
if (!db.objectStoreNames.contains('saves')) {
|
|
38
|
+
const saves = db.createObjectStore('saves', { keyPath: 'meta.id' });
|
|
39
|
+
saves.createIndex('ifid', 'meta.ifid', { unique: false });
|
|
40
|
+
saves.createIndex('playthroughId', 'meta.playthroughId', {
|
|
41
|
+
unique: false,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!db.objectStoreNames.contains('playthroughs')) {
|
|
46
|
+
const pt = db.createObjectStore('playthroughs', { keyPath: 'id' });
|
|
47
|
+
pt.createIndex('ifid', 'ifid', { unique: false });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!db.objectStoreNames.contains('meta')) {
|
|
51
|
+
db.createObjectStore('meta', { keyPath: 'key' });
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
req.onsuccess = () => resolve(req.result);
|
|
56
|
+
req.onerror = () => reject(req.error);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return dbPromise;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function tx(
|
|
63
|
+
storeName: string,
|
|
64
|
+
mode: IDBTransactionMode,
|
|
65
|
+
): Promise<IDBObjectStore> {
|
|
66
|
+
return openDB().then((db) =>
|
|
67
|
+
db.transaction(storeName, mode).objectStore(storeName),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function idbRequest<T>(req: IDBRequest<T>): Promise<T> {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
req.onsuccess = () => resolve(req.result);
|
|
74
|
+
req.onerror = () => reject(req.error);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- Saves ---
|
|
79
|
+
|
|
80
|
+
export async function putSave(record: SaveRecord): Promise<void> {
|
|
81
|
+
try {
|
|
82
|
+
const store = await tx('saves', 'readwrite');
|
|
83
|
+
await idbRequest(store.put(record));
|
|
84
|
+
} catch {
|
|
85
|
+
useFallback().saves.set(record.meta.id, record);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function getSave(id: string): Promise<SaveRecord | undefined> {
|
|
90
|
+
try {
|
|
91
|
+
const store = await tx('saves', 'readonly');
|
|
92
|
+
return (await idbRequest(store.get(id))) ?? undefined;
|
|
93
|
+
} catch {
|
|
94
|
+
return useFallback().saves.get(id);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function deleteSave(id: string): Promise<void> {
|
|
99
|
+
try {
|
|
100
|
+
const store = await tx('saves', 'readwrite');
|
|
101
|
+
await idbRequest(store.delete(id));
|
|
102
|
+
} catch {
|
|
103
|
+
useFallback().saves.delete(id);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function getSavesByIfid(ifid: string): Promise<SaveRecord[]> {
|
|
108
|
+
try {
|
|
109
|
+
const store = await tx('saves', 'readonly');
|
|
110
|
+
const index = store.index('ifid');
|
|
111
|
+
return (await idbRequest(index.getAll(ifid))) ?? [];
|
|
112
|
+
} catch {
|
|
113
|
+
return [...useFallback().saves.values()].filter(
|
|
114
|
+
(s) => s.meta.ifid === ifid,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- Playthroughs ---
|
|
120
|
+
|
|
121
|
+
export async function putPlaythrough(record: PlaythroughRecord): Promise<void> {
|
|
122
|
+
try {
|
|
123
|
+
const store = await tx('playthroughs', 'readwrite');
|
|
124
|
+
await idbRequest(store.put(record));
|
|
125
|
+
} catch {
|
|
126
|
+
useFallback().playthroughs.set(record.id, record);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function getPlaythroughsByIfid(
|
|
131
|
+
ifid: string,
|
|
132
|
+
): Promise<PlaythroughRecord[]> {
|
|
133
|
+
try {
|
|
134
|
+
const store = await tx('playthroughs', 'readonly');
|
|
135
|
+
const index = store.index('ifid');
|
|
136
|
+
return (await idbRequest(index.getAll(ifid))) ?? [];
|
|
137
|
+
} catch {
|
|
138
|
+
return [...useFallback().playthroughs.values()].filter(
|
|
139
|
+
(p) => p.ifid === ifid,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- Meta (key-value) ---
|
|
145
|
+
|
|
146
|
+
export async function getMeta<T = unknown>(
|
|
147
|
+
key: string,
|
|
148
|
+
): Promise<T | undefined> {
|
|
149
|
+
try {
|
|
150
|
+
const store = await tx('meta', 'readonly');
|
|
151
|
+
const row = await idbRequest(store.get(key));
|
|
152
|
+
return row ? (row as { key: string; value: T }).value : undefined;
|
|
153
|
+
} catch {
|
|
154
|
+
return useFallback().meta.get(key) as T | undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function setMeta(key: string, value: unknown): Promise<void> {
|
|
159
|
+
try {
|
|
160
|
+
const store = await tx('meta', 'readwrite');
|
|
161
|
+
await idbRequest(store.put({ key, value }));
|
|
162
|
+
} catch {
|
|
163
|
+
useFallback().meta.set(key, value);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SavePayload,
|
|
3
|
+
SaveMeta,
|
|
4
|
+
SaveRecord,
|
|
5
|
+
PlaythroughRecord,
|
|
6
|
+
SaveExport,
|
|
7
|
+
} from './types';
|
|
8
|
+
import {
|
|
9
|
+
putSave,
|
|
10
|
+
getSave,
|
|
11
|
+
deleteSave as idbDeleteSave,
|
|
12
|
+
getSavesByIfid,
|
|
13
|
+
putPlaythrough,
|
|
14
|
+
getPlaythroughsByIfid,
|
|
15
|
+
getMeta,
|
|
16
|
+
setMeta,
|
|
17
|
+
} from './idb';
|
|
18
|
+
|
|
19
|
+
type TitleGenerator = (payload: SavePayload) => string;
|
|
20
|
+
|
|
21
|
+
let titleGenerator: TitleGenerator | null = null;
|
|
22
|
+
let saveTitlePassageContent: string | null = null;
|
|
23
|
+
let initialized = false;
|
|
24
|
+
|
|
25
|
+
// --- Title Generation ---
|
|
26
|
+
|
|
27
|
+
export function setTitleGenerator(fn: TitleGenerator): void {
|
|
28
|
+
titleGenerator = fn;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function setSaveTitlePassage(content: string): void {
|
|
32
|
+
saveTitlePassageContent = content;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function generateTitle(payload: SavePayload): string {
|
|
36
|
+
// SaveTitle passage takes precedence
|
|
37
|
+
if (saveTitlePassageContent) {
|
|
38
|
+
try {
|
|
39
|
+
const fn = new Function(
|
|
40
|
+
'passage',
|
|
41
|
+
'variables',
|
|
42
|
+
saveTitlePassageContent,
|
|
43
|
+
) as (passage: string, variables: Record<string, unknown>) => string;
|
|
44
|
+
const result = fn(payload.passage, payload.variables);
|
|
45
|
+
if (typeof result === 'string' && result.trim()) return result.trim();
|
|
46
|
+
} catch {
|
|
47
|
+
// fall through to other generators
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (titleGenerator) {
|
|
52
|
+
try {
|
|
53
|
+
const result = titleGenerator(payload);
|
|
54
|
+
if (typeof result === 'string' && result.trim()) return result.trim();
|
|
55
|
+
} catch {
|
|
56
|
+
// fall through to default
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Default: passage name + timestamp
|
|
61
|
+
const now = new Date();
|
|
62
|
+
const time = now.toLocaleTimeString(undefined, {
|
|
63
|
+
hour: '2-digit',
|
|
64
|
+
minute: '2-digit',
|
|
65
|
+
});
|
|
66
|
+
return `${payload.passage} - ${time}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- Init ---
|
|
70
|
+
|
|
71
|
+
export async function initSaveSystem(): Promise<void> {
|
|
72
|
+
if (initialized) return;
|
|
73
|
+
initialized = true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- Playthroughs ---
|
|
77
|
+
|
|
78
|
+
export async function startNewPlaythrough(ifid: string): Promise<string> {
|
|
79
|
+
const existing = await getPlaythroughsByIfid(ifid);
|
|
80
|
+
const num = existing.length + 1;
|
|
81
|
+
|
|
82
|
+
const id = crypto.randomUUID();
|
|
83
|
+
const record: PlaythroughRecord = {
|
|
84
|
+
id,
|
|
85
|
+
ifid,
|
|
86
|
+
createdAt: new Date().toISOString(),
|
|
87
|
+
label: `Playthrough ${num}`,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
await putPlaythrough(record);
|
|
91
|
+
await setMeta(`currentPlaythroughId.${ifid}`, id);
|
|
92
|
+
return id;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function getCurrentPlaythroughId(
|
|
96
|
+
ifid: string,
|
|
97
|
+
): Promise<string | undefined> {
|
|
98
|
+
return getMeta<string>(`currentPlaythroughId.${ifid}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- Save CRUD ---
|
|
102
|
+
|
|
103
|
+
export async function createSave(
|
|
104
|
+
ifid: string,
|
|
105
|
+
playthroughId: string,
|
|
106
|
+
payload: SavePayload,
|
|
107
|
+
custom: Record<string, unknown> = {},
|
|
108
|
+
): Promise<SaveRecord> {
|
|
109
|
+
const now = new Date().toISOString();
|
|
110
|
+
const meta: SaveMeta = {
|
|
111
|
+
id: crypto.randomUUID(),
|
|
112
|
+
ifid,
|
|
113
|
+
playthroughId,
|
|
114
|
+
createdAt: now,
|
|
115
|
+
updatedAt: now,
|
|
116
|
+
title: generateTitle(payload),
|
|
117
|
+
passage: payload.passage,
|
|
118
|
+
custom,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const record: SaveRecord = { meta, payload: structuredClone(payload) };
|
|
122
|
+
await putSave(record);
|
|
123
|
+
return record;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function overwriteSave(
|
|
127
|
+
saveId: string,
|
|
128
|
+
payload: SavePayload,
|
|
129
|
+
): Promise<SaveRecord | undefined> {
|
|
130
|
+
const existing = await getSave(saveId);
|
|
131
|
+
if (!existing) return undefined;
|
|
132
|
+
|
|
133
|
+
existing.meta.updatedAt = new Date().toISOString();
|
|
134
|
+
existing.meta.passage = payload.passage;
|
|
135
|
+
existing.payload = structuredClone(payload);
|
|
136
|
+
await putSave(existing);
|
|
137
|
+
return existing;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function loadSave(
|
|
141
|
+
saveId: string,
|
|
142
|
+
): Promise<SavePayload | undefined> {
|
|
143
|
+
const record = await getSave(saveId);
|
|
144
|
+
return record?.payload;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function deleteSaveById(saveId: string): Promise<void> {
|
|
148
|
+
await idbDeleteSave(saveId);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function renameSave(
|
|
152
|
+
saveId: string,
|
|
153
|
+
newTitle: string,
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
const record = await getSave(saveId);
|
|
156
|
+
if (!record) return;
|
|
157
|
+
record.meta.title = newTitle;
|
|
158
|
+
record.meta.updatedAt = new Date().toISOString();
|
|
159
|
+
await putSave(record);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- Grouped Retrieval ---
|
|
163
|
+
|
|
164
|
+
export interface PlaythroughGroup {
|
|
165
|
+
playthrough: PlaythroughRecord;
|
|
166
|
+
saves: SaveRecord[];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function getSavesGrouped(
|
|
170
|
+
ifid: string,
|
|
171
|
+
): Promise<PlaythroughGroup[]> {
|
|
172
|
+
const [allSaves, allPlaythroughs] = await Promise.all([
|
|
173
|
+
getSavesByIfid(ifid),
|
|
174
|
+
getPlaythroughsByIfid(ifid),
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
const ptMap = new Map<string, PlaythroughRecord>();
|
|
178
|
+
for (const pt of allPlaythroughs) ptMap.set(pt.id, pt);
|
|
179
|
+
|
|
180
|
+
const groups = new Map<string, SaveRecord[]>();
|
|
181
|
+
for (const save of allSaves) {
|
|
182
|
+
const pid = save.meta.playthroughId;
|
|
183
|
+
if (!groups.has(pid)) groups.set(pid, []);
|
|
184
|
+
groups.get(pid)!.push(save);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Sort saves within each group newest-first
|
|
188
|
+
for (const saves of groups.values()) {
|
|
189
|
+
saves.sort(
|
|
190
|
+
(a, b) =>
|
|
191
|
+
new Date(b.meta.updatedAt).getTime() -
|
|
192
|
+
new Date(a.meta.updatedAt).getTime(),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Build result sorted by playthrough creation newest-first
|
|
197
|
+
const result: PlaythroughGroup[] = [];
|
|
198
|
+
const sortedPts = [...ptMap.values()].sort(
|
|
199
|
+
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
for (const pt of sortedPts) {
|
|
203
|
+
const saves = groups.get(pt.id) ?? [];
|
|
204
|
+
result.push({ playthrough: pt, saves });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Include any orphaned saves (playthrough record missing)
|
|
208
|
+
for (const [pid, saves] of groups) {
|
|
209
|
+
if (!ptMap.has(pid)) {
|
|
210
|
+
result.push({
|
|
211
|
+
playthrough: {
|
|
212
|
+
id: pid,
|
|
213
|
+
ifid,
|
|
214
|
+
createdAt: saves[0]?.meta.createdAt ?? new Date().toISOString(),
|
|
215
|
+
label: 'Unknown Playthrough',
|
|
216
|
+
},
|
|
217
|
+
saves,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- Quick Save ---
|
|
226
|
+
|
|
227
|
+
const AUTOSAVE_KEY_PREFIX = 'autosave.';
|
|
228
|
+
|
|
229
|
+
export async function quickSave(
|
|
230
|
+
ifid: string,
|
|
231
|
+
playthroughId: string,
|
|
232
|
+
payload: SavePayload,
|
|
233
|
+
): Promise<SaveRecord> {
|
|
234
|
+
const metaKey = `${AUTOSAVE_KEY_PREFIX}${ifid}`;
|
|
235
|
+
const existingId = await getMeta<string>(metaKey);
|
|
236
|
+
|
|
237
|
+
if (existingId) {
|
|
238
|
+
const updated = await overwriteSave(existingId, payload);
|
|
239
|
+
if (updated) return updated;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Create new autosave
|
|
243
|
+
const record = await createSave(ifid, playthroughId, payload, {
|
|
244
|
+
isAutosave: true,
|
|
245
|
+
});
|
|
246
|
+
await setMeta(metaKey, record.meta.id);
|
|
247
|
+
return record;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function hasQuickSave(ifid: string): Promise<boolean> {
|
|
251
|
+
const metaKey = `${AUTOSAVE_KEY_PREFIX}${ifid}`;
|
|
252
|
+
const existingId = await getMeta<string>(metaKey);
|
|
253
|
+
if (!existingId) return false;
|
|
254
|
+
const record = await getSave(existingId);
|
|
255
|
+
return record !== undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function loadQuickSave(
|
|
259
|
+
ifid: string,
|
|
260
|
+
): Promise<SavePayload | undefined> {
|
|
261
|
+
const metaKey = `${AUTOSAVE_KEY_PREFIX}${ifid}`;
|
|
262
|
+
const existingId = await getMeta<string>(metaKey);
|
|
263
|
+
if (!existingId) return undefined;
|
|
264
|
+
return loadSave(existingId);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --- Export / Import ---
|
|
268
|
+
|
|
269
|
+
export async function exportSave(
|
|
270
|
+
saveId: string,
|
|
271
|
+
): Promise<SaveExport | undefined> {
|
|
272
|
+
const record = await getSave(saveId);
|
|
273
|
+
if (!record) return undefined;
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
version: 1,
|
|
277
|
+
ifid: record.meta.ifid,
|
|
278
|
+
exportedAt: new Date().toISOString(),
|
|
279
|
+
save: record,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function importSave(
|
|
284
|
+
data: SaveExport,
|
|
285
|
+
ifid: string,
|
|
286
|
+
): Promise<SaveRecord> {
|
|
287
|
+
if (data.version !== 1) {
|
|
288
|
+
throw new Error(`Unsupported save version: ${data.version}`);
|
|
289
|
+
}
|
|
290
|
+
if (data.ifid !== ifid) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`Save is from a different story (expected IFID ${ifid}, got ${data.ifid})`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Re-assign a new ID to avoid collisions
|
|
297
|
+
const record = structuredClone(data.save);
|
|
298
|
+
record.meta.id = crypto.randomUUID();
|
|
299
|
+
record.meta.updatedAt = new Date().toISOString();
|
|
300
|
+
|
|
301
|
+
// Ensure the playthrough exists
|
|
302
|
+
const playthroughs = await getPlaythroughsByIfid(ifid);
|
|
303
|
+
const ptExists = playthroughs.some((p) => p.id === record.meta.playthroughId);
|
|
304
|
+
if (!ptExists) {
|
|
305
|
+
// Create an "Imported" playthrough
|
|
306
|
+
const pt: PlaythroughRecord = {
|
|
307
|
+
id: record.meta.playthroughId,
|
|
308
|
+
ifid,
|
|
309
|
+
createdAt: record.meta.createdAt,
|
|
310
|
+
label: 'Imported',
|
|
311
|
+
};
|
|
312
|
+
await putPlaythrough(pt);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
await putSave(record);
|
|
316
|
+
return record;
|
|
317
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { HistoryMoment } from '../store';
|
|
2
|
+
|
|
3
|
+
export interface SavePayload {
|
|
4
|
+
passage: string;
|
|
5
|
+
variables: Record<string, unknown>;
|
|
6
|
+
history: HistoryMoment[];
|
|
7
|
+
historyIndex: number;
|
|
8
|
+
visitCounts?: Record<string, number>;
|
|
9
|
+
renderCounts?: Record<string, number>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SaveMeta {
|
|
13
|
+
id: string;
|
|
14
|
+
ifid: string;
|
|
15
|
+
playthroughId: string;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
updatedAt: string;
|
|
18
|
+
title: string;
|
|
19
|
+
passage: string;
|
|
20
|
+
custom: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SaveRecord {
|
|
24
|
+
meta: SaveMeta;
|
|
25
|
+
payload: SavePayload;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PlaythroughRecord {
|
|
29
|
+
id: string;
|
|
30
|
+
ifid: string;
|
|
31
|
+
createdAt: string;
|
|
32
|
+
label: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SaveExport {
|
|
36
|
+
version: 1;
|
|
37
|
+
ifid: string;
|
|
38
|
+
exportedAt: string;
|
|
39
|
+
save: SaveRecord;
|
|
40
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useStoryStore } from './store';
|
|
2
|
+
|
|
3
|
+
export interface ToggleConfig {
|
|
4
|
+
label: string;
|
|
5
|
+
default: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ListConfig {
|
|
9
|
+
label: string;
|
|
10
|
+
options: string[];
|
|
11
|
+
default: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RangeConfig {
|
|
15
|
+
label: string;
|
|
16
|
+
min: number;
|
|
17
|
+
max: number;
|
|
18
|
+
step: number;
|
|
19
|
+
default: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type SettingDef =
|
|
23
|
+
| { type: 'toggle'; config: ToggleConfig }
|
|
24
|
+
| { type: 'list'; config: ListConfig }
|
|
25
|
+
| { type: 'range'; config: RangeConfig };
|
|
26
|
+
|
|
27
|
+
const definitions = new Map<string, SettingDef>();
|
|
28
|
+
let values: Record<string, unknown> = {};
|
|
29
|
+
|
|
30
|
+
function storageKey(): string {
|
|
31
|
+
const storyData = useStoryStore.getState().storyData;
|
|
32
|
+
const ifid = storyData?.ifid || 'unknown';
|
|
33
|
+
return `spindle.${ifid}.settings`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function persist(): void {
|
|
37
|
+
localStorage.setItem(storageKey(), JSON.stringify(values));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function loadFromStorage(): void {
|
|
41
|
+
try {
|
|
42
|
+
const raw = localStorage.getItem(storageKey());
|
|
43
|
+
if (raw) {
|
|
44
|
+
values = { ...values, ...JSON.parse(raw) };
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// ignore corrupted data
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const settings = {
|
|
52
|
+
addToggle(name: string, config: ToggleConfig): void {
|
|
53
|
+
definitions.set(name, { type: 'toggle', config });
|
|
54
|
+
if (!(name in values)) {
|
|
55
|
+
values[name] = config.default;
|
|
56
|
+
}
|
|
57
|
+
loadFromStorage();
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
addList(name: string, config: ListConfig): void {
|
|
61
|
+
definitions.set(name, { type: 'list', config });
|
|
62
|
+
if (!(name in values)) {
|
|
63
|
+
values[name] = config.default;
|
|
64
|
+
}
|
|
65
|
+
loadFromStorage();
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
addRange(name: string, config: RangeConfig): void {
|
|
69
|
+
definitions.set(name, { type: 'range', config });
|
|
70
|
+
if (!(name in values)) {
|
|
71
|
+
values[name] = config.default;
|
|
72
|
+
}
|
|
73
|
+
loadFromStorage();
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
get(name: string): unknown {
|
|
77
|
+
return values[name];
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
set(name: string, value: unknown): void {
|
|
81
|
+
values[name] = value;
|
|
82
|
+
persist();
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
getAll(): Record<string, unknown> {
|
|
86
|
+
return { ...values };
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
getDefinitions(): Map<string, SettingDef> {
|
|
90
|
+
return definitions;
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
hasAny(): boolean {
|
|
94
|
+
return definitions.size > 0;
|
|
95
|
+
},
|
|
96
|
+
};
|