import 'dart:convert'; import 'dart:io'; import 'dart:ui' show Offset, Size; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:uuid/uuid.dart'; import '../editor/board/board.dart'; import '../models/bookmark.dart'; import '../models/document.dart' as doc; import '../models/ink_point.dart'; import '../models/ink_stroke.dart'; import '../models/note.dart'; import '../models/pen_tool.dart'; import '../models/pointer_device_kind.dart'; class DatabaseService { static DatabaseService? _instance; late Database _database; DatabaseService._(); static Future getInstance() async { if (_instance != null) return _instance!; final service = DatabaseService._(); await service._initialize(); _instance = service; return service; } /// Test-only: drop the cached singleton so the next [getInstance] re-opens a /// fresh database (e.g. after pointing PathProviderPlatform at a new temp /// dir). Closes the current handle if one is open. @visibleForTesting static Future resetForTest() async { final existing = _instance; _instance = null; if (existing != null) { await existing._database.close(); } } Database get database => _database; Future _initialize() async { if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { sqfliteFfiInit(); databaseFactory = databaseFactoryFfi; } final dir = await getApplicationDocumentsDirectory(); final dbPath = p.join(dir.path, 'badnote.db'); _database = await openDatabase( dbPath, version: 7, onCreate: _onCreate, onUpgrade: _onUpgrade, ); } Future _onCreate(Database db, int version) async { // Core tables (original v1) await db.execute(''' CREATE TABLE notes ( id TEXT PRIMARY KEY, title TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, tags TEXT NOT NULL DEFAULT '[]' ) '''); await db.execute(''' CREATE TABLE strokes ( id TEXT PRIMARY KEY, note_id TEXT NOT NULL, tool TEXT NOT NULL, color INTEGER NOT NULL, stroke_width REAL NOT NULL, created_at TEXT NOT NULL, points TEXT NOT NULL, FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE ) '''); await db.execute('CREATE INDEX idx_strokes_note_id ON strokes(note_id)'); await _createFtsTable(db); // Documents & annotations (originally v2, now part of fresh install) await db.execute(''' CREATE TABLE documents ( id TEXT PRIMARY KEY, filename TEXT NOT NULL, doc_type TEXT NOT NULL, file_path TEXT NOT NULL, page_count INTEGER NOT NULL DEFAULT 0, rotation INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) '''); await db.execute(''' CREATE TABLE annotations ( id TEXT PRIMARY KEY, uuid TEXT NOT NULL, document_id TEXT NOT NULL, page_number INTEGER NOT NULL, annotation_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE ) '''); await db.execute( 'CREATE INDEX idx_annotations_doc_page ON annotations(document_id, page_number)', ); await db.execute(''' CREATE TABLE bookmarks ( id TEXT PRIMARY KEY, document_id TEXT NOT NULL, page_number INTEGER NOT NULL, label TEXT NOT NULL DEFAULT '', color INTEGER NOT NULL DEFAULT 4283215696, created_at TEXT NOT NULL, FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE ) '''); await db.execute( 'CREATE INDEX idx_bookmarks_doc ON bookmarks(document_id)', ); await db.execute(''' CREATE TABLE ocr_results ( id TEXT PRIMARY KEY, document_id TEXT NOT NULL, page_number INTEGER NOT NULL, ocr_text TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE ) '''); // Document FTS (v3) await db.execute(''' CREATE VIRTUAL TABLE document_fts USING fts5( document_id, page_number, content, tokenize='porter unicode61' ) '''); // Scratchpads (v5) await db.execute(''' CREATE TABLE scratchpads ( id TEXT PRIMARY KEY, document_id TEXT UNIQUE NOT NULL, strokes_json TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE ) '''); await db.execute( 'CREATE INDEX idx_scratchpads_doc ON scratchpads(document_id)', ); // Editor ink strokes (v6) await db.execute(''' CREATE TABLE ink ( id TEXT PRIMARY KEY, host_kind TEXT NOT NULL, host_id TEXT NOT NULL, stroke_json TEXT NOT NULL, ordinal INTEGER NOT NULL, updated_at INTEGER NOT NULL ) '''); await db.execute( 'CREATE INDEX idx_ink_host ON ink(host_kind, host_id)', ); // Notebook pages (v6) await db.execute(''' CREATE TABLE notebook_pages ( id TEXT PRIMARY KEY, document_id TEXT NOT NULL, ordinal INTEGER NOT NULL, source_page_index INTEGER NOT NULL, kind TEXT NOT NULL, created_at INTEGER NOT NULL ) '''); // Sticky-note board cards (v7): F7 双链 + 无限便利贴. await _createBoardCardsTable(db); } /// Sticky-note board cards table (F7). One row per [BoardCard]; a board is the /// set of rows sharing a [board_id]. Geometry is stored as plain columns /// (rows, not a blob) so a board round-trips and could be queried later. Future _createBoardCardsTable(DatabaseExecutor db) async { await db.execute(''' CREATE TABLE board_cards ( id TEXT PRIMARY KEY, board_id TEXT NOT NULL, x REAL NOT NULL, y REAL NOT NULL, w REAL NOT NULL, h REAL NOT NULL, text TEXT NOT NULL DEFAULT '', ordinal INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL ) '''); await db.execute( 'CREATE INDEX idx_board_cards_board ON board_cards(board_id)', ); } Future _onUpgrade(Database db, int oldVersion, int newVersion) async { if (oldVersion < 3) await _migrateV2toV3(db); if (oldVersion < 4) {} // v3->v4: version boundary (no-op schema) if (oldVersion < 5) await _migrateV4toV5(db); if (oldVersion < 6) await _migrateV5toV6(db); if (oldVersion < 7) await _migrateV6toV7(db); } Future _migrateV6toV7(Database db) async { await db.transaction((txn) async { await _createBoardCardsTable(txn); }); } Future _migrateV5toV6(Database db) async { await db.transaction((txn) async { await txn.execute(''' CREATE TABLE ink ( id TEXT PRIMARY KEY, host_kind TEXT NOT NULL, host_id TEXT NOT NULL, stroke_json TEXT NOT NULL, ordinal INTEGER NOT NULL, updated_at INTEGER NOT NULL ) '''); await txn.execute( 'CREATE INDEX idx_ink_host ON ink(host_kind, host_id)', ); await txn.execute(''' CREATE TABLE notebook_pages ( id TEXT PRIMARY KEY, document_id TEXT NOT NULL, ordinal INTEGER NOT NULL, source_page_index INTEGER NOT NULL, kind TEXT NOT NULL, created_at INTEGER NOT NULL ) '''); }); } Future _migrateV2toV3(Database db) async { // Wrap the whole migration in a transaction: a failure mid-migration // (after DROP TABLE annotations) would otherwise destroy data. await db.transaction((txn) async { // Add uuid column to annotations await txn.execute('ALTER TABLE annotations ADD COLUMN uuid TEXT'); // Generate UUIDs for existing rows await txn.rawUpdate( "UPDATE annotations SET uuid = hex(randomblob(16)) WHERE uuid IS NULL", ); // Recreate annotations table with UUID primary key await txn.execute(''' CREATE TABLE annotations_new ( id TEXT PRIMARY KEY, uuid TEXT NOT NULL, document_id TEXT NOT NULL, page_number INTEGER NOT NULL, annotation_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE ) '''); await txn.rawInsert(''' INSERT INTO annotations_new (id, uuid, document_id, page_number, annotation_json, created_at, updated_at) SELECT id, uuid, document_id, page_number, annotation_json, created_at, updated_at FROM annotations '''); await txn.execute('DROP TABLE annotations'); await txn.execute('ALTER TABLE annotations_new RENAME TO annotations'); await txn.execute( 'CREATE INDEX idx_annotations_doc_page ON annotations(document_id, page_number)', ); // Create document FTS table await txn.execute(''' CREATE VIRTUAL TABLE document_fts USING fts5( document_id, page_number, content, tokenize='porter unicode61' ) '''); // Add rotation column to documents await txn.execute( 'ALTER TABLE documents ADD COLUMN rotation INTEGER NOT NULL DEFAULT 0', ); }); } Future _migrateV4toV5(Database db) async { // Wrap in a transaction so a partial failure does not leave the schema // in an inconsistent state. await db.transaction((txn) async { // Create scratchpads table await txn.execute(''' CREATE TABLE scratchpads ( id TEXT PRIMARY KEY, document_id TEXT UNIQUE NOT NULL, strokes_json TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE ) '''); await txn.execute( 'CREATE INDEX idx_scratchpads_doc ON scratchpads(document_id)', ); }); } // ── Notes CRUD ────────────────────────────────────────────────────── Future> getAllNotes() async { final noteRows = await _database.query('notes', orderBy: 'updated_at DESC'); final notes = []; for (final row in noteRows) { notes.add(await _noteFromRow(row)); } return notes; } Future getNoteById(String id) async { final rows = await _database.query( 'notes', where: 'id = ?', whereArgs: [id], ); if (rows.isEmpty) return null; return _noteFromRow(rows.first); } Future insertNote(Note note) async { // Atomic: the note row, its strokes, and the FTS index must all commit // together or not at all. await _database.transaction((txn) async { await txn.insert('notes', { 'id': note.id, 'title': note.title, 'created_at': note.createdAt.toIso8601String(), 'updated_at': note.updatedAt.toIso8601String(), 'tags': jsonEncode(note.tags), }); for (final stroke in note.strokes) { await _insertStroke(txn, note.id, stroke); } await _extractAndIndexNoteContent(txn, note); }); } Future updateNote(Note note) async { // Atomic: this deletes all strokes then re-inserts them and rebuilds the // FTS entry. An interruption mid-way would permanently lose strokes, so // the whole sequence must run inside one transaction. await _database.transaction((txn) async { await txn.update( 'notes', { 'title': note.title, 'updated_at': note.updatedAt.toIso8601String(), 'tags': jsonEncode(note.tags), }, where: 'id = ?', whereArgs: [note.id], ); // Replace all strokes for this note await txn.delete('strokes', where: 'note_id = ?', whereArgs: [note.id]); for (final stroke in note.strokes) { await _insertStroke(txn, note.id, stroke); } await removeFromFts(txn, note.id); await _extractAndIndexNoteContent(txn, note); }); } Future deleteNote(String id) async { await _database.transaction((txn) async { await txn.delete('strokes', where: 'note_id = ?', whereArgs: [id]); await txn.delete('notes', where: 'id = ?', whereArgs: [id]); await removeFromFts(txn, id); }); } // ── Strokes ───────────────────────────────────────────────────────── Future _insertStroke( DatabaseExecutor db, String noteId, InkStroke stroke, ) async { await db.insert('strokes', { 'id': stroke.id, 'note_id': noteId, 'tool': stroke.tool.name, 'color': stroke.color, 'stroke_width': stroke.strokeWidth, 'created_at': stroke.createdAt.toIso8601String(), 'points': jsonEncode(stroke.points.map(_pointToJson).toList()), }); } Future> _getStrokesForNote(String noteId) async { final rows = await _database.query( 'strokes', where: 'note_id = ?', whereArgs: [noteId], orderBy: 'created_at ASC', ); return rows.map(_strokeFromRow).toList(); } // ── Serialization helpers ─────────────────────────────────────────── Map _pointToJson(InkPoint p) => { 'x': p.x, 'y': p.y, 'pressure': p.pressure, 'tilt': p.tilt, 'timestamp': p.timestamp, 'pointerDeviceKind': p.pointerDeviceKind.name, }; InkPoint _pointFromJson(Map json) => InkPoint( x: (json['x'] as num).toDouble(), y: (json['y'] as num).toDouble(), pressure: (json['pressure'] as num?)?.toDouble() ?? 0.5, tilt: (json['tilt'] as num?)?.toDouble() ?? 0.0, timestamp: json['timestamp'] as int, pointerDeviceKind: _parseDeviceKind(json['pointerDeviceKind'] as String?), ); InputDeviceKind _parseDeviceKind(String? value) { if (value == null) return InputDeviceKind.unknown; return InputDeviceKind.values.asNameMap()[value] ?? InputDeviceKind.unknown; } InkStroke _strokeFromRow(Map row) { final pointsJson = jsonDecode(row['points'] as String) as List; return InkStroke( id: row['id'] as String, points: pointsJson .map((p) => _pointFromJson(p as Map)) .toList(), tool: _parsePenTool(row['tool'] as String), color: row['color'] as int, strokeWidth: (row['stroke_width'] as num).toDouble(), createdAt: DateTime.parse(row['created_at'] as String), ); } PenTool _parsePenTool(String value) { return PenTool.values.asNameMap()[value] ?? PenTool.pen; } Future _noteFromRow(Map row) async { final tagsJson = jsonDecode(row['tags'] as String) as List; final strokes = await _getStrokesForNote(row['id'] as String); return Note( id: row['id'] as String, title: row['title'] as String, strokes: strokes, createdAt: DateTime.parse(row['created_at'] as String), updatedAt: DateTime.parse(row['updated_at'] as String), tags: tagsJson.cast(), ); } // ── Full-Text Search (FTS5) ──────────────────────────────────────── Future _createFtsTable(Database db) async { await db.execute(''' CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5( note_id, title, content, tokenize='porter unicode61' ) '''); } /// Index a note's text content for full-text search. /// [content] should include any typed text, OCR text, etc. Future indexNoteContent( DatabaseExecutor db, String noteId, String title, String content, ) async { await db.insert('notes_fts', { 'note_id': noteId, 'title': title, 'content': content, }); } /// Extract text content from a note's strokes and index it for FTS. /// Concatenates the title with any textContent from strokes. Future _extractAndIndexNoteContent( DatabaseExecutor db, Note note, ) async { final textParts = [note.title]; for (final stroke in note.strokes) { if (stroke.textContent != null && stroke.textContent!.isNotEmpty) { textParts.add(stroke.textContent!); } } final content = textParts.join(' '); await indexNoteContent(db, note.id, note.title, content); } /// Append OCR text to an existing note's FTS entry. /// Reads current content, merges with new OCR text, and re-indexes. Future appendOcrToFts(String noteId, String ocrText) async { if (ocrText.trim().isEmpty) return; // Read-modify-write must be atomic: querying the current content, removing // the old entry, and re-inserting the merged content all run inside one // transaction so a concurrent writer cannot cause a lost update. await _database.transaction((txn) async { // Read current FTS content final rows = await txn.query( 'notes_fts', where: 'note_id = ?', whereArgs: [noteId], ); String existingContent = ''; String existingTitle = ''; if (rows.isNotEmpty) { existingTitle = rows.first['title'] as String? ?? ''; existingContent = rows.first['content'] as String? ?? ''; } // Merge: append OCR text to existing content final mergedContent = existingContent.isEmpty ? ocrText : '$existingContent $ocrText'; // Remove old entry and re-insert with merged content await removeFromFts(txn, noteId); await indexNoteContent(txn, noteId, existingTitle, mergedContent); }); } /// Full-text search across indexed notes. Future> searchNotes(String query) async { if (query.trim().isEmpty) return []; // Sanitize query for FTS5: escape special chars and add prefix matching final sanitized = query.replaceAll('"', '').replaceAll("'", '').trim(); if (sanitized.isEmpty) return []; final ftsQuery = sanitized .split(RegExp(r'\s+')) .map((w) => '"$w"*') .join(' '); final rows = await _database.rawQuery( 'SELECT note_id FROM notes_fts WHERE notes_fts MATCH ? ORDER BY rank', [ftsQuery], ); final notes = []; for (final row in rows) { final noteId = row['note_id'] as String; final note = await getNoteById(noteId); if (note != null) { notes.add(note); } } return notes; } /// Remove a note from the FTS index. Future removeFromFts(DatabaseExecutor db, String noteId) async { await db.delete('notes_fts', where: 'note_id = ?', whereArgs: [noteId]); } // ── Document FTS ──────────────────────────────────────────────────── /// Index a page's text content for document full-text search. Future indexDocumentContent( String documentId, int pageNumber, String content, ) async { // Remove existing entry for this page first await _database.delete( 'document_fts', where: 'document_id = ? AND page_number = ?', whereArgs: [documentId, pageNumber], ); await _database.insert('document_fts', { 'document_id': documentId, 'page_number': pageNumber.toString(), 'content': content, }); } /// Remove a page from the document FTS index. Future removeDocumentFromFts( DatabaseExecutor db, String documentId, int pageNumber, ) async { await db.delete( 'document_fts', where: 'document_id = ? AND page_number = ?', whereArgs: [documentId, pageNumber], ); } /// Full-text search across indexed document pages. Future>> searchDocuments(String query) async { if (query.trim().isEmpty) return []; final sanitized = query.replaceAll('"', '').replaceAll("'", '').trim(); if (sanitized.isEmpty) return []; final ftsQuery = sanitized .split(RegExp(r'\s+')) .map((w) => '"$w"*') .join(' '); final rows = await _database.rawQuery( 'SELECT document_id, page_number, content FROM document_fts WHERE document_fts MATCH ? ORDER BY rank', [ftsQuery], ); return rows .map( (row) => { 'document_id': row['document_id'] as String, 'page_number': int.parse(row['page_number'] as String), 'content': row['content'] as String, }, ) .toList(); } // ── Documents CRUD ───────────────────────────────────────────────── Future insertDocument(doc.Document document) async { await _database.insert('documents', { 'id': document.id, 'filename': document.filename, 'doc_type': document.docType, 'file_path': document.filePath, 'page_count': document.pageCount, 'rotation': document.rotation, 'created_at': document.createdAt.toIso8601String(), 'updated_at': document.updatedAt.toIso8601String(), }); } Future getDocument(String id) async { final rows = await _database.query( 'documents', where: 'id = ?', whereArgs: [id], ); if (rows.isEmpty) return null; return _documentFromRow(rows.first); } Future getDocumentByPath(String filePath) async { final rows = await _database.query( 'documents', where: 'file_path = ?', whereArgs: [filePath], ); if (rows.isEmpty) return null; return _documentFromRow(rows.first); } Future> getAllDocuments() async { final rows = await _database.query('documents', orderBy: 'updated_at DESC'); return rows.map(_documentFromRow).toList(); } Future deleteDocument(String id) async { await _database.transaction((txn) async { await txn.delete( 'annotations', where: 'document_id = ?', whereArgs: [id], ); await txn.delete('bookmarks', where: 'document_id = ?', whereArgs: [id]); await txn.delete( 'ocr_results', where: 'document_id = ?', whereArgs: [id], ); await txn.delete( 'scratchpads', where: 'document_id = ?', whereArgs: [id], ); await txn.delete('documents', where: 'id = ?', whereArgs: [id]); }); } doc.Document _documentFromRow(Map row) { return doc.Document( id: row['id'] as String, filename: row['filename'] as String, docType: row['doc_type'] as String, filePath: row['file_path'] as String, pageCount: row['page_count'] as int, rotation: (row['rotation'] as int?) ?? 0, createdAt: DateTime.parse(row['created_at'] as String), updatedAt: DateTime.parse(row['updated_at'] as String), ); } // ── Annotations CRUD ─────────────────────────────────────────────── Future saveAnnotations( String documentId, int pageNumber, String annotationJson, ) async { await _database.delete( 'annotations', where: 'document_id = ? AND page_number = ?', whereArgs: [documentId, pageNumber], ); await _database.insert('annotations', { 'id': const Uuid().v4(), 'uuid': const Uuid().v4(), 'document_id': documentId, 'page_number': pageNumber, 'annotation_json': annotationJson, 'created_at': DateTime.now().toIso8601String(), 'updated_at': DateTime.now().toIso8601String(), }); } Future getAnnotations(String documentId, int pageNumber) async { final rows = await _database.query( 'annotations', where: 'document_id = ? AND page_number = ?', whereArgs: [documentId, pageNumber], ); if (rows.isEmpty) return null; return rows.first['annotation_json'] as String; } Future deleteDocumentAnnotations(String documentId) async { await _database.delete( 'annotations', where: 'document_id = ?', whereArgs: [documentId], ); } // ── Annotation/Bookmark Remapping ────────────────────────────────── /// After deleting a page at [deletedIndex], shift all annotations /// with page_number > deletedIndex down by 1. Future remapAnnotationsAfterDelete( String documentId, int deletedIndex, ) async { await _database.rawUpdate( 'UPDATE annotations SET page_number = page_number - 1 WHERE document_id = ? AND page_number > ?', [documentId, deletedIndex], ); } /// After inserting a page at [insertedIndex], shift all annotations /// with page_number >= insertedIndex up by 1. Future remapAnnotationsAfterInsert( String documentId, int insertedIndex, ) async { await _database.rawUpdate( 'UPDATE annotations SET page_number = page_number + 1 WHERE document_id = ? AND page_number >= ?', [documentId, insertedIndex], ); } /// After deleting a page at [deletedIndex], shift all bookmarks /// with page_number > deletedIndex down by 1. Future remapBookmarksAfterDelete( String documentId, int deletedIndex, ) async { await _database.rawUpdate( 'UPDATE bookmarks SET page_number = page_number - 1 WHERE document_id = ? AND page_number > ?', [documentId, deletedIndex], ); } /// After inserting a page at [insertedIndex], shift all bookmarks /// with page_number >= insertedIndex up by 1. Future remapBookmarksAfterInsert( String documentId, int insertedIndex, ) async { await _database.rawUpdate( 'UPDATE bookmarks SET page_number = page_number + 1 WHERE document_id = ? AND page_number >= ?', [documentId, insertedIndex], ); } /// Delete all annotations, bookmarks, and OCR data for a specific page. Future deletePageData(String documentId, int pageNumber) async { await _database.transaction((txn) async { await txn.delete( 'annotations', where: 'document_id = ? AND page_number = ?', whereArgs: [documentId, pageNumber], ); await txn.delete( 'bookmarks', where: 'document_id = ? AND page_number = ?', whereArgs: [documentId, pageNumber], ); await txn.delete( 'ocr_results', where: 'document_id = ? AND page_number = ?', whereArgs: [documentId, pageNumber], ); await removeDocumentFromFts(txn, documentId, pageNumber); }); } /// Update the stored page count for a document. Future updateDocumentPageCount( String documentId, int newPageCount, ) async { await _database.update( 'documents', { 'page_count': newPageCount, 'updated_at': DateTime.now().toIso8601String(), }, where: 'id = ?', whereArgs: [documentId], ); } // ── Bookmarks CRUD ───────────────────────────────────────────────── Future insertBookmark(Bookmark bookmark) async { await _database.insert('bookmarks', { 'id': bookmark.id, 'document_id': bookmark.documentId, 'page_number': bookmark.pageNumber, 'label': bookmark.label, 'color': bookmark.color, 'created_at': bookmark.createdAt.toIso8601String(), }); } Future> getBookmarks(String documentId) async { final rows = await _database.query( 'bookmarks', where: 'document_id = ?', whereArgs: [documentId], orderBy: 'page_number ASC', ); return rows.map(_bookmarkFromRow).toList(); } Future deleteBookmark(String id) async { await _database.delete('bookmarks', where: 'id = ?', whereArgs: [id]); } Bookmark _bookmarkFromRow(Map row) { return Bookmark( id: row['id'] as String, documentId: row['document_id'] as String, pageNumber: row['page_number'] as int, label: row['label'] as String, color: row['color'] as int, createdAt: DateTime.parse(row['created_at'] as String), ); } // ── Scratchpad CRUD ──────────────────────────────────────────────── /// Save scratchpad strokes for a document (upsert). Future saveScratchpad(String documentId, String strokesJson) async { final now = DateTime.now().toIso8601String(); await _database.rawInsert( '''INSERT INTO scratchpads (id, document_id, strokes_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(document_id) DO UPDATE SET strokes_json = excluded.strokes_json, updated_at = excluded.updated_at''', [const Uuid().v4(), documentId, strokesJson, now, now], ); } /// Load scratchpad strokes for a document. Future> loadScratchpad(String documentId) async { final rows = await _database.query( 'scratchpads', where: 'document_id = ?', whereArgs: [documentId], ); if (rows.isEmpty) return []; final json = rows.first['strokes_json'] as String; if (json.isEmpty || json == '[]') return []; final List list = jsonDecode(json) as List; return list .map((s) => InkStroke.fromJson(s as Map)) .toList(); } // ── Board cards CRUD (F7 双链 + 无限便利贴) ────────────────────────── /// Replace ALL cards for [boardId] with [cards]. Geometry is persisted as /// rows (id, x, y, w, h, text) so the board survives restart. The whole /// replace runs in one transaction so a crash mid-save cannot leave a /// half-written board. Future saveBoardCards( String boardId, List cards, ) async { final now = DateTime.now().millisecondsSinceEpoch; await _database.transaction((txn) async { await txn.delete( 'board_cards', where: 'board_id = ?', whereArgs: [boardId], ); for (var i = 0; i < cards.length; i++) { final c = cards[i]; await txn.insert('board_cards', { 'id': c.id, 'board_id': boardId, 'x': c.position.dx, 'y': c.position.dy, 'w': c.size.width, 'h': c.size.height, 'text': c.text, 'ordinal': i, 'updated_at': now, }); } }); } /// Load the [Board] for [boardId] (empty board when nothing is stored). Future loadBoard(String boardId) async { final rows = await _database.query( 'board_cards', where: 'board_id = ?', whereArgs: [boardId], orderBy: 'ordinal ASC', ); return Board([ for (final row in rows) BoardCard( id: row['id'] as String, position: Offset( (row['x'] as num).toDouble(), (row['y'] as num).toDouble(), ), size: Size( (row['w'] as num).toDouble(), (row['h'] as num).toDouble(), ), text: row['text'] as String? ?? '', ), ]); } }