@glassly/engine 0.3.5-dev.772 → 0.3.5-dev.776

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.
@@ -17,7 +17,14 @@
17
17
  * user-scoped keys so existing history survives the move.
18
18
  */
19
19
 
20
- import type {MemoryEntry, MemoryNoteSource, MemorySession, MemorySummary} from "@glassly/miniapp"
20
+ import type {
21
+ MemoryBackup,
22
+ MemoryEntry,
23
+ MemoryImportResult,
24
+ MemoryNoteSource,
25
+ MemorySession,
26
+ MemorySummary,
27
+ } from "@glassly/miniapp"
21
28
 
22
29
  import {searchMemoryEntries} from "../utils/memory/search"
23
30
 
@@ -91,6 +98,14 @@ export interface MemoryStats {
91
98
  oldestAt: number | null
92
99
  }
93
100
 
101
+ /** Thrown by importBackup for anything that isn't a backup this build can read. */
102
+ export class MemoryBackupError extends Error {
103
+ constructor(message: string) {
104
+ super(message)
105
+ this.name = "MemoryBackupError"
106
+ }
107
+ }
108
+
94
109
  export function hourStartOf(ms: number): number {
95
110
  return Math.floor(ms / HOUR_MS) * HOUR_MS
96
111
  }
@@ -359,6 +374,65 @@ export class MemoryStore {
359
374
  return Math.max(...hours)
360
375
  }
361
376
 
