Ocideck/lib/services/image_reference_service.dart

177 lines
5.9 KiB
Dart
Raw Normal View History

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<List<String>> findDeckFiles(Iterable<String> searchDirs) async {
final found = <String>{};
Future<void> walk(Directory dir, int depth) async {
List<FileSystemEntity> 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<Map<String, int>> countReferences(
Iterable<String> deckFiles,
Iterable<String> targets,
) async {
final wanted = {for (final t in targets) p.normalize(t)};
final counts = <String, int>{};
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<Map<String, int>> referencingFiles(
Iterable<String> deckFiles,
String target,
) async {
final wanted = p.normalize(target);
final result = <String, int>{};
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<bool> 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>(
(_) => ImageReferenceService(),
);