import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; /// Модель файла из Gitea Contents API class GiteaFile { final String name; final String path; final String type; // 'file' | 'dir' final String? sha; final String? content; // base64 encoded final int? size; final String? downloadUrl; GiteaFile({ required this.name, required this.path, required this.type, this.sha, this.content, this.size, this.downloadUrl, }); factory GiteaFile.fromJson(Map json) { return GiteaFile( name: json['name'] ?? '', path: json['path'] ?? '', type: json['type'] ?? 'file', sha: json['sha'], content: json['content'], size: json['size'], downloadUrl: json['download_url'], ); } /// Декодирует base64 content (Gitea возвращает base64 с переносами) String? get decodedContent { if (content == null || content!.isEmpty) return null; try { final clean = content!.replaceAll('\n', ''); return utf8.decode(base64Decode(clean)); } catch (e) { return null; } } } /// Сервис прямой синхронизации с Gitea REST API /// Используется в мобильном приложении вместо Obsidian Git plugin class GiteaSyncService { static final GiteaSyncService _instance = GiteaSyncService._internal(); factory GiteaSyncService() => _instance; GiteaSyncService._internal(); String? _baseUrl; String? _token; String? _owner; String? _repo; static const String _prefsBaseUrlKey = 'gitea_base_url'; static const String _prefsTokenKey = 'gitea_token'; static const String _prefsOwnerKey = 'gitea_owner'; static const String _prefsRepoKey = 'gitea_repo'; /// Инициализация из SharedPreferences Future initialize() async { final prefs = await SharedPreferences.getInstance(); _baseUrl = prefs.getString(_prefsBaseUrlKey); _token = prefs.getString(_prefsTokenKey); _owner = prefs.getString(_prefsOwnerKey); _repo = prefs.getString(_prefsRepoKey); } /// Настройка подключения Future configure({ required String baseUrl, required String token, required String owner, required String repo, }) async { _baseUrl = baseUrl.replaceAll(RegExp(r'/+$'), ''); _token = token; _owner = owner; _repo = repo; final prefs = await SharedPreferences.getInstance(); await prefs.setString(_prefsBaseUrlKey, _baseUrl!); await prefs.setString(_prefsTokenKey, _token!); await prefs.setString(_prefsOwnerKey, _owner!); await prefs.setString(_prefsRepoKey, _repo!); } bool get isConfigured => _baseUrl != null && _token != null && _owner != null && _repo != null; // ==================== AUTH / TEST ==================== /// Проверка соединения и валидности токена Future testConnection() async { if (!isConfigured) return false; try { final response = await _get('/api/v1/user'); return response.statusCode == 200; } catch (e) { return false; } } /// Получить информацию о текущем пользователе Future?> getCurrentUser() async { final response = await _get('/api/v1/user'); if (response.statusCode == 200) { return jsonDecode(response.body) as Map; } return null; } // ==================== REPOSITORY CONTENTS ==================== /// Получить список файлов в директории репозитория Future> listFiles({ String path = '', String branch = 'main', }) async { final response = await _get( '/api/v1/repos/$_owner/$_repo/contents/$path?ref=$branch', ); if (response.statusCode != 200) { throw Exception( 'Failed to list files: ${response.statusCode} ${response.body}'); } final List data = jsonDecode(response.body); return data .map((e) => GiteaFile.fromJson(e as Map)) .toList(); } /// Получить содержимое одного файла Future getFile({ required String path, String branch = 'main', }) async { final response = await _get( '/api/v1/repos/$_owner/$_repo/contents/$path?ref=$branch', ); if (response.statusCode != 200) { throw Exception( 'Failed to get file: ${response.statusCode} ${response.body}'); } final data = jsonDecode(response.body) as Map; return GiteaFile.fromJson(data); } /// Обновить/создать файл в репозитории Future updateFile({ required String path, required String content, required String commitMessage, String branch = 'main', String? authorName, String? authorEmail, }) async { // Получаем текущий sha если файл существует String? currentSha; try { final existing = await getFile(path: path, branch: branch); currentSha = existing.sha; } catch (_) { // файл не существует — создаём новый } final body = { 'content': base64Encode(utf8.encode(content)), 'message': commitMessage, 'branch': branch, }; if (currentSha != null) { body['sha'] = currentSha; } if (authorName != null && authorEmail != null) { body['author'] = { 'name': authorName, 'email': authorEmail, }; } final response = await _put( '/api/v1/repos/$_owner/$_repo/contents/$path', body: body, ); if (response.statusCode != 200 && response.statusCode != 201) { throw Exception( 'Failed to update file: ${response.statusCode} ${response.body}'); } } /// Удалить файл из репозитория Future deleteFile({ required String path, required String commitMessage, String branch = 'main', }) async { final existing = await getFile(path: path, branch: branch); final response = await _delete( '/api/v1/repos/$_owner/$_repo/contents/$path', body: { 'message': commitMessage, 'sha': existing.sha, 'branch': branch, }, ); if (response.statusCode != 200 && response.statusCode != 204) { throw Exception( 'Failed to delete file: ${response.statusCode} ${response.body}'); } } // ==================== GIT OPERATIONS ==================== /// Получить дерево файлов (рекурсивно) Future> getTree({ String branch = 'main', bool recursive = true, }) async { final response = await _get( '/api/v1/repos/$_owner/$_repo/git/trees/$branch?recursive=${recursive ? 1 : 0}', ); if (response.statusCode != 200) { throw Exception('Failed to get tree: ${response.statusCode}'); } final data = jsonDecode(response.body) as Map; final List tree = data['tree'] ?? []; return tree .where((e) => e['type'] == 'blob') .map((e) => GiteaFile( name: e['path'].toString().split('/').last, path: e['path'] ?? '', type: 'file', sha: e['sha'], size: e['size'], )) .toList(); } /// Получить последний коммит ветки Future?> getLatestCommit( {String branch = 'main'}) async { final response = await _get( '/api/v1/repos/$_owner/$_repo/commits?sha=$branch&limit=1', ); if (response.statusCode == 200) { final List data = jsonDecode(response.body); if (data.isNotEmpty) return data.first as Map; } return null; } /// Получить историю коммитов Future>> getCommits({ String branch = 'main', int page = 1, int limit = 30, }) async { final response = await _get( '/api/v1/repos/$_owner/$_repo/commits?sha=$branch&page=$page&limit=$limit', ); if (response.statusCode == 200) { final List data = jsonDecode(response.body); return data.cast>(); } return []; } // ==================== HELPERS ==================== Map _getHeaders() { return { 'Content-Type': 'application/json', 'Authorization': 'token $_token', }; } Future _get(String path) async { final uri = Uri.parse('$_baseUrl$path'); return http.get(uri, headers: _getHeaders()); } Future _put(String path, {required Map body}) async { final uri = Uri.parse('$_baseUrl$path'); return http.put( uri, headers: _getHeaders(), body: jsonEncode(body), ); } Future _delete(String path, {required Map body}) async { final uri = Uri.parse('$_baseUrl$path'); final request = http.Request('DELETE', uri) ..headers.addAll(_getHeaders()) ..body = jsonEncode(body); return http.Client().send(request).then((r) => http.Response.fromStream(r)); } }