332 lines
10 KiB
Dart
332 lines
10 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:drift/drift.dart';
|
|
import 'package:drift/native.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
import 'package:notas/data/note_positioning.dart';
|
|
|
|
part 'app_database.g.dart';
|
|
|
|
@DataClassName('DbCategory')
|
|
class Categories extends Table {
|
|
TextColumn get id => text()();
|
|
TextColumn get name => text().named('name')();
|
|
IntColumn get serverVersion =>
|
|
integer().named('server_version').withDefault(const Constant(0))();
|
|
BoolColumn get isDeleted =>
|
|
boolean().named('is_deleted').withDefault(const Constant(false))();
|
|
IntColumn get colorValue => integer().nullable().named('color_value')();
|
|
IntColumn get iconCodePoint => integer().nullable().named('icon_code_point')();
|
|
BoolColumn get isDirty =>
|
|
boolean().named('is_dirty').withDefault(const Constant(true))();
|
|
DateTimeColumn get updatedAt => dateTime().named('updated_at')();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {id};
|
|
}
|
|
|
|
@DataClassName('DbNote')
|
|
class Notes extends Table {
|
|
TextColumn get id => text().named('id')();
|
|
TextColumn get title => text()();
|
|
TextColumn get body => text()();
|
|
DateTimeColumn get createdAt => dateTime().named('created_at')();
|
|
DateTimeColumn get updatedAt => dateTime().named('updated_at')();
|
|
IntColumn get sortIndex => integer().named('sort_index')();
|
|
IntColumn get serverVersion =>
|
|
integer().named('server_version').withDefault(const Constant(0))();
|
|
BoolColumn get isDeleted =>
|
|
boolean().named('is_deleted').withDefault(const Constant(false))();
|
|
TextColumn get categoryId => text().nullable().named('category_id')();
|
|
BoolColumn get isDirty =>
|
|
boolean().named('is_dirty').withDefault(const Constant(true))();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {id};
|
|
}
|
|
|
|
@DriftDatabase(tables: [Notes, Categories])
|
|
class AppDatabase extends _$AppDatabase {
|
|
@override
|
|
int get schemaVersion => 4;
|
|
|
|
@override
|
|
MigrationStrategy get migration => MigrationStrategy(
|
|
onCreate: (Migrator migrator) async {
|
|
await migrator.createAll();
|
|
},
|
|
onUpgrade: (Migrator migrator, int from, int to) async {
|
|
if (from < 2) {
|
|
await migrator.addColumn(notes, notes.isDirty);
|
|
await migrator.addColumn(categories, categories.isDirty);
|
|
|
|
await customStatement('UPDATE notes SET is_dirty = 0');
|
|
await customStatement('UPDATE categories SET is_dirty = 0');
|
|
}
|
|
if (from < 3) {
|
|
await migrator.addColumn(categories, categories.colorValue);
|
|
await migrator.addColumn(categories, categories.iconCodePoint);
|
|
await customStatement('UPDATE categories SET color_value = NULL');
|
|
await customStatement('UPDATE categories SET icon_code_point = NULL');
|
|
}
|
|
if (from < 4) {
|
|
final List<DbNote> activeNotes = await (select(notes)
|
|
..where((n) => n.isDeleted.equals(false))
|
|
..orderBy([
|
|
(note) => OrderingTerm(expression: note.sortIndex),
|
|
]))
|
|
.get();
|
|
|
|
final List<int> rebalancedPositions = rebalanceNotePositions(
|
|
activeNotes.length,
|
|
);
|
|
|
|
for (var index = 0; index < activeNotes.length; index += 1) {
|
|
final DbNote row = activeNotes[index];
|
|
await (update(notes)..where((n) => n.id.equals(row.id))).write(
|
|
NotesCompanion(
|
|
sortIndex: Value(rebalancedPositions[index]),
|
|
updatedAt: Value(DateTime.now()),
|
|
isDirty: const Value(true),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
AppDatabase({required String encryptionKey})
|
|
: super(_openConnection(encryptionKey));
|
|
|
|
// ========== Categories ==========
|
|
Future<List<DbCategory>> getAllCategories() {
|
|
return (select(categories)..where((c) => c.isDeleted.equals(false))).get();
|
|
}
|
|
|
|
Future<int> upsertCategory(CategoriesCompanion category) {
|
|
return into(categories).insertOnConflictUpdate(category);
|
|
}
|
|
|
|
Future<void> deleteCategory(String id) {
|
|
return (update(categories)..where((c) => c.id.equals(id))).write(
|
|
CategoriesCompanion(
|
|
isDeleted: const Value(true),
|
|
updatedAt: Value(DateTime.now()),
|
|
isDirty: const Value(true),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ========== Notes ==========
|
|
Future<List<DbNote>> getAllNotes() {
|
|
return (select(notes)
|
|
..orderBy([
|
|
(note) => OrderingTerm(
|
|
expression: note.sortIndex,
|
|
mode: OrderingMode.desc,
|
|
),
|
|
])
|
|
..where((n) => n.isDeleted.equals(false)))
|
|
.get();
|
|
}
|
|
|
|
Future<int> insertNoteAtTop(NotesCompanion note) {
|
|
return transaction(() async {
|
|
final DbNote? topNote = await (select(notes)
|
|
..where((n) => n.isDeleted.equals(false))
|
|
..orderBy([
|
|
(note) => OrderingTerm(
|
|
expression: note.sortIndex,
|
|
mode: OrderingMode.desc,
|
|
),
|
|
])
|
|
..limit(1))
|
|
.getSingleOrNull();
|
|
|
|
final int nextSortIndex = topNote == null
|
|
? 0
|
|
: topNote.sortIndex + notePositionStep;
|
|
|
|
await into(notes).insert(
|
|
note.copyWith(sortIndex: Value<int>(nextSortIndex)),
|
|
);
|
|
return nextSortIndex;
|
|
});
|
|
}
|
|
|
|
Future<void> replaceAllNotes(List<NotesCompanion> noteList) {
|
|
return transaction(() async {
|
|
await (delete(notes)..where((n) => n.isDeleted.equals(false))).go();
|
|
|
|
for (final NotesCompanion note in noteList) {
|
|
await into(notes).insert(note);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> updateNoteRow(DbNote note) {
|
|
return update(notes).replace(note);
|
|
}
|
|
|
|
Future<void> deleteNote(String id, int removedIndex) async {
|
|
await (update(notes)..where((n) => n.id.equals(id))).write(
|
|
NotesCompanion(
|
|
isDeleted: const Value(true),
|
|
updatedAt: Value(DateTime.now()),
|
|
isDirty: const Value(true),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> deleteNoteAndShift({
|
|
required String id,
|
|
required int removedIndex,
|
|
}) {
|
|
return deleteNote(id, removedIndex);
|
|
}
|
|
|
|
Future<void> permanentlyDeleteNote(String id) async {
|
|
await (update(notes)..where((n) => n.id.equals(id))).write(
|
|
NotesCompanion(
|
|
title: const Value(''),
|
|
body: const Value(''),
|
|
categoryId: const Value(null),
|
|
isDeleted: const Value(true),
|
|
updatedAt: Value(DateTime.now()),
|
|
isDirty: const Value(true),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> moveNote({
|
|
required String id,
|
|
required int oldIndex,
|
|
required int newIndex,
|
|
}) {
|
|
return transaction(() async {
|
|
final List<DbNote> orderedNotes = await (select(notes)
|
|
..where((n) => n.isDeleted.equals(false))
|
|
..orderBy([
|
|
(note) => OrderingTerm(
|
|
expression: note.sortIndex,
|
|
mode: OrderingMode.desc,
|
|
),
|
|
]))
|
|
.get();
|
|
|
|
final int currentIndex = orderedNotes.indexWhere((DbNote row) => row.id == id);
|
|
if (currentIndex == -1) {
|
|
return;
|
|
}
|
|
|
|
final int safeNewIndex = newIndex.clamp(0, orderedNotes.length - 1);
|
|
if (currentIndex == safeNewIndex) {
|
|
return;
|
|
}
|
|
|
|
final DbNote movedNote = orderedNotes.removeAt(currentIndex);
|
|
orderedNotes.insert(safeNewIndex, movedNote);
|
|
|
|
final int? newStoredPosition;
|
|
if (safeNewIndex == 0) {
|
|
newStoredPosition = orderedNotes[1].sortIndex + notePositionStep;
|
|
} else if (safeNewIndex == orderedNotes.length - 1) {
|
|
newStoredPosition =
|
|
orderedNotes[orderedNotes.length - 2].sortIndex - notePositionStep;
|
|
} else {
|
|
newStoredPosition = midpointNotePosition(
|
|
higherPosition: orderedNotes[safeNewIndex - 1].sortIndex,
|
|
lowerPosition: orderedNotes[safeNewIndex + 1].sortIndex,
|
|
);
|
|
}
|
|
|
|
if (newStoredPosition == null) {
|
|
final List<int> rebalancedPositions = rebalanceNotePositions(
|
|
orderedNotes.length,
|
|
);
|
|
|
|
for (var index = 0; index < orderedNotes.length; index += 1) {
|
|
final DbNote row = orderedNotes[index];
|
|
await (update(notes)..where((n) => n.id.equals(row.id))).write(
|
|
NotesCompanion(
|
|
sortIndex: Value<int>(rebalancedPositions[index]),
|
|
updatedAt: Value<DateTime>(DateTime.now()),
|
|
isDirty: const Value(true),
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
await (update(notes)..where((n) => n.id.equals(id))).write(
|
|
NotesCompanion(
|
|
sortIndex: Value<int>(newStoredPosition),
|
|
updatedAt: Value<DateTime>(DateTime.now()),
|
|
isDirty: const Value(true),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
Future<List<DbNote>> getNotesChangedSince(DateTime since) {
|
|
return (select(
|
|
notes,
|
|
)..where((n) => n.updatedAt.isBiggerThanValue(since))).get();
|
|
}
|
|
|
|
Future<List<DbNote>> getDeletedNotes() {
|
|
// A note is considered deleted (in the trash) when `is_deleted` is true
|
|
// and at least one of `title` or `body` is not empty. Previously the
|
|
// query required both title AND body to be non-empty which excluded
|
|
// notes that had an empty body (common) from appearing in the trash.
|
|
return (select(notes)
|
|
..where(
|
|
(n) => n.isDeleted.equals(true) &
|
|
(n.title.isNotValue('') | n.body.isNotValue('')),
|
|
)
|
|
..orderBy([
|
|
(note) => OrderingTerm(
|
|
expression: note.sortIndex,
|
|
mode: OrderingMode.desc,
|
|
),
|
|
]))
|
|
.get();
|
|
}
|
|
|
|
Future<List<DbCategory>> getCategoriesChangedSince(DateTime since) {
|
|
return (select(
|
|
categories,
|
|
)..where((c) => c.updatedAt.isBiggerThanValue(since))).get();
|
|
}
|
|
|
|
// ========== Sync helpers ==========
|
|
Future<List<DbNote>> getUnsyncedNotes() {
|
|
return (select(notes)..where((n) => n.isDirty.equals(true))).get();
|
|
}
|
|
|
|
Future<List<DbCategory>> getUnsyncedCategories() {
|
|
return (select(categories)..where((c) => c.isDirty.equals(true))).get();
|
|
}
|
|
}
|
|
|
|
LazyDatabase _openConnection(String encryptionKey) {
|
|
return LazyDatabase(() async {
|
|
final Directory supportDir = await getApplicationSupportDirectory();
|
|
final File file = File(p.join(supportDir.path, 'notes.sqlite'));
|
|
|
|
return NativeDatabase(
|
|
file,
|
|
setup: (database) {
|
|
final String escapedKey = encryptionKey.replaceAll("'", "''");
|
|
|
|
// sqlite3mc can emulate SQLCipher file format for compatibility.
|
|
database.execute("PRAGMA cipher = 'sqlcipher'");
|
|
database.execute('PRAGMA legacy = 4');
|
|
database.execute("PRAGMA key = '$escapedKey'");
|
|
},
|
|
);
|
|
});
|
|
}
|