Add low level timeline entry filtering capabilities

This commit is contained in:
Hank Grabowski 2023-05-05 10:52:24 -04:00
parent f91080856f
commit 9e95427b9f
5 changed files with 423 additions and 0 deletions

View file

@ -0,0 +1,34 @@
enum ComparisonType {
containsCaseSensitive,
containsCaseInsensitive,
equals,
equalsIgnoreCase,
;
factory ComparisonType.parse(String? value) {
return ComparisonType.values.firstWhere(
(v) => v.name == value,
orElse: () => equals,
);
}
}
class StringFilter {
final String filterString;
final ComparisonType type;
const StringFilter({
required this.filterString,
required this.type,
});
Map<String, dynamic> toJson() => {
'filterString': filterString,
'type': type,
};
factory StringFilter.fromJson(Map<String, dynamic> json) => StringFilter(
filterString: json['filterString'],
type: ComparisonType.parse(json['type']),
);
}

View file

@ -0,0 +1,79 @@
import '../connection.dart';
import 'string_filter.dart';
enum TimelineEntryFilterAction {
hide,
warn,
;
factory TimelineEntryFilterAction.parse(String? value) {
return TimelineEntryFilterAction.values.firstWhere(
(v) => v.name == value,
orElse: () => warn,
);
}
}
class TimelineEntryFilter {
final TimelineEntryFilterAction action;
final String name;
final List<StringFilter> authorFilters;
final List<StringFilter> contentFilters;
final List<StringFilter> hashtagFilters;
const TimelineEntryFilter({
required this.action,
required this.name,
required this.authorFilters,
required this.contentFilters,
required this.hashtagFilters,
});
factory TimelineEntryFilter.create({
required TimelineEntryFilterAction action,
required String name,
List<Connection> authors = const [],
List<String> keywords = const [],
List<String> hashtags = const [],
}) {
return TimelineEntryFilter(
action: action,
name: name,
authorFilters: authors
.map((a) =>
StringFilter(filterString: a.id, type: ComparisonType.equals))
.toList(),
contentFilters: keywords
.map((k) => StringFilter(
filterString: k, type: ComparisonType.containsCaseInsensitive))
.toList(),
hashtagFilters: hashtags
.map((h) => StringFilter(
filterString: h, type: ComparisonType.equalsIgnoreCase))
.toList(),
);
}
Map<String, dynamic> toJson() => {
'action': action.name,
'name': name,
'authorFilters': authorFilters.map((f) => f.toJson()),
'contentFilters': contentFilters.map((f) => f.toJson()),
'hashtagFilters': hashtagFilters.map((f) => f.toJson()),
};
factory TimelineEntryFilter.fromJson(Map<String, dynamic> json) =>
TimelineEntryFilter(
action: TimelineEntryFilterAction.parse(json['action']),
name: json['name'],
authorFilters: (json['authorFilters'] as List<dynamic>)
.map((json) => StringFilter.fromJson(json))
.toList(),
contentFilters: (json['contentFilters'] as List<dynamic>)
.map((json) => StringFilter.fromJson(json))
.toList(),
hashtagFilters: (json['hashtagFilters'] as List<dynamic>)
.map((json) => StringFilter.fromJson(json))
.toList(),
);
}

View file

