Files
GitJournal/lib/core/note.dart
Vishesh Handa 18c93fbbb4 Note: Do not change the body if it hasn't been modified
I'm scared that for very large notes this won't be very fast, on the
other hand changing the body results in many other fields being
re-computed.
2020-05-26 18:07:24 +02:00

500 lines
11 KiB
Dart

import 'dart:io';
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/settings.dart';
import 'package:gitjournal/utils/markdown.dart';
import 'package:gitjournal/utils/logger.dart';
import 'package:gitjournal/utils/datetime.dart';
import 'package:path/path.dart' as p;
import 'package:meta/meta.dart';
import 'package:markdown/markdown.dart' as md;
import 'md_yaml_doc.dart';
import 'md_yaml_doc_codec.dart';
import 'note_serializer.dart';
enum NoteLoadState {
None,
Loading,
Loaded,
NotExists,
}
enum NoteType {
Unknown,
Checklist,
Journal,
}
class Note with NotesNotifier {
NotesFolderFS parent;
String _filePath;
String _title = "";
DateTime _created;
DateTime _modified;
String _body = "";
NoteType _type = NoteType.Unknown;
Set<String> _tags = {};
MdYamlDoc _data = MdYamlDoc();
NoteSerializer noteSerializer = NoteSerializer();
DateTime fileLastModified;
var _loadState = NoteLoadState.None;
var _serializer = MarkdownYAMLCodec();
// Computed from body
String _summary;
List<Link> _links;
static final _mdYamlDocLoader = MdYamlDocLoader();
Note(this.parent, this._filePath);
Note.newNote(this.parent) {
created = DateTime.now();
_loadState = NoteLoadState.Loaded;
}
String get filePath {
if (_filePath == null) {
_filePath = p.join(parent.folderPath, _buildFileName());
if (!_filePath.toLowerCase().endsWith('.md')) {
_filePath += '.md';
}
}
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();
_notifyModified();
}
String get body {
return _body;
}
set body(String newBody) {
if (newBody == _body) {
return;
}
_body = newBody;
_summary = null;
_links = null;
_notifyModified();
}
String get title {
return _title;
}
set title(String title) {
if (!canHaveMetadata) return;
_title = title;
_notifyModified();
}
NoteType get type {
return _type;
}
set type(NoteType type) {
if (!canHaveMetadata) return;
_type = type;
_notifyModified();
}
Set<String> get tags {
return _tags;
}
set tags(Set<String> tags) {
assert(tags != null);
if (!canHaveMetadata) return;
_tags = tags;
_notifyModified();
}
bool get canHaveMetadata {
return Settings.instance.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<NoteLoadState> 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;
}
} on FileSystemException catch (e) {
if (e.osError.errorCode == 2 /* File Not Found */) {
_loadState = NoteLoadState.NotExists;
_notifyModified();
return _loadState;
}
}
Log.d("Note modified: $_filePath");
}
try {
data = await _mdYamlDocLoader.loadDoc(_filePath);
} on MdYamlDocNotFoundException catch (_) {
_loadState = NoteLoadState.NotExists;
_notifyModified();
return _loadState;
}
fileLastModified = file.lastModifiedSync();
_loadState = NoteLoadState.Loaded;
_notifyModified();
return _loadState;
}
// FIXME: What about error handling?
Future<void> save() async {
assert(_filePath != null);
assert(_data != null);
assert(_data.body != null);
assert(_data.props != null);
var file = File(filePath);
var contents = _serializer.encode(data);
await file.writeAsString(contents);
}
// FIXME: What about error handling?
Future<void> 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
if (!newName.toLowerCase().endsWith('.md')) {
newName += '.md';
}
var oldFilePath = filePath;
var parentDirName = p.dirname(filePath);
var newFilePath = p.join(parentDirName, newName);
if (_loadState != NoteLoadState.None) {
// for new notes
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<void> 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<void> 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 = Settings.instance.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();
switch (parent.config.fileNameFormat) {
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(":", "_");
}
return date.toString();
}
Future<List<Link>> fetchLinks() async {
if (_links != null) {
return _links;
}
final doc = md.Document(
encodeHtml: false,
extensionSet: md.ExtensionSet.gitHubFlavored,
);
var lines = body.replaceAll('\r\n', '\n').split('\n');
var nodes = doc.parseLines(lines);
var possibleLinks = LinkExtractor().visit(nodes);
var links = <Link>[];
for (var l in possibleLinks) {
var path = l.filePath;
var isLocal = (path.startsWith('/') || path.startsWith('.')) &&
!path.contains('://');
if (isLocal) {
l.filePath = p.join(parent.folderPath, p.normalize(l.filePath));
links.add(l);
}
}
doc.linkReferences.forEach((key, value) {
var filePath = value.destination;
links.add(Link(term: key, filePath: filePath));
});
_links = links;
return links;
}
List<Link> links() {
return _links;
}
}
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;
}
}
}
class Link {
String term;
String filePath;
Link({@required this.term, @required this.filePath});
@override
int get hashCode => filePath.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Note &&
runtimeType == other.runtimeType &&
filePath == other.filePath;
@override
String toString() {
return 'Link{term: $term, filePath: $filePath}';
}
}
class LinkExtractor implements md.NodeVisitor {
List<Link> links = [];
@override
bool visitElementBefore(md.Element element) {
return true;
}
@override
void visitText(md.Text text) {}
@override
void visitElementAfter(md.Element el) {
final String tag = el.tag;
if (tag == 'a') {
var title = el.attributes['title'] ?? "";
if (title.isEmpty) {
for (var child in el.children) {
if (child is md.Text) {
title += child.text;
}
}
}
var url = el.attributes['href'];
var link = Link(term: title, filePath: url);
links.add(link);
}
}
List<Link> visit(List<md.Node> nodes) {
for (final node in nodes) {
node.accept(this);
}
return links;
}
}