todo-app/lib/data/note_query.dart
Krzysztof kuhy Rudnicki 9349747629
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Repo tasks: repo/done_check/by/evidence fields, filter chips, CLI close/list/search
Four optional note fields (repo, done_check, by, evidence) in the crdt_sync
record, written only when set, so editing a plain idea stays wire-identical
to v3 (note_repository_sync_compat_test). The log is schemaless: no stored
schema version; the legacy sqlite reader stays at v3.

NoteFilter gains a sources facet (ideas / every repo task / one repo);
search also matches the repo. The notes list shows only open ideas by
default (no badge); the filter sheet adds Ideas, Repo tasks and one chip
per repo when repo tasks exist. The per-note sheet shows the task fields
read-only. BACKLOG.md markers carry the new attributes, entity-escaped.

CLI: --repo --done-check --by on add, --close <id> --evidence (refused
without), --list [--repo] [--status], --search, and every id flag takes a
unique 8-character prefix. --list prints id8, status, priority, repo, title.

install_hooks.sh: the wrapper finds pre-commit's generated hook next to
itself, so commits from a git worktree (where .git is a file) work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 08:26:26 +01:00

195 lines
6.2 KiB
Dart

/// How notes are selected and ordered: the sort modes, the filter, and the
/// pure predicates behind them.
///
/// Split out of `note_repository.dart` for file size; that file re-exports
/// this one, so callers import it as before. Everything here is pure -- it
/// takes notes and a filter and returns a decision -- which is what made it
/// the natural seam.
library;
import 'package:todo/data/note.dart';
/// [NoteFilter.sources] entry for plain ideas (notes without a repo).
const kIdeasSource = '';
/// [NoteFilter.sources] entry admitting every repo task.
const kRepoTasksSource = '*';
/// How the history list should be ordered.
enum NoteSort {
/// Newest created first.
createdDesc,
/// Most recently modified first.
modifiedDesc,
/// A-Z by note text.
alphabetical,
/// Highest [Priority] first.
priorityDesc,
}
/// An immutable set of constraints for querying notes.
///
/// All fields combine with logical AND. Empty/null fields impose no
/// constraint. Lives in the data layer so the filtering it drives never leaks
/// into the UI. Construct copies with [copyWith] when toggling one facet.
class NoteFilter {
/// Creates a [NoteFilter] from its constraint fields.
const NoteFilter({
this.query = '',
this.priorities = const {},
this.statuses = const {},
this.sources = const {},
this.createdFrom,
this.createdTo,
this.updatedFrom,
this.updatedTo,
});
/// Case-insensitive substring matched against the note body.
final String query;
/// Notes must have one of these priorities. Empty means "any priority".
final Set<Priority> priorities;
/// Notes must have one of these statuses. Empty means "any status".
final Set<Status> statuses;
/// Which notes may show: [kIdeasSource], [kRepoTasksSource] and/or repo
/// names. Empty means every note.
final Set<String> sources;
/// Inclusive lower bound (by calendar day) on the creation date.
final DateTime? createdFrom;
/// Inclusive upper bound (by calendar day) on the creation date.
final DateTime? createdTo;
/// Inclusive lower bound (by calendar day) on the last-updated date.
final DateTime? updatedFrom;
/// Inclusive upper bound (by calendar day) on the last-updated date.
final DateTime? updatedTo;
/// True when no constraint is active (the unfiltered, full list).
bool get isEmpty =>
query.trim().isEmpty &&
priorities.isEmpty &&
statuses.isEmpty &&
sources.isEmpty &&
createdFrom == null &&
createdTo == null &&
updatedFrom == null &&
updatedTo == null;
/// Number of distinct active facets, for an "N filters" badge in the UI.
int get activeCount {
var n = 0;
if (query.trim().isNotEmpty) n++;
if (priorities.isNotEmpty) n++;
if (statuses.isNotEmpty) n++;
if (sources.isNotEmpty) n++;
if (createdFrom != null || createdTo != null) n++;
if (updatedFrom != null || updatedTo != null) n++;
return n;
}
/// Returns a copy with selected facets replaced. A `null` argument keeps
/// the current value; clearing a date is done via the dedicated clear
/// flags so `null` can mean "unchanged".
NoteFilter copyWith({
String? query,
Set<Priority>? priorities,
Set<Status>? statuses,
Set<String>? sources,
DateTime? createdFrom,
DateTime? createdTo,
DateTime? updatedFrom,
DateTime? updatedTo,
bool clearCreated = false,
bool clearUpdated = false,
}) {
return NoteFilter(
query: query ?? this.query,
priorities: priorities ?? this.priorities,
statuses: statuses ?? this.statuses,
sources: sources ?? this.sources,
createdFrom: clearCreated ? null : (createdFrom ?? this.createdFrom),
createdTo: clearCreated ? null : (createdTo ?? this.createdTo),
updatedFrom: clearUpdated ? null : (updatedFrom ?? this.updatedFrom),
updatedTo: clearUpdated ? null : (updatedTo ?? this.updatedTo),
);
}
}
/// Whether [note] passes every clause of [filter].
///
/// Clauses are AND-combined, and an empty clause matches everything -- an
/// empty priority set means "any priority", not "no priorities".
bool matchesFilter(Note note, NoteFilter filter) {
final query = filter.query.trim().toLowerCase();
if (query.isNotEmpty &&
!note.text.toLowerCase().contains(query) &&
!(note.repo?.toLowerCase().contains(query) ?? false)) {
return false;
}
if (filter.sources.isNotEmpty && !_sourceAllowed(note, filter.sources)) {
return false;
}
if (filter.priorities.isNotEmpty &&
!filter.priorities.contains(note.priority)) {
return false;
}
if (filter.statuses.isNotEmpty && !filter.statuses.contains(note.status)) {
return false;
}
if (!_withinDays(note.createdAt, filter.createdFrom, filter.createdTo)) {
return false;
}
if (!_withinDays(note.updatedAt, filter.updatedFrom, filter.updatedTo)) {
return false;
}
return true;
}
bool _sourceAllowed(Note note, Set<String> sources) {
final repo = note.repo;
if (repo == null) return sources.contains(kIdeasSource);
return sources.contains(kRepoTasksSource) || sources.contains(repo);
}
/// Inclusive day-granularity range check, matching the old SQL bounds:
/// `from` includes its whole day; `to` includes its whole day.
bool _withinDays(DateTime value, DateTime? from, DateTime? to) {
if (from != null && value.isBefore(_startOfDay(from))) return false;
if (to != null &&
!value.isBefore(_startOfDay(to).add(const Duration(days: 1)))) {
return false;
}
return true;
}
/// The comparator implementing [sort].
Comparator<Note> comparatorFor(NoteSort sort) {
switch (sort) {
case NoteSort.createdDesc:
return (a, b) => b.createdAt.compareTo(a.createdAt);
case NoteSort.modifiedDesc:
return (a, b) => b.updatedAt.compareTo(a.updatedAt);
case NoteSort.alphabetical:
return (a, b) => a.text.toLowerCase().compareTo(b.text.toLowerCase());
case NoteSort.priorityDesc:
return (a, b) {
final byPriority = b.priority.value.compareTo(a.priority.value);
return byPriority != 0
? byPriority
: b.updatedAt.compareTo(a.updatedAt);
};
}
}
/// Midnight (local) of [t]'s calendar day.
DateTime _startOfDay(DateTime t) => DateTime(t.year, t.month, t.day);