Clean up: move docs to Obsidian vault, remove outdated md files
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../models/mind_map_model.dart';
|
||||
|
||||
/// Парсер Markdown-файлов в MindMapNode структуру
|
||||
///
|
||||
/// Ожидаемая структура markdown:
|
||||
/// ```markdown
|
||||
/// ---
|
||||
/// title: Название карты
|
||||
/// tags: [crm, project]
|
||||
/// ---
|
||||
///
|
||||
/// # Корневой узел
|
||||
/// Описание корневого узла
|
||||
///
|
||||
/// ## Дочерний узел 1
|
||||
/// Описание
|
||||
///
|
||||
/// ### Внук 1
|
||||
///
|
||||
/// ## Дочерний узел 2
|
||||
/// ```
|
||||
class MarkdownMindmapParser {
|
||||
static const Uuid _uuid = Uuid();
|
||||
|
||||
/// Парсит markdown строку в дерево MindMapNode
|
||||
/// Возвращает корневой узел + все дочерние
|
||||
static List<MindMapNode> parse(String markdown, String projectId) {
|
||||
final lines = markdown.split('\n');
|
||||
final nodes = <MindMapNode>[];
|
||||
final now = DateTime.now();
|
||||
|
||||
String? currentParentId;
|
||||
final Map<int, String> levelToParentId = {};
|
||||
|
||||
for (final line in lines) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isEmpty) continue;
|
||||
|
||||
// Пропускаем frontmatter
|
||||
if (trimmed == '---') continue;
|
||||
if (trimmed.startsWith('title:') ||
|
||||
trimmed.startsWith('tags:') ||
|
||||
trimmed.startsWith('date:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Определяем уровень заголовка
|
||||
final headerMatch = _headerRegex.firstMatch(trimmed);
|
||||
if (headerMatch != null) {
|
||||
final hashes = headerMatch.group(1)!;
|
||||
final level = hashes.length;
|
||||
final title = trimmed.substring(hashes.length).trim();
|
||||
|
||||
final id = _uuid.v4();
|
||||
|
||||
// Определяем parentId на основе уровня
|
||||
if (level == 1) {
|
||||
// Корневой узел
|
||||
currentParentId = null;
|
||||
levelToParentId.clear();
|
||||
} else {
|
||||
// Ищем ближайший родитель с уровнем меньше текущего
|
||||
String? parentId;
|
||||
for (int l = level - 1; l >= 1; l--) {
|
||||
if (levelToParentId.containsKey(l)) {
|
||||
parentId = levelToParentId[l];
|
||||
break;
|
||||
}
|
||||
}
|
||||
currentParentId = parentId;
|
||||
}
|
||||
|
||||
levelToParentId[level] = id;
|
||||
|
||||
nodes.add(MindMapNode(
|
||||
id: id,
|
||||
projectId: projectId,
|
||||
parentId: currentParentId,
|
||||
level: level,
|
||||
title: title,
|
||||
color: _levelColor(level),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Если нет ни одного заголовка — создаём один корневой узел с текстом
|
||||
if (nodes.isEmpty && markdown.trim().isNotEmpty) {
|
||||
final firstLine = markdown.trim().split('\n').first.trim();
|
||||
nodes.add(MindMapNode(
|
||||
id: _uuid.v4(),
|
||||
projectId: projectId,
|
||||
parentId: null,
|
||||
level: 0,
|
||||
title: firstLine.length > 50 ? '${firstLine.substring(0, 50)}...' : firstLine,
|
||||
color: _levelColor(0),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/// Конвертирует дерево MindMapNode обратно в Markdown
|
||||
static String toMarkdown(List<MindMapNode> nodes, {String? title}) {
|
||||
if (nodes.isEmpty) return '';
|
||||
|
||||
final buffer = StringBuffer();
|
||||
|
||||
// Frontmatter
|
||||
buffer.writeln('---');
|
||||
buffer.writeln('title: ${title ?? 'Mindmap'}');
|
||||
buffer.writeln('date: ${DateTime.now().toIso8601String()}');
|
||||
buffer.writeln('---');
|
||||
buffer.writeln();
|
||||
|
||||
// Сортируем по уровню, затем по порядку
|
||||
final sorted = List<MindMapNode>.from(nodes)
|
||||
..sort((a, b) {
|
||||
final levelCompare = a.level.compareTo(b.level);
|
||||
if (levelCompare != 0) return levelCompare;
|
||||
return a.createdAt.compareTo(b.createdAt);
|
||||
});
|
||||
|
||||
for (final node in sorted) {
|
||||
final hashes = '#' * node.level.clamp(1, 6);
|
||||
buffer.writeln('$hashes ${node.title}');
|
||||
if (node.description != null && node.description!.isNotEmpty) {
|
||||
buffer.writeln(node.description);
|
||||
}
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
return buffer.toString().trim();
|
||||
}
|
||||
|
||||
/// Извлекает plain text описание из markdown (без заголовков)
|
||||
static String? extractDescription(String markdown) {
|
||||
final lines = markdown.split('\n');
|
||||
final buffer = StringBuffer();
|
||||
bool inFrontmatter = false;
|
||||
|
||||
for (final line in lines) {
|
||||
final trimmed = line.trim();
|
||||
|
||||
if (trimmed == '---') {
|
||||
inFrontmatter = !inFrontmatter;
|
||||
continue;
|
||||
}
|
||||
if (inFrontmatter) continue;
|
||||
if (trimmed.startsWith('#')) continue;
|
||||
|
||||
if (trimmed.isNotEmpty) {
|
||||
buffer.writeln(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
final result = buffer.toString().trim();
|
||||
return result.isEmpty ? null : result;
|
||||
}
|
||||
|
||||
/// Извлекает wiki-ссылки [[Link]] из текста
|
||||
static List<String> extractWikiLinks(String text) {
|
||||
final matches = _wikiLinkRegex.allMatches(text);
|
||||
return matches.map((m) => m.group(1)!).toList();
|
||||
}
|
||||
|
||||
// ==================== PRIVATE ====================
|
||||
|
||||
static final RegExp _headerRegex = RegExp(r'^(#{1,6})\s+(.+)$');
|
||||
static final RegExp _wikiLinkRegex = RegExp(r'\[\[([^\]]+)\]\]');
|
||||
|
||||
static String _levelColor(int level) {
|
||||
switch (level) {
|
||||
case 0:
|
||||
return '#E53935'; // red
|
||||
case 1:
|
||||
return '#1E88E5'; // blue
|
||||
case 2:
|
||||
return '#43A047'; // green
|
||||
case 3:
|
||||
return '#FB8C00'; // orange
|
||||
case 4:
|
||||
return '#8E24AA'; // purple
|
||||
case 5:
|
||||
return '#00ACC1'; // cyan
|
||||
default:
|
||||
return '#78909C'; // grey
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import 'dart:convert';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
/// Hive модель для кэшированного mindmap-файла
|
||||
class CachedMindmap {
|
||||
final String path;
|
||||
final String content;
|
||||
final String? sha;
|
||||
final DateTime lastSync;
|
||||
final DateTime lastModified;
|
||||
final String branch;
|
||||
|
||||
CachedMindmap({
|
||||
required this.path,
|
||||
required this.content,
|
||||
this.sha,
|
||||
required this.lastSync,
|
||||
required this.lastModified,
|
||||
this.branch = 'main',
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'path': path,
|
||||
'content': content,
|
||||
'sha': sha,
|
||||
'lastSync': lastSync.toIso8601String(),
|
||||
'lastModified': lastModified.toIso8601String(),
|
||||
'branch': branch,
|
||||
};
|
||||
|
||||
factory CachedMindmap.fromJson(Map<String, dynamic> json) {
|
||||
return CachedMindmap(
|
||||
path: json['path'] ?? '',
|
||||
content: json['content'] ?? '',
|
||||
sha: json['sha'],
|
||||
lastSync:
|
||||
DateTime.parse(json['lastSync'] ?? DateTime.now().toIso8601String()),
|
||||
lastModified: DateTime.parse(
|
||||
json['lastModified'] ?? DateTime.now().toIso8601String()),
|
||||
branch: json['branch'] ?? 'main',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Оффлайн репозиторий для mindmaps
|
||||
/// Хранит кэш файлов из Gitea и очередь изменений для синхронизации
|
||||
class OfflineMindmapRepository {
|
||||
static const String _boxName = 'mindmaps_cache';
|
||||
static const String _queueBoxName = 'mindmaps_queue';
|
||||
static const String _metaKey = '_meta_sync_state';
|
||||
|
||||
Box<String>? _box;
|
||||
Box<String>? _queueBox;
|
||||
|
||||
Future<void> initialize() async {
|
||||
_box = await Hive.openBox<String>(_boxName);
|
||||
_queueBox = await Hive.openBox<String>(_queueBoxName);
|
||||
}
|
||||
|
||||
bool get isInitialized => _box != null && _box!.isOpen;
|
||||
|
||||
// ==================== CACHE ====================
|
||||
|
||||
/// Сохранить файл в кэш
|
||||
Future<void> cacheMindmap(CachedMindmap mindmap) async {
|
||||
if (!isInitialized) throw Exception('Repository not initialized');
|
||||
await _box!.put(mindmap.path, jsonEncode(mindmap.toJson()));
|
||||
}
|
||||
|
||||
/// Получить файл из кэша
|
||||
CachedMindmap? getMindmap(String path) {
|
||||
if (!isInitialized) return null;
|
||||
final raw = _box!.get(path);
|
||||
if (raw == null) return null;
|
||||
try {
|
||||
return CachedMindmap.fromJson(jsonDecode(raw));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Получить все кэшированные mindmaps
|
||||
List<CachedMindmap> getAllMindmaps() {
|
||||
if (!isInitialized) return [];
|
||||
return _box!.values
|
||||
.map((raw) {
|
||||
try {
|
||||
return CachedMindmap.fromJson(jsonDecode(raw));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whereType<CachedMindmap>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Удалить файл из кэша
|
||||
Future<void> deleteMindmap(String path) async {
|
||||
if (!isInitialized) return;
|
||||
await _box!.delete(path);
|
||||
}
|
||||
|
||||
/// Очистить весь кэш
|
||||
Future<void> clearCache() async {
|
||||
if (!isInitialized) return;
|
||||
await _box!.clear();
|
||||
}
|
||||
|
||||
// ==================== SYNC QUEUE ====================
|
||||
|
||||
/// Добавить изменение в очередь (для offline → online sync)
|
||||
Future<void> queueChange({
|
||||
required String path,
|
||||
required String content,
|
||||
required String operation, // 'create' | 'update' | 'delete'
|
||||
String? commitMessage,
|
||||
}) async {
|
||||
if (!isInitialized) throw Exception('Repository not initialized');
|
||||
final entry = {
|
||||
'path': path,
|
||||
'content': content,
|
||||
'operation': operation,
|
||||
'commitMessage': commitMessage ?? 'Mobile sync: $path',
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
};
|
||||
await _queueBox!.put(
|
||||
'${DateTime.now().millisecondsSinceEpoch}_$path', jsonEncode(entry));
|
||||
}
|
||||
|
||||
/// Получить все изменения из очереди
|
||||
List<Map<String, dynamic>> getPendingChanges() {
|
||||
if (!isInitialized) return [];
|
||||
return _queueBox!.values
|
||||
.map((raw) {
|
||||
try {
|
||||
return jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.toList()
|
||||
..sort((a, b) => (a['timestamp'] ?? '').compareTo(b['timestamp'] ?? ''));
|
||||
}
|
||||
|
||||
/// Удалить выполненное изменение из очереди
|
||||
Future<void> removeQueuedChange(String key) async {
|
||||
if (!isInitialized) return;
|
||||
await _queueBox!.delete(key);
|
||||
}
|
||||
|
||||
/// Очистить очередь
|
||||
Future<void> clearQueue() async {
|
||||
if (!isInitialized) return;
|
||||
await _queueBox!.clear();
|
||||
}
|
||||
|
||||
bool get hasPendingChanges => (_queueBox?.length ?? 0) > 0;
|
||||
|
||||
// ==================== SYNC STATE ====================
|
||||
|
||||
/// Сохранить время последней успешной синхронизации
|
||||
Future<void> setLastSync(DateTime time) async {
|
||||
if (!isInitialized) return;
|
||||
await _box!.put(_metaKey, jsonEncode({'lastSync': time.toIso8601String()}));
|
||||
}
|
||||
|
||||
/// Получить время последней синхронизации
|
||||
DateTime? getLastSync() {
|
||||
if (!isInitialized) return null;
|
||||
final raw = _box!.get(_metaKey);
|
||||
if (raw == null) return null;
|
||||
try {
|
||||
final data = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return DateTime.tryParse(data['lastSync'] ?? '');
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import '../screens/admin/projects_management_screen.dart';
|
||||
import '../screens/admin/notifications_settings_screen.dart';
|
||||
import '../screens/admin/mind_map_admin_screen.dart';
|
||||
import '../screens/admin/ai_stats_screen.dart';
|
||||
import '../screens/gitea_sync_settings_screen.dart';
|
||||
import '../screens/gitea_mindmaps_list_screen.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
// Глобальный ключ для навигации
|
||||
@@ -81,6 +83,16 @@ class AppRouter {
|
||||
return MindMapSettingsScreen(projectId: projectId);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'gitea-sync',
|
||||
name: 'gitea-sync',
|
||||
builder: (context, state) => const GiteaSyncSettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'gitea-mindmaps',
|
||||
name: 'gitea-mindmaps',
|
||||
builder: (context, state) => const GiteaMindmapsListScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
|
||||
Reference in New Issue
Block a user