relatica/lib/services/entry_manager_service.dart

511 wiersze
15 KiB
Dart

import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:result_monad/result_monad.dart';
import '../friendica_client/friendica_client.dart';
import '../friendica_client/paging_data.dart';
import '../globals.dart';
import '../models/TimelineIdentifiers.dart';
import '../models/entry_tree_item.dart';
import '../models/exec_error.dart';
import '../models/image_entry.dart';
import '../models/media_attachment_uploads/new_entry_media_items.dart';
import '../models/timeline_entry.dart';
import 'auth_service.dart';
import 'media_upload_attachment_helper.dart';
class EntryManagerService extends ChangeNotifier {
static final _logger = Logger('$EntryManagerService');
final _entries = <String, TimelineEntry>{};
final _parentPostIds = <String, String>{};
final _postNodes = <String, _Node>{};
void clear() {
_entries.clear();
_parentPostIds.clear();
_postNodes.clear();
}
_Node? _getPostRootNode(String id) {
final fromPosts = _postNodes[id];
if (fromPosts != null) {
return fromPosts;
}
final parentId = _parentPostIds[id];
if (parentId == null) {
return null;
}
return _postNodes[parentId];
}
Result<EntryTreeItem, ExecError> getPostTreeEntryBy(String id) {
_logger.finest('Getting post: $id');
final auth = getIt<AuthService>();
final postNode = _getPostRootNode(id);
if (postNode == null) {
return Result.error(ExecError(
type: ErrorType.notFound,
message: 'Unknown post id: $id',
));
}
return Result.ok(_nodeToTreeItem(postNode, auth.currentId));
}
Result<TimelineEntry, ExecError> getEntryById(String id) {
if (_entries.containsKey(id)) {
return Result.ok(_entries[id]!);
}
return Result.error(ExecError(
type: ErrorType.notFound,
message: 'Timeline entry not found: $id',
));
}
FutureResult<bool, ExecError> deleteEntryById(String id) async {
_logger.finest('Delete entry: $id');
final auth = getIt<AuthService>();
final clientResult = auth.currentClient;
if (clientResult.isFailure) {
_logger.severe('Error getting Friendica client: ${clientResult.error}');
return clientResult.errorCast();
}
final client = clientResult.value;
final result = await client.deleteEntryById(id);
if (result.isFailure) {
return result.errorCast();
}
_cleanupEntriesForId(id);
notifyListeners();
return Result.ok(true);
}
FutureResult<bool, ExecError> createNewStatus(
String text, {
String spoilerText = '',
String inReplyToId = '',
required NewEntryMediaItems mediaItems,
required List<ImageEntry> existingMediaItems,
}) async {
_logger.finest('Creating new post: $text');
final auth = getIt<AuthService>();
final clientResult = auth.currentClient;
if (clientResult.isFailure) {
_logger.severe('Error getting Friendica client: ${clientResult.error}');
return clientResult.errorCast();
}
final client = clientResult.value;
final mediaIds = existingMediaItems.map((m) => m.scales.first.id).toList();
for (final item in mediaItems.attachments) {
if (item.isExistingServerItem) {
continue;
}
final String extension = p.extension(item.localFilePath);
late final String filename;
if (item.remoteFilename.isEmpty) {
filename = p.basename(item.localFilePath);
} else {
if (item.remoteFilename
.toLowerCase()
.endsWith(extension.toLowerCase())) {
filename = item.remoteFilename;
} else {
filename = "${item.remoteFilename}$extension";
}
}
final uploadResult =
await MediaUploadAttachmentHelper.getUploadableImageBytes(
item.localFilePath,
).andThenAsync(
(imageBytes) async => await client.uploadFileAsAttachment(
bytes: imageBytes,
album: mediaItems.albumName,
description: item.description,
fileName: filename,
),
);
if (uploadResult.isSuccess) {
mediaIds.add(uploadResult.value.scales.first.id);
} else {
return Result.error(ExecError(
type: ErrorType.localError,
message: 'Error uploading image: ${uploadResult.error}'));
}
}
final result = await client
.createNewStatus(
text: text,
spoilerText: spoilerText,
inReplyToId: inReplyToId,
mediaIds: mediaIds)
.andThenSuccessAsync((item) async {
await processNewItems([item], client.credentials.username, null);
return item;
}).andThenSuccessAsync((item) async {
if (inReplyToId.isNotEmpty) {
late final rootPostId;
if (_postNodes.containsKey(inReplyToId)) {
rootPostId = inReplyToId;
} else {
rootPostId = _parentPostIds[inReplyToId];
}
await refreshStatusChain(rootPostId);
}
return item;
});
return result.mapValue((status) {
_logger.finest('${status.id} status created');
return true;
}).mapError(
(error) {
_logger.finest('Error creating post: $error');
return ExecError(
type: ErrorType.localError,
message: error.toString(),
);
},
);
}
FutureResult<List<EntryTreeItem>, ExecError> updateTimeline(
TimelineIdentifiers type, int maxId, int sinceId) async {
_logger.fine(() => 'Updating timeline');
final auth = getIt<AuthService>();
final clientResult = auth.currentClient;
if (clientResult.isFailure) {
_logger.severe('Error getting Friendica client: ${clientResult.error}');
return clientResult.errorCast();
}
final client = clientResult.value;
final itemsResult = await client.getTimeline(
type: type,
page: PagingData(
maxId: maxId > 0 ? maxId : null,
sinceId: sinceId > 0 ? sinceId : null,
),
);
if (itemsResult.isFailure) {
_logger.severe('Error getting timeline: ${itemsResult.error}');
return itemsResult.errorCast();
}
itemsResult.value.sort((t1, t2) => t1.id.compareTo(t2.id));
final updatedPosts =
await processNewItems(itemsResult.value, auth.currentId, client);
_logger.finest(() {
final postCount = _entries.values.where((e) => e.parentId.isEmpty).length;
final commentCount = _entries.length - postCount;
final orphanCount = _entries.values
.where(
(e) => e.parentId.isNotEmpty && !_entries.containsKey(e.parentId))
.length;
return 'End of update # posts: $postCount, #comments: $commentCount, #orphans: $orphanCount';
});
return Result.ok(updatedPosts);
}
Future<List<EntryTreeItem>> processNewItems(
List<TimelineEntry> items,
String currentId,
FriendicaClient? client,
) async {
items.sort((i1, i2) => int.parse(i1.id).compareTo(int.parse(i2.id)));
final allSeenItems = [...items];
for (final item in items) {
_entries[item.id] = item;
}
final orphans = <TimelineEntry>[];
for (final item in items) {
if (item.parentId.isEmpty) {
continue;
}
final parent = _entries[item.parentId];
if (parent == null) {
orphans.add(item);
} else {
if (parent.parentId.isEmpty) {
_parentPostIds[item.id] = parent.id;
}
}
}
for (final o in orphans) {
await client
?.getPostOrComment(o.id, fullContext: true)
.andThenSuccessAsync((items) async {
final parentPostId = items.firstWhere((e) => e.parentId.isEmpty).id;
_parentPostIds[o.id] = parentPostId;
allSeenItems.addAll(items);
for (final item in items) {
_entries[item.id] = item;
_parentPostIds[item.id] = parentPostId;
}
});
}
allSeenItems.sort((i1, i2) {
if (i1.parentId.isEmpty && i2.parentId.isNotEmpty) {
return -1;
}
if (i2.parentId.isEmpty && i1.parentId.isNotEmpty) {
return 1;
}
return int.parse(i1.id).compareTo(int.parse(i2.id));
});
final postNodesToReturn = <_Node>{};
var lastCount = 0;
while (allSeenItems.isNotEmpty) {
final seenItemsCopy = [...allSeenItems];
for (final item in seenItemsCopy) {
if (item.parentId.isEmpty) {
final postNode =
_postNodes.putIfAbsent(item.id, () => _Node(item.id));
postNodesToReturn.add(postNode);
allSeenItems.remove(item);
} else {
final parentParentPostId = _postNodes.containsKey(item.parentId)
? item.parentId
: _parentPostIds[item.parentId];
final parentPostNode = _postNodes[parentParentPostId]!;
postNodesToReturn.add(parentPostNode);
_parentPostIds[item.id] = parentPostNode.id;
if (parentPostNode.getChildById(item.id) == null) {
final newNode = _Node(item.id);
final injectionNode = parentPostNode.id == item.parentId
? parentPostNode
: parentPostNode.getChildById(item.parentId);
if (injectionNode == null) {
continue;
} else {
injectionNode.addChild(newNode);
}
}
allSeenItems.remove(item);
}
}
if (allSeenItems.isNotEmpty && allSeenItems.length == lastCount) {
_logger.severe(
'Had ${allSeenItems.length} items left over after all iterations');
break;
}
lastCount = allSeenItems.length;
}
final updatedPosts = postNodesToReturn
.map((node) => _nodeToTreeItem(node, currentId))
.toList();
_logger.finest(
'Completed processing new items ${client == null ? 'sub level' : 'top level'}');
return updatedPosts;
}
FutureResult<EntryTreeItem, ExecError> refreshStatusChain(String id) async {
_logger.finest('Refreshing post: $id');
final auth = getIt<AuthService>();
final clientResult = auth.currentClient;
if (clientResult.isFailure) {
_logger.severe('Error getting Friendica client: ${clientResult.error}');
return clientResult.errorCast();
}
final client = clientResult.value;
final result = await client
.getPostOrComment(id, fullContext: false)
.andThenAsync((rootItems) async => await client
.getPostOrComment(id, fullContext: true)
.andThenSuccessAsync(
(contextItems) async => [...rootItems, ...contextItems]))
.andThenSuccessAsync((items) async {
await processNewItems(items, client.credentials.username, null);
});
return result.mapValue((_) {
_logger.finest('$id post updated');
return _nodeToTreeItem(_getPostRootNode(id)!, auth.currentId);
}).mapError(
(error) {
_logger.finest('$id error updating: $error');
return ExecError(
type: ErrorType.localError,
message: error.toString(),
);
},
);
}
FutureResult<EntryTreeItem, ExecError> resharePost(String id) async {
_logger.finest('Resharing post: $id');
final auth = getIt<AuthService>();
final clientResult = auth.currentClient;
if (clientResult.isFailure) {
_logger.severe('Error getting Friendica client: ${clientResult.error}');
return clientResult.errorCast();
}
final client = clientResult.value;
final result =
await client.resharePost(id).andThenSuccessAsync((item) async {
await processNewItems([item], client.credentials.username, null);
});
return result.mapValue((_) {
_logger.finest('$id post updated after reshare');
return _nodeToTreeItem(_postNodes[id]!, auth.currentId);
}).mapError(
(error) {
_logger.finest('$id error updating: $error');
return ExecError(
type: ErrorType.localError,
message: error.toString(),
);
},
);
}
FutureResult<bool, ExecError> unResharePost(String id) async {
_logger.finest('Unresharing post: $id');
final auth = getIt<AuthService>();
final clientResult = auth.currentClient;
if (clientResult.isFailure) {
_logger.severe('Error getting Friendica client: ${clientResult.error}');
return clientResult.errorCast();
}
final client = clientResult.value;
final result =
await client.unResharePost(id).andThenSuccessAsync((item) async {
await processNewItems([item], client.credentials.username, null);
});
if (result.isFailure) {
return Result.error(result.error);
}
_cleanupEntriesForId(id);
notifyListeners();
return Result.ok(true);
}
FutureResult<EntryTreeItem, ExecError> toggleFavorited(
String id, bool newStatus) async {
final auth = getIt<AuthService>();
final clientResult = auth.currentClient;
if (clientResult.isFailure) {
_logger.severe('Error getting Friendica client: ${clientResult.error}');
return clientResult.errorCast();
}
final client = clientResult.value;
final result = await client.changeFavoriteStatus(id, newStatus);
if (result.isFailure) {
return result.errorCast();
}
final update = result.value;
_entries[update.id] = update;
final node = update.parentId.isEmpty
? _postNodes[update.id]!
: _postNodes[update.parentId]!.getChildById(update.id)!;
notifyListeners();
return Result.ok(_nodeToTreeItem(node, auth.currentId));
}
EntryTreeItem _nodeToTreeItem(_Node node, String currentId) {
final childenEntries = <String, EntryTreeItem>{};
for (final c in node.children) {
childenEntries[c.id] = _nodeToTreeItem(c, currentId);
}
final entry = _entries[node.id]!;
final isMine =
entry.authorId == currentId || entry.reshareAuthorId == currentId;
return EntryTreeItem(
_entries[node.id]!,
isMine: isMine,
initialChildren: childenEntries,
);
}
void _cleanupEntriesForId(String id) {
if (_parentPostIds.containsKey(id)) {
_parentPostIds.remove(id);
}
if (_entries.containsKey(id)) {
_entries.remove(id);
}
if (_postNodes.containsKey(id)) {
_postNodes.remove(id);
}
}
}
class _Node {
final String id;
final _children = <String, _Node>{};
List<_Node> get children => _children.values.toList();
_Node(this.id, {Map<String, _Node>? initialChildren}) {
if (initialChildren != null) {
_children.addAll(initialChildren);
}
}
void addChild(_Node node) {
_children[node.id] = node;
}
_Node? removeChildById(String id) {
if (_children.containsKey(id)) {
_children.remove(id);
}
for (final c in _children.values) {
c.removeChildById(id);
}
}
_Node? getChildById(String id) {
if (_children.containsKey(id)) {
return _children[id]!;
}
for (final c in _children.values) {
final result = c.getChildById(id);
if (result != null) {
return result;
}
}
return null;
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is _Node && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
}