mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-07-04 14:43:13 +02:00
Work-in-progress feature set accumulated ahead of the log-meal compact layout change: a food bank browser, a shared entry-edit screen, an app-wide settings service, and a substantially reworked history screen with filtering/sorting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UorgLvWJ4huH55tmXoUAZ
204 lines
6.3 KiB
Dart
204 lines
6.3 KiB
Dart
/// 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<String, String> 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<List<GitHubFile>> 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<Map<String, dynamic>>()
|
|
.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/<id>/food_log.json` layout, where each device id is itself a
|
|
/// directory one level above its file.
|
|
Future<List<String>> 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<String, dynamic>>()
|
|
.map((e) => e['name'] as String)
|
|
.toList();
|
|
}
|
|
|
|
/// Fetches and UTF-8-decodes a file's contents. Returns null if absent.
|
|
Future<String?> 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<String, dynamic>;
|
|
// 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<void> putFileText(
|
|
String path,
|
|
String text, {
|
|
String? sha,
|
|
String? message,
|
|
}) async {
|
|
final body = <String, dynamic>{
|
|
'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<void> 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<bool> 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}',
|
|
);
|
|
}
|
|
}
|
|
}
|