Files
BadNote/lib/services/database_service.dart
Akiba So 72428dc075
Some checks failed
CI / Test (Server, optional) (push) Failing after 2m10s
Windows Build / Build Windows (x64) (push) Failing after 29s
CI / Test (Flutter, Linux) (push) Has been cancelled
CI / Analyze (Flutter) (push) Has been cancelled
Fix bugs across app + server, optimize UI/UX, add Gitea CI
Bug fixes (Flutter):
- Wrap multi-statement DB writes (insert/update/delete note, deleteDocument,
  deletePageData, OCR FTS merge, migrations) in transactions to prevent data
  loss on interruption and a read-modify-write FTS race.
- Fix PdfDocument leaks on exception (try/finally dispose) and preserve image
  aspect ratio when stamping images onto PDF pages.
- Guard file-picker against empty selection (was .single -> crash).
- Fix eraser ConcurrentModificationError and unmodifiable-list crash on PDF
  pages; capture page synchronously on save to stop wrong-page data loss.
- Fix Riverpod DB-not-ready races, broken pull-to-refresh, settings load race,
  and search N+1; transform stored annotations on PDF page rotation.
- Normalize pen pressure for devices without a pressure range.
- PPT: single source of truth for slide strokes so ink displays and exports.

UI/UX:
- Material 3 typography, theme-aware colors (dark-mode fixes), hover cursors
  and right-click/visible actions on desktop, keyboard shortcuts (undo/redo/
  save/find), toolbar overflow handling, friendlier empty states, semantic OCR
  status badges, relative timestamps, 1-based page indicators, large-deck PPT
  navigation, and a scratchpad-scope label in split view.

Server (optional backend):
- Persist JWT secret (was per-process random), block path traversal in storage,
  fix CORS '*'+credentials, add OCR job ownership checks, last-writer-wins sync
  guard, constant-time login, and split out heavy OCR deps so the API/tests run
  without them.

CI: Gitea workflows for format+analyze+test (Linux, system sqlite) and a
Windows release build; pristine `flutter analyze`, all Flutter and server tests
green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 03:18:00 +08:00

842 lines
27 KiB
Dart

import 'dart:convert';
import 'dart:io';
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 '../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<DatabaseService> getInstance() async {
if (_instance != null) return _instance!;
final service = DatabaseService._();
await service._initialize();
_instance = service;
return service;
}
Database get database => _database;
Future<void> _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: 5,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
}
Future<void> _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)',
);
}
Future<void> _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);
}
Future<void> _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<void> _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<List<Note>> getAllNotes() async {
final noteRows = await _database.query('notes', orderBy: 'updated_at DESC');
final notes = <Note>[];
for (final row in noteRows) {
notes.add(await _noteFromRow(row));
}
return notes;
}
Future<Note?> getNoteById(String id) async {
final rows = await _database.query(
'notes',
where: 'id = ?',
whereArgs: [id],
);
if (rows.isEmpty) return null;
return _noteFromRow(rows.first);
}
Future<void> 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<void> 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<void> 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<void> _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<List<InkStroke>> _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<String, dynamic> _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<String, dynamic> 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<String, dynamic> 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<String, dynamic>))
.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<Note> _noteFromRow(Map<String, dynamic> 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<String>(),
);
}
// ── Full-Text Search (FTS5) ────────────────────────────────────────
Future<void> _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<void> 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<void> _extractAndIndexNoteContent(
DatabaseExecutor db,
Note note,
) async {
final textParts = <String>[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<void> 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<List<Note>> 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 = <Note>[];
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<void> 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<void> 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<void> 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<List<Map<String, dynamic>>> 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<void> 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<doc.Document?> getDocument(String id) async {
final rows = await _database.query(
'documents',
where: 'id = ?',
whereArgs: [id],
);
if (rows.isEmpty) return null;
return _documentFromRow(rows.first);
}
Future<doc.Document?> 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<List<doc.Document>> getAllDocuments() async {
final rows = await _database.query('documents', orderBy: 'updated_at DESC');
return rows.map(_documentFromRow).toList();
}
Future<void> 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<String, dynamic> 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<void> 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<String?> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<List<Bookmark>> 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<void> deleteBookmark(String id) async {
await _database.delete('bookmarks', where: 'id = ?', whereArgs: [id]);
}
Bookmark _bookmarkFromRow(Map<String, dynamic> 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<void> 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<List<InkStroke>> 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<dynamic> list = jsonDecode(json) as List<dynamic>;
return list
.map((s) => InkStroke.fromJson(s as Map<String, dynamic>))
.toList();
}
}