@bardway/trpg-notebook-native 0.0.0-stage → 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.
@@ -0,0 +1,650 @@
1
+ package expo.modules.notebookstorage
2
+
3
+ import android.content.Context
4
+ import android.database.DatabaseErrorHandler
5
+ import android.database.sqlite.SQLiteDatabase
6
+ import android.net.Uri
7
+ import android.os.Build
8
+ import org.json.JSONArray
9
+ import org.json.JSONObject
10
+ import java.io.File
11
+ import java.nio.ByteBuffer
12
+ import java.nio.charset.CodingErrorAction
13
+ import java.nio.charset.StandardCharsets
14
+ import java.security.MessageDigest
15
+ import java.util.UUID
16
+
17
+ /** All calls run on the module's single IO queue. User content never leaves noBackupFilesDir. */
18
+ internal class NotebookStore(private val context: Context) {
19
+ private var database: SQLiteDatabase? = null
20
+ private val root = File(context.noBackupFilesDir, "trpg-notebook")
21
+ private val maxFileBytes = 16 * 1024 * 1024
22
+ private val maxSessionBytes = 256L * 1024 * 1024
23
+ private val maxAccountBytes = 512L * 1024 * 1024
24
+
25
+ private fun fail(message: String): Nothing = throw IllegalStateException(message)
26
+ private fun str(obj: JSONObject, key: String, max: Int = 256): String {
27
+ val value = obj.opt(key)
28
+ if (value !is String || value.isEmpty() || value.length > max) fail("手记参数无效: $key")
29
+ return value
30
+ }
31
+ private fun number(obj: JSONObject, key: String): Double {
32
+ val value = obj.opt(key)
33
+ if (value !is Number || !value.toDouble().isFinite()) fail("手记数值无效: $key")
34
+ return value.toDouble()
35
+ }
36
+ private fun hash(text: String): String = MessageDigest.getInstance("SHA-256")
37
+ .digest(text.toByteArray(StandardCharsets.UTF_8)).joinToString("") { "%02x".format(it) }
38
+
39
+ private fun db(): SQLiteDatabase {
40
+ database?.let { return it }
41
+ if (!root.exists() && !root.mkdirs()) fail("无法建立手记存储目录")
42
+ val file = File(root, "notebook.sqlite")
43
+ // Android's default corruption handler deletes database files. Preserve them for recovery.
44
+ val errorHandler = DatabaseErrorHandler { fail("手记数据库损坏,已保留原始数据") }
45
+ val opened = if (Build.VERSION.SDK_INT >= 28) {
46
+ SQLiteDatabase.openDatabase(file, SQLiteDatabase.OpenParams.Builder()
47
+ .addOpenFlags(SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING)
48
+ .setJournalMode("WAL").setSynchronousMode("FULL").setErrorHandler(errorHandler).build())
49
+ } else {
50
+ SQLiteDatabase.openDatabase(file.absolutePath, null,
51
+ SQLiteDatabase.CREATE_IF_NECESSARY, errorHandler).also {
52
+ // API 24-27 has no per-pool synchronous configuration: retain one rollback-journal connection.
53
+ it.disableWriteAheadLogging()
54
+ it.rawQuery("PRAGMA journal_mode=DELETE", null).use { cursor -> cursor.moveToFirst() }
55
+ it.execSQL("PRAGMA synchronous=FULL")
56
+ }
57
+ }
58
+ try {
59
+ opened.setForeignKeyConstraintsEnabled(true)
60
+ opened.rawQuery("PRAGMA quick_check(1)", null).use {
61
+ if (!it.moveToFirst() || it.getString(0) != "ok") fail("手记数据库校验失败,已保留原始数据")
62
+ }
63
+ if (opened.version > 3) fail("手记数据版本较新,请使用较新版本应用")
64
+ if (opened.version < 3) {
65
+ // Rebuilding a referenced parent table with foreign keys enabled would cascade-delete ink.
66
+ // Disable outside the transaction, then verify every reference before committing v3.
67
+ opened.setForeignKeyConstraintsEnabled(false)
68
+ try {
69
+ opened.beginTransaction()
70
+ try {
71
+ if (opened.version == 0) createSchema(opened)
72
+ else if (opened.version == 1) migrateVersionOne(opened)
73
+ opened.execSQL("ALTER TABLE documents ADD COLUMN memo_template TEXT")
74
+ validateStoredWorkspaces(opened)
75
+ opened.rawQuery("PRAGMA foreign_key_check", null).use {
76
+ if (it.moveToFirst()) fail("手记迁移引用校验失败,已保留原始数据")
77
+ }
78
+ opened.version = 3
79
+ opened.setTransactionSuccessful()
80
+ } finally { opened.endTransaction() }
81
+ } finally { opened.setForeignKeyConstraintsEnabled(true) }
82
+ }
83
+ database = opened
84
+ return opened
85
+ } catch (error: Throwable) {
86
+ opened.close()
87
+ throw error
88
+ }
89
+ }
90
+
91
+ private fun createDocumentsTable(database: SQLiteDatabase, table: String) {
92
+ database.execSQL("CREATE TABLE $table(id TEXT PRIMARY KEY,account_id TEXT NOT NULL,session_id TEXT NOT NULL,kind TEXT NOT NULL CHECK(kind IN ('memo','imported-markdown')),title TEXT NOT NULL,ordinal INTEGER NOT NULL,revision INTEGER NOT NULL DEFAULT 0,layout TEXT,FOREIGN KEY(account_id,session_id) REFERENCES workspaces(account_id,session_id))")
93
+ }
94
+ private fun createSchema(database: SQLiteDatabase) {
95
+ database.execSQL("CREATE TABLE workspaces(account_id TEXT NOT NULL,session_id TEXT NOT NULL,generation TEXT NOT NULL,active_id TEXT,deleted INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(account_id,session_id))")
96
+ createDocumentsTable(database, "documents")
97
+ database.execSQL("CREATE UNIQUE INDEX fixed_documents ON documents(account_id,session_id,kind) WHERE kind='memo'")
98
+ database.execSQL("CREATE TABLE contents(document_id TEXT PRIMARY KEY,markdown TEXT,content_hash TEXT,FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE)")
99
+ database.execSQL("CREATE TABLE strokes(document_id TEXT NOT NULL,id TEXT NOT NULL,payload TEXT NOT NULL,PRIMARY KEY(document_id,id),FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE)")
100
+ database.execSQL("CREATE TABLE operations(document_id TEXT NOT NULL,op_id TEXT NOT NULL,payload_hash TEXT NOT NULL,payload TEXT,revision INTEGER NOT NULL,PRIMARY KEY(document_id,op_id),FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE)")
101
+ database.execSQL("CREATE TABLE viewports(document_id TEXT PRIMARY KEY,payload TEXT NOT NULL,FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE)")
102
+ database.execSQL("CREATE TABLE snapshots(document_id TEXT NOT NULL,revision INTEGER NOT NULL,payload TEXT NOT NULL,payload_hash TEXT NOT NULL,PRIMARY KEY(document_id,revision),FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE)")
103
+ }
104
+
105
+ /** Only an untouched, verifiably empty automatic page may be removed. Erased ink is history. */
106
+ private fun isAutomaticPlaceholder(database: SQLiteDatabase, id: String): Boolean {
107
+ database.rawQuery("SELECT d.revision,c.document_id,(c.markdown IS NULL OR length(c.markdown)=0),EXISTS(SELECT 1 FROM strokes WHERE document_id=d.id),EXISTS(SELECT 1 FROM operations WHERE document_id=d.id) FROM documents d LEFT JOIN contents c ON c.document_id=d.id WHERE d.id=?", arrayOf(id)).use {
108
+ if (!it.moveToFirst() || it.isNull(1)) fail("手记正文记录不完整,已保留原始数据")
109
+ if (it.getLong(0) != 0L || it.getInt(2) != 1 || it.getInt(3) != 0 || it.getInt(4) != 0) return false
110
+ }
111
+ var hasEmptySnapshot = false
112
+ database.rawQuery("SELECT revision,length(payload),payload_hash FROM snapshots WHERE document_id=?", arrayOf(id)).use {
113
+ while (it.moveToNext()) {
114
+ // An unfamiliar/corrupt recovery record is retained rather than treated as an empty page.
115
+ if (it.getLong(0) != 0L || it.getLong(1) > 256) return false
116
+ val payload = readTextColumn(database, "snapshots", "payload", "document_id=? AND revision=?", arrayOf(id, it.getLong(0).toString()))
117
+ if (hash(payload) != it.getString(2)) return false
118
+ try { if (JSONArray(payload).length() != 0) return false } catch (_: Exception) { return false }
119
+ hasEmptySnapshot = true
120
+ }
121
+ }
122
+ return hasEmptySnapshot
123
+ }
124
+
125
+ private fun migrateVersionOne(database: SQLiteDatabase) {
126
+ val legacyIds = mutableListOf<String>()
127
+ database.rawQuery("SELECT id FROM documents WHERE kind='appendix'", null).use {
128
+ while (it.moveToNext()) legacyIds.add(it.getString(0))
129
+ }
130
+ for (id in legacyIds) {
131
+ if (isAutomaticPlaceholder(database, id)) {
132
+ for (table in listOf("contents", "strokes", "operations", "viewports", "snapshots"))
133
+ database.execSQL("DELETE FROM $table WHERE document_id=?", arrayOf(id))
134
+ database.execSQL("DELETE FROM documents WHERE id=?", arrayOf(id))
135
+ } else {
136
+ database.execSQL("UPDATE documents SET kind='imported-markdown' WHERE id=?", arrayOf(id))
137
+ database.execSQL("UPDATE contents SET markdown='',content_hash=? WHERE document_id=? AND markdown IS NULL", arrayOf(hash(""), id))
138
+ }
139
+ }
140
+ database.execSQL("UPDATE workspaces SET active_id=(SELECT id FROM documents d WHERE d.account_id=workspaces.account_id AND d.session_id=workspaces.session_id AND d.kind='memo') WHERE deleted=0 AND NOT EXISTS(SELECT 1 FROM documents d WHERE d.id=workspaces.active_id AND d.account_id=workspaces.account_id AND d.session_id=workspaces.session_id)")
141
+ // Child tables retain their document IDs, row order, payloads, hashes and operation receipts.
142
+ createDocumentsTable(database, "documents_v2")
143
+ database.execSQL("INSERT INTO documents_v2(id,account_id,session_id,kind,title,ordinal,revision,layout) SELECT id,account_id,session_id,kind,title,ordinal,revision,layout FROM documents")
144
+ database.execSQL("DROP TABLE documents")
145
+ database.execSQL("ALTER TABLE documents_v2 RENAME TO documents")
146
+ database.execSQL("CREATE UNIQUE INDEX fixed_documents ON documents(account_id,session_id,kind) WHERE kind='memo'")
147
+ }
148
+
149
+ private fun validateStoredWorkspaces(database: SQLiteDatabase) {
150
+ database.rawQuery("SELECT 1 FROM workspaces w WHERE w.deleted=0 AND ((SELECT COUNT(*) FROM documents d WHERE d.account_id=w.account_id AND d.session_id=w.session_id AND d.kind='memo')<>1 OR NOT EXISTS(SELECT 1 FROM documents d WHERE d.id=w.active_id AND d.account_id=w.account_id AND d.session_id=w.session_id)) LIMIT 1", null).use {
151
+ if (it.moveToFirst()) fail("手记工作区迁移校验失败,已保留原始数据")
152
+ }
153
+ database.rawQuery("SELECT 1 FROM documents d LEFT JOIN contents c ON c.document_id=d.id LEFT JOIN viewports v ON v.document_id=d.id WHERE c.document_id IS NULL OR v.document_id IS NULL OR (d.kind='imported-markdown' AND c.markdown IS NULL) OR (d.kind='memo' AND c.markdown IS NOT NULL) LIMIT 1", null).use {
154
+ if (it.moveToFirst()) fail("手记文档迁移校验失败,已保留原始数据")
155
+ }
156
+ }
157
+
158
+ private fun <T> transaction(block: (SQLiteDatabase) -> T): T {
159
+ val database = db()
160
+ database.beginTransaction()
161
+ try {
162
+ val result = block(database)
163
+ database.setTransactionSuccessful()
164
+ return result
165
+ } finally { database.endTransaction() }
166
+ }
167
+
168
+ private fun defaultViewport() = JSONObject().put("scale", 1).put("paperOffsetX", 0).put("paperOffsetY", 0)
169
+ /** Keep each CursorWindow row below its device-specific limit, including API 24-27. */
170
+ private fun readTextColumn(database: SQLiteDatabase, table: String, column: String, condition: String, args: Array<String>): String {
171
+ val result = StringBuilder()
172
+ var offset = 1L
173
+ while (true) {
174
+ val chunk = database.rawQuery("SELECT substr($column,?,65536) FROM $table WHERE $condition", arrayOf(offset.toString(), *args)).use {
175
+ if (!it.moveToFirst() || it.isNull(0)) return result.toString()
176
+ it.getString(0)
177
+ }
178
+ if (chunk.isEmpty()) break
179
+ result.append(chunk)
180
+ // SQLite substr indexes Unicode code points rather than Java UTF-16 units.
181
+ offset += chunk.codePointCount(0, chunk.length)
182
+ }
183
+ return result.toString()
184
+ }
185
+ private fun validateContext(database: SQLiteDatabase, request: JSONObject): JSONObject {
186
+ val scope = request.getJSONObject("context")
187
+ val account = str(scope, "accountId")
188
+ val session = str(scope, "sessionId")
189
+ val generation = str(scope, "generation")
190
+ database.rawQuery("SELECT generation,deleted FROM workspaces WHERE account_id=? AND session_id=?", arrayOf(account, session)).use {
191
+ if (!it.moveToFirst() || it.getInt(1) != 0 || it.getString(0) != generation) fail("手记工作区已失效")
192
+ }
193
+ return scope
194
+ }
195
+ private fun validateDocument(database: SQLiteDatabase, request: JSONObject): String {
196
+ val scope = validateContext(database, request)
197
+ val id = str(request, "documentId")
198
+ database.rawQuery("SELECT id FROM documents WHERE id=? AND account_id=? AND session_id=?", arrayOf(id, str(scope, "accountId"), str(scope, "sessionId"))).use {
199
+ if (!it.moveToFirst()) fail("手记文档不存在")
200
+ }
201
+ return id
202
+ }
203
+ private fun parseMemoTemplateInput(input: JSONObject): JSONObject {
204
+ val rows = input.getJSONArray("rows")
205
+ if (rows.length() > 1000) fail("Memo template row limit exceeded")
206
+ val cleanRows = JSONArray()
207
+ val ids = mutableSetOf<String>()
208
+ for (index in 0 until rows.length()) {
209
+ val row = rows.getJSONObject(index)
210
+ if (!ids.add(str(row, "id"))) fail("Memo template row identity reused")
211
+ cleanRows.put(JSONObject().put("id", str(row, "id")).put("label", str(row, "label", 8192)))
212
+ }
213
+ val rowHeight = number(input, "rowHeight")
214
+ val labelWidth = number(input, "labelColumnWidth")
215
+ val bodyGap = number(input, "bodyGap")
216
+ val bodyHeight = number(input, "bodyHeight")
217
+ val offset = if (rows.length() == 0) 0.0 else rows.length() * rowHeight + bodyGap
218
+ if (rowHeight !in 1.0..1e7 || labelWidth !in 1.0..1600.0 || bodyGap !in 0.0..1e7 || bodyHeight <= 0 || bodyHeight > 1e7 || offset + bodyHeight > 1e7) fail("Memo template dimensions invalid")
219
+ return JSONObject().put("rows", cleanRows).put("rowHeight", rowHeight).put("labelColumnWidth", labelWidth)
220
+ .put("bodyGap", bodyGap).put("bodyHeight", bodyHeight).put("bodyOffsetY", offset)
221
+ }
222
+ private fun readMemoTemplate(database: SQLiteDatabase, id: String): JSONObject? {
223
+ val exists = database.rawQuery("SELECT memo_template IS NOT NULL FROM documents WHERE id=?", arrayOf(id)).use { it.moveToFirst() && it.getInt(0) == 1 }
224
+ if (!exists) return null
225
+ val template = JSONObject(readTextColumn(database, "documents", "memo_template", "id=?", arrayOf(id)))
226
+ val normalized = parseMemoTemplateInput(template)
227
+ str(template, "id")
228
+ if (number(template, "version") != 1.0 || number(template, "bodyOffsetY") != number(normalized, "bodyOffsetY")) fail("Memo template invalid")
229
+ return template
230
+ }
231
+ private fun validateMemoTemplateIdentity(database: SQLiteDatabase, id: String, request: JSONObject): JSONObject? {
232
+ val template = readMemoTemplate(database, id)
233
+ if (template == null) {
234
+ if (request.has("memoTemplateId")) fail("Memo template已失效")
235
+ } else if (request.opt("memoTemplateId") != template.getString("id")) fail("Memo template已失效")
236
+ return template
237
+ }
238
+ private fun summaries(database: SQLiteDatabase, account: String, session: String): JSONArray {
239
+ val result = JSONArray()
240
+ database.rawQuery("SELECT id,kind,title,ordinal,revision FROM documents WHERE account_id=? AND session_id=? ORDER BY ordinal", arrayOf(account, session)).use {
241
+ while (it.moveToNext()) result.put(JSONObject().put("id", it.getString(0)).put("kind", it.getString(1))
242
+ .put("title", it.getString(2)).put("ordinal", it.getInt(3)).put("revision", it.getLong(4)))
243
+ }
244
+ return result
245
+ }
246
+ private fun insertDocument(database: SQLiteDatabase, account: String, session: String, kind: String, title: String, ordinal: Int, markdown: String?): String {
247
+ val id = UUID.randomUUID().toString()
248
+ database.execSQL("INSERT INTO documents(id,account_id,session_id,kind,title,ordinal) VALUES(?,?,?,?,?,?)", arrayOf<Any>(id, account, session, kind, title, ordinal))
249
+ database.execSQL("INSERT INTO contents(document_id,markdown,content_hash) VALUES(?,?,?)", arrayOf(id, markdown, markdown?.let(::hash)))
250
+ database.execSQL("INSERT INTO viewports(document_id,payload) VALUES(?,?)", arrayOf(id, defaultViewport().toString()))
251
+ writeSnapshot(database, id, 0)
252
+ return id
253
+ }
254
+ private fun totalBytes(database: SQLiteDatabase, account: String, session: String?): Long {
255
+ val condition = if (session == null) "d.account_id=?" else "d.account_id=? AND d.session_id=?"
256
+ val args = if (session == null) arrayOf(account) else arrayOf(account, session)
257
+ database.rawQuery("SELECT COALESCE(SUM(length(CAST(c.markdown AS BLOB))),0) FROM contents c JOIN documents d ON d.id=c.document_id WHERE $condition", args).use {
258
+ it.moveToFirst()
259
+ return it.getLong(0)
260
+ }
261
+ }
262
+
263
+ fun capabilities(): String {
264
+ val database = db()
265
+ val journal = database.rawQuery("PRAGMA journal_mode", null).use { it.moveToFirst(); it.getString(0).lowercase() }
266
+ val sync = database.rawQuery("PRAGMA synchronous", null).use { it.moveToFirst(); it.getInt(0) }
267
+ if (sync != 2 || journal !in listOf("wal", "delete", "truncate", "persist")) fail("手记持久化配置不可用")
268
+ return JSONObject().put("persistent", true).put("excludedFromBackup", true)
269
+ .put("journalMode", if (journal == "wal") "wal" else "delete").put("synchronous", "full").toString()
270
+ }
271
+
272
+ fun ensureWorkspace(payload: String): String = transaction { database ->
273
+ val request = JSONObject(payload)
274
+ val account = str(request, "accountId")
275
+ val session = str(request, "sessionId")
276
+ var generation: String? = null
277
+ var active: String? = null
278
+ database.rawQuery("SELECT generation,active_id,deleted FROM workspaces WHERE account_id=? AND session_id=?", arrayOf(account, session)).use {
279
+ if (it.moveToFirst()) {
280
+ if (it.getInt(2) != 0) fail("该场手记已随会话删除")
281
+ generation = it.getString(0)
282
+ active = it.getString(1)
283
+ }
284
+ }
285
+ if (generation == null) {
286
+ generation = UUID.randomUUID().toString()
287
+ database.execSQL("INSERT INTO workspaces(account_id,session_id,generation) VALUES(?,?,?)", arrayOf(account, session, generation))
288
+ active = insertDocument(database, account, session, "memo", "Memo", 0, null)
289
+ database.execSQL("UPDATE workspaces SET active_id=? WHERE account_id=? AND session_id=?", arrayOf(active, account, session))
290
+ }
291
+ JSONObject().put("context", JSONObject().put("accountId", account).put("sessionId", session).put("generation", generation))
292
+ .put("documents", summaries(database, account, session)).put("activeDocumentId", active).toString()
293
+ }
294
+
295
+ /** Capture host-provided rows once; original ink, operations and recovery records remain intact. */
296
+ fun initializeMemoTemplate(payload: String): String = transaction { database ->
297
+ val request = JSONObject(payload)
298
+ val id = validateDocument(database, request)
299
+ var existingLayout: JSONObject? = null
300
+ database.rawQuery("SELECT kind,layout IS NULL FROM documents WHERE id=?", arrayOf(id)).use {
301
+ if (!it.moveToFirst() || it.getString(0) != "memo") fail("Templates require a blank memo")
302
+ if (it.getInt(1) == 0) existingLayout = JSONObject(readTextColumn(database, "documents", "layout", "id=?", arrayOf(id)))
303
+ }
304
+ val candidate = parseMemoTemplateInput(request.getJSONObject("template"))
305
+ if (readMemoTemplate(database, id) == null) {
306
+ val template = candidate
307
+ val templateId = UUID.randomUUID().toString()
308
+ template.put("id", templateId).put("version", 1)
309
+ val layout = existingLayout
310
+ if (layout != null) {
311
+ if (number(layout, "paperWidth") != 1600.0 || number(layout, "paperHeight") != 10000.0 || layout.has("memoTemplateId") || number(template, "bodyHeight") < number(layout, "paperHeight")) fail("Original memo layout invalid; data retained")
312
+ layout.put("paperHeight", number(template, "bodyHeight") + number(template, "bodyOffsetY")).put("geometry", JSONArray())
313
+ .put("fingerprint", "memo-template:$templateId").put("memoTemplateId", templateId)
314
+ }
315
+ database.execSQL("UPDATE documents SET memo_template=?,layout=? WHERE id=?", arrayOf(template.toString(), layout?.toString(), id))
316
+ database.execSQL("UPDATE viewports SET payload=? WHERE document_id=?", arrayOf(defaultViewport().put("fitToWidth", true).toString(), id))
317
+ }
318
+ readDocument(payload)
319
+ }
320
+
321
+ private fun readStrokes(database: SQLiteDatabase, id: String): JSONArray {
322
+ val result = JSONArray()
323
+ database.rawQuery("SELECT id FROM strokes WHERE document_id=? ORDER BY rowid", arrayOf(id)).use {
324
+ while (it.moveToNext()) result.put(JSONObject(readTextColumn(database, "strokes", "payload", "document_id=? AND id=?", arrayOf(id, it.getString(0)))))
325
+ }
326
+ return result
327
+ }
328
+ private fun recoverStrokes(database: SQLiteDatabase, id: String, revision: Long, layout: JSONObject, memoTemplate: JSONObject?): JSONArray {
329
+ var lastError: Throwable? = null
330
+ val candidates = mutableListOf<Pair<Long, String>>()
331
+ database.rawQuery("SELECT revision,payload_hash FROM snapshots WHERE document_id=? ORDER BY revision DESC", arrayOf(id)).use {
332
+ while (it.moveToNext()) {
333
+ val snapshot = readTextColumn(database, "snapshots", "payload", "document_id=? AND revision=?", arrayOf(id, it.getLong(0).toString()))
334
+ if (hash(snapshot) == it.getString(1)) candidates.add(it.getLong(0) to snapshot)
335
+ }
336
+ }
337
+ for ((snapshotRevision, snapshot) in candidates) {
338
+ try {
339
+ val restored = linkedMapOf<String, JSONObject>()
340
+ val snapshotStrokes = JSONArray(snapshot)
341
+ for (index in 0 until snapshotStrokes.length()) {
342
+ val stroke = snapshotStrokes.getJSONObject(index)
343
+ validateStroke(stroke, id, layout, memoTemplate)
344
+ restored[str(stroke, "id")] = stroke
345
+ }
346
+ var nextRevision = snapshotRevision + 1
347
+ database.rawQuery("SELECT revision,op_id,payload_hash,payload IS NULL FROM operations WHERE document_id=? AND revision>? ORDER BY revision", arrayOf(id, snapshotRevision.toString())).use {
348
+ while (it.moveToNext()) {
349
+ if (it.getLong(0) != nextRevision++ || it.getInt(3) == 1) fail("手记恢复操作不完整")
350
+ val operationText = readTextColumn(database, "operations", "payload", "document_id=? AND op_id=?", arrayOf(id, it.getString(1)))
351
+ if (hash(operationText) != it.getString(2)) fail("手记恢复操作校验失败")
352
+ val operation = JSONObject(operationText)
353
+ when (str(operation, "type")) {
354
+ "addStroke" -> {
355
+ val stroke = operation.getJSONObject("stroke")
356
+ validateStroke(stroke, id, layout, memoTemplate)
357
+ restored[str(stroke, "id")] = stroke
358
+ }
359
+ "eraseStrokes" -> {
360
+ val ids = operation.getJSONArray("strokeIds")
361
+ for (index in 0 until ids.length()) restored.remove(ids.getString(index))
362
+ }
363
+ else -> fail("手记恢复操作类型无效")
364
+ }
365
+ }
366
+ }
367
+ if (nextRevision - 1 != revision) fail("手记恢复版本不完整")
368
+ return transaction { db ->
369
+ db.execSQL("DELETE FROM strokes WHERE document_id=?", arrayOf(id))
370
+ restored.values.forEach { stroke -> db.execSQL("INSERT INTO strokes(document_id,id,payload) VALUES(?,?,?)", arrayOf(id, str(stroke, "id"), stroke.toString())) }
371
+ readStrokes(db, id)
372
+ }
373
+ } catch (error: Throwable) { lastError = error }
374
+ }
375
+ throw IllegalStateException("手记笔迹恢复失败,已保留快照与操作记录", lastError)
376
+ }
377
+ fun readDocument(payload: String): String {
378
+ val request = JSONObject(payload)
379
+ val database = db()
380
+ val id = validateDocument(database, request)
381
+ val memoTemplate = readMemoTemplate(database, id)
382
+ database.rawQuery("SELECT d.kind,d.title,d.ordinal,d.revision,d.layout IS NULL,c.markdown IS NULL,c.content_hash,v.payload FROM documents d JOIN contents c ON c.document_id=d.id JOIN viewports v ON v.document_id=d.id WHERE d.id=?", arrayOf(id)).use {
383
+ if (!it.moveToFirst()) fail("手记文档记录不完整")
384
+ val layout = if (it.getInt(4) == 1) null else JSONObject(readTextColumn(database, "documents", "layout", "id=?", arrayOf(id)))
385
+ val strokes = try {
386
+ readStrokes(database, id).also { strokes ->
387
+ if (layout != null) for (index in 0 until strokes.length()) validateStroke(strokes.getJSONObject(index), id, layout, memoTemplate)
388
+ }
389
+ } catch (error: Exception) {
390
+ if (layout == null) throw error
391
+ recoverStrokes(database, id, it.getLong(3), layout, memoTemplate)
392
+ }
393
+ return JSONObject().put("id", id).put("kind", it.getString(0)).put("title", it.getString(1))
394
+ .put("ordinal", it.getInt(2)).put("revision", it.getLong(3))
395
+ .put("layout", layout ?: JSONObject.NULL)
396
+ .put("memoTemplate", memoTemplate ?: JSONObject.NULL)
397
+ .put("markdown", if (it.getInt(5) == 1) JSONObject.NULL else readTextColumn(database, "contents", "markdown", "document_id=?", arrayOf(id)))
398
+ .put("contentHash", if (it.isNull(6)) JSONObject.NULL else it.getString(6))
399
+ .put("viewport", JSONObject(it.getString(7))).put("strokes", strokes).toString()
400
+ }
401
+ }
402
+
403
+ fun importDocument(payload: String): String = transaction { database ->
404
+ val request = JSONObject(payload)
405
+ val scope = validateContext(database, request)
406
+ val account = str(scope, "accountId")
407
+ val session = str(scope, "sessionId")
408
+ val incoming = request.getJSONObject("document")
409
+ val markdown = incoming.getString("markdown")
410
+ val bytes = markdown.toByteArray(StandardCharsets.UTF_8).size
411
+ if (bytes > maxFileBytes) fail("Markdown 文件超过 16 MiB")
412
+ if (totalBytes(database, account, session) + bytes > maxSessionBytes) fail("本场手记正文超过 256 MiB")
413
+ if (totalBytes(database, account, null) + bytes > maxAccountBytes) fail("本账号手记正文超过 512 MiB")
414
+ val baseTitle = str(incoming, "title", 4096)
415
+ var title = baseTitle
416
+ val titles = mutableSetOf<String>()
417
+ var ordinal = 1
418
+ database.rawQuery("SELECT title,ordinal FROM documents WHERE account_id=? AND session_id=?", arrayOf(account, session)).use {
419
+ while (it.moveToNext()) { titles.add(it.getString(0)); ordinal = maxOf(ordinal, it.getInt(1) + 1) }
420
+ }
421
+ var count = 2
422
+ while (title in titles) { title = "$baseTitle(${count++})" }
423
+ val id = insertDocument(database, account, session, "imported-markdown", title, ordinal, markdown)
424
+ database.execSQL("UPDATE workspaces SET active_id=? WHERE account_id=? AND session_id=?", arrayOf(id, account, session))
425
+ JSONObject().put("id", id).put("kind", "imported-markdown").put("title", title).put("ordinal", ordinal).put("revision", 0).toString()
426
+ }
427
+
428
+ private fun validateStroke(stroke: JSONObject, documentId: String, layout: JSONObject, memoTemplate: JSONObject?) {
429
+ str(stroke, "id")
430
+ if (str(stroke, "documentId") != documentId) fail("笔迹所属文档无效")
431
+ if (number(stroke, "layoutVersion") != number(layout, "layoutVersion")) fail("笔迹排版版本不一致")
432
+ if (stroke.has("coordinateSpace") && stroke.opt("coordinateSpace") != "paper") fail("笔迹坐标空间无效")
433
+ val maxHeight = if (memoTemplate != null && !stroke.has("coordinateSpace")) number(memoTemplate, "bodyHeight") else number(layout, "paperHeight")
434
+ val width = number(stroke, "width")
435
+ if (width <= 0 || width > 64) fail("笔宽超出范围")
436
+ if (!Regex("#[0-9a-fA-F]{6}").matches(str(stroke, "color", 7))) fail("笔迹颜色无效")
437
+ str(stroke, "createdAt", 64)
438
+ val points = stroke.getJSONArray("points")
439
+ if (points.length() < 1 || points.length() > 100000) fail("笔迹采样点数量超限")
440
+ for (index in 0 until points.length()) {
441
+ val point = points.getJSONObject(index)
442
+ val x = number(point, "x")
443
+ val y = number(point, "y")
444
+ if (x < 0 || y < 0 || x > number(layout, "paperWidth") || y > maxHeight) fail("笔迹坐标超出纸面")
445
+ if (point.has("pressure") && number(point, "pressure") !in 0.0..1.0) fail("笔压超出范围")
446
+ if (point.has("t") && number(point, "t") < 0) fail("笔迹时间无效")
447
+ }
448
+ }
449
+ private fun applyOperation(database: SQLiteDatabase, documentId: String, operation: JSONObject, layout: JSONObject, memoTemplate: JSONObject?) {
450
+ when (str(operation, "type")) {
451
+ "addStroke" -> {
452
+ val stroke = operation.getJSONObject("stroke")
453
+ validateStroke(stroke, documentId, layout, memoTemplate)
454
+ database.execSQL("INSERT INTO strokes(document_id,id,payload) VALUES(?,?,?)", arrayOf(documentId, str(stroke, "id"), stroke.toString()))
455
+ }
456
+ "eraseStrokes" -> {
457
+ val ids = operation.getJSONArray("strokeIds")
458
+ if (ids.length() > 100000) fail("擦除笔迹数量超限")
459
+ for (index in 0 until ids.length()) {
460
+ val id = ids.get(index)
461
+ if (id !is String || id.isEmpty() || id.length > 256) fail("擦除笔迹标识无效")
462
+ database.execSQL("DELETE FROM strokes WHERE document_id=? AND id=?", arrayOf(documentId, id))
463
+ }
464
+ }
465
+ else -> fail("手记操作类型无效")
466
+ }
467
+ }
468
+ private fun writeSnapshot(database: SQLiteDatabase, id: String, revision: Long) {
469
+ val payload = readStrokes(database, id).toString()
470
+ database.execSQL("INSERT OR REPLACE INTO snapshots(document_id,revision,payload,payload_hash) VALUES(?,?,?,?)", arrayOf<Any>(id, revision, payload, hash(payload)))
471
+ database.execSQL("DELETE FROM snapshots WHERE document_id=? AND revision NOT IN (SELECT revision FROM snapshots WHERE document_id=? ORDER BY revision DESC LIMIT 2)", arrayOf(id, id))
472
+ database.execSQL("UPDATE operations SET payload=NULL WHERE document_id=? AND revision < (SELECT MIN(revision) FROM snapshots WHERE document_id=?)", arrayOf(id, id))
473
+ }
474
+ fun commitOperation(payload: String): String = transaction { database ->
475
+ if (payload.toByteArray(StandardCharsets.UTF_8).size > 8 * 1024 * 1024) fail("笔迹操作体积超限")
476
+ val request = JSONObject(payload)
477
+ val id = validateDocument(database, request)
478
+ val operation = request.getJSONObject("operation")
479
+ val opId = str(operation, "opId")
480
+ val operationHash = hash(operation.toString())
481
+ var duplicate = false
482
+ var revision = 0L
483
+ database.rawQuery("SELECT revision,payload_hash FROM operations WHERE document_id=? AND op_id=?", arrayOf(id, opId)).use {
484
+ if (it.moveToFirst()) {
485
+ if (it.getString(1) != operationHash) fail("相同操作编号包含不同笔迹")
486
+ duplicate = true
487
+ revision = it.getLong(0)
488
+ }
489
+ }
490
+ if (!duplicate) {
491
+ var layout: JSONObject? = null
492
+ database.rawQuery("SELECT revision,layout IS NULL FROM documents WHERE id=?", arrayOf(id)).use {
493
+ it.moveToFirst()
494
+ revision = it.getLong(0) + 1
495
+ if (it.getInt(1) == 0) layout = JSONObject(readTextColumn(database, "documents", "layout", "id=?", arrayOf(id)))
496
+ }
497
+ val profile = layout ?: fail("纸面尚未完成排版,请等待加载")
498
+ applyOperation(database, id, operation, profile, readMemoTemplate(database, id))
499
+ database.execSQL("INSERT INTO operations(document_id,op_id,payload_hash,payload,revision) VALUES(?,?,?,?,?)", arrayOf<Any>(id, opId, operationHash, operation.toString(), revision))
500
+ database.execSQL("UPDATE documents SET revision=? WHERE id=?", arrayOf<Any>(revision, id))
501
+ if (revision % 64L == 0L) writeSnapshot(database, id, revision)
502
+ }
503
+ JSONObject().put("context", request.getJSONObject("context")).put("documentId", id).put("opId", opId)
504
+ .put("revision", revision).put("duplicate", duplicate).toString()
505
+ }
506
+
507
+ fun saveViewport(payload: String): String = transaction { database ->
508
+ val request = JSONObject(payload)
509
+ val id = validateDocument(database, request)
510
+ validateMemoTemplateIdentity(database, id, request)
511
+ val viewport = request.getJSONObject("viewport")
512
+ validateViewport(viewport)
513
+ database.execSQL("UPDATE viewports SET payload=? WHERE document_id=?", arrayOf(viewport.toString(), id))
514
+ "{}"
515
+ }
516
+ private fun validateViewport(viewport: JSONObject) {
517
+ if (viewport.has("fitToWidth") && viewport.opt("fitToWidth") != true) fail("视口适配标记无效")
518
+ if (number(viewport, "scale") !in 0.25..4.0) fail("缩放比例超出范围")
519
+ if (kotlin.math.abs(number(viewport, "paperOffsetX")) > 1e7 || kotlin.math.abs(number(viewport, "paperOffsetY")) > 1e7) fail("视口位置超出范围")
520
+ }
521
+ fun saveLayoutProfile(payload: String): String = transaction { database ->
522
+ val request = JSONObject(payload)
523
+ val id = validateDocument(database, request)
524
+ val memoTemplate = validateMemoTemplateIdentity(database, id, request)
525
+ val layout = request.getJSONObject("layout")
526
+ if (memoTemplate == null) {
527
+ if (layout.has("memoTemplateId")) fail("Memo template已失效")
528
+ } else if (layout.opt("memoTemplateId") != memoTemplate.getString("id")) fail("Memo template已失效")
529
+ val mappedViewport = if (request.has("viewport")) request.getJSONObject("viewport") else null
530
+ if (mappedViewport != null) validateViewport(mappedViewport)
531
+ listOf("rendererVersion", "fontAssetVersion", "styleProfileId", "fingerprint").forEach { str(layout, it) }
532
+ val version = number(layout, "layoutVersion")
533
+ if (version < 1 || version % 1 != 0.0) fail("排版版本无效")
534
+ for (key in listOf("paperWidth", "paperHeight", "contentWidth")) {
535
+ if (number(layout, key) <= 0 || number(layout, key) > 1e7) fail("纸面尺寸无效")
536
+ }
537
+ if (number(layout, "contentWidth") > number(layout, "paperWidth")) fail("正文宽度超出纸面")
538
+ if (layout.has("geometry")) {
539
+ val geometry = layout.getJSONArray("geometry")
540
+ if (geometry.length() > 200000) fail("纸面几何信息超限")
541
+ for (index in 0 until geometry.length()) {
542
+ val box = geometry.getJSONObject(index)
543
+ for (key in listOf("x", "y", "width", "height")) {
544
+ val value = number(box, key)
545
+ if (kotlin.math.abs(value) > 1e7 || (key in listOf("width", "height") && value < 0)) fail("纸面几何信息无效")
546
+ }
547
+ }
548
+ }
549
+ var existing: String? = null
550
+ var kind = ""
551
+ database.rawQuery("SELECT layout IS NULL,kind FROM documents WHERE id=?", arrayOf(id)).use { it.moveToFirst(); existing = if (it.getInt(0) == 1) null else readTextColumn(database, "documents", "layout", "id=?", arrayOf(id)); kind = it.getString(1) }
552
+ val memoHeight = memoTemplate?.let { number(it, "bodyHeight") + number(it, "bodyOffsetY") } ?: 10000.0
553
+ if (kind == "memo" && (number(layout, "paperWidth") != 1600.0 || number(layout, "paperHeight") != memoHeight)) fail("Memo纸面边界不可修改")
554
+ if (memoTemplate != null && (str(layout, "fingerprint") != "memo-template:${memoTemplate.getString("id")}" || layout.optJSONArray("geometry")?.length() != 0)) fail("Memo template排版无效")
555
+ if (existing != null) {
556
+ val saved = JSONObject(existing)
557
+ if (!compatibleLayout(saved, layout)) {
558
+ if (!canUpgradeEmptyLayout(database, id, saved, layout)) fail("已保存纸面排版不可静默修改")
559
+ val revision = database.rawQuery("SELECT revision FROM documents WHERE id=?", arrayOf(id)).use { it.moveToFirst(); it.getLong(0) }
560
+ // The current empty state must be recoverable without replaying ink from the old layout.
561
+ writeSnapshot(database, id, revision)
562
+ database.execSQL("UPDATE documents SET layout=? WHERE id=?", arrayOf(layout.toString(), id))
563
+ }
564
+ } else {
565
+ database.execSQL("UPDATE documents SET layout=? WHERE id=?", arrayOf(layout.toString(), id))
566
+ }
567
+ if (mappedViewport != null) database.execSQL("UPDATE viewports SET payload=? WHERE document_id=?", arrayOf(mappedViewport.toString(), id))
568
+ "{}"
569
+ }
570
+ /** Only empty current ink can reflow; existing operation receipts remain authoritative. */
571
+ private fun canUpgradeEmptyLayout(database: SQLiteDatabase, id: String, saved: JSONObject, candidate: JSONObject): Boolean {
572
+ if (str(saved, "styleProfileId") != "paper-v1" || str(candidate, "styleProfileId") != "paper-v2") return false
573
+ for (key in listOf("rendererVersion", "fontAssetVersion")) {
574
+ if (str(saved, key) != str(candidate, key)) return false
575
+ }
576
+ for (key in listOf("layoutVersion", "paperWidth", "contentWidth")) {
577
+ if (number(saved, key) != number(candidate, key)) return false
578
+ }
579
+ return database.rawQuery(
580
+ "SELECT NOT EXISTS(SELECT 1 FROM strokes WHERE document_id=?)",
581
+ arrayOf(id)
582
+ ).use { cursor ->
583
+ cursor.moveToFirst() && cursor.getInt(0) == 1
584
+ }
585
+ }
586
+ private fun compatibleLayout(saved: JSONObject, candidate: JSONObject): Boolean {
587
+ if (saved.opt("memoTemplateId") != candidate.opt("memoTemplateId")) return false
588
+ for (key in listOf("rendererVersion", "fontAssetVersion", "styleProfileId")) {
589
+ if (str(saved, key) != str(candidate, key)) return false
590
+ }
591
+ if (number(saved, "layoutVersion") != number(candidate, "layoutVersion")) return false
592
+ for (key in listOf("paperWidth", "paperHeight", "contentWidth")) {
593
+ if (kotlin.math.abs(number(saved, key) - number(candidate, key)) > 1) return false
594
+ }
595
+ if (!saved.has("geometry") || !candidate.has("geometry")) return str(saved, "fingerprint") == str(candidate, "fingerprint")
596
+ val first = saved.getJSONArray("geometry")
597
+ val second = candidate.getJSONArray("geometry")
598
+ if (first.length() != second.length()) return false
599
+ if (first.length() == 0) return str(saved, "fingerprint") == str(candidate, "fingerprint")
600
+ for (index in 0 until first.length()) {
601
+ for (key in listOf("x", "y", "width", "height")) {
602
+ if (kotlin.math.abs(number(first.getJSONObject(index), key) - number(second.getJSONObject(index), key)) > 1) return false
603
+ }
604
+ }
605
+ return true
606
+ }
607
+ fun setActiveDocument(payload: String): String = transaction { database ->
608
+ val request = JSONObject(payload)
609
+ val id = validateDocument(database, request)
610
+ val scope = request.getJSONObject("context")
611
+ database.execSQL("UPDATE workspaces SET active_id=? WHERE account_id=? AND session_id=?", arrayOf(id, str(scope, "accountId"), str(scope, "sessionId")))
612
+ "{}"
613
+ }
614
+ fun deleteSession(payload: String): String = transaction { database ->
615
+ val request = JSONObject(payload)
616
+ val account = str(request, "accountId")
617
+ val session = str(request, "sessionId")
618
+ database.execSQL("DELETE FROM documents WHERE account_id=? AND session_id=?", arrayOf(account, session))
619
+ database.execSQL("INSERT OR REPLACE INTO workspaces(account_id,session_id,generation,active_id,deleted) VALUES(?,?,?,NULL,1)", arrayOf(account, session, UUID.randomUUID().toString()))
620
+ "{}"
621
+ }
622
+ fun readImport(payload: String): String {
623
+ val request = JSONObject(payload)
624
+ val uri = Uri.parse(str(request, "uri", 8192))
625
+ if (uri.scheme != "file") fail("请选择系统已缓存的 Markdown 文件")
626
+ val file = File(uri.path ?: fail("导入缓存路径无效")).canonicalFile
627
+ if (!file.canonicalPath.startsWith(context.cacheDir.canonicalPath + File.separator)) fail("导入来源不属于应用缓存")
628
+ if (!file.isFile || file.length() > maxFileBytes) fail("Markdown 文件不可读或超过 16 MiB")
629
+ val bytes = file.inputStream().use { stream ->
630
+ val result = java.io.ByteArrayOutputStream()
631
+ val buffer = ByteArray(8192)
632
+ var size = 0
633
+ while (true) {
634
+ val read = stream.read(buffer)
635
+ if (read < 0) break
636
+ size += read
637
+ if (size > maxFileBytes) fail("Markdown 文件超过 16 MiB")
638
+ result.write(buffer, 0, read)
639
+ }
640
+ result.toByteArray()
641
+ }
642
+ val decoder = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT)
643
+ val decoded = try { decoder.decode(ByteBuffer.wrap(bytes)).toString() } catch (_: Exception) { fail("文件编码无效,请转换为 UTF-8") }
644
+ val markdown = decoded.removePrefix("\uFEFF")
645
+ if (markdown.any { it.code < 32 && it != '\n' && it != '\r' && it != '\t' }) fail("文件包含二进制或损坏内容")
646
+ return JSONObject().put("markdown", markdown).put("sizeBytes", bytes.size).toString()
647
+ }
648
+ fun flush(): String { database?.let { if (it.inTransaction()) fail("手记事务仍在执行") }; return "{}" }
649
+ fun close() { database?.close(); database = null }
650
+ }