import 'dart:io'; import 'package:path/path.dart' as p; import 'ticket.dart'; class TicketStore { final String kanbanDir; final String prefix; const TicketStore({required this.kanbanDir, required this.prefix}); Future create({ required String title, required String type, required String column, String body = '', }) async { final columnDir = Directory(p.join(kanbanDir, column)); await columnDir.create(recursive: true); final id = _formatId(await _nextNumber()); final ticket = Ticket( id: id, title: title, type: type, column: column, created: DateTime.now().toUtc(), body: body, comments: const [], ); await File(p.join(columnDir.path, '$id.md')).writeAsString(ticket.toFileContent()); return ticket; } Future findById(String id) async { final found = await _findTicketFile(id); if (found == null) return null; return Ticket.fromFileContent(id, await found.file.readAsString(), found.column); } Future> list() async { final dir = Directory(kanbanDir); if (!await dir.exists()) return const []; final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-\d{4}\.md$'); final tickets = []; await for (final entity in dir.list()) { if (entity is! Directory) continue; final col = p.basename(entity.path); if (col == 'archive' || col == 'attachments') continue; await for (final file in entity.list()) { if (file is! File) continue; final name = p.basename(file.path); if (!pattern.hasMatch(name)) continue; final id = p.basenameWithoutExtension(name); tickets.add(Ticket.fromFileContent(id, await file.readAsString(), col)); } } tickets.sort((a, b) => a.id.compareTo(b.id)); return tickets; } Future addComment(String id, String comment) async { final found = await _findTicketFile(id); if (found == null) throw ArgumentError('Ticket $id not found.'); final ticket = Ticket.fromFileContent(id, await found.file.readAsString(), found.column); final updated = ticket.copyWith(comments: [...ticket.comments, comment]); await found.file.writeAsString(updated.toFileContent()); return updated; } Future linkTickets(String id, String targetId, String type) async { if (!linkTypeInverses.containsKey(type)) { throw ArgumentError( 'Unknown link type "$type". ' 'Valid: ${linkTypeInverses.keys.join(', ')}', ); } final ticket = await findById(id); if (ticket == null) throw ArgumentError('Ticket $id not found.'); final target = await findById(targetId); if (target == null) throw ArgumentError('Ticket $targetId not found.'); // Forward link (idempotent — skip if already linked to same target). if (!ticket.links.any((l) => l.targetId == targetId)) { final updated = ticket.copyWith( links: [...ticket.links, TicketLink(targetId: targetId, type: type)], ); final found = (await _findTicketFile(id))!; await found.file.writeAsString(updated.toFileContent()); } // Inverse link on the target. final inverseType = linkTypeInverses[type]!; if (!target.links.any((l) => l.targetId == id)) { final updatedTarget = target.copyWith( links: [...target.links, TicketLink(targetId: id, type: inverseType)], ); final foundTarget = (await _findTicketFile(targetId))!; await foundTarget.file.writeAsString(updatedTarget.toFileContent()); } return (await findById(id))!; } Future unlinkTickets(String id, String targetId) async { final found = await _findTicketFile(id); if (found == null) throw ArgumentError('Ticket $id not found.'); final ticket = Ticket.fromFileContent(id, await found.file.readAsString(), found.column); final updated = ticket.copyWith( links: ticket.links.where((l) => l.targetId != targetId).toList(), ); await found.file.writeAsString(updated.toFileContent()); // Remove inverse link on target (if it exists). final foundTarget = await _findTicketFile(targetId); if (foundTarget != null) { final target = Ticket.fromFileContent( targetId, await foundTarget.file.readAsString(), foundTarget.column, ); final updatedTarget = target.copyWith( links: target.links.where((l) => l.targetId != id).toList(), ); await foundTarget.file.writeAsString(updatedTarget.toFileContent()); } return (await findById(id))!; } /// Returns counts of tickets grouped by column and type. Future> stats() async { final tickets = await list(); final byColumn = {}; final byType = {}; for (final t in tickets) { byColumn[t.column] = (byColumn[t.column] ?? 0) + 1; byType[t.type] = (byType[t.type] ?? 0) + 1; } return {'total': tickets.length, 'byColumn': byColumn, 'byType': byType}; } Future update( String id, { String? title, String? type, String? column, String? body, }) async { final found = await _findTicketFile(id); if (found == null) throw ArgumentError('Ticket $id not found.'); final ticket = Ticket.fromFileContent(id, await found.file.readAsString(), found.column); final updated = ticket.copyWith(title: title, type: type, column: column, body: body); if (column != null && column != ticket.column) { // Column changed — move the file to the new column directory. await found.file.delete(); final newColDir = Directory(p.join(kanbanDir, column)); await newColDir.create(recursive: true); await File(p.join(newColDir.path, '$id.md')).writeAsString(updated.toFileContent()); } else { await found.file.writeAsString(updated.toFileContent()); } return updated; } Future delete(String id) async { final found = await _findTicketFile(id); if (found == null) throw ArgumentError('Ticket $id not found.'); await found.file.delete(); // Clean up per-ticket attachment directory if present. final attachmentsDir = Directory(p.join(kanbanDir, 'attachments', id)); if (await attachmentsDir.exists()) await attachmentsDir.delete(recursive: true); } /// Searches all column subdirectories (one level deep) for a ticket file. /// Skips the [attachments] directory. Includes [archive]. Future<({File file, String column})?> _findTicketFile(String id) async { final dir = Directory(kanbanDir); if (!await dir.exists()) return null; await for (final entity in dir.list()) { if (entity is! Directory) continue; if (p.basename(entity.path) == 'attachments') continue; final file = File(p.join(entity.path, '$id.md')); if (await file.exists()) return (file: file, column: p.basename(entity.path)); } return null; } Future _nextNumber() async { final dir = Directory(kanbanDir); if (!await dir.exists()) return 1; final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-(\d+)\.md$'); var max = 0; await for (final entity in dir.list()) { if (entity is! Directory || p.basename(entity.path) == 'attachments') continue; await for (final file in entity.list()) { final match = pattern.firstMatch(p.basename(file.path)); if (match != null) { final n = int.parse(match.group(1)!); if (n > max) max = n; } } } return max + 1; } String _formatId(int n) => '$prefix-${n.toString().padLeft(4, '0')}'; }