focus-flow/packages/scheduler_export/lib/src/export_controller.dart

303 lines
8.2 KiB
Dart

import 'dart:convert';
import 'package:scheduler_core/scheduler_core.dart' as core;
import 'package:scheduler_persistence/persistence.dart';
/// Export metadata shared with every writer.
final class ExportContext {
/// Creates an export context.
ExportContext({
required this.ownerId,
required String timezone,
required this.version,
}) : timezone = _requiredTrimmed(timezone, 'timezone');
/// Owner scope being exported.
final core.OwnerId ownerId;
/// Timezone id used to interpret local export presentation.
final String timezone;
/// Export format/schema version.
final int version;
}
/// Thrown when an export cannot complete.
final class ExportException implements Exception {
/// Creates an export exception with a diagnostic [message].
const ExportException(this.message);
/// Diagnostic text for logs and tests.
final String message;
@override
String toString() => 'ExportException: $message';
}
/// Thrown when repository reads fail during export.
final class ExportRepositoryException extends ExportException {
/// Creates an exception wrapping a repository [failure].
ExportRepositoryException(this.failure)
: super('Repository export read failed: ${failure.runtimeType}');
/// Repository failure returned by an adapter.
final RepositoryFailure failure;
}
/// Writer interface for readable exports.
abstract interface class ExportWriter {
/// Start writing an export for [context].
Future<void> begin(ExportContext context);
/// Write one task record.
Future<void> writeTask(core.Task task);
/// Finish writing the export.
Future<void> end();
}
/// Factory signature for sink-backed export writers.
typedef ExportWriterFactory = ExportWriter Function(StringSink sink);
/// Registry that resolves export format names to writer factories.
final class ExportWriterRegistry {
/// Creates a registry from [factories].
ExportWriterRegistry({
Map<String, ExportWriterFactory> factories =
const <String, ExportWriterFactory>{},
}) : _factories = Map<String, ExportWriterFactory>.fromEntries(
factories.entries.map(
(entry) => MapEntry(_normalizeFormat(entry.key), entry.value),
),
);
final Map<String, ExportWriterFactory> _factories;
/// Default registry with JSON and CSV stub writers.
factory ExportWriterRegistry.defaults() {
return ExportWriterRegistry(
factories: <String, ExportWriterFactory>{
'json': JsonExportWriter.new,
'csv': CsvExportWriter.new,
},
);
}
/// Available format names.
Set<String> get formats => Set<String>.unmodifiable(_factories.keys);
/// Register [factory] for [format].
void register(String format, ExportWriterFactory factory) {
_factories[_normalizeFormat(format)] = factory;
}
/// Create a writer for [format] using [sink].
ExportWriter create(String format, StringSink sink) {
final normalized = _normalizeFormat(format);
final factory = _factories[normalized];
if (factory == null) {
throw ExportException('Unknown export format: $format');
}
return factory(sink);
}
}
/// Coordinates repository reads and export writer calls.
final class ExportController {
/// Creates an export controller using [taskRepository].
ExportController({
required TaskRepository taskRepository,
ExportWriterRegistry? writerRegistry,
}) : _taskRepository = taskRepository,
_writerRegistry = writerRegistry ?? ExportWriterRegistry.defaults();
final TaskRepository _taskRepository;
final ExportWriterRegistry _writerRegistry;
/// Create a writer for [format] and export owner-scoped tasks to [sink].
Future<void> exportTasksToSink({
required ExportContext context,
required String format,
required StringSink sink,
int pageSize = 100,
}) {
final writer = _writerRegistry.create(format, sink);
return exportTasks(
context: context,
writer: writer,
pageSize: pageSize,
);
}
/// Export all owner-scoped tasks through [writer].
Future<void> exportTasks({
required ExportContext context,
required ExportWriter writer,
int pageSize = 100,
}) async {
if (pageSize <= 0) {
throw ArgumentError.value(pageSize, 'pageSize', 'Must be positive.');
}
await writer.begin(context);
String? cursor;
do {
final result = await _taskRepository.findByOwner(
ownerId: context.ownerId,
page: core.PageRequest(cursor: cursor, limit: pageSize),
);
if (result.isLeft) {
throw ExportRepositoryException(result.left);
}
final page = result.right;
for (final task in page.items) {
await writer.writeTask(task);
}
cursor = page.nextCursor;
} while (cursor != null);
await writer.end();
}
}
/// Minimal JSON export writer stub.
final class JsonExportWriter implements ExportWriter {
/// Creates a JSON writer that writes to the provided string sink.
JsonExportWriter(this._sink);
final StringSink _sink;
ExportContext? _context;
bool _hasTask = false;
bool _ended = false;
@override
Future<void> begin(ExportContext context) async {
_assertNotEnded();
_context = context;
_sink.write(
'{"context":${jsonEncode(_contextToJson(context))},"tasks":[',
);
}
@override
Future<void> writeTask(core.Task task) async {
final context = _requireContext();
if (_hasTask) {
_sink.write(',');
}
_sink.write(jsonEncode(core.TaskDocumentMapping.toDocument(
task,
ownerId: context.ownerId.value,
)));
_hasTask = true;
}
@override
Future<void> end() async {
_requireContext();
_assertNotEnded();
_sink.write(']}');
_ended = true;
}
ExportContext _requireContext() {
final context = _context;
if (context == null) {
throw const ExportException('Writer has not started.');
}
return context;
}
void _assertNotEnded() {
if (_ended) {
throw const ExportException('Writer has already ended.');
}
}
}
/// Minimal CSV export writer stub.
final class CsvExportWriter implements ExportWriter {
/// Creates a CSV writer that writes to the provided string sink.
CsvExportWriter(this._sink);
final StringSink _sink;
bool _started = false;
bool _ended = false;
@override
Future<void> begin(ExportContext context) async {
_assertNotEnded();
_started = true;
_sink.writeln(
'id,title,projectId,type,status,durationMinutes,scheduledStartUtc,'
'scheduledEndUtc',
);
}
@override
Future<void> writeTask(core.Task task) async {
if (!_started) {
throw const ExportException('Writer has not started.');
}
_assertNotEnded();
_sink.writeln(<String>[
task.id,
task.title,
task.projectId,
core.PersistenceEnumMapping.encodeTaskType(task.type),
core.PersistenceEnumMapping.encodeTaskStatus(task.status),
task.durationMinutes?.toString() ?? '',
task.scheduledStart == null
? ''
: core.PersistenceDateTimeConvention.toStoredString(
task.scheduledStart!,
),
task.scheduledEnd == null
? ''
: core.PersistenceDateTimeConvention.toStoredString(
task.scheduledEnd!,
),
].map(_csvCell).join(','));
}
@override
Future<void> end() async {
if (!_started) {
throw const ExportException('Writer has not started.');
}
_assertNotEnded();
_ended = true;
}
void _assertNotEnded() {
if (_ended) {
throw const ExportException('Writer has already ended.');
}
}
}
Map<String, Object?> _contextToJson(ExportContext context) {
return <String, Object?>{
'ownerId': context.ownerId.value,
'timezone': context.timezone,
'version': context.version,
};
}
String _csvCell(String value) {
final needsQuotes =
value.contains(',') || value.contains('"') || value.contains('\n');
final escaped = value.replaceAll('"', '""');
return needsQuotes ? '"$escaped"' : escaped;
}
String _normalizeFormat(String format) {
return _requiredTrimmed(format, 'format').toLowerCase();
}
String _requiredTrimmed(String value, String name) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
throw ArgumentError.value(value, name, 'Value must not be blank.');
}
return trimmed;
}