import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; import '../utils/log.dart'; /// Vindt en herschrijft afbeeldingsverwijzingen (`![…](pad)`) in /// Marp-markdownbestanden op schijf. Zo gaan bij het opruimen van duplicaten /// ook presentaties mee die nu niet geopend zijn. class ImageReferenceService { /// Zelfde mappen als FileService.scanPresentations overslaat. static const _ignoredDirs = { 'images', 'logos', 'themes', 'node_modules', 'build', '.git', '.dart_tool', }; static const _maxDepth = 4; /// Markdown-afbeelding: `![alt of bg-directive](pad)`. static final _imageRef = RegExp(r'!\[([^\]]*)\]\(([^)\n]+)\)'); /// Zoek recursief alle `.md`-bestanden onder [searchDirs] (begrensd op /// diepte, asset- en verborgen mappen worden overgeslagen). Dubbele treffers /// via overlappende zoekpaden worden één keer teruggegeven. Future> findDeckFiles(Iterable searchDirs) async { final found = {}; Future walk(Directory dir, int depth) async { List entries; try { entries = await dir.list(followLinks: false).toList(); } catch (e) { logWarning('ImageReferenceService.findDeckFiles: list directory', e); return; } for (final entity in entries) { if (entity is File) { if (entity.path.toLowerCase().endsWith('.md')) { found.add(p.normalize(entity.path)); } } else if (entity is Directory && depth < _maxDepth) { final name = p.basename(entity.path); if (_ignoredDirs.contains(name) || name.startsWith('.')) continue; await walk(entity, depth + 1); } } } for (final dirPath in searchDirs) { if (dirPath.isEmpty) continue; final root = Directory(dirPath); if (!root.existsSync()) continue; await walk(root, 0); } return found.toList(); } /// Tel per pad uit [targets] hoe vaak het in [deckFiles] wordt genoemd. /// Paden in de markdown worden opgelost relatief aan de map van het /// `.md`-bestand. Paden zonder verwijzingen ontbreken in het resultaat. Future> countReferences( Iterable deckFiles, Iterable targets, ) async { final wanted = {for (final t in targets) p.normalize(t)}; final counts = {}; for (final deckFile in deckFiles) { String content; try { content = await File(deckFile).readAsString(); } catch (e) { logWarning('ImageReferenceService.countReferences: read deck file', e); continue; } final mdDir = p.dirname(deckFile); for (final match in _imageRef.allMatches(content)) { final resolved = _resolve(match.group(2)!, mdDir); if (resolved == null) continue; for (final target in wanted) { if (p.equals(resolved, target)) { counts[target] = (counts[target] ?? 0) + 1; break; } } } } return counts; } /// Per deckbestand: hoe vaak [target] erin wordt genoemd. Bestanden zonder /// treffer ontbreken in het resultaat. Gebruikt voor de waarschuwing bij /// verwijderen, zodat ook niet-geopende presentaties zichtbaar zijn. Future> referencingFiles( Iterable deckFiles, String target, ) async { final wanted = p.normalize(target); final result = {}; for (final deckFile in deckFiles) { String content; try { content = await File(deckFile).readAsString(); } catch (e) { logWarning('ImageReferenceService.referencingFiles: read deck file', e); continue; } final mdDir = p.dirname(deckFile); var count = 0; for (final match in _imageRef.allMatches(content)) { final resolved = _resolve(match.group(2)!, mdDir); if (resolved != null && p.equals(resolved, wanted)) count++; } if (count > 0) result[deckFile] = count; } return result; } /// Herschrijf in [deckFile] elke verwijzing naar [fromAbsolute] zodat die /// naar [toAbsolute] wijst. Alleen het pad binnen `![…](…)` verandert; de /// rest van het bestand blijft byte-voor-byte gelijk. Geeft true terug /// wanneer het bestand daadwerkelijk is gewijzigd. Future replaceReferences( String deckFile, String fromAbsolute, String toAbsolute, ) async { final file = File(deckFile); String content; try { content = await file.readAsString(); } catch (e) { logWarning('ImageReferenceService.replaceReferences: read deck file', e); return false; } final mdDir = p.dirname(deckFile); var changed = false; final updated = content.replaceAllMapped(_imageRef, (m) { final ref = m.group(2)!; final resolved = _resolve(ref, mdDir); if (resolved == null || !p.equals(resolved, fromAbsolute)) { return m.group(0)!; } changed = true; // Blijf relatief schrijven als de verwijzing dat al was en het nieuwe // pad binnen de projectmap ligt; anders absoluut. final replacement = !p.isAbsolute(ref.trim()) && p.isWithin(mdDir, toAbsolute) ? p.relative(toAbsolute, from: mdDir) : toAbsolute; return '![${m.group(1)}]($replacement)'; }); if (!changed) return false; try { await file.writeAsString(updated); } catch (e) { logWarning('ImageReferenceService.replaceReferences: write deck file', e); return false; } return true; } String? _resolve(String ref, String mdDir) { final cleaned = ref.trim(); if (cleaned.isEmpty || cleaned.contains('://')) return null; return p.normalize( p.isAbsolute(cleaned) ? cleaned : p.join(mdDir, cleaned), ); } } final imageReferenceServiceProvider = Provider( (_) => ImageReferenceService(), );