mirror of
https://github.com/GitJournal/GitJournal.git
synced 2025-08-06 15:21:21 +08:00

Fixes #78 This is probably the largest commit that I have ever made. From now on - every File always has an mtime and ctime which is fetched from git. Notes can optionally override that time by providing yaml metadata. Additionally the 'filePath' and 'folderPath' is now relative to the repoPath instead of being the full path. This will slow down GitJournal like crazy as all the mtimes and ctime still need to be cached. For my test repo it takes about 23 seconds for GitJournal to become responsive.
697 lines
17 KiB
Dart
697 lines
17 KiB
Dart
/*
|
|
* SPDX-FileCopyrightText: 2019-2021 Vishesh Handa <me@vhanda.in>
|
|
*
|
|
* 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 = <File>[];
|
|
var _folders = <NotesFolderFS>[];
|
|
var _entityMap = <String, dynamic>{};
|
|
|
|
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));
|
|
assert(oldPath.startsWith(repoPath));
|
|
|
|
_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));
|
|
assert(oldPath.startsWith(repoPath));
|
|
|
|
_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<File>.from(_files);
|
|
filesCopy.forEach(_removeFile);
|
|
|
|
var foldersCopy = List<NotesFolderFS>.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<Note> get notes {
|
|
return _files.whereType<Note>().toList();
|
|
}
|
|
|
|
@override
|
|
List<NotesFolder> get subFolders => subFoldersFS;
|
|
|
|
List<IgnoredFile> get ignoredFiles =>
|
|
_files.whereType<IgnoredFile>().toList();
|
|
|
|
List<NotesFolderFS> 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<void> loadNotes() async {
|
|
const maxParallel = 10;
|
|
var futures = <Future>[];
|
|
|
|
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 = <Future>[];
|
|
}
|
|
}
|
|
|
|
await Future.wait(futures);
|
|
}
|
|
|
|
Future<void> loadRecursively() async {
|
|
await load();
|
|
await loadNotes();
|
|
|
|
var futures = <Future>[];
|
|
for (var folder in _folders) {
|
|
var f = folder.loadRecursively();
|
|
futures.add(f);
|
|
}
|
|
|
|
await Future.wait(futures);
|
|
}
|
|
|
|
Future<void> load() => _lock.synchronized(_load);
|
|
|
|
Future<void> _load() async {
|
|
var ignoreFilePath = p.join(fullFolderPath, ".gjignore");
|
|
if (io.File(ignoreFilePath).existsSync()) {
|
|
Log.i("Ignoring $folderPath as it has .gjignore");
|
|
return;
|
|
}
|
|
|
|
var newEntityMap = <String, dynamic>{};
|
|
var newFiles = <File>[];
|
|
var newFolders = <NotesFolderFS>[];
|
|
|
|
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<File>.from(folder._files);
|
|
filesCopy.forEach(folder._removeFile);
|
|
|
|
var foldersCopy = List<NotesFolderFS>.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<Note> 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 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<SplayTreeSet<String>> getNoteTagsRecursively(
|
|
InlineTagsView inlineTagsView,
|
|
) async {
|
|
return _fetchTags(this, inlineTagsView, SplayTreeSet<String>());
|
|
}
|
|
|
|
Future<List<Note>> matchNotes(NoteMatcherAsync pred) async {
|
|
var matchedNotes = <Note>[];
|
|
await _matchNotes(matchedNotes, pred);
|
|
return matchedNotes;
|
|
}
|
|
|
|
Future<List<Note>> _matchNotes(
|
|
List<Note> 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) {
|
|
switch (note.fileFormat) {
|
|
case NoteFileFormat.OrgMode:
|
|
if (!newName.toLowerCase().endsWith('.org')) {
|
|
newName += '.org';
|
|
}
|
|
break;
|
|
|
|
case NoteFileFormat.Txt:
|
|
if (!newName.toLowerCase().endsWith('.txt')) {
|
|
newName += '.txt';
|
|
}
|
|
break;
|
|
|
|
case NoteFileFormat.Markdown:
|
|
default:
|
|
if (!newName.toLowerCase().endsWith('.md')) {
|
|
newName += '.md';
|
|
}
|
|
break;
|
|
}
|
|
|
|
var oldFilePath = note.fullFilePath;
|
|
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.apply(filePath: 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<bool> Function(Note n);
|
|
|
|
Future<SplayTreeSet<String>> _fetchTags(
|
|
NotesFolder folder,
|
|
InlineTagsView inlineTagsView,
|
|
SplayTreeSet<String> 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;
|
|
}
|