Files
Avtex/lib/core/gitea_sync_service.dart
T

322 lines
9.3 KiB
Dart

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<String, dynamic> 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<void> initialize() async {
final prefs = await SharedPreferences.getInstance();
_baseUrl = prefs.getString(_prefsBaseUrlKey);
_token = prefs.getString(_prefsTokenKey);
_owner = prefs.getString(_prefsOwnerKey);
_repo = prefs.getString(_prefsRepoKey);
}
/// Настройка подключения
Future<void> 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<bool> testConnection() async {
if (!isConfigured) return false;
try {
final response = await _get('/api/v1/user');
return response.statusCode == 200;
} catch (e) {
return false;
}
}
/// Получить информацию о текущем пользователе
Future<Map<String, dynamic>?> getCurrentUser() async {
final response = await _get('/api/v1/user');
if (response.statusCode == 200) {
return jsonDecode(response.body) as Map<String, dynamic>;
}
return null;
}
// ==================== REPOSITORY CONTENTS ====================
/// Получить список файлов в директории репозитория
Future<List<GiteaFile>> 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<dynamic> data = jsonDecode(response.body);
return data
.map((e) => GiteaFile.fromJson(e as Map<String, dynamic>))
.toList();
}
/// Получить содержимое одного файла
Future<GiteaFile> 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<String, dynamic>;
return GiteaFile.fromJson(data);
}
/// Обновить/создать файл в репозитории
Future<void> 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 = <String, dynamic>{
'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<void> 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<List<GiteaFile>> 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<String, dynamic>;
final List<dynamic> 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<Map<String, dynamic>?> 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<dynamic> data = jsonDecode(response.body);
if (data.isNotEmpty) return data.first as Map<String, dynamic>;
}
return null;
}
/// Получить историю коммитов
Future<List<Map<String, dynamic>>> 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<dynamic> data = jsonDecode(response.body);
return data.cast<Map<String, dynamic>>();
}
return [];
}
// ==================== HELPERS ====================
Map<String, String> _getHeaders() {
return {
'Content-Type': 'application/json',
'Authorization': 'token $_token',
};
}
Future<http.Response> _get(String path) async {
final uri = Uri.parse('$_baseUrl$path');
return http.get(uri, headers: _getHeaders());
}
Future<http.Response> _put(String path,
{required Map<String, dynamic> body}) async {
final uri = Uri.parse('$_baseUrl$path');
return http.put(
uri,
headers: _getHeaders(),
body: jsonEncode(body),
);
}
Future<http.Response> _delete(String path,
{required Map<String, dynamic> 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));
}
}