import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:preferences/preferences.dart'; import 'package:localstorage/localstorage.dart'; import 'package:yaml/yaml.dart'; import 'package:toolheim/data/warband_roster.dart'; class GitHubAdapter extends ChangeNotifier { final LocalStorage storage = new LocalStorage('rosters'); List _syncErrors = new List(); DateTime _lastSync; List _rosters = []; String _activeRosterFilePath; String get repository => PrefService.getString('repository'); String get path => PrefService.getString('path'); bool _syncInProgress = false; bool get isSyncInProgress => _syncInProgress; DateTime get lastSync => _lastSync; List get syncErrors => _syncErrors; List get rosters => _rosters; WarbandRoster get activeRoster { if (_rosters.length == 0) { return null; } if (_activeRosterFilePath == null) { return _rosters.first; } return _rosters.firstWhere( (roster) => roster.filePath == _activeRosterFilePath, orElse: () => _rosters.first); } set activeRoster(WarbandRoster roster) { _activeRosterFilePath = roster.filePath; notifyListeners(); } GitHubAdapter() { load(); } /// Search for warband files in the GitHub repository /// /// This method will search for matching files and check their content in a /// subfolder (see fields [repository] and [path]). If the file /// contain errors or can't read, a sync error message will be written into /// the [syncErrors] list. Future search() async { Stream warbandFileStream() async* { // Get all files which could be potential warband files (end with // mordheim.yml and contain the word "heros"). // TODO: Check rate limit // TODO: Extract github access to separate class http.Response response = await http.get( "https://api.github.com/search/code?q=heros+repo:" + repository + "+filename:mordheim.yml+path:\"" + path + "\""); // GitHub is not reachable if (response.statusCode != 200) { _syncErrors.add('Could not find any warband roster files.'); yield null; return; } // No valid response from GitHub dynamic searchResults; try { searchResults = jsonDecode(response.body); } on FormatException catch (e) { _syncErrors.add('Could not parse GitHub response. ' + e.toString()); yield null; return; } // Find suitable files for examination RegExp fileRegex = new RegExp(r"[a-zA-Z]+\.mordheim\.ya?ml"); for (dynamic searchResult in searchResults['items']) { if (fileRegex.hasMatch(searchResult['name'])) { yield searchResult['path'].toString(); } } } _syncErrors.clear(); _rosters.clear(); _syncInProgress = true; notifyListeners(); await for (String filePath in warbandFileStream()) { WarbandRoster roster = await fetchWarband(filePath); Version latestVersion = await getLatestVersion(filePath); if (roster != null && latestVersion != null) { roster.playerName = getPlayerNameFromFilePath(filePath); roster.version = latestVersion; roster.filePath = filePath; _rosters.add(roster); notifyListeners(); } } // Sort by CP _rosters.sort((a, b) => b.campaignPoints.compareTo(a.campaignPoints)); _lastSync = DateTime.now(); _syncInProgress = false; await save(); notifyListeners(); } Future update() async { _syncInProgress = true; _syncErrors.clear(); notifyListeners(); for (var i = 0; i < rosters.length; i++) { Version newVersion = await getLatestVersion(rosters[i].filePath); // File does not exist any more, we remove the roster if (newVersion == null) { rosters.removeAt(i); notifyListeners(); continue; } // New version found, so we fetch the updated roster if (newVersion.gitHash != rosters[i].version.gitHash) { WarbandRoster newRoster = await fetchWarband(rosters[i].filePath); if (newRoster == null) { continue; } newRoster.playerName = rosters[i].playerName; newRoster.version = newVersion; newRoster.filePath = rosters[i].filePath; rosters[i] = newRoster; notifyListeners(); } } _rosters.sort((a, b) => b.campaignPoints.compareTo(a.campaignPoints)); _lastSync = DateTime.now(); _syncInProgress = false; await save(); notifyListeners(); } String getPlayerNameFromFilePath(String filePath) { // We try to get the name of the player from the name of the folder // in which the file resists String cleanedPath = path; if (path.startsWith('/')) { cleanedPath = cleanedPath.substring(1); } if (cleanedPath.endsWith('/')) { cleanedPath = cleanedPath.substring(0, cleanedPath.length - 1); } List pathParts = filePath.substring(cleanedPath.length + 1).split('/'); String playerName = 'Lonely Recluse'; if (pathParts.length >= 2) { playerName = pathParts.first; } return playerName; } Future getLatestVersion(String filePath) async { // Fetch last change and some metainformation of the file http.Response response = await http.get("https://api.github.com/repos/" + repository + "/commits?path=" + filePath); if (response.statusCode != 200) { _syncErrors .add(filePath + ': Could not load the warband metadata from GitHub.'); notifyListeners(); return null; } // No valid response from GitHub dynamic commits; try { commits = jsonDecode(response.body); } on FormatException catch (e) { _syncErrors .add(filePath + ': Could not parse GitHub response. ' + e.toString()); notifyListeners(); return null; } // No commits available if (commits.length == 0) { return null; } dynamic latestCommit = commits.first; return new Version( latestCommit['sha'], latestCommit['commit']['committer']['date'], latestCommit['commit']['author']['name'], latestCommit['commit']['message']); } Future fetchWarband(String filePath) async { http.Response response; try { response = await http.get("https://raw.githubusercontent.com/" + repository + '/master/' + filePath); } catch (e) { // We ignore this error, because it will handle from the _syncErrors // later (see below). } if (response == null) { return null; } try { YamlMap yamlObject = loadYaml(response.body); return WarbandRoster.fromJson(yamlObject); } catch (e) { _syncErrors.add(filePath + ': ' + e.message); notifyListeners(); } return null; } Future save() async { await storage.clear(); await storage.setItem('lastSync', _lastSync.toIso8601String()); await storage.setItem('rosters', _rosters); await storage.setItem('activeRosterFilePath', _activeRosterFilePath); } Future load() async { await storage.ready; String lastSync = storage.getItem('lastSync'); if (lastSync != null) { _lastSync = DateTime.parse(lastSync); } _activeRosterFilePath = storage.getItem('activeRosterFilePath'); List rosters = storage.getItem('rosters'); if (rosters != null) { rosters.forEach((warband) { _rosters.add(WarbandRoster.fromJson(warband)); }); } } }