feat(sieve): local email filters alongside server filters (#90)
Reuse the same Sieve UI for both server-side (ManageSieve/JMAP) and local email filters. Both filter sets are stored and managed independently. Changes: - Add LocalSieveScripts table (DB schema v29) to store local Sieve scripts - Add LocalSieveRepository with full CRUD and activate-script support - Add isLocal param to SieveScriptsScreen and SieveScriptEditScreen; each screen shows a banner explaining whether scripts run on the server or device - Add routes /accounts/:id/sieve/local and /accounts/:id/sieve/local/edit - Split "Email filters" account menu entry into "Server email filters" and "Local email filters" (local is always available, server requires ManageSieve) - Wire up localSieveRepositoryProvider in DI Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
cc052db6c7
commit
0620663630
@@ -243,6 +243,16 @@ class SearchHistoryEntries extends Table {
|
||||
DateTimeColumn get searchedAt => dateTime()();
|
||||
}
|
||||
|
||||
@DataClassName('LocalSieveScriptRow')
|
||||
class LocalSieveScripts extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get accountId =>
|
||||
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
|
||||
TextColumn get name => text()();
|
||||
TextColumn get content => text().withDefault(const Constant(''))();
|
||||
BoolColumn get isActive => boolean().withDefault(const Constant(false))();
|
||||
}
|
||||
|
||||
@DataClassName('UndoActionRow')
|
||||
class UndoActions extends Table {
|
||||
TextColumn get id => text()();
|
||||
@@ -273,13 +283,14 @@ class UndoActions extends Table {
|
||||
SyncHealth,
|
||||
UndoActions,
|
||||
SearchHistoryEntries,
|
||||
LocalSieveScripts,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 28;
|
||||
int get schemaVersion => 29;
|
||||
|
||||
Future<void> _createEmailFts() async {
|
||||
await customStatement('''
|
||||
@@ -508,6 +519,9 @@ class AppDatabase extends _$AppDatabase {
|
||||
if (from < 28) {
|
||||
await m.addColumn(emailBodies, emailBodies.mimeTreeJson);
|
||||
}
|
||||
if (from < 29) {
|
||||
await m.createTable(localSieveScripts);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/sieve_script.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart';
|
||||
|
||||
class LocalSieveRepository {
|
||||
LocalSieveRepository(this._db);
|
||||
|
||||
final AppDatabase _db;
|
||||
|
||||
Future<List<SieveScript>> listScripts(String accountId) async {
|
||||
final rows = await (_db.select(_db.localSieveScripts)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.get();
|
||||
return rows
|
||||
.map(
|
||||
(r) => SieveScript(
|
||||
id: r.id.toString(),
|
||||
name: r.name,
|
||||
blobId: r.id.toString(),
|
||||
isActive: r.isActive,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<String> getScriptContent(String accountId, String blobId) async {
|
||||
final rowId = int.parse(blobId);
|
||||
final row = await (_db.select(_db.localSieveScripts)
|
||||
..where(
|
||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
if (row == null) throw Exception('Local script not found: $blobId');
|
||||
return row.content;
|
||||
}
|
||||
|
||||
Future<SieveScript> saveScript(
|
||||
String accountId, {
|
||||
String? id,
|
||||
required String name,
|
||||
required String content,
|
||||
}) async {
|
||||
if (id != null) {
|
||||
final rowId = int.parse(id);
|
||||
await (_db.update(_db.localSieveScripts)
|
||||
..where(
|
||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||
))
|
||||
.write(
|
||||
LocalSieveScriptsCompanion(
|
||||
name: Value(name),
|
||||
content: Value(content),
|
||||
),
|
||||
);
|
||||
final updated = await (_db.select(_db.localSieveScripts)
|
||||
..where(
|
||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
return SieveScript(
|
||||
id: id,
|
||||
name: name,
|
||||
blobId: id,
|
||||
isActive: updated?.isActive ?? false,
|
||||
);
|
||||
}
|
||||
final rowId = await _db.into(_db.localSieveScripts).insert(
|
||||
LocalSieveScriptsCompanion.insert(
|
||||
accountId: accountId,
|
||||
name: name,
|
||||
content: Value(content),
|
||||
),
|
||||
);
|
||||
final idStr = rowId.toString();
|
||||
return SieveScript(id: idStr, name: name, blobId: idStr, isActive: false);
|
||||
}
|
||||
|
||||
Future<void> deleteScript(String accountId, String scriptId) async {
|
||||
final rowId = int.parse(scriptId);
|
||||
await (_db.delete(_db.localSieveScripts)
|
||||
..where(
|
||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||
))
|
||||
.go();
|
||||
}
|
||||
|
||||
Future<void> activateScript(String accountId, String scriptId) async {
|
||||
await _db.transaction(() async {
|
||||
await (_db.update(_db.localSieveScripts)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.write(const LocalSieveScriptsCompanion(isActive: Value(false)));
|
||||
final rowId = int.parse(scriptId);
|
||||
await (_db.update(_db.localSieveScripts)
|
||||
..where(
|
||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||
))
|
||||
.write(const LocalSieveScriptsCompanion(isActive: Value(true)));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import 'package:sharedinbox/core/storage/secure_storage.dart';
|
||||
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
|
||||
import 'package:sharedinbox/core/sync/reliability_runner.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart' hide Email, EmailBody;
|
||||
import 'package:sharedinbox/data/db/local_sieve_repository.dart';
|
||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||
import 'package:sharedinbox/data/jmap/sieve_repository.dart';
|
||||
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||
@@ -155,6 +156,10 @@ final sieveRepositoryProvider = Provider<SieveRepository>((ref) {
|
||||
);
|
||||
});
|
||||
|
||||
final localSieveRepositoryProvider = Provider<LocalSieveRepository>((ref) {
|
||||
return LocalSieveRepository(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final connectionTestServiceProvider = Provider<ConnectionTestService>((ref) {
|
||||
return ConnectionTestServiceImpl(
|
||||
ref.watch(httpClientProvider),
|
||||
|
||||
@@ -77,6 +77,21 @@ final router = GoRouter(
|
||||
script: state.extra as SieveScript?,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':accountId/sieve/local',
|
||||
builder: (ctx, state) => SieveScriptsScreen(
|
||||
accountId: state.pathParameters['accountId']!,
|
||||
isLocal: true,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':accountId/sieve/local/edit',
|
||||
builder: (ctx, state) => SieveScriptEditScreen(
|
||||
accountId: state.pathParameters['accountId']!,
|
||||
script: state.extra as SieveScript?,
|
||||
isLocal: true,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':accountId/search',
|
||||
builder: (ctx, state) =>
|
||||
|
||||
@@ -160,9 +160,13 @@ class _AccountTile extends ConsumerWidget {
|
||||
),
|
||||
if (_sieveSupported(account))
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.emailFilters,
|
||||
child: Text('Email filters'),
|
||||
value: _AccountAction.emailFiltersRemote,
|
||||
child: Text('Server email filters'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.emailFiltersLocal,
|
||||
child: Text('Local email filters'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.export,
|
||||
child: Text('Export account'),
|
||||
@@ -203,9 +207,12 @@ class _AccountTile extends ConsumerWidget {
|
||||
case _AccountAction.edit:
|
||||
await context.push('/accounts/${account.id}/edit');
|
||||
break;
|
||||
case _AccountAction.emailFilters:
|
||||
case _AccountAction.emailFiltersRemote:
|
||||
await context.push('/accounts/${account.id}/sieve');
|
||||
break;
|
||||
case _AccountAction.emailFiltersLocal:
|
||||
await context.push('/accounts/${account.id}/sieve/local');
|
||||
break;
|
||||
case _AccountAction.export:
|
||||
await context.push('/accounts/${account.id}/export');
|
||||
break;
|
||||
@@ -344,7 +351,15 @@ class _Step extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
enum _AccountAction { syncLog, verifySync, edit, emailFilters, export, delete }
|
||||
enum _AccountAction {
|
||||
syncLog,
|
||||
verifySync,
|
||||
edit,
|
||||
emailFiltersRemote,
|
||||
emailFiltersLocal,
|
||||
export,
|
||||
delete,
|
||||
}
|
||||
|
||||
/// Whether to surface the "Email filters" (Sieve) entry for [account].
|
||||
///
|
||||
|
||||
@@ -11,6 +11,7 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget {
|
||||
super.key,
|
||||
required this.accountId,
|
||||
this.script,
|
||||
this.isLocal = false,
|
||||
});
|
||||
|
||||
final String accountId;
|
||||
@@ -18,6 +19,9 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget {
|
||||
/// Null when creating a new script.
|
||||
final SieveScript? script;
|
||||
|
||||
/// True for locally-executed scripts; false for server-side (ManageSieve/JMAP).
|
||||
final bool isLocal;
|
||||
|
||||
@override
|
||||
ConsumerState<SieveScriptEditScreen> createState() =>
|
||||
_SieveScriptEditScreenState();
|
||||
@@ -50,9 +54,13 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
||||
Future<void> _loadContent() async {
|
||||
setState(() => _loadingContent = true);
|
||||
try {
|
||||
final content = await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.getScriptContent(widget.accountId, widget.script!.blobId);
|
||||
final content = widget.isLocal
|
||||
? await ref
|
||||
.read(localSieveRepositoryProvider)
|
||||
.getScriptContent(widget.accountId, widget.script!.blobId)
|
||||
: await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.getScriptContent(widget.accountId, widget.script!.blobId);
|
||||
if (mounted) {
|
||||
_contentController.text = content;
|
||||
setState(() => _loadingContent = false);
|
||||
@@ -78,12 +86,21 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await ref.read(sieveRepositoryProvider).saveScript(
|
||||
widget.accountId,
|
||||
id: widget.script?.id,
|
||||
name: name,
|
||||
content: _contentController.text,
|
||||
);
|
||||
if (widget.isLocal) {
|
||||
await ref.read(localSieveRepositoryProvider).saveScript(
|
||||
widget.accountId,
|
||||
id: widget.script?.id,
|
||||
name: name,
|
||||
content: _contentController.text,
|
||||
);
|
||||
} else {
|
||||
await ref.read(sieveRepositoryProvider).saveScript(
|
||||
widget.accountId,
|
||||
id: widget.script?.id,
|
||||
name: name,
|
||||
content: _contentController.text,
|
||||
);
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
|
||||
@@ -8,10 +8,17 @@ import 'package:sharedinbox/core/models/sieve_script.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
|
||||
class SieveScriptsScreen extends ConsumerStatefulWidget {
|
||||
const SieveScriptsScreen({super.key, required this.accountId});
|
||||
const SieveScriptsScreen({
|
||||
super.key,
|
||||
required this.accountId,
|
||||
this.isLocal = false,
|
||||
});
|
||||
|
||||
final String accountId;
|
||||
|
||||
/// True for locally-executed scripts; false for server-side (ManageSieve/JMAP).
|
||||
final bool isLocal;
|
||||
|
||||
@override
|
||||
ConsumerState<SieveScriptsScreen> createState() => _SieveScriptsScreenState();
|
||||
}
|
||||
@@ -21,6 +28,10 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
|
||||
String get _editRoute => widget.isLocal
|
||||
? '/accounts/${widget.accountId}/sieve/local/edit'
|
||||
: '/accounts/${widget.accountId}/sieve/edit';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -33,8 +44,13 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final scripts =
|
||||
await ref.read(sieveRepositoryProvider).listScripts(widget.accountId);
|
||||
final scripts = widget.isLocal
|
||||
? await ref
|
||||
.read(localSieveRepositoryProvider)
|
||||
.listScripts(widget.accountId)
|
||||
: await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.listScripts(widget.accountId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_scripts = scripts;
|
||||
@@ -53,15 +69,19 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
|
||||
Future<void> _activate(SieveScript script) async {
|
||||
try {
|
||||
await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.activateScript(widget.accountId, script.id);
|
||||
if (widget.isLocal) {
|
||||
await ref
|
||||
.read(localSieveRepositoryProvider)
|
||||
.activateScript(widget.accountId, script.id);
|
||||
} else {
|
||||
await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.activateScript(widget.accountId, script.id);
|
||||
}
|
||||
await _load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Failed to activate: $e'),
|
||||
@@ -91,15 +111,19 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
);
|
||||
if (!(confirmed ?? false) || !mounted) return;
|
||||
try {
|
||||
await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.deleteScript(widget.accountId, script.id);
|
||||
if (widget.isLocal) {
|
||||
await ref
|
||||
.read(localSieveRepositoryProvider)
|
||||
.deleteScript(widget.accountId, script.id);
|
||||
} else {
|
||||
await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.deleteScript(widget.accountId, script.id);
|
||||
}
|
||||
await _load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Failed to delete: $e'),
|
||||
@@ -112,11 +136,15 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Email filters')),
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
widget.isLocal ? 'Local email filters' : 'Server email filters',
|
||||
),
|
||||
),
|
||||
body: _buildBody(),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () async {
|
||||
await context.push('/accounts/${widget.accountId}/sieve/edit');
|
||||
await context.push(_editRoute);
|
||||
await _load();
|
||||
},
|
||||
child: const Icon(Icons.add),
|
||||
@@ -144,22 +172,68 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
);
|
||||
}
|
||||
final scripts = _scripts ?? [];
|
||||
if (scripts.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('No Sieve scripts. Tap + to create one.'),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
itemCount: scripts.length,
|
||||
itemBuilder: (ctx, i) => _ScriptTile(
|
||||
script: scripts[i],
|
||||
accountId: widget.accountId,
|
||||
onActivate: () => _activate(scripts[i]),
|
||||
onDelete: () => _delete(scripts[i]),
|
||||
onEdited: _load,
|
||||
return Column(
|
||||
children: [
|
||||
_SieveSourceBanner(isLocal: widget.isLocal),
|
||||
Expanded(
|
||||
child: scripts.isEmpty
|
||||
? const Center(
|
||||
child: Text('No Sieve scripts. Tap + to create one.'),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
itemCount: scripts.length,
|
||||
itemBuilder: (ctx, i) => _ScriptTile(
|
||||
script: scripts[i],
|
||||
accountId: widget.accountId,
|
||||
editRoute: _editRoute,
|
||||
onActivate: () => _activate(scripts[i]),
|
||||
onDelete: () => _delete(scripts[i]),
|
||||
onEdited: _load,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SieveSourceBanner extends StatelessWidget {
|
||||
const _SieveSourceBanner({required this.isLocal});
|
||||
|
||||
final bool isLocal;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = isLocal
|
||||
? 'These scripts run locally on this device. '
|
||||
'Server email filters are separate and independent.'
|
||||
: 'These scripts run on the mail server (ManageSieve / JMAP). '
|
||||
'Local email filters are separate and independent.';
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
isLocal ? Icons.phone_android : Icons.dns,
|
||||
size: 18,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -169,6 +243,7 @@ class _ScriptTile extends StatelessWidget {
|
||||
const _ScriptTile({
|
||||
required this.script,
|
||||
required this.accountId,
|
||||
required this.editRoute,
|
||||
required this.onActivate,
|
||||
required this.onDelete,
|
||||
required this.onEdited,
|
||||
@@ -176,6 +251,7 @@ class _ScriptTile extends StatelessWidget {
|
||||
|
||||
final SieveScript script;
|
||||
final String accountId;
|
||||
final String editRoute;
|
||||
final VoidCallback onActivate;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onEdited;
|
||||
@@ -193,10 +269,7 @@ class _ScriptTile extends StatelessWidget {
|
||||
onSelected: (action) async {
|
||||
switch (action) {
|
||||
case _ScriptAction.edit:
|
||||
await context.push(
|
||||
'/accounts/$accountId/sieve/edit',
|
||||
extra: script,
|
||||
);
|
||||
await context.push(editRoute, extra: script);
|
||||
onEdited();
|
||||
case _ScriptAction.activate:
|
||||
onActivate();
|
||||
@@ -219,7 +292,7 @@ class _ScriptTile extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
await context.push('/accounts/$accountId/sieve/edit', extra: script);
|
||||
await context.push(editRoute, extra: script);
|
||||
onEdited();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sharedinbox/data/db/local_sieve_repository.dart';
|
||||
|
||||
import 'db_test_helper.dart';
|
||||
|
||||
void main() {
|
||||
configureSqliteForTests();
|
||||
|
||||
group('LocalSieveRepository', () {
|
||||
test('listScripts returns empty list for new account', () async {
|
||||
final db = openTestDatabase();
|
||||
final repo = LocalSieveRepository(db);
|
||||
final scripts = await repo.listScripts('acc-1');
|
||||
expect(scripts, isEmpty);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('saveScript creates and retrieves a script', () async {
|
||||
final db = openTestDatabase();
|
||||
final repo = LocalSieveRepository(db);
|
||||
final saved = await repo.saveScript(
|
||||
'acc-1',
|
||||
name: 'My filter',
|
||||
content: 'require ["fileinto"]; fileinto "Archive";',
|
||||
);
|
||||
expect(saved.name, 'My filter');
|
||||
expect(saved.isActive, false);
|
||||
|
||||
final scripts = await repo.listScripts('acc-1');
|
||||
expect(scripts, hasLength(1));
|
||||
expect(scripts.first.name, 'My filter');
|
||||
expect(scripts.first.isActive, false);
|
||||
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('getScriptContent returns saved content', () async {
|
||||
final db = openTestDatabase();
|
||||
final repo = LocalSieveRepository(db);
|
||||
const content = 'require ["fileinto"]; fileinto "Archive";';
|
||||
final saved = await repo.saveScript(
|
||||
'acc-1',
|
||||
name: 'Test',
|
||||
content: content,
|
||||
);
|
||||
|
||||
final loaded = await repo.getScriptContent('acc-1', saved.blobId);
|
||||
expect(loaded, content);
|
||||
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('saveScript with id updates existing script', () async {
|
||||
final db = openTestDatabase();
|
||||
final repo = LocalSieveRepository(db);
|
||||
final created = await repo.saveScript(
|
||||
'acc-1',
|
||||
name: 'Old name',
|
||||
content: 'stop;',
|
||||
);
|
||||
|
||||
await repo.saveScript(
|
||||
'acc-1',
|
||||
id: created.id,
|
||||
name: 'New name',
|
||||
content: 'keep;',
|
||||
);
|
||||
|
||||
final scripts = await repo.listScripts('acc-1');
|
||||
expect(scripts, hasLength(1));
|
||||
expect(scripts.first.name, 'New name');
|
||||
|
||||
final content = await repo.getScriptContent('acc-1', created.blobId);
|
||||
expect(content, 'keep;');
|
||||
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('deleteScript removes the script', () async {
|
||||
final db = openTestDatabase();
|
||||
final repo = LocalSieveRepository(db);
|
||||
final saved = await repo.saveScript(
|
||||
'acc-1',
|
||||
name: 'To delete',
|
||||
content: 'stop;',
|
||||
);
|
||||
|
||||
await repo.deleteScript('acc-1', saved.id);
|
||||
|
||||
final scripts = await repo.listScripts('acc-1');
|
||||
expect(scripts, isEmpty);
|
||||
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('activateScript sets one script active, deactivates others', () async {
|
||||
final db = openTestDatabase();
|
||||
final repo = LocalSieveRepository(db);
|
||||
final s1 = await repo.saveScript(
|
||||
'acc-1',
|
||||
name: 'Script 1',
|
||||
content: 'stop;',
|
||||
);
|
||||
final s2 = await repo.saveScript(
|
||||
'acc-1',
|
||||
name: 'Script 2',
|
||||
content: 'keep;',
|
||||
);
|
||||
|
||||
await repo.activateScript('acc-1', s1.id);
|
||||
var scripts = await repo.listScripts('acc-1');
|
||||
expect(scripts.firstWhere((s) => s.id == s1.id).isActive, true);
|
||||
expect(scripts.firstWhere((s) => s.id == s2.id).isActive, false);
|
||||
|
||||
await repo.activateScript('acc-1', s2.id);
|
||||
scripts = await repo.listScripts('acc-1');
|
||||
expect(scripts.firstWhere((s) => s.id == s1.id).isActive, false);
|
||||
expect(scripts.firstWhere((s) => s.id == s2.id).isActive, true);
|
||||
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('scripts are isolated per account', () async {
|
||||
final db = openTestDatabase();
|
||||
final repo = LocalSieveRepository(db);
|
||||
await repo.saveScript('acc-1', name: 'Filter A', content: 'stop;');
|
||||
await repo.saveScript('acc-2', name: 'Filter B', content: 'keep;');
|
||||
|
||||
final acc1Scripts = await repo.listScripts('acc-1');
|
||||
expect(acc1Scripts, hasLength(1));
|
||||
expect(acc1Scripts.first.name, 'Filter A');
|
||||
|
||||
final acc2Scripts = await repo.listScripts('acc-2');
|
||||
expect(acc2Scripts, hasLength(1));
|
||||
expect(acc2Scripts.first.name, 'Filter B');
|
||||
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -14,7 +14,7 @@ void main() {
|
||||
group('Migration', () {
|
||||
test('schemaVersion matches expected value', () async {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
expect(db.schemaVersion, 28);
|
||||
expect(db.schemaVersion, 29);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
@@ -183,6 +183,9 @@ void main() {
|
||||
)
|
||||
.get();
|
||||
|
||||
// v29: local_sieve_scripts table.
|
||||
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
|
||||
|
||||
await db.close();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
});
|
||||
@@ -335,11 +338,14 @@ void main() {
|
||||
)
|
||||
.get();
|
||||
|
||||
// v29: local_sieve_scripts table.
|
||||
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
|
||||
|
||||
await db.close();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
});
|
||||
|
||||
test('fresh install creates all tables at schemaVersion 28', () async {
|
||||
test('fresh install creates all tables at schemaVersion 29', () async {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
await db.select(db.accounts).get();
|
||||
|
||||
@@ -363,6 +369,7 @@ void main() {
|
||||
'sync_health',
|
||||
'undo_actions',
|
||||
'search_history_entries',
|
||||
'local_sieve_scripts', // v29
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user