import 'dart:io'; import 'package:path/path.dart' as p; import 'package:uuid/uuid.dart'; import 'package:gitjournal/core/links_loader.dart'; import 'package:gitjournal/core/md_yaml_doc_loader.dart'; import 'package:gitjournal/core/note_notifier.dart'; import 'package:gitjournal/core/notes_folder_fs.dart'; import 'package:gitjournal/core/processors/image_extractor.dart'; import 'package:gitjournal/core/processors/inline_tags.dart'; import 'package:gitjournal/error_reporting.dart'; import 'package:gitjournal/settings.dart'; import 'package:gitjournal/utils/datetime.dart'; import 'package:gitjournal/utils/logger.dart'; import 'package:gitjournal/utils/markdown.dart'; import 'link.dart'; import 'md_yaml_doc.dart'; import 'md_yaml_doc_codec.dart'; import 'note_serializer.dart'; typedef void NoteSelectedFunction(Note note); typedef bool NoteBoolPropertyFunction(Note note); enum NoteLoadState { None, Loading, Loaded, NotExists, Error, } enum NoteType { Unknown, Checklist, Journal, } class NoteFileFormatInfo { static List allowedExtensions() { return [ '.md', '.txt', ]; } static String defaultExtension(NoteFileFormat format) { switch (format) { case NoteFileFormat.Markdown: return ".md"; case NoteFileFormat.Txt: return ".txt"; default: return ".md"; } } static bool isAllowedFileName(String filePath) { var noteFilePath = filePath.toLowerCase(); var isMarkdownFile = noteFilePath.endsWith('.md'); var isTxtFile = noteFilePath.endsWith('.txt'); return isMarkdownFile || isTxtFile; } } enum NoteFileFormat { Markdown, Txt, } class Note with NotesNotifier { NotesFolderFS parent; String _filePath; String _title = ""; DateTime _created; DateTime _modified; String _body = ""; NoteType _type = NoteType.Unknown; Set _tags = {}; Map _extraProps = {}; NoteFileFormat _fileFormat; MdYamlDoc _data = MdYamlDoc(); NoteSerializer noteSerializer; DateTime fileLastModified; var _loadState = NoteLoadState.None; var _serializer = MarkdownYAMLCodec(); // Computed from body String _summary; List _links; Set _inlineTags; Set _images; static final _mdYamlDocLoader = MdYamlDocLoader(); static final _linksLoader = LinksLoader(); Note(this.parent, this._filePath) { noteSerializer = NoteSerializer.fromConfig(parent.config); } Note.newNote( this.parent, { Map extraProps = const {}, String fileName = "", }) { created = DateTime.now(); _loadState = NoteLoadState.Loaded; _fileFormat = NoteFileFormat.Markdown; noteSerializer = NoteSerializer.fromConfig(parent.config); if (extraProps.isNotEmpty) { extraProps.forEach((key, value) { _data.props[key] = value; }); noteSerializer.decode(_data, this); } if (fileName.isNotEmpty) { // FIXME: We should ensure a note with this fileName does not already // exist if (!NoteFileFormatInfo.isAllowedFileName(fileName)) { fileName += NoteFileFormatInfo.defaultExtension(NoteFileFormat.Markdown); } _filePath = p.join(parent.folderPath, fileName); Log.i("Constructing new note with path $_filePath"); } } String get filePath { if (_filePath == null) { try { _filePath = p.join(parent.folderPath, _buildFileName()); } catch (e, stackTrace) { Log.e("_buildFileName: $e"); logExceptionWarning(e, stackTrace); _filePath = p.join(parent.folderPath, Uuid().v4()); } switch (_fileFormat) { case NoteFileFormat.Txt: if (!_filePath.toLowerCase().endsWith('.txt')) { _filePath += '.txt'; } break; case NoteFileFormat.Markdown: default: if (!_filePath.toLowerCase().endsWith('.md')) { _filePath += '.md'; } break; } } return _filePath; } String get fileName { return p.basename(filePath); } DateTime get created { return _created; } set created(DateTime dt) { if (!canHaveMetadata) return; _created = dt; _notifyModified(); } DateTime get modified { return _modified; } set modified(DateTime dt) { if (!canHaveMetadata) return; _modified = dt; _notifyModified(); } void updateModified() { modified = DateTime.now(); } String get body { return _body; } set body(String newBody) { if (newBody == _body) { return; } _body = newBody; _summary = null; _links = null; _inlineTags = null; _images = null; _notifyModified(); } String get title { return _title; } set title(String title) { _title = title; _notifyModified(); } NoteType get type { return _type; } set type(NoteType type) { if (!canHaveMetadata) return; _type = type; _notifyModified(); } Set get tags { return _tags; } set tags(Set tags) { assert(tags != null); if (!canHaveMetadata) return; _tags = tags; _notifyModified(); } Set get inlineTags { if (_loadState != NoteLoadState.Loaded) return {}; if (_inlineTags == null) { var tagPrefixes = parent.config.inlineTagPrefixes; var p = InlineTagsProcessor(tagPrefixes: tagPrefixes); _inlineTags = p.extractTags(body); } return _inlineTags; } Set get images { if (_loadState != NoteLoadState.Loaded) return {}; var p = ImageExtractor(); _images ??= p.extract(body); return _images; } Map get extraProps { return _extraProps; } set extraProps(Map props) { if (!canHaveMetadata) return; _extraProps = props; _notifyModified(); } bool get canHaveMetadata { if (_fileFormat == NoteFileFormat.Txt) { return false; } return parent.config.yamlHeaderEnabled; } MdYamlDoc get data { noteSerializer.encode(this, _data); return _data; } set data(MdYamlDoc data) { _data = data; noteSerializer.decode(_data, this); _notifyModified(); } bool isEmpty() { return body.isEmpty; } String get summary { if (_loadState != NoteLoadState.Loaded) return ""; _summary ??= stripMarkdownFormatting(body); return _summary; } NoteLoadState get loadState { return _loadState; } Future load() async { assert(_filePath != null); assert(_filePath.isNotEmpty); if (_loadState == NoteLoadState.Loading) { return _loadState; } final file = File(_filePath); if (_loadState == NoteLoadState.Loaded) { try { var fileLastModified = file.lastModifiedSync(); if (this.fileLastModified == fileLastModified) { return _loadState; } this.fileLastModified = fileLastModified; } catch (e, stackTrace) { if (e is FileSystemException && e.osError.errorCode == 2 /* File Not Found */) { _loadState = NoteLoadState.NotExists; _notifyModified(); return _loadState; } logExceptionWarning(e, stackTrace); _loadState = NoteLoadState.Error; _notifyModified(); return _loadState; } Log.d("Note modified: $_filePath"); } var fpLowerCase = _filePath.toLowerCase(); var isMarkdown = fpLowerCase.endsWith('.md'); var isTxt = fpLowerCase.endsWith('.txt'); if (isMarkdown) { try { data = await _mdYamlDocLoader.loadDoc(_filePath); _fileFormat = NoteFileFormat.Markdown; } on MdYamlDocNotFoundException catch (_) { _loadState = NoteLoadState.NotExists; _notifyModified(); return _loadState; } on MdYamlParsingException catch (err, stackTrace) { logException(err, stackTrace); _loadState = NoteLoadState.Error; _notifyModified(); return _loadState; } } else if (isTxt) { try { body = await File(_filePath).readAsString(); _fileFormat = NoteFileFormat.Txt; } catch (e, stackTrace) { logExceptionWarning(e, stackTrace); _loadState = NoteLoadState.Error; _notifyModified(); return _loadState; } } else { _loadState = NoteLoadState.Error; _notifyModified(); return _loadState; } fileLastModified = file.lastModifiedSync(); _loadState = NoteLoadState.Loaded; _notifyModified(); return _loadState; } // FIXME: What about error handling? Future save() async { assert(_data != null); assert(_data.body != null); assert(_data.props != null); var file = File(filePath); var contents = _serializer.encode(data); await file.writeAsString(contents, flush: true); } String serialize() { return _serializer.encode(data); } // FIXME: What about error handling? Future remove() async { assert(_filePath != null); var file = File(_filePath); await file.delete(); } void rename(String newName) { // Do not let the user rename it to a non-markdown file switch (_fileFormat) { 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 = filePath; var parentDirName = p.dirname(filePath); var newFilePath = p.join(parentDirName, newName); // The file will not exist for new notes if (File(oldFilePath).existsSync()) { File(filePath).renameSync(newFilePath); } _filePath = newFilePath; notifyRenameListeners(this, oldFilePath); _notifyModified(); } bool move(NotesFolderFS destFolder) { var destPath = p.join(destFolder.folderPath, fileName); if (File(destPath).existsSync()) { return false; } File(filePath).renameSync(destPath); parent.remove(this); parent = destFolder; destFolder.add(this); _notifyModified(); return true; } Future addImage(File file) async { var absImagePath = _buildImagePath(file); await file.copy(absImagePath); var relativeImagePath = p.relative(absImagePath, from: parent.folderPath); if (!relativeImagePath.startsWith('.')) { relativeImagePath = './$relativeImagePath'; } var imageMarkdown = "![Image]($relativeImagePath)\n"; if (body.isEmpty) { body = imageMarkdown; } else { body = "$body\n$imageMarkdown"; } } Future addImageSync(File file) async { var absImagePath = _buildImagePath(file); file.copySync(absImagePath); var relativeImagePath = p.relative(absImagePath, from: parent.folderPath); if (!relativeImagePath.startsWith('.')) { relativeImagePath = './$relativeImagePath'; } var imageMarkdown = "![Image]($relativeImagePath)\n"; if (body.isEmpty) { body = imageMarkdown; } else { body = "$body\n$imageMarkdown"; } } String _buildImagePath(File file) { String baseFolder; var imageSpec = parent.config.imageLocationSpec; if (imageSpec == '.') { baseFolder = parent.folderPath; } else { baseFolder = parent.rootFolder.getFolderWithSpec(imageSpec).folderPath; baseFolder ??= parent.folderPath; } var imageFileName = p.basename(file.path); return p.join(baseFolder, imageFileName); } @override int get hashCode => _filePath.hashCode; @override bool operator ==(Object other) => identical(this, other) || other is Note && runtimeType == other.runtimeType && _filePath == other._filePath && _data == other._data; @override String toString() { return 'Note{filePath: $_filePath, created: $created, modified: $modified, data: $_data, loadState: $_loadState}'; } void _notifyModified() { notifyModifiedListeners(this); notifyListeners(); } String pathSpec() { if (parent == null) { return fileName; } return p.join(parent.pathSpec(), fileName); } String _buildFileName() { var date = created ?? modified ?? fileLastModified ?? DateTime.now(); var isJournal = type == NoteType.Journal; switch (!isJournal ? parent.config.fileNameFormat : parent.config.journalFileNameFormat) { case NoteFileNameFormat.SimpleDate: return toSimpleDateTime(date); case NoteFileNameFormat.FromTitle: if (title.isNotEmpty) { return buildTitleFileName(parent.folderPath, title); } else { return toSimpleDateTime(date); } break; case NoteFileNameFormat.Iso8601: return toIso8601(date); case NoteFileNameFormat.Iso8601WithTimeZone: return toIso8601WithTimezone(date); case NoteFileNameFormat.Iso8601WithTimeZoneWithoutColon: return toIso8601WithTimezone(date).replaceAll(":", "_"); case NoteFileNameFormat.UuidV4: return Uuid().v4(); case NoteFileNameFormat.Zettelkasten: return toZettleDateTime(date); } return date.toString(); } Future> fetchLinks() async { if (_links != null) { return _links; } _links = await _linksLoader.parseLinks(body: _body, filePath: _filePath); return _links; } List links() { return _links; } NoteFileFormat get fileFormat { return _fileFormat; } } String buildTitleFileName(String parentDir, String title) { // Sanitize the title - these characters are not allowed in Windows title = title.replaceAll(RegExp(r'[/<\>":|?*]'), '_'); var fileName = title + ".md"; var fullPath = p.join(parentDir, fileName); var file = File(fullPath); if (!file.existsSync()) { return fileName; } for (var i = 1;; i++) { var fileName = title + "_$i.md"; var fullPath = p.join(parentDir, fileName); var file = File(fullPath); if (!file.existsSync()) { return fileName; } } }