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 begin(ExportContext context); /// Write one task record. Future writeTask(core.Task task); /// Finish writing the export. Future 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 factories = const {}, }) : _factories = Map.fromEntries( factories.entries.map( (entry) => MapEntry(_normalizeFormat(entry.key), entry.value), ), ); final Map _factories; /// Default registry with JSON and CSV stub writers. factory ExportWriterRegistry.defaults() { return ExportWriterRegistry( factories: { 'json': JsonExportWriter.new, 'csv': CsvExportWriter.new, }, ); } /// Available format names. Set get formats => Set.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 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 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 begin(ExportContext context) async { _assertNotEnded(); _context = context; _sink.write( '{"context":${jsonEncode(_contextToJson(context))},"tasks":[', ); } @override Future 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 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 begin(ExportContext context) async { _assertNotEnded(); _started = true; _sink.writeln( 'id,title,projectId,type,status,durationMinutes,scheduledStartUtc,' 'scheduledEndUtc', ); } @override Future writeTask(core.Task task) async { if (!_started) { throw const ExportException('Writer has not started.'); } _assertNotEnded(); _sink.writeln([ 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 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 _contextToJson(ExportContext context) { return { '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; }