/* * SPDX-FileCopyrightText: 2019-2021 Vishesh Handa * * SPDX-License-Identifier: AGPL-3.0-or-later */ import 'dart:collection'; import 'package:easy_localization/easy_localization.dart'; import 'package:path/path.dart' as p; import 'package:path/path.dart'; import 'package:synchronized/synchronized.dart'; import 'package:universal_io/io.dart' as io; import 'package:gitjournal/core/file/file_storage.dart'; import 'package:gitjournal/core/file/unopened_files.dart'; import 'package:gitjournal/core/note_storage.dart'; import 'package:gitjournal/core/views/inline_tags_view.dart'; import 'package:gitjournal/generated/locale_keys.g.dart'; import 'package:gitjournal/logger/logger.dart'; import '../file/file.dart'; import '../file/ignored_file.dart'; import '../note.dart'; import 'notes_folder.dart'; import 'notes_folder_notifier.dart'; class NotesFolderFS with NotesFolderNotifier implements NotesFolder { final NotesFolderFS? _parent; String _folderPath; final _lock = Lock(); var _files = []; var _folders = []; var _entityMap = {}; final NotesFolderConfig _config; late FileStorage fileStorage; NotesFolderFS( NotesFolderFS parent, this._folderPath, this._config, this.fileStorage) : _parent = parent { assert(!_folderPath.startsWith(p.separator)); assert(!_folderPath.endsWith(p.separator)); } NotesFolderFS.root(this._config, this.fileStorage) : _parent = null, _folderPath = ""; @override void dispose() { for (var f in _folders) { f.removeListener(_entityChanged); } super.dispose(); } @override NotesFolder? get parent => _parent; /// Always ends with a '/' String get repoPath => fileStorage.repoPath; NotesFolderFS? get parentFS => _parent; void _entityChanged() { notifyListeners(); } void noteModified(Note note) { if (_entityMap.containsKey(note.filePath)) { notifyNoteModified(-1, note); } } void _noteRenamed(Note note, String oldPath) { assert(!oldPath.startsWith(p.separator)); _lock.synchronized(() { assert(_entityMap.containsKey(oldPath)); _entityMap.remove(oldPath); _entityMap[note.filePath] = note; notifyNoteRenamed(-1, note, oldPath); }); } void _subFolderRenamed(NotesFolderFS folder, String oldPath) { assert(!oldPath.startsWith(p.separator)); _lock.synchronized(() { assert(_entityMap.containsKey(oldPath)); _entityMap.remove(oldPath); _entityMap[folder.folderPath] = folder; }); } void reset(FileStorage newFileStorage) { fileStorage = newFileStorage; assert(folderPath.isEmpty); if (folderPath.isNotEmpty) { throw Exception('Reset can only be called from the rootFolder'); } var filesCopy = List.from(_files); filesCopy.forEach(_removeFile); var foldersCopy = List.from(_folders); foldersCopy.forEach(removeFolder); assert(_files.isEmpty); assert(_folders.isEmpty); notifyListeners(); } /// Will never end with '/' String get folderPath => _folderPath; /// Will never end with '/' String get fullFolderPath { if (_folderPath.isEmpty) { return repoPath.substring(0, repoPath.length - 1); } return p.join(repoPath, _folderPath); } @override bool get isEmpty { return !hasNotes && _folders.isEmpty; } @override String get name => basename(folderPath); bool get hasSubFolders { return _folders.isNotEmpty; } @override bool get hasNotes { return _files.indexWhere((n) => n is Note) != -1; } bool get hasNotesRecursive { if (hasNotes) { return true; } for (var folder in _folders) { if (folder.hasNotesRecursive) { return true; } } return false; } int get numberOfNotes { return notes.length; } @override List get notes { return _files.whereType().toList(); } @override List get subFolders => subFoldersFS; List get ignoredFiles => _files.whereType().toList(); List get subFoldersFS { // FIXME: This is really not ideal _folders.sort((NotesFolderFS a, NotesFolderFS b) => a.folderPath.compareTo(b.folderPath)); return _folders; } void addFile(File file) { _files.add(file); } Future loadNotes() async { const maxParallel = 10; var futures = []; var storage = NoteStorage(); for (var i = 0; i < _files.length; i++) { late Future future; var file = _files[i]; if (file is UnopenedFile) { future = (int index, UnopenedFile file) async { var result = await storage.load(file, file.parent); if (result.isFailure) { var reason = IgnoreReason.Custom; var reasonError = result.error; if (result.error! .toString() .toLowerCase() .contains("failed to decode data using encoding 'utf-8'")) { // FIXME: There has got to be an easier way reason = IgnoreReason.InvalidEncoding; } _files[index] = IgnoredFile( file: file, reason: reason, customError: reasonError, ); return; } _files[index] = result.getOrThrow(); notifyNoteAdded(index, result.getOrThrow()); }(i, file); } else if (file is Note) { future = (int index, Note note) async { var result = await storage.reload(note); if (result.isFailure) { _files[index] = IgnoredFile( file: file, reason: IgnoreReason.Custom, ); return; } _files[index] = result.getOrThrow(); notifyNoteModified(index, result.getOrThrow()); }(i, file); } else { continue; } // FIXME: Collected all the Errors, and report them back, along with "WHY", and the contents of the Note // Each of these needs to be reported to sentry, as Note loading should never fail futures.add(future); if (futures.length >= maxParallel) { await Future.wait(futures); futures = []; } } await Future.wait(futures); } Future loadRecursively() async { await load(); await loadNotes(); var futures = []; for (var folder in _folders) { var f = folder.loadRecursively(); futures.add(f); } await Future.wait(futures); } Future load() => _lock.synchronized(_load); Future _load() async { var ignoreFilePath = p.join(fullFolderPath, ".gjignore"); if (io.File(ignoreFilePath).existsSync()) { Log.i("Ignoring $folderPath as it has .gjignore"); return; } var newEntityMap = {}; var newFiles = []; var newFolders = []; final dir = io.Directory(fullFolderPath); var lister = dir.list(recursive: false, followLinks: false); await for (var fsEntity in lister) { if (fsEntity is io.Link) { continue; } assert(fsEntity.path.startsWith(p.separator)); var filePath = fsEntity.path.substring(repoPath.length); if (fsEntity is io.Directory) { var subFolder = NotesFolderFS(this, filePath, _config, fileStorage); if (subFolder.name.startsWith('.')) { // Log.v("Ignoring Folder", props: { // "path": filePath, // "reason": "Hidden folder", // }); continue; } // Log.v("Found Folder", props: {"path": filePath}); newFolders.add(subFolder); newEntityMap[filePath] = subFolder; continue; } assert(fsEntity is io.File); var fileR = await fileStorage.load(filePath); if (fileR.isFailure) { print(fileR.error); Log.e("NotesFolderFS FileStorage Failure", ex: fileR.error, stacktrace: fileR.stackTrace); assert(fileR.isFailure == false); continue; } var file = fileR.getOrThrow(); var fileName = p.basename(filePath); if (fileName.startsWith('.')) { var ignoredFile = IgnoredFile( file: file, reason: IgnoreReason.HiddenFile, ); newFiles.add(ignoredFile); newEntityMap[filePath] = ignoredFile; continue; } var formatInfo = NoteFileFormatInfo(config); if (!formatInfo.isAllowedFileName(filePath)) { var ignoredFile = IgnoredFile( file: file, reason: IgnoreReason.InvalidExtension, ); newFiles.add(ignoredFile); newEntityMap[filePath] = ignoredFile; continue; } // Log.v("Found file", props: {"path": filePath}); var fileToBeProcessed = UnopenedFile( file: file, parent: this, ); newFiles.add(fileToBeProcessed); newEntityMap[filePath] = fileToBeProcessed; } var originalPathsList = _entityMap.keys.toSet(); var newPathsList = newEntityMap.keys.toSet(); var origEntityMap = _entityMap; _entityMap = newEntityMap; _files = newFiles; _folders = newFolders; var pathsRemoved = originalPathsList.difference(newPathsList); for (var path in pathsRemoved) { var e = origEntityMap[path]; assert(e is NotesFolder || e is File); if (e is File) { if (e is Note) { notifyNoteRemoved(-1, e); } } else { _removeFolderListeners(e); notifyFolderRemoved(-1, e); } } var pathsAdded = newPathsList.difference(originalPathsList); for (var path in pathsAdded) { var e = _entityMap[path]; assert(e is NotesFolder || e is File); if (e is File) { if (e is Note) { notifyNoteAdded(-1, e); } } else { _addFolderListeners(e); notifyFolderAdded(-1, e); } } var pathsPossiblyChanged = newPathsList.intersection(originalPathsList); for (var i = 0; i < _files.length; i++) { var filePath = _files[i].filePath; if (!pathsPossiblyChanged.contains(filePath)) { continue; } var ent = origEntityMap[filePath]; if (ent is Note) { _files[i] = ent; } } } void add(Note note) { assert(note.parent == this); _files.add(note); _entityMap[note.filePath] = note; notifyNoteAdded(-1, note); } void remove(Note note) { assert(note.parent == this); _removeFile(note); } void _removeFile(File f) { assert(_files.indexWhere((n) => n.filePath == f.filePath) != -1); assert(_entityMap.containsKey(f.filePath)); var index = _files.indexWhere((n) => n.filePath == f.filePath); _files.removeAt(index); if (f is Note) { notifyNoteRemoved(index, f); } } void create() { // Git doesn't track Directories, only files, so we create an empty .gitignore file // in the directory instead. var gitIgnoreFilePath = p.join(fullFolderPath, ".gitignore"); var file = io.File(gitIgnoreFilePath); if (!file.existsSync()) { file.createSync(recursive: true); } notifyListeners(); } void addFolder(NotesFolderFS folder) { assert(folder.parent == this); _addFolderListeners(folder); _folders.add(folder); _entityMap[folder.folderPath] = folder; notifyFolderAdded(_folders.length - 1, folder); } void removeFolder(NotesFolderFS folder) { var filesCopy = List.from(folder._files); filesCopy.forEach(folder._removeFile); var foldersCopy = List.from(folder._folders); foldersCopy.forEach(folder.removeFolder); _removeFolderListeners(folder); assert(_folders.indexWhere((f) => f.folderPath == folder.folderPath) != -1); assert(_entityMap.containsKey(folder.folderPath)); var index = _folders.indexWhere((f) => f.folderPath == folder.folderPath); assert(index != -1); _folders.removeAt(index); _entityMap.remove(folder.folderPath); notifyFolderRemoved(index, folder); } void rename(String newName) { var oldPath = folderPath; var dir = io.Directory(fullFolderPath); _folderPath = p.join(dirname(oldPath), newName); assert(!_folderPath.endsWith(p.separator)); if (io.Directory(fullFolderPath).existsSync()) { throw Exception("Directory already exists"); } dir.renameSync(fullFolderPath); notifyThisFolderRenamed(this, oldPath); } void _addFolderListeners(NotesFolderFS folder) { folder.addListener(_entityChanged); folder.addThisFolderRenamedListener(_subFolderRenamed); } void _removeFolderListeners(NotesFolderFS folder) { folder.removeListener(_entityChanged); folder.removeThisFolderRenamedListener(_subFolderRenamed); } @override String get publicName { var spec = folderPath; if (spec.isEmpty) { return tr(LocaleKeys.rootFolder); } return spec; } Iterable getAllNotes() sync* { for (var f in _files) { if (f is Note) { yield f; } } for (var folder in _folders) { var notes = folder.getAllNotes(); for (var note in notes) { yield note; } } } @override NotesFolder get fsFolder { return this; } NotesFolderFS? getFolderWithSpec(String spec) { if (folderPath == spec) { return this; } for (var f in _folders) { var res = f.getFolderWithSpec(spec); if (res != null) { return res; } } return null; } NotesFolderFS getOrBuildFolderWithSpec(String spec) { assert(!spec.startsWith(p.separator)); if (spec == '.') { return this; } var components = spec.split(p.separator); var folder = this; for (var i = 0; i < components.length; i++) { var c = components.sublist(0, i + 1); var folderPath = c.join(p.separator); var folders = folder.subFoldersFS; var folderIndex = folders.indexWhere((f) => f.folderPath == folderPath); if (folderIndex != -1) { folder = folders[folderIndex]; continue; } var subFolder = NotesFolderFS(folder, folderPath, _config, fileStorage); folder.addFolder(subFolder); folder = subFolder; } return folder; } NotesFolderFS get rootFolder { var folder = this; while (folder.parent != null) { folder = folder.parent as NotesFolderFS; } return folder; } Note? getNoteWithSpec(String spec) { // FIXME: Once each note is stored with the spec as the path, this becomes // so much easier! var parts = spec.split(p.separator); var folder = this; while (parts.length != 1) { var folderName = parts[0]; bool foundFolder = false; for (var f in _folders) { if (f.name == folderName) { folder = f; foundFolder = true; break; } } if (!foundFolder) { return null; } parts.removeAt(0); } var fileName = parts[0]; for (var note in folder.notes) { if (note.fileName == fileName) { return note; } } return null; } @override NotesFolderConfig get config => _config; Future> getNoteTagsRecursively( InlineTagsView inlineTagsView, ) async { return _fetchTags(this, inlineTagsView, SplayTreeSet()); } Future> matchNotes(NoteMatcherAsync pred) async { var matchedNotes = []; await _matchNotes(matchedNotes, pred); return matchedNotes; } Future> _matchNotes( List matchedNotes, NoteMatcherAsync pred, ) async { for (var file in _files) { if (file is! Note) { continue; } var note = file; var matches = await pred(note); if (matches) { matchedNotes.add(note); } } for (var folder in _folders) { await folder._matchNotes(matchedNotes, pred); } return matchedNotes; } /// /// Do not let the user rename it to a different file-type. /// void renameNote(Note note, String newName) { assert(!newName.contains(p.separator)); var oldFilePath = note.filePath; var parentDirName = p.dirname(oldFilePath); var newFilePath = p.join(parentDirName, newName); // The file will not exist for new notes var file = io.File(oldFilePath); if (file.existsSync()) { file.renameSync(newFilePath); } note.applyFilePath(newFilePath); _noteRenamed(note, oldFilePath); } static bool moveNote(Note note, NotesFolderFS destFolder) { var destPath = p.join(destFolder.fullFolderPath, note.fileName); if (io.File(destPath).existsSync()) { return false; } io.File(note.fullFilePath).renameSync(destPath); note.parent.remove(note); note.parent = destFolder; note.parent.add(note); return true; } void visit(void Function(File) visitor) { for (var f in _files) { visitor(f); } for (var folder in _folders) { folder.visit(visitor); } } } typedef NoteMatcherAsync = Future Function(Note n); Future> _fetchTags( NotesFolder folder, InlineTagsView inlineTagsView, SplayTreeSet tags, ) async { for (var note in folder.notes) { tags.addAll(note.tags); tags.addAll(await inlineTagsView.fetch(note)); } for (var folder in folder.subFolders) { tags = await _fetchTags(folder, inlineTagsView, tags); } return tags; }