@keepkit/core 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/dist/index.js ADDED
@@ -0,0 +1,1093 @@
1
+ import {
2
+ DEFAULT_INDEXEDDB_DATABASE,
3
+ DEFAULT_INDEXEDDB_STORE,
4
+ DEFAULT_STORAGE_KEY,
5
+ DEFAULT_SYNC_QUEUE_DATABASE,
6
+ DEFAULT_SYNC_QUEUE_KEY,
7
+ DEFAULT_SYNC_QUEUE_STORE,
8
+ IndexedDBAdapter,
9
+ IndexedDBSyncQueueAdapter,
10
+ KeepStorageAccessError,
11
+ KeepStorageError,
12
+ KeepStorageParseError,
13
+ KeepStorageQuotaError,
14
+ LocalStorageAdapter,
15
+ LocalStorageSyncQueueAdapter,
16
+ SyncStorageAdapter,
17
+ createStorageAdapter,
18
+ normalizeKeepTags
19
+ } from "./chunk-H4A322SZ.js";
20
+
21
+ // src/migration.ts
22
+ async function mergeKeepItems(localItems, target) {
23
+ if (target.merge) return target.merge(localItems);
24
+ const remoteItems = await target.getAll();
25
+ const byId = new Map(remoteItems.map((item) => [item.id, item]));
26
+ for (const localItem of localItems) {
27
+ const remoteItem = byId.get(localItem.id);
28
+ if (!remoteItem || localItem.updatedAt > remoteItem.updatedAt) {
29
+ byId.set(localItem.id, localItem);
30
+ }
31
+ }
32
+ const merged = [...byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
33
+ await Promise.all(merged.map((item) => target.set(item)));
34
+ return merged;
35
+ }
36
+ async function migrateKeepItems(source, target) {
37
+ const localItems = await source.getAll();
38
+ const merged = await mergeKeepItems(localItems, target);
39
+ await source.clear();
40
+ return merged;
41
+ }
42
+
43
+ // src/schema.ts
44
+ var KeepSchemaValidationError = class extends Error {
45
+ constructor(message, options = {}) {
46
+ super(message);
47
+ this.name = "KeepSchemaValidationError";
48
+ this.cause = options.cause;
49
+ this.itemId = options.itemId;
50
+ }
51
+ };
52
+ async function parseKeepMeta(schema, value) {
53
+ try {
54
+ if ("parse" in schema) return await schema.parse(value);
55
+ if ("safeParse" in schema) {
56
+ const result2 = await schema.safeParse(value);
57
+ if (result2.success) return result2.data;
58
+ throw new KeepSchemaValidationError("KeepKit metadata did not match the configured schema.", {
59
+ cause: result2.error
60
+ });
61
+ }
62
+ const result = await schema["~standard"].validate(value);
63
+ if (!result.issues && "value" in result) return result.value;
64
+ throw new KeepSchemaValidationError("KeepKit metadata did not match the configured schema.", {
65
+ cause: result.issues
66
+ });
67
+ } catch (cause) {
68
+ if (cause instanceof KeepSchemaValidationError) throw cause;
69
+ throw new KeepSchemaValidationError("KeepKit metadata did not match the configured schema.", {
70
+ cause
71
+ });
72
+ }
73
+ }
74
+ async function validateKeepItem(item, schema) {
75
+ return { ...item, meta: await parseKeepMeta(schema, item.meta) };
76
+ }
77
+
78
+ // src/backup.ts
79
+ var KEEP_BACKUP_FORMAT = "keepkit";
80
+ var KEEP_BACKUP_VERSION = 1;
81
+ var KeepBackupParseError = class extends Error {
82
+ constructor(message, options) {
83
+ super(message);
84
+ this.name = "KeepBackupParseError";
85
+ if (options?.cause !== void 0) this.cause = options.cause;
86
+ }
87
+ };
88
+ var KeepBackupImportError = class extends Error {
89
+ constructor(message, options) {
90
+ super(message);
91
+ this.name = "KeepBackupImportError";
92
+ this.mode = options.mode;
93
+ this.imported = options.imported;
94
+ this.failed = options.failed;
95
+ if (options.cause !== void 0) this.cause = options.cause;
96
+ }
97
+ };
98
+ async function exportItems(adapter) {
99
+ const backup = {
100
+ format: KEEP_BACKUP_FORMAT,
101
+ version: KEEP_BACKUP_VERSION,
102
+ exportedAt: Date.now(),
103
+ items: await adapter.getAll()
104
+ };
105
+ return JSON.stringify(backup, null, 2);
106
+ }
107
+ async function importItems(adapter, data, options = {}) {
108
+ const backup = parseBackup(data);
109
+ const mode = options.mode ?? "merge";
110
+ const validItems = [];
111
+ let failed = 0;
112
+ for (const item of backup.items) {
113
+ if (!options.schema) {
114
+ validItems.push(item);
115
+ continue;
116
+ }
117
+ try {
118
+ validItems.push(await validateKeepItem(item, options.schema));
119
+ } catch (cause) {
120
+ options.onInvalidItem?.(cause, item);
121
+ if ((options.invalidItemPolicy ?? "error") === "drop") {
122
+ failed += 1;
123
+ continue;
124
+ }
125
+ throw cause;
126
+ }
127
+ }
128
+ let items;
129
+ if (mode === "merge") {
130
+ try {
131
+ items = await mergeKeepItems(validItems, adapter);
132
+ } catch (cause) {
133
+ throw new KeepBackupImportError("KeepKit could not merge the backup.", {
134
+ mode,
135
+ imported: 0,
136
+ failed: validItems.length + failed,
137
+ cause
138
+ });
139
+ }
140
+ } else {
141
+ let imported = 0;
142
+ try {
143
+ await adapter.clear();
144
+ for (const item of validItems) {
145
+ await adapter.set(item);
146
+ imported += 1;
147
+ }
148
+ items = await adapter.getAll();
149
+ } catch (cause) {
150
+ throw new KeepBackupImportError("KeepKit could not replace the stored items.", {
151
+ mode,
152
+ imported,
153
+ failed: validItems.length + failed - imported,
154
+ cause
155
+ });
156
+ }
157
+ }
158
+ return { mode, imported: validItems.length, failed, total: items.length, items };
159
+ }
160
+ function parseBackup(data) {
161
+ let value = data;
162
+ if (typeof data === "string") {
163
+ try {
164
+ value = JSON.parse(data);
165
+ } catch (cause) {
166
+ throw new KeepBackupParseError("KeepKit backup is not valid JSON.", { cause });
167
+ }
168
+ }
169
+ if (!isRecord(value)) throw new KeepBackupParseError("KeepKit backup must be an object.");
170
+ if (value.format !== KEEP_BACKUP_FORMAT || value.version !== KEEP_BACKUP_VERSION) {
171
+ throw new KeepBackupParseError("KeepKit backup format or version is unsupported.");
172
+ }
173
+ if (typeof value.exportedAt !== "number" || !Number.isFinite(value.exportedAt)) {
174
+ throw new KeepBackupParseError("KeepKit backup has an invalid export timestamp.");
175
+ }
176
+ if (!Array.isArray(value.items) || !value.items.every(isKeepItem)) {
177
+ throw new KeepBackupParseError("KeepKit backup contains invalid items.");
178
+ }
179
+ return value;
180
+ }
181
+ function isKeepItem(value) {
182
+ if (!isRecord(value)) return false;
183
+ return typeof value.id === "string" && typeof value.savedAt === "number" && Number.isFinite(value.savedAt) && typeof value.updatedAt === "number" && Number.isFinite(value.updatedAt) && "meta" in value && (value.targetType === void 0 || typeof value.targetType === "string") && (value.note === void 0 || typeof value.note === "string") && (value.schemaVersion === void 0 || typeof value.schemaVersion === "number" && Number.isFinite(value.schemaVersion)) && (value.revision === void 0 || typeof value.revision === "string") && (value.tags === void 0 || Array.isArray(value.tags) && value.tags.every((tag) => typeof tag === "string"));
184
+ }
185
+ function isRecord(value) {
186
+ return typeof value === "object" && value !== null;
187
+ }
188
+
189
+ // src/hooks/useKeepItem.ts
190
+ import { useCallback as useCallback3 } from "react";
191
+
192
+ // src/KeepProvider.tsx
193
+ import {
194
+ createContext,
195
+ useCallback,
196
+ useContext,
197
+ useEffect,
198
+ useMemo,
199
+ useRef,
200
+ useSyncExternalStore
201
+ } from "react";
202
+
203
+ // src/store.ts
204
+ var KeepStore = class {
205
+ constructor(initialState) {
206
+ this.listeners = /* @__PURE__ */ new Set();
207
+ this.getSnapshot = () => this.state;
208
+ this.subscribe = (listener) => {
209
+ this.listeners.add(listener);
210
+ return () => this.listeners.delete(listener);
211
+ };
212
+ this.state = initialState;
213
+ }
214
+ setState(next) {
215
+ let changed = false;
216
+ for (const key of Object.keys(next)) {
217
+ if (!Object.is(this.state[key], next[key])) {
218
+ changed = true;
219
+ break;
220
+ }
221
+ }
222
+ if (!changed) return;
223
+ this.state = { ...this.state, ...next };
224
+ for (const listener of this.listeners) listener();
225
+ }
226
+ };
227
+
228
+ // src/KeepProvider.tsx
229
+ import { jsx } from "react/jsx-runtime";
230
+ var defaultStorage = new LocalStorageAdapter();
231
+ var KeepContext = createContext(null);
232
+ var KeepStoreContext = createContext(null);
233
+ function KeepProvider({
234
+ storage = defaultStorage,
235
+ onSave,
236
+ onRemove,
237
+ onNoteUpdate,
238
+ onTagsUpdate,
239
+ onChange,
240
+ onError,
241
+ plugins = [],
242
+ schemaVersion,
243
+ schema,
244
+ invalidItemPolicy = "error",
245
+ onInvalidItem,
246
+ migrateMeta,
247
+ children
248
+ }) {
249
+ const storeRef = useRef(null);
250
+ if (!storeRef.current) {
251
+ storeRef.current = new KeepStore({
252
+ items: [],
253
+ isLoading: true,
254
+ isHydrated: false,
255
+ isMutating: false,
256
+ error: null
257
+ });
258
+ }
259
+ const store = storeRef.current;
260
+ const state = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
261
+ const { items, isLoading, isHydrated, isMutating, error } = state;
262
+ const itemsRef = useRef(items);
263
+ const pluginsRef = useRef(plugins);
264
+ pluginsRef.current = plugins;
265
+ const handlersRef = useRef({ onSave, onRemove, onNoteUpdate, onTagsUpdate, onChange, onError });
266
+ handlersRef.current = { onSave, onRemove, onNoteUpdate, onTagsUpdate, onChange, onError };
267
+ const migrationRef = useRef({
268
+ schemaVersion,
269
+ migrateMeta,
270
+ schema,
271
+ invalidItemPolicy,
272
+ onInvalidItem
273
+ });
274
+ migrationRef.current = { schemaVersion, migrateMeta, schema, invalidItemPolicy, onInvalidItem };
275
+ const operationTailRef = useRef(Promise.resolve());
276
+ const pendingRefreshesRef = useRef(0);
277
+ const pendingMutationsRef = useRef(0);
278
+ const syncStorage = isSyncCapableStorage(storage) ? storage : void 0;
279
+ const getSyncState = useCallback(
280
+ () => syncStorage?.getSyncState() ?? IDLE_SYNC_STATE,
281
+ [syncStorage]
282
+ );
283
+ const subscribeSync = useCallback(
284
+ (listener) => syncStorage?.subscribeSync(listener) ?? (() => void 0),
285
+ [syncStorage]
286
+ );
287
+ const syncState = useSyncExternalStore(subscribeSync, getSyncState, getSyncState);
288
+ const reportError = useCallback(
289
+ (cause, context) => {
290
+ store.setState({ error: cause });
291
+ handlersRef.current.onError?.(cause, context);
292
+ for (const plugin of pluginsRef.current) plugin.onError?.(cause, context);
293
+ },
294
+ [store]
295
+ );
296
+ const setItems = useCallback(
297
+ (next) => {
298
+ itemsRef.current = next;
299
+ store.setState({ items: next });
300
+ },
301
+ [store]
302
+ );
303
+ const runBeforePlugins = useCallback(
304
+ async (context) => {
305
+ for (const plugin of pluginsRef.current) await plugin.before?.(context);
306
+ return context;
307
+ },
308
+ []
309
+ );
310
+ const runAfterPlugins = useCallback(
311
+ async (context) => {
312
+ for (const plugin of pluginsRef.current) await plugin.after?.(context);
313
+ },
314
+ []
315
+ );
316
+ const enqueueOperation = useCallback((operation) => {
317
+ const run = operationTailRef.current.then(operation, operation);
318
+ operationTailRef.current = run.then(
319
+ () => void 0,
320
+ () => void 0
321
+ );
322
+ return run;
323
+ }, []);
324
+ const refresh = useCallback(async () => {
325
+ pendingRefreshesRef.current += 1;
326
+ store.setState({ isLoading: true });
327
+ try {
328
+ await enqueueOperation(async () => {
329
+ try {
330
+ let next = await storage.getAll();
331
+ let needsMigrationPersist = false;
332
+ if (migrationRef.current.schemaVersion !== void 0) {
333
+ const migrated = await Promise.all(
334
+ next.map(async (item) => {
335
+ const currentSchemaVersion = migrationRef.current.schemaVersion;
336
+ if (item.schemaVersion === currentSchemaVersion) return item;
337
+ const meta = migrationRef.current.migrateMeta ? await migrationRef.current.migrateMeta(
338
+ item.meta,
339
+ item.schemaVersion ?? 0,
340
+ currentSchemaVersion,
341
+ item
342
+ ) : item.meta;
343
+ return { ...item, meta, schemaVersion: currentSchemaVersion };
344
+ })
345
+ );
346
+ if (migrated.some((item, index) => item !== next[index])) {
347
+ next = migrated;
348
+ needsMigrationPersist = true;
349
+ }
350
+ }
351
+ if (migrationRef.current.schema) {
352
+ const validated = [];
353
+ for (const item of next) {
354
+ try {
355
+ validated.push(await parseKeepMetaItem(item, migrationRef.current.schema));
356
+ } catch (cause) {
357
+ migrationRef.current.onInvalidItem?.(cause, item);
358
+ if (migrationRef.current.invalidItemPolicy === "drop") continue;
359
+ throw cause;
360
+ }
361
+ }
362
+ next = validated;
363
+ }
364
+ if (needsMigrationPersist) {
365
+ if (storage.setMany) await storage.setMany(next);
366
+ else for (const item of next) await storage.set(item);
367
+ }
368
+ setItems(next);
369
+ store.setState({ error: null });
370
+ } catch (cause) {
371
+ reportError(cause, { action: "refresh" });
372
+ }
373
+ });
374
+ } finally {
375
+ pendingRefreshesRef.current -= 1;
376
+ if (pendingRefreshesRef.current === 0) store.setState({ isLoading: false });
377
+ store.setState({ isHydrated: true });
378
+ }
379
+ }, [enqueueOperation, reportError, setItems, storage, store]);
380
+ useEffect(() => {
381
+ void refresh();
382
+ }, [refresh]);
383
+ useEffect(() => {
384
+ if (!storage.subscribe) return;
385
+ return storage.subscribe(() => void refresh());
386
+ }, [refresh, storage]);
387
+ const runMutation = useCallback(
388
+ (action, id, createPlan) => {
389
+ pendingMutationsRef.current += 1;
390
+ store.setState({ isMutating: true });
391
+ const run = enqueueOperation(async () => {
392
+ const previous = itemsRef.current;
393
+ const plan = createPlan(previous);
394
+ if (!plan) return;
395
+ try {
396
+ if (plan.pluginContext) await runBeforePlugins(plan.pluginContext);
397
+ setItems(plan.next);
398
+ store.setState({ error: null });
399
+ await plan.persist();
400
+ plan.onSuccess?.();
401
+ if (plan.pluginContext) await runAfterPlugins(plan.pluginContext);
402
+ if (plan.pluginContext) {
403
+ const change = { ...plan.pluginContext, phase: "local" };
404
+ void Promise.resolve(handlersRef.current.onChange?.(change)).catch(
405
+ (cause) => reportError(cause, { action, id })
406
+ );
407
+ }
408
+ } catch (cause) {
409
+ setItems(previous);
410
+ reportError(cause, { action, id });
411
+ throw cause;
412
+ }
413
+ });
414
+ return run.finally(() => {
415
+ pendingMutationsRef.current -= 1;
416
+ if (pendingMutationsRef.current === 0) store.setState({ isMutating: false });
417
+ });
418
+ },
419
+ [enqueueOperation, reportError, runAfterPlugins, runBeforePlugins, setItems, store]
420
+ );
421
+ const saveItem = useCallback(
422
+ async (item) => {
423
+ let normalizedItem;
424
+ try {
425
+ const meta = migrationRef.current.schema ? await parseKeepMeta(migrationRef.current.schema, item.meta) : item.meta;
426
+ normalizedItem = {
427
+ ...item,
428
+ meta,
429
+ tags: normalizeKeepTags(item.tags),
430
+ ...migrationRef.current.schemaVersion === void 0 ? {} : { schemaVersion: migrationRef.current.schemaVersion }
431
+ };
432
+ } catch (cause) {
433
+ reportError(cause, { action: "save", id: item.id });
434
+ throw cause;
435
+ }
436
+ await runMutation("save", normalizedItem.id, (previous) => ({
437
+ next: [
438
+ ...previous.filter((current) => current.id !== normalizedItem.id),
439
+ normalizedItem
440
+ ].sort((a, b) => b.updatedAt - a.updatedAt),
441
+ persist: () => storage.set(normalizedItem),
442
+ onSuccess: () => handlersRef.current.onSave?.(normalizedItem),
443
+ pluginContext: { action: "save", id: normalizedItem.id, item: normalizedItem }
444
+ }));
445
+ },
446
+ [reportError, runMutation, storage]
447
+ );
448
+ const updateNote = useCallback(
449
+ async (id, note) => {
450
+ const nextNote = note?.trim() || void 0;
451
+ await runMutation("updateNote", id, (previous) => {
452
+ const current = previous.find((item) => item.id === id);
453
+ if (!current) return void 0;
454
+ const next = { ...current, note: nextNote, updatedAt: Date.now() };
455
+ return {
456
+ next: previous.map((item) => item.id === id ? next : item),
457
+ persist: () => storage.set(next),
458
+ onSuccess: () => handlersRef.current.onNoteUpdate?.(id, nextNote),
459
+ pluginContext: { action: "updateNote", id, item: next }
460
+ };
461
+ });
462
+ },
463
+ [runMutation, storage]
464
+ );
465
+ const updateTags = useCallback(
466
+ async (id, tags) => {
467
+ const nextTags = normalizeKeepTags(tags);
468
+ await runMutation("updateTags", id, (previous) => {
469
+ const current = previous.find((item) => item.id === id);
470
+ if (!current) return void 0;
471
+ const next = { ...current, tags: nextTags, updatedAt: Date.now() };
472
+ return {
473
+ next: previous.map((item) => item.id === id ? next : item),
474
+ persist: () => storage.set(next),
475
+ onSuccess: () => handlersRef.current.onTagsUpdate?.(id, nextTags),
476
+ pluginContext: { action: "updateTags", id, item: next }
477
+ };
478
+ });
479
+ },
480
+ [runMutation, storage]
481
+ );
482
+ const updateTagsBatch = useCallback(
483
+ async (ids, tags) => {
484
+ const idSet = new Set(ids);
485
+ const nextTags = normalizeKeepTags(tags);
486
+ await runMutation("updateTagsBatch", void 0, (previous) => {
487
+ const currentItems = previous.filter((item) => idSet.has(item.id));
488
+ if (currentItems.length === 0) return void 0;
489
+ const updatedItems = currentItems.map((item) => ({
490
+ ...item,
491
+ tags: nextTags,
492
+ updatedAt: Date.now()
493
+ }));
494
+ const updatedById = new Map(updatedItems.map((item) => [item.id, item]));
495
+ return {
496
+ next: previous.map((item) => updatedById.get(item.id) ?? item),
497
+ persist: async () => {
498
+ if (storage.setMany) {
499
+ await storage.setMany(updatedItems);
500
+ return;
501
+ }
502
+ const completed = [];
503
+ try {
504
+ for (const item of updatedItems) {
505
+ await storage.set(item);
506
+ completed.push(item);
507
+ }
508
+ } catch (cause) {
509
+ const previousById = new Map(currentItems.map((item) => [item.id, item]));
510
+ await Promise.allSettled(
511
+ completed.map((item) => {
512
+ const previousItem = previousById.get(item.id);
513
+ return previousItem ? storage.set(previousItem) : Promise.resolve();
514
+ })
515
+ );
516
+ throw cause;
517
+ }
518
+ },
519
+ onSuccess: () => {
520
+ updatedItems.forEach((item) => {
521
+ handlersRef.current.onTagsUpdate?.(item.id, nextTags);
522
+ });
523
+ },
524
+ pluginContext: { action: "updateTagsBatch", items: updatedItems }
525
+ };
526
+ });
527
+ },
528
+ [runMutation, storage]
529
+ );
530
+ const addTagsBatch = useCallback(
531
+ async (ids, tags) => {
532
+ const additions = normalizeKeepTags(tags) ?? [];
533
+ const idSet = new Set(ids);
534
+ const currentItems = itemsRef.current.filter((item) => idSet.has(item.id));
535
+ await Promise.all(
536
+ currentItems.map(
537
+ (item) => updateTags(item.id, normalizeKeepTags([...item.tags ?? [], ...additions]))
538
+ )
539
+ );
540
+ },
541
+ [updateTags]
542
+ );
543
+ const removeTagsBatch = useCallback(
544
+ async (ids, tags) => {
545
+ const removals = new Set(normalizeKeepTags(tags) ?? []);
546
+ const idSet = new Set(ids);
547
+ const currentItems = itemsRef.current.filter((item) => idSet.has(item.id));
548
+ await Promise.all(
549
+ currentItems.map(
550
+ (item) => updateTags(
551
+ item.id,
552
+ normalizeKeepTags((item.tags ?? []).filter((tag) => !removals.has(tag)))
553
+ )
554
+ )
555
+ );
556
+ },
557
+ [updateTags]
558
+ );
559
+ const removeItem = useCallback(
560
+ async (id) => {
561
+ await runMutation("remove", id, (previous) => {
562
+ const current = previous.find((item) => item.id === id);
563
+ if (!current) return void 0;
564
+ return {
565
+ next: previous.filter((item) => item.id !== id),
566
+ persist: () => storage.remove(id),
567
+ onSuccess: () => handlersRef.current.onRemove?.(current),
568
+ pluginContext: { action: "remove", id, item: current }
569
+ };
570
+ });
571
+ },
572
+ [runMutation, storage]
573
+ );
574
+ const removeItems = useCallback(
575
+ async (ids) => {
576
+ const idSet = new Set(ids);
577
+ await runMutation("removeBatch", void 0, (previous) => {
578
+ const removedItems = previous.filter((item) => idSet.has(item.id));
579
+ if (removedItems.length === 0) return void 0;
580
+ return {
581
+ next: previous.filter((item) => !idSet.has(item.id)),
582
+ persist: async () => {
583
+ if (storage.removeMany) {
584
+ await storage.removeMany(removedItems.map((item) => item.id));
585
+ return;
586
+ }
587
+ const completed = [];
588
+ try {
589
+ for (const item of removedItems) {
590
+ await storage.remove(item.id);
591
+ completed.push(item);
592
+ }
593
+ } catch (cause) {
594
+ await Promise.allSettled(completed.map((item) => storage.set(item)));
595
+ throw cause;
596
+ }
597
+ },
598
+ onSuccess: () => {
599
+ removedItems.forEach((item) => {
600
+ handlersRef.current.onRemove?.(item);
601
+ });
602
+ },
603
+ pluginContext: { action: "removeBatch", items: removedItems }
604
+ };
605
+ });
606
+ },
607
+ [runMutation, storage]
608
+ );
609
+ const clear = useCallback(
610
+ () => runMutation("clear", void 0, (_previous) => ({
611
+ next: [],
612
+ persist: () => storage.clear(),
613
+ pluginContext: { action: "clear", items: [] }
614
+ })),
615
+ [runMutation, storage]
616
+ );
617
+ const flushSync = useCallback(
618
+ () => syncStorage ? syncStorage.flushSync() : Promise.resolve(),
619
+ [syncStorage]
620
+ );
621
+ const value = useMemo(
622
+ () => ({
623
+ items,
624
+ isLoading,
625
+ isHydrated,
626
+ isMutating,
627
+ error,
628
+ syncState,
629
+ saveItem,
630
+ updateNote,
631
+ updateTags,
632
+ updateTagsBatch,
633
+ addTagsBatch,
634
+ removeTagsBatch,
635
+ removeItem,
636
+ removeItems,
637
+ clear,
638
+ refresh,
639
+ flushSync
640
+ }),
641
+ [
642
+ clear,
643
+ error,
644
+ flushSync,
645
+ isHydrated,
646
+ isLoading,
647
+ isMutating,
648
+ items,
649
+ syncState,
650
+ refresh,
651
+ removeItem,
652
+ saveItem,
653
+ updateNote,
654
+ updateTags,
655
+ updateTagsBatch,
656
+ addTagsBatch,
657
+ removeTagsBatch,
658
+ removeItems
659
+ ]
660
+ );
661
+ const actions = useMemo(
662
+ () => ({
663
+ saveItem,
664
+ updateNote,
665
+ updateTags,
666
+ updateTagsBatch,
667
+ addTagsBatch,
668
+ removeTagsBatch,
669
+ removeItem,
670
+ removeItems,
671
+ clear,
672
+ refresh
673
+ }),
674
+ [
675
+ addTagsBatch,
676
+ clear,
677
+ refresh,
678
+ removeItem,
679
+ removeItems,
680
+ removeTagsBatch,
681
+ saveItem,
682
+ updateNote,
683
+ updateTags,
684
+ updateTagsBatch
685
+ ]
686
+ );
687
+ const storeAccess = useMemo(() => ({ store, actions }), [actions, store]);
688
+ return /* @__PURE__ */ jsx(KeepStoreContext.Provider, { value: storeAccess, children: /* @__PURE__ */ jsx(KeepContext.Provider, { value, children }) });
689
+ }
690
+ var IDLE_SYNC_STATE = Object.freeze({
691
+ status: "idle",
692
+ pendingCount: 0,
693
+ conflictIds: []
694
+ });
695
+ function isSyncCapableStorage(storage) {
696
+ return "getSyncState" in storage && typeof storage.getSyncState === "function" && "subscribeSync" in storage && typeof storage.subscribeSync === "function" && "flushSync" in storage && typeof storage.flushSync === "function";
697
+ }
698
+ async function parseKeepMetaItem(item, schema) {
699
+ return { ...item, meta: await parseKeepMeta(schema, item.meta) };
700
+ }
701
+ function useKeepContext() {
702
+ const context = useContext(KeepContext);
703
+ if (!context) throw new Error("Keep hooks must be used inside a KeepProvider");
704
+ return context;
705
+ }
706
+ function useKeepStore() {
707
+ const context = useContext(KeepStoreContext);
708
+ if (!context) throw new Error("Keep hooks must be used inside a KeepProvider");
709
+ return context;
710
+ }
711
+
712
+ // src/hooks/useKeepStoreSelector.ts
713
+ import { useCallback as useCallback2, useRef as useRef2, useSyncExternalStore as useSyncExternalStore2 } from "react";
714
+ function useKeepStoreSelector(store, selector) {
715
+ const cacheRef = useRef2(null);
716
+ const getSelectedSnapshot = useCallback2(() => {
717
+ const snapshot = store.getSnapshot();
718
+ const cached = cacheRef.current;
719
+ if (cached?.snapshot === snapshot && cached.selector === selector) return cached.selected;
720
+ const selected = selector(snapshot);
721
+ cacheRef.current = { snapshot, selector, selected };
722
+ return selected;
723
+ }, [selector, store]);
724
+ return useSyncExternalStore2(store.subscribe, getSelectedSnapshot, getSelectedSnapshot);
725
+ }
726
+
727
+ // src/hooks/useKeepItem.ts
728
+ function useKeepItem(id, itemPayload) {
729
+ const { store, actions } = useKeepStore();
730
+ const item = useKeepStoreSelector(
731
+ store,
732
+ useCallback3((state) => state.items.find((current) => current.id === id), [id])
733
+ );
734
+ const isLoading = useKeepStoreSelector(
735
+ store,
736
+ useCallback3((state) => state.isLoading, [])
737
+ );
738
+ const isMutating = useKeepStoreSelector(
739
+ store,
740
+ useCallback3((state) => state.isMutating, [])
741
+ );
742
+ const error = useKeepStoreSelector(
743
+ store,
744
+ useCallback3((state) => state.error, [])
745
+ );
746
+ const save = useCallback3(async () => {
747
+ if (!itemPayload) {
748
+ throw new Error(`An itemPayload is required to save item "${id}".`);
749
+ }
750
+ const now = Date.now();
751
+ await actions.saveItem({
752
+ id,
753
+ ...itemPayload,
754
+ savedAt: item?.savedAt ?? now,
755
+ updatedAt: now
756
+ });
757
+ }, [actions, id, item?.savedAt, itemPayload]);
758
+ const remove = useCallback3(() => actions.removeItem(id), [actions, id]);
759
+ const toggle = useCallback3(() => item ? remove() : save(), [item, remove, save]);
760
+ const updateNote = useCallback3((note) => actions.updateNote(id, note), [actions, id]);
761
+ const updateTags = useCallback3((tags) => actions.updateTags(id, tags), [actions, id]);
762
+ return {
763
+ item,
764
+ isSaved: Boolean(item),
765
+ isLoading,
766
+ isMutating,
767
+ error,
768
+ save,
769
+ remove,
770
+ toggle,
771
+ updateNote,
772
+ updateTags
773
+ };
774
+ }
775
+
776
+ // src/hooks/useKeepList.ts
777
+ import { useCallback as useCallback4, useMemo as useMemo2 } from "react";
778
+ function useKeepList(options = {}) {
779
+ const { store, actions } = useKeepStore();
780
+ const {
781
+ filter,
782
+ filterFn,
783
+ limit,
784
+ offset,
785
+ order,
786
+ savedBetween,
787
+ search,
788
+ searchQuery,
789
+ sort,
790
+ sortBy,
791
+ tag,
792
+ tags: queryTags,
793
+ targetType
794
+ } = options;
795
+ const queryOptions = useMemo2(
796
+ () => ({
797
+ filter,
798
+ filterFn,
799
+ limit,
800
+ offset,
801
+ order,
802
+ savedBetween,
803
+ search,
804
+ searchQuery,
805
+ sort,
806
+ sortBy,
807
+ tag,
808
+ tags: queryTags,
809
+ targetType
810
+ }),
811
+ [
812
+ filter,
813
+ filterFn,
814
+ limit,
815
+ offset,
816
+ order,
817
+ savedBetween,
818
+ search,
819
+ searchQuery,
820
+ sort,
821
+ sortBy,
822
+ tag,
823
+ queryTags,
824
+ targetType
825
+ ]
826
+ );
827
+ const selector = useMemo2(() => {
828
+ let previousResult;
829
+ return (state) => {
830
+ const next = queryKeepItems(state.items, queryOptions);
831
+ if (previousResult && previousResult.totalCount === next.totalCount && sameItems(previousResult.items, next.items) && sameCounts(previousResult.tagCounts, next.tagCounts)) {
832
+ return previousResult;
833
+ }
834
+ previousResult = next;
835
+ return previousResult;
836
+ };
837
+ }, [queryOptions]);
838
+ const query = useKeepStoreSelector(store, selector);
839
+ const tagsSelector = useMemo2(() => {
840
+ let previous;
841
+ return (state) => {
842
+ const next = [...new Set(state.items.flatMap((item) => item.tags ?? []))].sort();
843
+ if (previous?.length === next.length && previous.every((tag2, index) => tag2 === next[index])) {
844
+ return previous;
845
+ }
846
+ previous = next;
847
+ return next;
848
+ };
849
+ }, []);
850
+ const items = query.items;
851
+ const totalCount = query.totalCount;
852
+ const tagCounts = query.tagCounts;
853
+ const tags = useKeepStoreSelector(store, tagsSelector);
854
+ const isLoading = useKeepStoreSelector(
855
+ store,
856
+ useCallback4((state) => state.isLoading, [])
857
+ );
858
+ const isHydrated = useKeepStoreSelector(
859
+ store,
860
+ useCallback4((state) => state.isHydrated, [])
861
+ );
862
+ const isMutating = useKeepStoreSelector(
863
+ store,
864
+ useCallback4((state) => state.isMutating, [])
865
+ );
866
+ const error = useKeepStoreSelector(
867
+ store,
868
+ useCallback4((state) => state.error, [])
869
+ );
870
+ const remove = useCallback4((id) => actions.removeItem(id), [actions]);
871
+ const removeBatch = useCallback4((ids) => actions.removeItems(ids), [actions]);
872
+ const updateTagsBatch = useCallback4(
873
+ (ids, tags2) => actions.updateTagsBatch(ids, tags2),
874
+ [actions]
875
+ );
876
+ const addTagsBatch = useCallback4(
877
+ (ids, tags2) => actions.addTagsBatch(ids, tags2),
878
+ [actions]
879
+ );
880
+ const removeTagsBatch = useCallback4(
881
+ (ids, tags2) => actions.removeTagsBatch(ids, tags2),
882
+ [actions]
883
+ );
884
+ return {
885
+ items,
886
+ totalCount,
887
+ tags,
888
+ tagCounts,
889
+ isLoading,
890
+ isHydrated,
891
+ isMutating,
892
+ error,
893
+ remove,
894
+ removeBatch,
895
+ updateTagsBatch,
896
+ addTagsBatch,
897
+ removeTagsBatch,
898
+ clear: actions.clear,
899
+ refresh: actions.refresh
900
+ };
901
+ }
902
+ function queryKeepItems(source, options = {}) {
903
+ const filtered = source.filter((item) => {
904
+ const [from, to] = options.savedBetween ?? [];
905
+ const savedAt = item.savedAt;
906
+ const lowerBound = from === void 0 ? void 0 : toTimestamp(from);
907
+ const upperBound = to === void 0 ? void 0 : toTimestamp(to);
908
+ return (options.targetType === void 0 || item.targetType === options.targetType) && (options.tag === void 0 || item.tags?.includes(options.tag) === true) && (options.tags === void 0 || options.tags.every((tag) => item.tags?.includes(tag))) && (lowerBound === void 0 || savedAt >= lowerBound) && (upperBound === void 0 || savedAt <= upperBound) && matchesSearch(item, options.searchQuery, options.search) && (options.filter?.(item) ?? true) && (options.filterFn?.(item) ?? true);
909
+ });
910
+ const tagCounts = getTagCounts(filtered);
911
+ const sortBy = options.sortBy ?? options.sort?.by;
912
+ const direction = (options.order ?? options.sort?.direction) === "asc" ? 1 : -1;
913
+ const sorted = sortBy ? [...filtered].sort((a, b) => (a[sortBy] - b[sortBy]) * direction) : filtered;
914
+ const offset = Math.max(0, options.offset ?? 0);
915
+ const items = options.limit === void 0 ? sorted.slice(offset) : sorted.slice(offset, offset + Math.max(0, options.limit));
916
+ return { items, totalCount: sorted.length, tagCounts };
917
+ }
918
+ function getTagCounts(items) {
919
+ const counts = {};
920
+ for (const item of items) {
921
+ for (const tag of item.tags ?? []) counts[tag] = (counts[tag] ?? 0) + 1;
922
+ }
923
+ return Object.fromEntries(Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)));
924
+ }
925
+ function matchesSearch(item, searchQuery, search) {
926
+ const query = search?.query ?? searchQuery;
927
+ if (!query?.trim()) return true;
928
+ const fields = search?.fields ?? ["note", "meta", "tags"];
929
+ const values = fields.map((field) => {
930
+ if (field === "note") return item.note ?? "";
931
+ if (field === "tags") return (item.tags ?? []).join(" ");
932
+ try {
933
+ return JSON.stringify(item.meta) ?? "";
934
+ } catch {
935
+ return String(item.meta);
936
+ }
937
+ });
938
+ const text = values.join(" ").toLocaleLowerCase();
939
+ if (!search) return text.includes(query.trim().toLocaleLowerCase());
940
+ const normalized = query.trim().toLocaleLowerCase();
941
+ const needles = search.tokenize === false ? [normalized] : normalized.split(/\s+/).filter(Boolean);
942
+ const matches = needles.map((needle) => text.includes(needle));
943
+ return search.mode === "or" ? matches.some(Boolean) : matches.every(Boolean);
944
+ }
945
+ function toTimestamp(value) {
946
+ return value instanceof Date ? value.getTime() : value;
947
+ }
948
+ function sameItems(left, right) {
949
+ return left.length === right.length && left.every((item, index) => item === right[index]);
950
+ }
951
+ function sameCounts(left, right) {
952
+ const leftEntries = Object.entries(left);
953
+ const rightEntries = Object.entries(right);
954
+ return leftEntries.length === rightEntries.length && leftEntries.every(([key, value], index) => {
955
+ const [rightKey, rightValue] = rightEntries[index] ?? [];
956
+ return key === rightKey && value === rightValue;
957
+ });
958
+ }
959
+
960
+ // src/KeepButton.tsx
961
+ import {
962
+ Children,
963
+ cloneElement,
964
+ isValidElement
965
+ } from "react";
966
+ import { jsx as jsx2 } from "react/jsx-runtime";
967
+ function KeepButton({
968
+ item,
969
+ children,
970
+ savedLabel = "Saved",
971
+ unsavedLabel = "Save",
972
+ asChild = false,
973
+ onToggleError,
974
+ onClick,
975
+ disabled,
976
+ ...buttonProps
977
+ }) {
978
+ const state = useKeepItem(item.id, {
979
+ meta: item.meta,
980
+ targetType: item.targetType,
981
+ note: item.note,
982
+ tags: item.tags
983
+ });
984
+ const { isSaved, toggle } = state;
985
+ const isDisabled = disabled ?? state.isMutating;
986
+ async function handleClick(event) {
987
+ if (asChild) {
988
+ onClick?.(event);
989
+ } else {
990
+ onClick?.(
991
+ event
992
+ );
993
+ }
994
+ if (event.defaultPrevented) return;
995
+ try {
996
+ await toggle();
997
+ } catch (error) {
998
+ onToggleError?.(error);
999
+ }
1000
+ }
1001
+ const content = typeof children === "function" ? children(state) : children ?? (isSaved ? savedLabel : unsavedLabel);
1002
+ function handleElementClick(event) {
1003
+ if (asChild && isValidElement(content)) {
1004
+ content.props.onClick?.(event);
1005
+ }
1006
+ if (!event.defaultPrevented) void handleClick(event);
1007
+ }
1008
+ const commonProps = {
1009
+ ...buttonProps,
1010
+ "aria-pressed": isSaved,
1011
+ "aria-label": ("aria-label" in buttonProps ? buttonProps["aria-label"] : void 0) ?? (isSaved ? "Remove saved item" : "Save item"),
1012
+ disabled: isDisabled,
1013
+ onClick: handleElementClick
1014
+ };
1015
+ if (asChild) {
1016
+ const child = Children.only(content);
1017
+ if (!isValidElement(child)) {
1018
+ throw new Error("KeepButton with asChild requires a single React element child.");
1019
+ }
1020
+ return cloneElement(child, commonProps);
1021
+ }
1022
+ return /* @__PURE__ */ jsx2(
1023
+ "button",
1024
+ {
1025
+ ...commonProps,
1026
+ type: "type" in buttonProps ? buttonProps.type ?? "button" : "button",
1027
+ children: content
1028
+ }
1029
+ );
1030
+ }
1031
+
1032
+ // src/createKeepKit.tsx
1033
+ import { jsx as jsx3 } from "react/jsx-runtime";
1034
+ function createKeepKit(options = {}) {
1035
+ return {
1036
+ KeepProvider: (props) => /* @__PURE__ */ jsx3(KeepProvider, { ...options, ...props }),
1037
+ KeepButton: (props) => /* @__PURE__ */ jsx3(KeepButton, { ...props }),
1038
+ useKeepContext: () => useKeepContext(),
1039
+ useKeepItem: (id, itemPayload) => useKeepItem(id, itemPayload),
1040
+ useKeepList: (options2) => useKeepList(options2)
1041
+ };
1042
+ }
1043
+
1044
+ // src/integrations.ts
1045
+ function createKeepInvalidationPlugin(options) {
1046
+ return {
1047
+ name: options.name ?? "keepkit-cache-invalidation",
1048
+ after: async (context) => {
1049
+ const keys = typeof options.queryKeys === "function" ? options.queryKeys(context) : [options.queryKeys];
1050
+ await Promise.all(keys.map((queryKey) => options.invalidate(queryKey, context)));
1051
+ }
1052
+ };
1053
+ }
1054
+ export {
1055
+ DEFAULT_INDEXEDDB_DATABASE,
1056
+ DEFAULT_INDEXEDDB_STORE,
1057
+ DEFAULT_STORAGE_KEY,
1058
+ DEFAULT_SYNC_QUEUE_DATABASE,
1059
+ DEFAULT_SYNC_QUEUE_KEY,
1060
+ DEFAULT_SYNC_QUEUE_STORE,
1061
+ IndexedDBAdapter,
1062
+ IndexedDBSyncQueueAdapter,
1063
+ KEEP_BACKUP_FORMAT,
1064
+ KEEP_BACKUP_VERSION,
1065
+ KeepBackupImportError,
1066
+ KeepBackupParseError,
1067
+ KeepButton,
1068
+ KeepProvider,
1069
+ KeepSchemaValidationError,
1070
+ KeepStorageAccessError,
1071
+ KeepStorageError,
1072
+ KeepStorageParseError,
1073
+ KeepStorageQuotaError,
1074
+ LocalStorageAdapter,
1075
+ LocalStorageSyncQueueAdapter,
1076
+ SyncStorageAdapter,
1077
+ createKeepInvalidationPlugin,
1078
+ createKeepKit,
1079
+ createStorageAdapter,
1080
+ exportItems,
1081
+ getTagCounts,
1082
+ importItems,
1083
+ mergeKeepItems,
1084
+ migrateKeepItems,
1085
+ normalizeKeepTags,
1086
+ parseKeepMeta,
1087
+ queryKeepItems,
1088
+ useKeepContext,
1089
+ useKeepItem,
1090
+ useKeepList,
1091
+ validateKeepItem
1092
+ };
1093
+ //# sourceMappingURL=index.js.map