/// Minimal GitHub Contents API client, ported verbatim from `~/todo`'s /// `sync/github_client.dart` -- it is already generic over owner/repo/token /// and has nothing todo-specific to strip. library; import 'dart:convert'; import 'package:http/http.dart' as http; /// A file entry in a GitHub repository directory listing. class GitHubFile { /// Creates a [GitHubFile] from its name/path/sha. const GitHubFile({ required this.name, required this.path, required this.sha, }); /// The entry's bare file name (no path prefix). final String name; /// The entry's full repo-relative path. final String path; /// Git blob SHA; required to update or delete the file. final String sha; } /// Raised when the GitHub API returns an unexpected status. class GitHubApiException implements Exception { /// Creates an exception for the given [statusCode]/[message]. GitHubApiException(this.statusCode, this.message); /// The HTTP status code returned by the API. final int statusCode; /// A human-readable description of what failed. final String message; @override String toString() => 'GitHubApiException($statusCode): $message'; } /// Minimal GitHub REST client scoped to the Contents API. /// /// This is the only component that holds the access token, mirroring the /// "server holds credentials" pattern: the rest of the app deals in food /// entries and merged logs, never in raw HTTP or secrets. class GitHubClient { /// Creates a client scoped to one repo, authenticated with [token]. GitHubClient({ required this.owner, required this.repo, required String token, http.Client? httpClient, this.branch = 'main', }) // Dart forbids private named params, so this can't be an initializing // formal; assign it explicitly. // ignore: prefer_initializing_formals : _token = token, _http = httpClient ?? http.Client(); /// The repo owner/org (e.g. `"kuhyx"`). final String owner; /// The repo name (e.g. `"diet-guard-sync"`). final String repo; /// The branch to read/write against. final String branch; final String _token; final http.Client _http; static const _apiBase = 'https://api.github.com'; Map get _headers => { 'Authorization': 'Bearer $_token', 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'diet-guard-app-sync', }; Uri _contentsUri(String path) => Uri.parse('$_apiBase/repos/$owner/$repo/contents/$path'); /// Lists the files directly under [dirPath]. Returns an empty list if the /// directory does not exist yet (e.g. before the first sync). Future> listDirectory(String dirPath) async { final res = await _http.get( _contentsUri(dirPath).replace(queryParameters: {'ref': branch}), headers: _headers, ); if (res.statusCode == 404) return []; _ensureOk(res, 'list $dirPath'); final decoded = jsonDecode(res.body); if (decoded is! List) return []; return decoded .cast>() .where((e) => e['type'] == 'file') .map( (e) => GitHubFile( name: e['name'] as String, path: e['path'] as String, sha: e['sha'] as String, ), ) .toList(); } /// Lists the names of every entry (file *or* directory) directly under /// [dirPath]. Returns an empty list if the directory does not exist yet. /// /// Unlike [listDirectory] (files-only, for a flat layout), this also /// surfaces subdirectory names -- needed for diet_guard's per-device /// `devices//food_log.json` layout, where each device id is itself a /// directory one level above its file. Future> listEntryNames(String dirPath) async { final res = await _http.get( _contentsUri(dirPath).replace(queryParameters: {'ref': branch}), headers: _headers, ); if (res.statusCode == 404) return []; _ensureOk(res, 'list $dirPath'); final decoded = jsonDecode(res.body); if (decoded is! List) return []; return decoded .cast>() .map((e) => e['name'] as String) .toList(); } /// Fetches and UTF-8-decodes a file's contents. Returns null if absent. Future getFileText(String path) async { final res = await _http.get( _contentsUri(path).replace(queryParameters: {'ref': branch}), headers: _headers, ); if (res.statusCode == 404) return null; _ensureOk(res, 'get $path'); final json = jsonDecode(res.body) as Map; // GitHub base64-encodes file content, wrapping lines at 60 chars. final raw = (json['content'] as String).replaceAll('\n', ''); return utf8.decode(base64.decode(raw)); } /// Creates or updates [path] with [text]. Pass the current [sha] when /// updating an existing file; omit it to create a new one. Future putFileText( String path, String text, { String? sha, String? message, }) async { final body = { 'message': message ?? 'sync: update $path', 'content': base64.encode(utf8.encode(text)), 'branch': branch, 'sha': ?sha, }; final res = await _http.put( _contentsUri(path), headers: _headers, body: jsonEncode(body), ); _ensureOk(res, 'put $path'); } /// Deletes the file at [path] (requires its current [sha]). Future deleteFile(String path, String sha, {String? message}) async { final res = await _http.delete( _contentsUri(path), headers: _headers, body: jsonEncode({ 'message': message ?? 'sync: delete $path', 'sha': sha, 'branch': branch, }), ); _ensureOk(res, 'delete $path'); } /// Cheap auth/connectivity probe used by the settings "Test connection" /// button: succeeds only if the token can read the repo. Future canAccessRepo() async { final res = await _http.get( Uri.parse('$_apiBase/repos/$owner/$repo'), headers: _headers, ); return res.statusCode == 200; } /// Closes the underlying HTTP client. void close() => _http.close(); void _ensureOk(http.Response res, String action) { if (res.statusCode < 200 || res.statusCode >= 300) { throw GitHubApiException( res.statusCode, 'Failed to $action: ${res.body}', ); } } }