377
+ /** The whole timeline as a backup file body (Settings > Memory, session.memory.exportFile). */
378
+ async exportBackup(): Promise<MemoryBackup> {
379
+ await this.ensureLoaded()
380
+ return {
381
+ format: "glassly-memory",
382
+ version: 1,
383
+ exportedAt: Date.now(),
384
+ entries: [...this.entries],
385
+ summaries: [...this.summaries].sort((a, b) => a.hourStart - b.hourStart),
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Merge a backup into the timeline. Skips entries whose id is stored, notes
391
+ * whose text is already in the same hour, and hours that already have a
392
+ * summary. Throws MemoryBackupError when `data` isn't a memory backup.
393
+ */
394
+ async importBackup(data: unknown): Promise<MemoryImportResult> {
395
+ const backup = parseBackup(data)
396
+ await this.ensureLoaded()
397
+ const result: MemoryImportResult = {entries: 0, summaries: 0, skipped: 0}
398
+
399
+ const ids = new Set(this.entries.map((e) => e.id))
400
+ const noteKeys = new Set(this.entries.filter((e) => e.kind === "note").map(noteKey))
401
+ for (const entry of backup.entries) {
402
+ const key = entry.kind === "note" ? noteKey(entry) : null
403
+ if (ids.has(entry.id) || (key != null && noteKeys.has(key))) {
404
+ result.skipped++
405
+ continue
406
+ }
407
+ ids.add(entry.id)
408
+ if (key != null) noteKeys.add(key)
409
+ this.entries.push(entry)
410
+ result.entries++
411
+ }
412
+
413
+ const byHour = new Map(this.summaries.map((s) => [s.hourStart, s]))
414
+ for (const summary of backup.summaries) {
415
+ if (byHour.get(summary.hourStart)?.text.trim()) {
416
+ result.skipped++
417
+ continue
418
+ }
419
+ byHour.set(summary.hourStart, summary)
420
+ result.summaries++
421
+ }
422
+
423
+ if (result.entries > 0) {
424
+ this.entries.sort((a, b) => a.at - b.at)
425
+ this.prune()
426
+ await this.persistLog()
427
+ }
428
+ if (result.summaries > 0) {
429
+ this.summaries = [...byHour.values()].sort((a, b) => a.hourStart - b.hourStart).slice(-MEMORY_BOUNDS.maxSummaries)
430
+ await this.persistSummaries()
431
+ }
432
+ if (result.entries > 0 || result.summaries > 0) this.emitChanged()
433
+ return result
434
+ }
435
+
362
436
  /** Plain-text export of a scope, for the OS share sheet. */
363
437
  async exportText(scope: "session" | "day" | "all", at?: number): Promise<string> {
364
438
  const sessions = (await this.sessionList()).filter((s) => {
@@ -563,10 +637,45 @@ function parseArray(raw: unknown): unknown[] {
563
637
  function normalize(text: string): string {
564
638
  return text
565
639
  .toLowerCase()
566
- .replace(/[^a-z0-9]+/g, " ")
640
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
567
641
  .trim()
568
642
  }
569
643
 
644
+ function noteKey(entry: MemoryEntry): string {
645
+ return `${hourStartOf(entry.at)}:${normalize(entry.text)}`
646
+ }
647
+
648
+ function parseBackup(data: unknown): {entries: MemoryEntry[]; summaries: MemorySummary[]} {
649
+ const raw = data as Record<string, unknown> | null
650
+ if (!raw || typeof raw !== "object" || raw.format !== "glassly-memory" || !Array.isArray(raw.entries)) {
651
+ throw new MemoryBackupError("Not a Glassly memory backup")
652
+ }
653
+ if (typeof raw.version !== "number" || raw.version > 1) {
654
+ throw new MemoryBackupError(`Unsupported memory backup version ${String(raw.version)}`)
655
+ }
656
+ const entries: MemoryEntry[] = []
657
+ for (const v of raw.entries) {
658
+ if (!isEntry(v) || !v.id || !v.text.trim() || !Number.isFinite(v.at)) continue
659
+ entries.push({
660
+ id: v.id,
661
+ kind: v.kind,
662
+ text: v.text.trim(),
663
+ at: v.at,
664
+ speakerId: typeof v.speakerId === "string" ? v.speakerId : null,
665
+ ...(typeof v.resolvedSpeakerName === "string" && v.resolvedSpeakerName
666
+ ? {resolvedSpeakerName: v.resolvedSpeakerName}
667
+ : {}),
668
+ language: typeof v.language === "string" ? v.language : null,
669
+ ...(v.kind === "note" ? {source: v.source === "voice" || v.source === "auto" ? v.source : "ui"} : {}),
670
+ })
671
+ }
672
+ const summaries = (Array.isArray(raw.summaries) ? raw.summaries : [])
673
+ .filter(isSummary)
674
+ .filter((s) => s.text.trim() && Number.isFinite(s.hourStart))
675
+ .map((s) => ({hourStart: s.hourStart, text: s.text, createdAt: s.createdAt}))
676
+ return {entries, summaries}
677
+ }
678
+
570
679
  function isEntry(v: unknown): v is MemoryEntry {
571
680
  if (!v || typeof v !== "object") return false
572
681
  const e = v as Record<string, unknown>
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Memory backup files: the share-sheet export and file-picker import behind
3
+ * Settings > Memory and session.memory.exportFile/importFile.
4
+ */
5
+
6
+ import type {MemoryExportResult, MemoryImportResult} from "@glassly/miniapp"
7
+ import {File, Paths} from "expo-file-system"
8
+ import {Platform} from "react-native"
9
+ import Share from "react-native-share"
10
+
11
+ import {MemoryBackupError} from "./MemoryStore"
12
+ import {memoryStore} from "./memoryStoreInstance"
13
+
14
+ // Some Android document providers report .json files as octet-stream.
15
+ const PICK_MIME_TYPES =
16
+ Platform.OS === "android" ? ["application/json", "application/octet-stream"] : ["application/json"]
17
+
18
+ export async function exportMemoryFile(): Promise<MemoryExportResult> {
19
+ const backup = await memoryStore.exportBackup()
20
+ const filename = `glassly-memory-${localDate(backup.exportedAt)}.json`
21
+ const file = new File(Paths.cache, filename)
22
+ file.writeSync(JSON.stringify(backup))
23
+ try {
24
+ await Share.open({url: file.uri, type: "application/json", filename, title: "Glassly memory"})
25
+ return {success: true}
26
+ } catch (error: any) {
27
+ // react-native-share rejects when the user dismisses the sheet.
28
+ if (error?.message?.includes("User did not share")) return {success: false, cancelled: true}
29
+ throw error
30
+ }
31
+ }
32
+
33
+ /** Resolves null when the user cancels the picker. */
34
+ export async function importMemoryFile(): Promise<MemoryImportResult | null> {
35
+ const picked = await File.pickFileAsync({mimeTypes: PICK_MIME_TYPES})
36
+ if (picked.canceled) return null
37
+ let data: unknown
38
+ try {
39
+ data = JSON.parse(await picked.result.text())
40
+ } catch {
41
+ throw new MemoryBackupError("Not a Glassly memory backup")
42
+ }
43
+ return memoryStore.importBackup(data)
44
+ }
45
+
46
+ function localDate(ms: number): string {
47
+ const d = new Date(ms)
48
+ const pad = (n: number) => String(n).padStart(2, "0")
49
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
50
+ }