diff --git a/lib/main.dart b/lib/main.dart index 73ce3cc..5f5dd93 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'helper/command_parser.dart'; import 'intent.dart'; import 'locale/en_us.dart'; import 'model/config.dart'; +import 'model/database.dart'; import 'model/setting_options.dart'; import 'model/state.dart'; import 'model/theme.dart'; @@ -39,6 +40,7 @@ class _ThemedAppState extends ConsumerState { @override void initState() { WidgetsBinding.instance.addPostFrameCallback((final _) async { + await HistoryDatabase.load(); final intialBrightnessMode = ref.read(brightnessModeProvider); final prefs = await UserConfig.load(); final userBrightness = prefs.theme.brightness; diff --git a/lib/model/database.dart b/lib/model/database.dart new file mode 100644 index 0000000..1b7c682 --- /dev/null +++ b/lib/model/database.dart @@ -0,0 +1,129 @@ +import 'dart:io'; + +// import 'package:csv/csv.dart'; +// import 'package:csv/csv.dart'; +// import 'package:fast_csv/csv_parser.dart'; +import 'package:xdg_directories/xdg_directories.dart' as xdg; + +import '../util/csv_parser.dart'; + +enum ItemType { + video, + playlist, + channel, +} + +class HistoryDatabase { + const HistoryDatabase({required this.history}); + + final List history; + + static Future load({final int? limit}) async { + final file = File('${xdg.dataHome.path}/yatta/history.csv'); + return HistoryDatabase( + history: const CsvParser() + .parse(await file.readAsLines()) + .map((final e) => switch (e) { + { + 'id': final String id, + 'type': final String type, + 'provider': final String provider, + 'title': final String title, + 'description': final String description, + 'url': final String url, + 'viewCount': final String viewCount, + 'channelId': final String channelId, + 'channelTitle': final String channelTitle, + 'iconUrl': final String iconUrl, + 'thumbnailUrl': final String thumbnailUrl, + 'previewUrl': final String previewUrl, + 'publishDate': final String publishDate, + 'duration': _, + 'romanizedMetadata': final String romanizedMetadata, + 'history': final String history, + } => + HistoryModel( + id: id, + title: title, + description: description, + duration: Duration.zero, + romanizedMetadata: romanizedMetadata, + publishDate: publishDate, + type: switch (type) { + 'video' => ItemType.video, + 'playlist' => ItemType.playlist, + 'channel' => ItemType.channel, + _ => ItemType.video, + }, + history: history.split(','), + channelId: channelId, + iconUrl: iconUrl, + thumbnailUrl: thumbnailUrl, + previewUrl: previewUrl, + provider: provider, + channelTitle: channelTitle, + url: url, + viewCount: int.tryParse(viewCount), + ), + _ => throw UnimplementedError(), + }) + .toList()); + } +} + +class HistoryModel { + const HistoryModel({ + required this.id, + required this.title, + required this.duration, + required this.description, + required this.romanizedMetadata, + required this.publishDate, + required this.type, + required this.history, + required this.channelId, + required this.iconUrl, + required this.thumbnailUrl, + required this.previewUrl, + required this.provider, + required this.channelTitle, + required this.url, + required this.viewCount, + }); + + /// Can be video id or playlist id + final String id; + + final ItemType type; + + /// Video site (e.g. "youtube") + final String provider; + + final String title; + final String description; + final String url; + final int? viewCount; + + /// Channel name + final String channelTitle; + final String channelId; + + /// Cover image < 120px width + final String iconUrl; + + /// Cover image < 32px width + final String thumbnailUrl; + + /// Cover image < 48px width + final String previewUrl; + + /// ISO 8601 format time + final String publishDate; + final Duration duration; + + /// ISO 8601 format time + final List history; + + /// Search friendly string + final String romanizedMetadata; +} diff --git a/lib/page/history.dart b/lib/page/history.dart index 12111f9..77281a5 100644 --- a/lib/page/history.dart +++ b/lib/page/history.dart @@ -7,6 +7,7 @@ import 'package:youtube_api/youtube_api.dart'; import '../../intent.dart'; import '../helper/command_parser.dart'; +import '../model/database.dart'; import '../widget/keyboard_navigation.dart'; import '../widget/list_items/list_item.dart'; @@ -19,8 +20,8 @@ class HistoryPage extends StatefulWidget { class _HistoryPageState extends State { final FocusNode searchBarFocus = FocusNode(); - List? filteredList; - List? historyList; + List? filteredList; + List? historyList; late final Map> _actionMap = { SearchBarFocusIntent: CallbackAction( onInvoke: (final _) => _requestSearchBarFocus(), @@ -40,13 +41,10 @@ class _HistoryPageState extends State { } Future fetchHistory() async { - final prefs = await SharedPreferences.getInstance(); + final database = await HistoryDatabase.load(); setState(() { - historyList = prefs - .getStringList('history') - ?.map((final e) => YoutubeVideo.fromString(e)) - .toList(); + historyList = database.history; filteredList = historyList; }); } @@ -91,26 +89,25 @@ class _HistoryPageState extends State { filteredList![filteredList!.length - index - 1]; final title = youtubeVideo.title.parseHtmlEntities(); - final listItem = switch (youtubeVideo.kind) { - 'video' => ListItemVideo( + final listItem = switch (youtubeVideo.type) { + ItemType.video => ListItemVideo( title: title, channelTitle: youtubeVideo.channelTitle, description: youtubeVideo.description, - duration: youtubeVideo.duration!, - thumbnailUrl: youtubeVideo.thumbnail.medium.url, - publishedAt: youtubeVideo.publishedAt, + duration: youtubeVideo.duration.toString(), + thumbnailUrl: youtubeVideo.thumbnailUrl, + publishedAt: youtubeVideo.publishDate, ), - 'channel' => ListItemChannel( + ItemType.channel => ListItemChannel( channelTitle: youtubeVideo.channelTitle, - thumbnailUrl: youtubeVideo.thumbnail.medium.url, + thumbnailUrl: youtubeVideo.thumbnailUrl, ), - 'playlist' => ListItemPlaylist( + ItemType.playlist => ListItemPlaylist( title: title, channelTitle: youtubeVideo.channelTitle, description: youtubeVideo.description, - thumbnailUrl: youtubeVideo.thumbnail.medium.url, + thumbnailUrl: youtubeVideo.thumbnailUrl, ), - _ => const SizedBox.shrink() }; return ListItem( diff --git a/lib/util/csv_parser.dart b/lib/util/csv_parser.dart new file mode 100644 index 0000000..2405b4d --- /dev/null +++ b/lib/util/csv_parser.dart @@ -0,0 +1,42 @@ +/// Not exactly RFC 4810 +class CsvParser { + const CsvParser({this.separator = ';'}); + final String separator; + + static List _splitLine(final String line) { + var buff = ['']; + var inQuotationMark = false; + + for (var i = 0; i < line.length; i++) { + if (!inQuotationMark) { + if (line[i] == ',') { + buff = [...buff, '']; + continue; + } + } + if (line[i] == '"') { + inQuotationMark = !inQuotationMark; + continue; + } + buff.last += line[i]; + } + return buff; + } + + List> parse(final List csv) { + var lineNumber = 0; + late final List header; + final content = >[]; + + csv.forEach((final line) { + if (lineNumber == 0) { + header = _splitLine(line); + } else { + content.add(_splitLine(line)); + } + lineNumber++; + }); + + return content.map((final e) => Map.fromIterables(header, e)).toList(); + } +} diff --git a/test/csv_parser_test.dart b/test/csv_parser_test.dart new file mode 100644 index 0000000..52062b3 --- /dev/null +++ b/test/csv_parser_test.dart @@ -0,0 +1,37 @@ +import 'package:test/test.dart'; +import 'package:yatta/util/csv_parser.dart'; + +const String csv = ''' +id,type,provider,title,description,url,viewCount,channelId,channelTitle,iconUrl,thumbnailUrl,previewUrl,publishDate,duration,history,romanized +W2muWA-40Uk,video,youtube,三月のパンタシア 『夜光』,三月のパンタシア-夜光 小説「さよならの空はあの青い花の輝きとよく似ていた」(みあ著)主題歌 ...,https://youtu.be/W2muWA-40Uk,,UC4lk0Ob-F3ptOQUUq8s0pzQ,三月のパンタシア Official YouTube Channel,https://i.ytimg.com/vi/W2muWA-40Uk/default.jpg,https://i.ytimg.com/vi/W2muWA-40Uk/mqdefault.jpg,https://i.ytimg.com/vi/W2muWA-40Uk/hqdefault.jpg,2021-07-21T13:00:10Z,03:40,"2021-07-21T13:00:10Z,2022-07-21T13:00:10Z",sangatsu no phantasia yakou sangatsu no phantasia yakou sousetsu sayonara no sora wa ano aoi hana no kagayaki to yoku nite ita mia cho shudaika'''; + +void main() { + group('Parse history', () { + const parsedCsv = [ + { + 'id': 'W2muWA-40Uk', + 'type': 'video', + 'provider': 'youtube', + 'title': '三月のパンタシア 『夜光』', + 'description': '三月のパンタシア-夜光 小説「さよならの空はあの青い花の輝きとよく似ていた」(みあ著)主題歌 ...', + 'url': 'https://youtu.be/W2muWA-40Uk', + 'viewCount': '', + 'channelId': 'UC4lk0Ob-F3ptOQUUq8s0pzQ', + 'channelTitle': '三月のパンタシア Official YouTube Channel', + 'iconUrl': 'https://i.ytimg.com/vi/W2muWA-40Uk/default.jpg', + 'thumbnailUrl': 'https://i.ytimg.com/vi/W2muWA-40Uk/mqdefault.jpg', + 'previewUrl': 'https://i.ytimg.com/vi/W2muWA-40Uk/hqdefault.jpg', + 'publishDate': '2021-07-21T13:00:10Z', + 'duration': '03:40', + 'history': '2021-07-21T13:00:10Z,2022-07-21T13:00:10Z', + 'romanized': ''' +sangatsu no phantasia yakou sangatsu no phantasia yakou sousetsu sayonara no sora wa ano aoi hana no kagayaki to yoku nite ita mia cho shudaika''' + } + ]; + + test( + 'history', + () => expect(const CsvParser().parse(csv.split('\n')), parsedCsv), + ); + }); +} diff --git a/test/helper_test.dart b/test/helper_test.dart index be653aa..b9a0b8a 100644 --- a/test/helper_test.dart +++ b/test/helper_test.dart @@ -1,5 +1,5 @@ import 'package:test/test.dart'; -import 'package:yatta/helper.dart'; +import 'package:yatta/helper/command_parser.dart'; void main() { group('All available', () { @@ -48,8 +48,8 @@ void main() { ]; const parsedNotifySend = [ 'notify-send', - '"Playing video"', - '"This is a title\\nThis is a description"', + 'Playing video', + 'This is a title\\nThis is a description', ]; final helper = (final String command) { @@ -101,7 +101,7 @@ void main() { 'background=#1f1f1f' ]; const parsedCurl = ['curl', '--output', '/tmp/notifyicon.png']; - const parsedNotifySend = ['notify-send', '"Playing video"', '"\\n"']; + const parsedNotifySend = ['notify-send', 'Playing video', '\\n']; test('xwinwrap', () => expect(parseCommand(xwinwrap), parsedXwinwrap)); test('kitty', () => expect(parseCommand(kitty), parsedKitty));