Files
GitJournal/lib/core/note.dart
Vishesh Handa 42d6386096 Add basic txt file support
With this 'txt' files are now read and written. However, they don't have
any metadata so they don't show up correctly in the sort order and
currently the Markdown Editor is still available for them.

Related to #55
2020-06-02 00:15:18 +02:00

564 lines
13 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/error_reporting.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,
Error,
}
enum NoteType {
Unknown,
Checklist,
Journal,
}
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<String> _tags = {};
NoteFileFormat _fileFormat;
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());
switch (_fileFormat) {
case NoteFileFormat.Markdown:
if (!_filePath.toLowerCase().endsWith('.md')) {
_filePath += '.md';
}
break;
case NoteFileFormat.Txt:
if (!_filePath.toLowerCase().endsWith('.txt')) {
_filePath += '.txt';
}
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();
_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 {
if (_fileFormat == NoteFileFormat.Txt) {
return false;
}
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;
}
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;
}
} 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<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
switch (_fileFormat) {
case NoteFileFormat.Markdown:
if (!newName.toLowerCase().endsWith('.md')) {
newName += '.md';
}
break;
case NoteFileFormat.Txt:
if (!newName.toLowerCase().endsWith('.txt')) {
newName += '.txt';
}
break;
}
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;
}
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;
}
}
}
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;
}
}