@ -48,6 +48,8 @@ class TimelineEntry {
final bool isFavorited;
final List<String> tags;
final List<LinkData> links;
final List<Connection> likes;
@ -81,6 +83,7 @@ class TimelineEntry {
this.externalLink = '',
this.locationData = const LocationData(),
this.isFavorited = false,
this.tags = const [],
this.links = const [],
this.likes = const [],
this.dislikes = const [],
@ -112,6 +115,7 @@ class TimelineEntry {
reshareAuthorId = 'Random parent author id ${randomId()}',
locationData = LocationData.randomBuilt(),
isFavorited = DateTime.now().second ~/ 2 == 0 ? true : false,
tags = [],
links = [],
likes = [],
dislikes = [],
@ -140,6 +144,7 @@ class TimelineEntry {
String? reshareAuthorId,
LocationData? locationData,
bool? isFavorited,
List<String>? tags,
List<LinkData>? links,
List<Connection>? likes,
List<Connection>? dislikes,
@ -170,6 +175,7 @@ class TimelineEntry {
reshareAuthorId: parentAuthorId ?? this.reshareAuthorId,
locationData: locationData ?? this.locationData,
isFavorited: isFavorited ?? this.isFavorited,
tags: tags ?? this.tags,
links: links ?? this.links,
likes: likes ?? this.likes,
dislikes: dislikes ?? this.dislikes,
@ -213,6 +219,7 @@ class TimelineEntry {
externalLink == other.externalLink &&
locationData == other.locationData &&
isFavorited == other.isFavorited &&
tags == other.tags &&
links == other.links &&
likes == other.likes &&
dislikes == other.dislikes &&
@ -241,6 +248,7 @@ class TimelineEntry {
externalLink.hashCode ^
locationData.hashCode ^
isFavorited.hashCode ^
tags.hashCode ^
links.hashCode ^
likes.hashCode ^
dislikes.hashCode ^

View file

@ -0,0 +1,97 @@
import '../models/filters/string_filter.dart';
import '../models/filters/timeline_entry_filter.dart';
import '../models/timeline_entry.dart';
class FilterResult {
final TimelineEntryFilterAction action;
final bool isFiltered;
const FilterResult(this.isFiltered, this.action);
String toActionString() {
return isFiltered ? action.name : 'show';
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is FilterResult &&
runtimeType == other.runtimeType &&
action == other.action &&
isFiltered == other.isFiltered;
@override
int get hashCode => action.hashCode ^ isFiltered.hashCode;
}
FilterResult runFilters(
TimelineEntry entry,
List<TimelineEntryFilter> filters,
) {
var isFiltered = false;
var action = TimelineEntryFilterAction.warn;
for (final filter in filters) {
if (filter.isFiltered(entry)) {
isFiltered = true;
if (filter.action == TimelineEntryFilterAction.hide) {
action = TimelineEntryFilterAction.hide;
break;
}
}
}
return FilterResult(isFiltered, action);
}
extension StringFilterOps on StringFilter {
bool isFiltered(String value) {
switch (type) {
case ComparisonType.containsCaseSensitive:
return value.contains(filterString);
case ComparisonType.containsCaseInsensitive:
return value.toLowerCase().contains(filterString.toLowerCase());
case ComparisonType.equals:
return value == filterString;
case ComparisonType.equalsIgnoreCase:
return value.toLowerCase() == filterString.toLowerCase();
}
}
}
extension TimelineEntryFilterOps on TimelineEntryFilter {
bool isFiltered(TimelineEntry entry) {
if (authorFilters.isEmpty &&
hashtagFilters.isEmpty &&
contentFilters.isEmpty) {
return false;
}
var authorFiltered = authorFilters.isEmpty ? true : false;
for (final filter in authorFilters) {
if (filter.isFiltered(entry.authorId)) {
authorFiltered = true;
break;
}
}
var hashtagFiltered = hashtagFilters.isEmpty ? true : false;
for (final filter in hashtagFilters) {
for (final tag in entry.tags) {
if (filter.isFiltered(tag)) {
hashtagFiltered = true;
break;
}
}
}
var contentFiltered = contentFilters.isEmpty ? true : false;
for (final filter in contentFilters) {
if (filter.isFiltered(entry.body)) {
contentFiltered = true;
break;
}
}
return authorFiltered && hashtagFiltered && contentFiltered;
}
}

View file

@ -0,0 +1,205 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:relatica/models/connection.dart';
import 'package:relatica/models/filters/string_filter.dart';
import 'package:relatica/models/filters/timeline_entry_filter.dart';
import 'package:relatica/models/timeline_entry.dart';
import 'package:relatica/utils/filter_runner.dart';
void main() {
final entries = [
TimelineEntry(body: 'Hello world', authorId: '1', tags: ['greeting']),
TimelineEntry(body: 'Goodbye', authorId: '1', tags: ['SendOff']),
TimelineEntry(body: 'Lorem ipsum', authorId: '1', tags: ['latin']),
TimelineEntry(body: 'Hello world', authorId: '2', tags: ['greeting']),
TimelineEntry(body: 'Goodbye', authorId: '2', tags: ['SendOff']),
TimelineEntry(body: 'Lorem ipsum', authorId: '2', tags: ['LATIN']),
TimelineEntry(body: 'Chao', authorId: '2', tags: ['sendoff']),
];
group('Test StringFilter', () {
test('Test equals', () {
const filter = StringFilter(
filterString: 'hello',
type: ComparisonType.equals,
);
expect(filter.isFiltered('hello'), equals(true));
expect(filter.isFiltered('Hello'), equals(false));
expect(filter.isFiltered('hello!'), equals(false));
expect(filter.isFiltered('help'), equals(false));
});
test('Test equalsIgnoreCase', () {
const filter = StringFilter(
filterString: 'hello',
type: ComparisonType.equalsIgnoreCase,
);
expect(filter.isFiltered('hello'), equals(true));
expect(filter.isFiltered('Hello'), equals(true));
expect(filter.isFiltered('hello!'), equals(false));
expect(filter.isFiltered('help'), equals(false));
});
test('Test containsCaseSensitive', () {
const filter = StringFilter(
filterString: 'hello',
type: ComparisonType.containsCaseSensitive,
);
expect(filter.isFiltered('hello world'), equals(true));
expect(filter.isFiltered('Hello World'), equals(false));
expect(filter.isFiltered('hello world'), equals(true));
expect(filter.isFiltered('help'), equals(false));
});
test('Test containsCaseInsensitive', () {
const filter = StringFilter(
filterString: 'hello',
type: ComparisonType.containsCaseInsensitive,
);
expect(filter.isFiltered('hello world'), equals(true));
expect(filter.isFiltered('Hello World'), equals(true));
expect(filter.isFiltered('hello world'), equals(true));
expect(filter.isFiltered('help'), equals(false));
});
});
group('Test TimelineEntryFilter', () {
test('Empty Filter', () {
final filter = TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'filter',
);
final expected = [false, false, false, false, false, false, false];
final actual = entries.map((e) => filter.isFiltered(e)).toList();
expect(actual, equals(expected));
});
test('Test Content Filter', () {
final filter = TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'filter',
keywords: ['hello', 'good'],
);
final expected = [true, true, false, true, true, false, false];
final actual = entries.map((e) => filter.isFiltered(e)).toList();
expect(actual, equals(expected));
});
test('Test Author Filter', () {
final filter = TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'filter',
authors: [Connection(id: '2')],
);
final expected = [false, false, false, true, true, true, true];
final actual = entries.map((e) => filter.isFiltered(e)).toList();
expect(actual, equals(expected));
});
test('Test Tag Filter', () {
final filter = TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'filter',
hashtags: ['latin', 'greet'],
);
final expected = [false, false, true, false, false, true, false];
final actual = entries.map((e) => filter.isFiltered(e)).toList();
expect(actual, equals(expected));
});
test('Test Author plus content', () {
final filter = TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'filter',
authors: [Connection(id: '2')],
keywords: ['good'],
);
final expected = [false, false, false, false, true, false, false];
final actual = entries.map((e) => filter.isFiltered(e)).toList();
expect(actual, equals(expected));
});
test('Test Author plus tag', () {
final filter = TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'filter',
authors: [Connection(id: '2')],
hashtags: ['latin', 'greet'],
);
final expected = [false, false, false, false, false, true, false];
final actual = entries.map((e) => filter.isFiltered(e)).toList();
expect(actual, equals(expected));
});
test('Test Content plus tag', () {
final filter = TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'filter',
keywords: ['chao'],
hashtags: ['SENDOFF'],
);
final expected = [false, false, false, false, false, false, true];
final actual = entries.map((e) => filter.isFiltered(e)).toList();
expect(actual, equals(expected));
});
test('Test all', () {
final filter1 = TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'filter',
authors: [Connection(id: '2'), Connection(id: '3')],
keywords: ['chao'],
hashtags: ['SENDOFF'],
);
final expected1 = [false, false, false, false, false, false, true];
final actual1 = entries.map((e) => filter1.isFiltered(e)).toList();
expect(actual1, equals(expected1));
final filter2 = TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'filter',
authors: [Connection(id: '1'), Connection(id: '3')],
keywords: ['chao'],
hashtags: ['SENDOFF'],
);
final expected2 = [false, false, false, false, false, false, false];
final actual2 = entries.map((e) => filter2.isFiltered(e)).toList();
expect(actual2, equals(expected2));
});
});
test('Test runner', () {
final runnerEntries = [
...entries,
TimelineEntry(body: 'User 3 Post #1', authorId: '3'),
];
final filters = [
TimelineEntryFilter.create(
action: TimelineEntryFilterAction.warn,
name: 'send-off-hide-filter',
hashtags: ['SENDOFF'],
),
TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'author-3-hide',
authors: [Connection(id: '3')],
),
TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'send-off-hide-filter',
authors: [Connection(id: '1')],
hashtags: ['SENDOFF'],
)
];
final expected = [
'show',
'hide',
'show',
'show',
'warn',
'show',
'warn',
'hide',
];
final actual = runnerEntries
.map((e) => runFilters(e, filters).toActionString())
.toList();
expect(expected, equals(actual));
});
}