Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
727f10462c |
@@ -1,35 +1,11 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
check:
|
||||
name: Full Project Check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
wait_seconds=$((runner_start - queued_epoch))
|
||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Dagger Remote Engine
|
||||
env:
|
||||
|
||||
@@ -15,23 +15,6 @@ jobs:
|
||||
linux: ${{ steps.diff.outputs.linux }}
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
wait_seconds=$((runner_start - queued_epoch))
|
||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -158,23 +141,6 @@ jobs:
|
||||
if: needs.check-changes.outputs.android == 'true'
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
wait_seconds=$((runner_start - queued_epoch))
|
||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 100
|
||||
@@ -209,23 +175,6 @@ jobs:
|
||||
if: needs.check-changes.outputs.android == 'true'
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
wait_seconds=$((runner_start - queued_epoch))
|
||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 100
|
||||
@@ -254,23 +203,6 @@ jobs:
|
||||
if: needs.check-changes.outputs.linux == 'true'
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
wait_seconds=$((runner_start - queued_epoch))
|
||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 100
|
||||
@@ -304,23 +236,6 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
wait_seconds=$((runner_start - queued_epoch))
|
||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- name: Set CI/Full-Pass or CI/Full-Fail label on tracking issue
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
|
||||
@@ -14,23 +14,6 @@ jobs:
|
||||
has_changes: ${{ steps.diff.outputs.has_changes }}
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
wait_seconds=$((runner_start - queued_epoch))
|
||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -67,23 +50,6 @@ jobs:
|
||||
if: needs.check-changes.outputs.has_changes == 'true'
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
wait_seconds=$((runner_start - queued_epoch))
|
||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -18,23 +18,6 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
wait_seconds=$((runner_start - queued_epoch))
|
||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -13,27 +13,23 @@ Automation is handled by [agentloop](https://github.com/guettli/agentloop) runni
|
||||
| Label | Trigger | Outcome |
|
||||
|---|---|---|
|
||||
| `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` |
|
||||
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue routes to `loop/merge` |
|
||||
| `loop/merge` | Merge agent rebases, waits for CI, and merges the PR | Issue moves to `loop/merge-done` |
|
||||
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue moves to `loop/code-done` |
|
||||
|
||||
**State machine:**
|
||||
|
||||
```
|
||||
loop/plan → loop/plan-in-process → loop/plan-done
|
||||
↘ NeedSupervisor (on failure)
|
||||
loop/plan → loop/plan-in-progress → loop/plan-done
|
||||
↘ NeedSupervisor (on failure)
|
||||
|
||||
loop/code → loop/code-in-process → loop/merge (via route)
|
||||
↘ NeedSupervisor (on failure)
|
||||
|
||||
loop/merge → loop/merge-in-process → loop/merge-done
|
||||
↘ NeedSupervisor (on failure)
|
||||
loop/code → loop/code-in-progress → loop/code-done
|
||||
↘ NeedSupervisor (on failure)
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions).
|
||||
- An issue with `NeedSupervisor` needs human attention — investigate, fix, then re-label.
|
||||
- The merge agent merges the PR automatically once CI is green. A human still reviews the PR before it merges if branch protection requires a review.
|
||||
- The coding agent opens a PR but does NOT close the issue. A human reviews the PR and closes the issue after merging.
|
||||
- Planning agents only post a comment — they do NOT write code or open PRs.
|
||||
- `loop/*` labels are managed by agentloop — do not set them manually while an agent is active.
|
||||
|
||||
@@ -43,9 +39,9 @@ loop/merge → loop/merge-in-process → loop/merge-done
|
||||
1. Create issue
|
||||
2. Add label loop/plan → agent writes plan as comment
|
||||
3. Review plan, request changes or approve
|
||||
4. Add label loop/code → agent implements + opens PR + hands off to merge
|
||||
5. (Optional) Review PR before it merges
|
||||
6. Merge agent waits for CI and merges the PR automatically
|
||||
4. Add label loop/code → agent implements + opens PR
|
||||
5. Review PR, merge
|
||||
6. Close issue
|
||||
```
|
||||
|
||||
## Code conventions
|
||||
|
||||
+2
-2
@@ -539,7 +539,7 @@ func (m *Ci) TestBackend(ctx context.Context) (string, error) {
|
||||
return m.WithStalwart(m.setup(m.backendSrc())).
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter test --concurrency=1 --reporter expanded --no-pub --exclude-tags=nightly test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`flutter test --concurrency=1 --reporter expanded --no-pub test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
@@ -570,7 +570,7 @@ func (m *Ci) ChaosMonkeyBackend(ctx context.Context) (string, error) {
|
||||
return m.WithStalwart(m.setup(m.backendSrc())).
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub --tags=nightly >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
const int dbSchemaVersion = 40;
|
||||
const int dbSchemaVersion = 39;
|
||||
|
||||
@@ -58,7 +58,7 @@ abstract class EmailRepository {
|
||||
);
|
||||
|
||||
/// Searches the local DB across all mailboxes of [accountId] (or all accounts
|
||||
/// if null) by subject, preview, and notes. Fast, works offline.
|
||||
/// if null) by subject and preview. Fast, works offline.
|
||||
Future<List<Email>> searchEmailsGlobal(String? accountId, String query);
|
||||
|
||||
/// Returns all locally cached emails in any mailbox of [accountId] (or all
|
||||
|
||||
+10
-61
@@ -7,7 +7,6 @@ import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sharedinbox/core/db_schema_version.dart';
|
||||
import 'package:sqlite3/sqlite3.dart' show Database;
|
||||
|
||||
part 'database.g.dart';
|
||||
|
||||
@@ -339,17 +338,6 @@ class EmailNotes extends Table {
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
/// Records the first time the user ran each app version (identified by GIT_HASH).
|
||||
/// Added in schema v40.
|
||||
@DataClassName('InstalledVersionRow')
|
||||
class InstalledVersions extends Table {
|
||||
TextColumn get gitHash => text()();
|
||||
DateTimeColumn get installedAt => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {gitHash};
|
||||
}
|
||||
|
||||
/// App-wide user preferences, stored as a singleton row (id always 1).
|
||||
@DataClassName('UserPreferencesRow')
|
||||
class UserPreferences extends Table {
|
||||
@@ -396,7 +384,6 @@ class UserPreferences extends Table {
|
||||
UserPreferences,
|
||||
ImageTrustedSenders,
|
||||
EmailNotes,
|
||||
InstalledVersions,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
@@ -676,30 +663,8 @@ class AppDatabase extends _$AppDatabase {
|
||||
if (from < 39) {
|
||||
await m.createTable(emailNotes);
|
||||
}
|
||||
if (from < 40) {
|
||||
await m.createTable(installedVersions);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/// Inserts a row for [gitHash] the first time that version is seen.
|
||||
/// Subsequent calls for the same hash are silently ignored so the original
|
||||
/// install timestamp is preserved.
|
||||
Future<void> recordInstalledVersionIfNew(String gitHash) async {
|
||||
if (gitHash.isEmpty) return;
|
||||
await into(installedVersions).insert(
|
||||
InstalledVersionsCompanion.insert(
|
||||
gitHash: gitHash,
|
||||
installedAt: DateTime.now(),
|
||||
),
|
||||
mode: InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, DateTime>> loadInstalledVersions() async {
|
||||
final rows = await select(installedVersions).get();
|
||||
return {for (final r in rows) r.gitHash: r.installedAt};
|
||||
}
|
||||
}
|
||||
|
||||
// Resolved once in main() via initDatabasePath() before runApp().
|
||||
@@ -794,34 +759,18 @@ Future<String> resolveDatabasePathForTesting() => _resolveDatabasePath();
|
||||
void resetDatabasePathForTesting() => _dbPath = null;
|
||||
Future<String?> androidFallbackPathForTesting() => _androidFallbackPath();
|
||||
|
||||
/// Configures PRAGMAs on a newly opened SQLite connection.
|
||||
///
|
||||
/// busy_timeout must come first so subsequent statements retry on SQLITE_BUSY
|
||||
/// instead of immediately failing.
|
||||
///
|
||||
/// journal_mode = WAL is wrapped in a try/catch because a concurrent
|
||||
/// WorkManager background task may already have the DB open when the app
|
||||
/// starts. SQLITE_BUSY_SNAPSHOT (extended code 261, primary code 5) is
|
||||
/// returned in that situation; it only occurs when the DB is already in WAL
|
||||
/// mode, so the pragma would be a no-op anyway and it is safe to continue.
|
||||
void _setupPragmas(Database db) {
|
||||
db.execute('PRAGMA busy_timeout = 5000;');
|
||||
try {
|
||||
db.execute('PRAGMA journal_mode = WAL;');
|
||||
} on SqliteException catch (e) {
|
||||
// resultCode strips the extended bits: both SQLITE_BUSY (5) and
|
||||
// SQLITE_BUSY_SNAPSHOT (261) reduce to 5. Re-throw anything else.
|
||||
if (e.resultCode != 5) rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
LazyDatabase _openConnection() {
|
||||
return LazyDatabase(() async {
|
||||
final file = File(await _resolveDatabasePath());
|
||||
return NativeDatabase.createInBackground(file, setup: _setupPragmas);
|
||||
return NativeDatabase.createInBackground(
|
||||
file,
|
||||
setup: (db) {
|
||||
// WAL lets readers and writers proceed concurrently (different account
|
||||
// sync loops share the same DB). busy_timeout makes SQLite retry for
|
||||
// up to 5 s instead of immediately returning SQLITE_BUSY.
|
||||
db.execute('PRAGMA journal_mode = WAL;');
|
||||
db.execute('PRAGMA busy_timeout = 5000;');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Exposed so tests can run the exact production setup logic on a raw
|
||||
// sqlite3 connection (same pattern as resolveDatabasePathForTesting).
|
||||
void setupPragmasForTesting(Database db) => _setupPragmas(db);
|
||||
|
||||
@@ -2922,9 +2922,9 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
|
||||
final sql = accountId != null
|
||||
? 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
||||
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY e.received_at DESC LIMIT 50'
|
||||
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY rank LIMIT 50'
|
||||
: 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
||||
' WHERE email_fts MATCH ? ORDER BY e.received_at DESC LIMIT 50';
|
||||
' WHERE email_fts MATCH ? ORDER BY rank LIMIT 50';
|
||||
final variables = accountId != null
|
||||
? [Variable<String>(ftsQuery), Variable<String>(accountId)]
|
||||
: [Variable<String>(ftsQuery)];
|
||||
@@ -2934,56 +2934,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
final emailRows = await Future.wait(
|
||||
queryRows.map((r) => _db.emails.mapFromRow(r)),
|
||||
);
|
||||
|
||||
final noteRows = await _searchEmailsByNotes(accountId, null, query);
|
||||
|
||||
final seen = <String>{};
|
||||
final merged = <model.Email>[];
|
||||
for (final e in [...emailRows.map(_toModel), ...noteRows]) {
|
||||
if (seen.add(e.id)) merged.add(e);
|
||||
}
|
||||
merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
||||
return merged;
|
||||
}
|
||||
|
||||
/// Returns emails whose associated notes contain all words from [query].
|
||||
/// Optionally filtered by [accountId] and [mailboxPath].
|
||||
Future<List<model.Email>> _searchEmailsByNotes(
|
||||
String? accountId,
|
||||
String? mailboxPath,
|
||||
String query,
|
||||
) async {
|
||||
final words =
|
||||
query.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList();
|
||||
if (words.isEmpty) return [];
|
||||
|
||||
final noteConditions = words.map((_) => 'n.note_text LIKE ?').join(' AND ');
|
||||
final likeVars = words.map((w) => Variable<String>('%$w%')).toList();
|
||||
|
||||
final extraConditions = StringBuffer();
|
||||
final extraVars = <Variable<String>>[];
|
||||
if (accountId != null) {
|
||||
extraConditions.write(' AND e.account_id = ?');
|
||||
extraVars.add(Variable<String>(accountId));
|
||||
}
|
||||
if (mailboxPath != null) {
|
||||
extraConditions.write(' AND e.mailbox_path = ?');
|
||||
extraVars.add(Variable<String>(mailboxPath));
|
||||
}
|
||||
|
||||
final sql = 'SELECT DISTINCT e.* FROM emails e'
|
||||
' JOIN email_notes n ON n.message_id = e.message_id'
|
||||
' AND n.account_id = e.account_id'
|
||||
' WHERE $noteConditions$extraConditions'
|
||||
' ORDER BY e.received_at DESC LIMIT 50';
|
||||
|
||||
final rows = await _db.customSelect(
|
||||
sql,
|
||||
variables: [...likeVars, ...extraVars],
|
||||
readsFrom: {_db.emails, _db.emailNotes},
|
||||
).get();
|
||||
final emailRows =
|
||||
await Future.wait(rows.map((r) => _db.emails.mapFromRow(r)));
|
||||
return emailRows.map(_toModel).toList();
|
||||
}
|
||||
|
||||
@@ -2993,7 +2943,9 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
static String _toFtsQuery(String query) {
|
||||
final words = query
|
||||
.trim()
|
||||
.split(RegExp(r'[^\w]+'))
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((w) => w.isNotEmpty)
|
||||
.map((w) => w.replaceAll(RegExp(r'[^\w]'), ''))
|
||||
.where((w) => w.isNotEmpty)
|
||||
.toList();
|
||||
if (words.isEmpty) return '';
|
||||
@@ -3095,42 +3047,68 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
// Results are limited to emails already synced into the local SQLite FTS5
|
||||
// index; call syncEmails first to ensure the index is up-to-date.
|
||||
Future<List<model.Email>> searchEmails(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
String query,
|
||||
) async {
|
||||
final ftsQuery = _toFtsQuery(query);
|
||||
if (ftsQuery.isEmpty) return [];
|
||||
|
||||
const sql = 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
||||
' WHERE email_fts MATCH ? AND e.account_id = ? AND e.mailbox_path = ?'
|
||||
' ORDER BY e.received_at DESC LIMIT 50';
|
||||
final variables = [
|
||||
Variable<String>(ftsQuery),
|
||||
Variable<String>(accountId),
|
||||
Variable<String>(mailboxPath),
|
||||
];
|
||||
|
||||
final queryRows = await _db
|
||||
.customSelect(sql, variables: variables, readsFrom: {_db.emails}).get();
|
||||
final emailRows = await Future.wait(
|
||||
queryRows.map((r) => _db.emails.mapFromRow(r)),
|
||||
final account = (await _accounts.getAccount(accountId))!;
|
||||
final password = await _accounts.getPassword(accountId);
|
||||
final client = await _imapConnect(
|
||||
account,
|
||||
_effectiveUsername(account),
|
||||
password,
|
||||
);
|
||||
try {
|
||||
await client.selectMailboxByPath(mailboxPath);
|
||||
final terms =
|
||||
query.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList();
|
||||
final searchCriteria = terms.map((term) {
|
||||
final escaped = term.replaceAll('"', '\\"');
|
||||
return 'OR SUBJECT "$escaped" TEXT "$escaped"';
|
||||
}).join(' ');
|
||||
final result = await client.uidSearchMessages(
|
||||
searchCriteria: searchCriteria,
|
||||
);
|
||||
final uids = result.matchingSequence?.toList() ?? [];
|
||||
if (uids.isEmpty) return [];
|
||||
|
||||
final noteRows = await _searchEmailsByNotes(accountId, mailboxPath, query);
|
||||
|
||||
final seen = <String>{};
|
||||
final merged = <model.Email>[];
|
||||
for (final e in [...emailRows.map(_toModel), ...noteRows]) {
|
||||
if (seen.add(e.id)) merged.add(e);
|
||||
final fetch = await client.uidFetchMessages(
|
||||
imap.MessageSequence.fromIds(uids, isUid: true),
|
||||
'(UID FLAGS ENVELOPE)',
|
||||
);
|
||||
return fetch.messages
|
||||
.where((msg) => msg.uid != null && msg.envelope != null)
|
||||
.map((msg) {
|
||||
final envelope = msg.envelope!;
|
||||
final uid = msg.uid!;
|
||||
final emailId = '$accountId:$uid';
|
||||
return model.Email(
|
||||
id: emailId,
|
||||
accountId: accountId,
|
||||
mailboxPath: mailboxPath,
|
||||
uid: uid,
|
||||
subject: envelope.subject,
|
||||
sentAt: envelope.date,
|
||||
receivedAt: envelope.date ?? DateTime.now(),
|
||||
from: _toAddressList(envelope.from),
|
||||
to: _toAddressList(envelope.to),
|
||||
cc: _toAddressList(envelope.cc),
|
||||
isSeen: msg.flags?.contains(r'\Seen') ?? false,
|
||||
isFlagged: msg.flags?.contains(r'\Flagged') ?? false,
|
||||
hasAttachment: msg.hasAttachments(),
|
||||
);
|
||||
}).toList();
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
||||
return merged;
|
||||
}
|
||||
|
||||
List<model.EmailAddress> _toAddressList(List<imap.MailAddress>? addresses) =>
|
||||
(addresses ?? const [])
|
||||
.map((a) => model.EmailAddress(name: a.personalName, email: a.email))
|
||||
.toList();
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Computes a stable threadId from RFC 2822 headers.
|
||||
|
||||
@@ -294,10 +294,6 @@ final noteRepositoryProvider = Provider<NoteRepository>((ref) {
|
||||
);
|
||||
});
|
||||
|
||||
final installedVersionsProvider = FutureProvider<Map<String, DateTime>>((ref) {
|
||||
return ref.watch(dbProvider).loadInstalledVersions();
|
||||
});
|
||||
|
||||
/// Stream of notes for a specific email, identified by (accountId, messageId).
|
||||
final notesProvider =
|
||||
StreamProvider.autoDispose.family<List<EmailNote>, (String, String)>(
|
||||
|
||||
@@ -86,8 +86,6 @@ class SharedInboxApp extends ConsumerStatefulWidget {
|
||||
ConsumerState<SharedInboxApp> createState() => _SharedInboxAppState();
|
||||
}
|
||||
|
||||
const _kGitHash = String.fromEnvironment('GIT_HASH');
|
||||
|
||||
class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
||||
@override
|
||||
void initState() {
|
||||
@@ -95,11 +93,6 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
||||
// Start background IMAP sync once — runs for the lifetime of the app.
|
||||
ref.read(syncManagerProvider).start();
|
||||
ref.read(reliabilityRunnerProvider).start();
|
||||
if (_kGitHash.isNotEmpty) {
|
||||
unawaited(
|
||||
ref.read(dbProvider).recordInstalledVersionIfNew(_kGitHash),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -109,7 +102,6 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
@@ -117,7 +109,6 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
),
|
||||
routerConfig: router,
|
||||
);
|
||||
|
||||
@@ -2,90 +2,21 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class ChangeLogScreen extends ConsumerWidget {
|
||||
class ChangeLogScreen extends StatelessWidget {
|
||||
const ChangeLogScreen({super.key});
|
||||
|
||||
static const _months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
|
||||
static String _formatInstallDate(DateTime dt) {
|
||||
final h = dt.hour.toString().padLeft(2, '0');
|
||||
final m = dt.minute.toString().padLeft(2, '0');
|
||||
final month = _months[dt.month - 1];
|
||||
return '$h:$m, ${dt.day} $month ${dt.year}';
|
||||
}
|
||||
|
||||
static const _repoUrl = 'https://codeberg.org/guettli/sharedinbox';
|
||||
|
||||
static final _issueRefPattern = RegExp(r'#(\d+)');
|
||||
|
||||
static String _linkifyIssueRefs(String text) {
|
||||
return text.replaceAllMapped(
|
||||
_issueRefPattern,
|
||||
(m) => '[#${m[1]}]($_repoUrl/issues/${m[1]})',
|
||||
);
|
||||
}
|
||||
|
||||
// Changelog lines have the form:
|
||||
// * 2026-06-05 [abc1234](https://...): subject
|
||||
// This pattern captures the short hash inside the markdown link.
|
||||
static final _hashPattern = RegExp(r'\[([0-9a-f]{6,12})\]\(');
|
||||
|
||||
static String _injectInstallMarkers(
|
||||
String changelog,
|
||||
Map<String, DateTime> versions,
|
||||
) {
|
||||
if (versions.isEmpty) return changelog;
|
||||
final lines = changelog.split('\n');
|
||||
final buf = StringBuffer();
|
||||
for (final line in lines) {
|
||||
final match = _hashPattern.firstMatch(line);
|
||||
if (match != null) {
|
||||
final lineHash = match.group(1)!;
|
||||
for (final entry in versions.entries) {
|
||||
final stored = entry.key;
|
||||
final matches = stored == lineHash ||
|
||||
stored.startsWith(lineHash) ||
|
||||
lineHash.startsWith(stored);
|
||||
if (!matches) continue;
|
||||
buf.write(
|
||||
'\n---\n\n**Installed: ${_formatInstallDate(entry.value)}**\n\n',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
buf.writeln(line);
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final installedVersions = ref.watch(installedVersionsProvider);
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('ChangeLog')),
|
||||
body: FutureBuilder<String>(
|
||||
future:
|
||||
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
|
||||
future: DefaultAssetBundle.of(
|
||||
context,
|
||||
).loadString('assets/changelog.txt'),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting ||
|
||||
installedVersions.isLoading) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
@@ -93,12 +24,9 @@ class ChangeLogScreen extends ConsumerWidget {
|
||||
child: Text('Error loading changelog: ${snapshot.error}'),
|
||||
);
|
||||
}
|
||||
final raw = snapshot.data ?? 'No changelog entries found.';
|
||||
final content = _linkifyIssueRefs(raw);
|
||||
final versions = installedVersions.value ?? {};
|
||||
final annotated = _injectInstallMarkers(content, versions);
|
||||
final content = snapshot.data ?? 'No changelog entries found.';
|
||||
return Markdown(
|
||||
data: annotated,
|
||||
data: content,
|
||||
onTapLink: (text, href, title) {
|
||||
if (href != null) {
|
||||
unawaited(
|
||||
|
||||
@@ -57,7 +57,6 @@ class CrashScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
theme: ThemeData(splashFactory: NoSplash.splashFactory),
|
||||
home: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Something went wrong'),
|
||||
|
||||
@@ -55,10 +55,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
// generation has advanced (prevents out-of-order IMAP responses from
|
||||
// overwriting fresh results with results for an older query).
|
||||
int _searchGeneration = 0;
|
||||
// The query whose results are currently settled in _searchResults.
|
||||
// Used to skip redundant re-runs when the user presses Enter on an
|
||||
// already-settled search (issue #473).
|
||||
String? _lastSettledQuery;
|
||||
bool get _selecting =>
|
||||
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
|
||||
|
||||
@@ -70,7 +66,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
setState(() {
|
||||
_searchResults = null;
|
||||
_searchLoading = false;
|
||||
_lastSettledQuery = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -127,17 +122,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
}
|
||||
|
||||
Future<void> _runSearch(String query) async {
|
||||
final q = query.trim();
|
||||
if (q.isEmpty) {
|
||||
setState(() {
|
||||
_searchResults = null;
|
||||
_lastSettledQuery = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Skip if results are already settled for this exact query — prevents the
|
||||
// Enter key from re-triggering a search that already completed.
|
||||
if (_searchResults != null && !_searchLoading && q == _lastSettledQuery) {
|
||||
if (query.trim().isEmpty) {
|
||||
setState(() => _searchResults = null);
|
||||
return;
|
||||
}
|
||||
final generation = ++_searchGeneration;
|
||||
@@ -145,12 +131,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
try {
|
||||
final results = await ref
|
||||
.read(emailRepositoryProvider)
|
||||
.searchEmails(widget.accountId, widget.mailboxPath, q);
|
||||
.searchEmails(widget.accountId, widget.mailboxPath, query.trim());
|
||||
if (mounted && generation == _searchGeneration) {
|
||||
setState(() {
|
||||
_searchResults = results;
|
||||
_lastSettledQuery = q;
|
||||
});
|
||||
setState(() => _searchResults = results);
|
||||
}
|
||||
} finally {
|
||||
if (mounted && generation == _searchGeneration) {
|
||||
@@ -568,8 +551,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
|
||||
if (wasSearching && mounted) {
|
||||
// Filter deleted emails out of the local results immediately.
|
||||
// Calling searchEmails here would still return deleted rows because the
|
||||
// delete is only enqueued — not yet applied to the local DB.
|
||||
// Calling searchEmails here would hit the IMAP server, which still has
|
||||
// the emails because the delete is only enqueued — not yet applied.
|
||||
final deletedIds = ids.toSet();
|
||||
final remaining = (_searchResults ?? [])
|
||||
.where((e) => !deletedIds.contains(e.id))
|
||||
|
||||
+1
-1
@@ -19,7 +19,6 @@ dependencies:
|
||||
|
||||
# Local persistence (offline-first)
|
||||
drift: ^2.20.3
|
||||
sqlite3: ^3.1.5 # used directly in lib/data/db/database.dart (_setupPragmas)
|
||||
sqlite3_flutter_libs: ^0.6.0+eol
|
||||
path_provider: ^2.1.5
|
||||
path: ^1.9.1
|
||||
@@ -79,6 +78,7 @@ dev_dependencies:
|
||||
mockito: ^5.4.4
|
||||
fake_async: ^1.3.1
|
||||
path_provider_platform_interface: ^2.1.2
|
||||
sqlite3: ^3.1.5 # used directly in test/unit/db_test_helper.dart; 3.x required for Database.close()
|
||||
url_launcher_platform_interface: ^2.3.2
|
||||
plugin_platform_interface: ^2.1.8
|
||||
flutter_launcher_icons: ^0.14.0
|
||||
|
||||
@@ -76,12 +76,11 @@ if [ "$_elapsed" -gt 10 ]; then
|
||||
echo "::warning::ssh-keyscan took ${_elapsed}s — Dagger engine host may be slow to respond"
|
||||
fi
|
||||
|
||||
# Create a background SSH tunnel to the Dagger engine Unix socket.
|
||||
# Forwards local TCP port 8080 directly to /run/dagger/engine.sock on the remote host,
|
||||
# eliminating the need for a socat bridge on the server side.
|
||||
# Create a background SSH tunnel to the Dagger engine.
|
||||
# We map local port 8080 to remote port 1774 (where our socat bridge is listening).
|
||||
echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..."
|
||||
_t0=$SECONDS
|
||||
timeout 30 ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:/run/dagger/engine.sock "dagger@$DAGGER_ENGINE_HOST"
|
||||
timeout 30 ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST"
|
||||
_elapsed=$(( SECONDS - _t0 ))
|
||||
if [ "$_elapsed" -gt 10 ]; then
|
||||
echo "::warning::SSH tunnel setup took ${_elapsed}s"
|
||||
|
||||
@@ -10,9 +10,6 @@
|
||||
// CHAOS_ROUNDS (default: 30) — number of random operations to perform
|
||||
// CHAOS_SEED (default: current epoch ms) — seed for reproducibility
|
||||
|
||||
@Tags(['nightly'])
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
@@ -135,7 +132,7 @@ void main() {
|
||||
tearDown(() => db.close());
|
||||
|
||||
test('chaos monkey — random operations do not crash the repository',
|
||||
timeout: Timeout.none, () async {
|
||||
() async {
|
||||
final seedStr = _env('CHAOS_SEED');
|
||||
final seed = seedStr.isEmpty
|
||||
? DateTime.now().millisecondsSinceEpoch
|
||||
|
||||
@@ -421,7 +421,6 @@ void main() {
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final results = await r.emails.searchEmails('test', 'INBOX', uniqueWord);
|
||||
expect(results, hasLength(1));
|
||||
@@ -433,7 +432,6 @@ void main() {
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final results = await r.emails.searchEmails(
|
||||
'test',
|
||||
|
||||
@@ -453,191 +453,6 @@ void main() {
|
||||
expect(results.first.subject, 'foobar baz');
|
||||
});
|
||||
|
||||
test('searchEmails filters by mailboxPath using local FTS5', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
// Insert matching email in INBOX.
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 1,
|
||||
subject: const Value('Meeting agenda'),
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
// Insert matching email in a different mailbox — must not appear.
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:2',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'Sent',
|
||||
uid: 2,
|
||||
subject: const Value('Meeting follow-up'),
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
|
||||
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'meeting');
|
||||
expect(results, hasLength(1));
|
||||
expect(results.first.subject, 'Meeting agenda');
|
||||
expect(results.first.mailboxPath, 'INBOX');
|
||||
});
|
||||
|
||||
test('searchEmailsGlobal includes emails matched by note text', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
// Email whose subject does NOT match — but its note does.
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 1,
|
||||
messageId: const Value('<msg1@example.com>'),
|
||||
subject: const Value('Weekly report'),
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
// Add a note referencing the email's messageId.
|
||||
await r.db.into(r.db.emailNotes).insert(
|
||||
EmailNotesCompanion.insert(
|
||||
id: 'note-1',
|
||||
accountId: 'acc-1',
|
||||
messageId: '<msg1@example.com>',
|
||||
noteText: 'Urgent follow-up needed',
|
||||
serverId: '42',
|
||||
createdAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
|
||||
final results = await r.emails.searchEmailsGlobal(null, 'urgent');
|
||||
expect(results, hasLength(1));
|
||||
expect(results.first.subject, 'Weekly report');
|
||||
});
|
||||
|
||||
test('searchEmails includes emails matched by note text in mailbox',
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 1,
|
||||
messageId: const Value('<msg1@example.com>'),
|
||||
subject: const Value('Project update'),
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
// Email in a different mailbox — its note must not appear in INBOX search.
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:2',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'Sent',
|
||||
uid: 2,
|
||||
messageId: const Value('<msg2@example.com>'),
|
||||
subject: const Value('Other email'),
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db.into(r.db.emailNotes).insert(
|
||||
EmailNotesCompanion.insert(
|
||||
id: 'note-1',
|
||||
accountId: 'acc-1',
|
||||
messageId: '<msg1@example.com>',
|
||||
noteText: 'remember to call client',
|
||||
serverId: '42',
|
||||
createdAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db.into(r.db.emailNotes).insert(
|
||||
EmailNotesCompanion.insert(
|
||||
id: 'note-2',
|
||||
accountId: 'acc-1',
|
||||
messageId: '<msg2@example.com>',
|
||||
noteText: 'remember to call client',
|
||||
serverId: '43',
|
||||
createdAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
|
||||
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'client');
|
||||
expect(results, hasLength(1));
|
||||
expect(results.first.subject, 'Project update');
|
||||
expect(results.first.mailboxPath, 'INBOX');
|
||||
});
|
||||
|
||||
test('searchEmailsGlobal returns results sorted by receivedAt descending',
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 1,
|
||||
subject: const Value('Older report'),
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:2',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 2,
|
||||
subject: const Value('Newer report'),
|
||||
receivedAt: DateTime(2024, 6),
|
||||
),
|
||||
);
|
||||
|
||||
final results = await r.emails.searchEmailsGlobal(null, 'report');
|
||||
expect(results, hasLength(2));
|
||||
expect(results[0].subject, 'Newer report');
|
||||
expect(results[1].subject, 'Older report');
|
||||
});
|
||||
|
||||
test('searchEmails returns results sorted by receivedAt descending',
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 1,
|
||||
subject: const Value('Older meeting'),
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:2',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 2,
|
||||
subject: const Value('Newer meeting'),
|
||||
receivedAt: DateTime(2024, 6),
|
||||
),
|
||||
);
|
||||
|
||||
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'meeting');
|
||||
expect(results, hasLength(2));
|
||||
expect(results[0].subject, 'Newer meeting');
|
||||
expect(results[1].subject, 'Older meeting');
|
||||
});
|
||||
|
||||
test(
|
||||
'searchAddresses returns results sorted by most recently used',
|
||||
() async {
|
||||
|
||||
@@ -14,7 +14,7 @@ void main() {
|
||||
group('Migration', () {
|
||||
test('schemaVersion matches expected value', () async {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
expect(db.schemaVersion, 40);
|
||||
expect(db.schemaVersion, 39);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
@@ -427,15 +427,12 @@ void main() {
|
||||
// v39: email_notes table.
|
||||
await db.customSelect('SELECT count(*) FROM email_notes').get();
|
||||
|
||||
// v40: installed_versions table.
|
||||
await db.customSelect('SELECT count(*) FROM installed_versions').get();
|
||||
|
||||
await db.close();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
},
|
||||
);
|
||||
|
||||
test('fresh install creates all tables at schemaVersion 40', () async {
|
||||
test('fresh install creates all tables at schemaVersion 39', () async {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
await db.select(db.accounts).get();
|
||||
|
||||
@@ -465,7 +462,6 @@ void main() {
|
||||
'user_preferences', // v34
|
||||
'image_trusted_senders', // v37
|
||||
'email_notes', // v39
|
||||
'installed_versions', // v40
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -504,46 +500,7 @@ void main() {
|
||||
// v39: email_notes table.
|
||||
await db.customSelect('SELECT count(*) FROM email_notes').get();
|
||||
|
||||
// v40: installed_versions table.
|
||||
await db.customSelect('SELECT count(*) FROM installed_versions').get();
|
||||
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
// Regression test for https://codeberg.org/guettli/sharedinbox/issues/508:
|
||||
// _openConnection's setup callback must not crash when PRAGMA journal_mode =
|
||||
// WAL fails with SQLITE_BUSY_SNAPSHOT (extended code 261, primary code 5)
|
||||
// because a WorkManager background task already has the DB open in WAL mode.
|
||||
group('WAL setup (#508)', () {
|
||||
test(
|
||||
'setupPragmasForTesting does not throw when WAL is already active and '
|
||||
'another connection holds an open read transaction',
|
||||
() {
|
||||
final dbFile = File('test_wal_busy_508.db');
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
addTearDown(() {
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
});
|
||||
|
||||
// conn1: enable WAL and keep a read transaction open — simulates a
|
||||
// WorkManager background task that opened the DB before the foreground
|
||||
// app starts.
|
||||
final conn1 = sqlite.sqlite3.open(dbFile.path);
|
||||
conn1.execute('PRAGMA journal_mode = WAL;');
|
||||
conn1.execute('BEGIN;');
|
||||
conn1.select('SELECT 1;');
|
||||
|
||||
// conn2: run the exact production setup through setupPragmasForTesting.
|
||||
// This must not throw even though conn1 holds an open transaction and
|
||||
// the DB is already in WAL mode.
|
||||
final conn2 = sqlite.sqlite3.open(dbFile.path);
|
||||
expect(() => setupPragmasForTesting(conn2), returnsNormally);
|
||||
|
||||
conn1.execute('ROLLBACK;');
|
||||
conn1.close();
|
||||
conn2.close();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,10 +50,7 @@ Widget _buildScreen({List<Account> accounts = const []}) {
|
||||
FakeAccountRepository(accounts),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: ThemeData(splashFactory: NoSplash.splashFactory),
|
||||
home: const AboutScreen(),
|
||||
),
|
||||
child: const MaterialApp(home: AboutScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
||||
|
||||
class _FakeAssetBundle extends CachingAssetBundle {
|
||||
@@ -23,33 +19,16 @@ class _FakeAssetBundle extends CachingAssetBundle {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildScreen({
|
||||
required Map<String, String> assets,
|
||||
Map<String, DateTime> installedVersions = const {},
|
||||
}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
dbProvider.overrideWith((ref) {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
ref.onDispose(db.close);
|
||||
return db;
|
||||
}),
|
||||
installedVersionsProvider.overrideWith((ref) async => installedVersions),
|
||||
],
|
||||
child: DefaultAssetBundle(
|
||||
bundle: _FakeAssetBundle(assets),
|
||||
child: const MaterialApp(home: ChangeLogScreen()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const _fakeChangelog =
|
||||
'* 2024-01-01 feat: initial release\n* 2024-01-02 fix: resolve crash\n';
|
||||
|
||||
void main() {
|
||||
testWidgets('ChangeLogScreen shows changelog content', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildScreen(assets: {'assets/changelog.txt': _fakeChangelog}),
|
||||
DefaultAssetBundle(
|
||||
bundle: _FakeAssetBundle({'assets/changelog.txt': _fakeChangelog}),
|
||||
child: const MaterialApp(home: ChangeLogScreen()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -62,58 +41,14 @@ void main() {
|
||||
testWidgets('ChangeLogScreen shows error when asset is missing', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_buildScreen(assets: {}));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Error loading changelog'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ChangeLogScreen injects install marker for a known hash', (
|
||||
tester,
|
||||
) async {
|
||||
const changelog =
|
||||
'* 2024-01-01 [abc1234](https://example.com/abc1234): feat: initial release\n';
|
||||
final installedAt = DateTime(2024, 6, 15, 14, 32);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildScreen(
|
||||
assets: {'assets/changelog.txt': changelog},
|
||||
installedVersions: {'abc1234': installedAt},
|
||||
DefaultAssetBundle(
|
||||
bundle: _FakeAssetBundle({}),
|
||||
child: const MaterialApp(home: ChangeLogScreen()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Installed: 14:32'), findsOneWidget);
|
||||
expect(find.textContaining('15 Jun 2024'), findsOneWidget);
|
||||
expect(find.textContaining('initial release'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ChangeLogScreen shows no markers when no version recorded', (
|
||||
tester,
|
||||
) async {
|
||||
const changelog =
|
||||
'* 2024-01-01 [abc1234](https://example.com/abc1234): feat: initial release\n';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildScreen(assets: {'assets/changelog.txt': changelog}),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Installed:'), findsNothing);
|
||||
expect(find.textContaining('initial release'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ChangeLogScreen renders #NNN as a tappable link', (
|
||||
tester,
|
||||
) async {
|
||||
const changelog = '* 2024-03-01 fix: resolve crash, see #42\n';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildScreen(assets: {'assets/changelog.txt': changelog}),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The link text "#42" must be visible in the rendered output.
|
||||
expect(find.textContaining('#42'), findsOneWidget);
|
||||
expect(find.textContaining('Error loading changelog'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,6 +104,30 @@ void main() {
|
||||
expect(find.byIcon(Icons.star), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tapping search icon shows search bar', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.search));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.text('Search…'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('submitting a search query shows "No results" when empty', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -530,108 +554,6 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'pressing Enter on already-settled search does not re-run search (issue #473)',
|
||||
(tester) async {
|
||||
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Match');
|
||||
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Match');
|
||||
|
||||
var searchCallCount = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(
|
||||
onSearch: (_) async {
|
||||
searchCallCount++;
|
||||
return [email1, email2];
|
||||
},
|
||||
emailBody: const EmailBody(emailId: '', attachments: []),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Run the initial search.
|
||||
await tester.enterText(find.byType(TextField), 'Match');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Alpha Match'), findsOneWidget);
|
||||
expect(find.text('Beta Match'), findsOneWidget);
|
||||
|
||||
final countAfterFirstSearch = searchCallCount;
|
||||
|
||||
// Re-focus the search bar (simulates user tapping back into the field
|
||||
// with the keyboard still visible) and press Enter again on the same,
|
||||
// already-settled query.
|
||||
await tester.tap(find.byType(TextField));
|
||||
await tester.pump();
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The search must NOT re-run; call count must not increase.
|
||||
expect(
|
||||
searchCallCount,
|
||||
countAfterFirstSearch,
|
||||
reason:
|
||||
'Enter on settled results must not re-run the search (issue #473)',
|
||||
);
|
||||
// Results must still be visible — no loading spinner.
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
expect(find.text('Alpha Match'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'folder search returns results from local cache without any network call',
|
||||
(tester) async {
|
||||
// Verifies that searchEmails is backed by local SQLite (not IMAP).
|
||||
// The repository throws if a network call is attempted, yet search
|
||||
// must still return results.
|
||||
final email = testEmail(subject: 'Cached subject');
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(
|
||||
onSearch: (_) async {
|
||||
// Local DB: return cached results immediately.
|
||||
return [email];
|
||||
},
|
||||
emailBody: const EmailBody(emailId: '', attachments: []),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'Cached');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Cached subject'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('deleting all search results pops back to previous screen', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -580,7 +580,6 @@ Widget buildApp({
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
@@ -588,7 +587,6 @@ Widget buildApp({
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user