303 lines
8.2 KiB
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;
|
|
}
|