Try to support the proper checklist format

According the markdownguide.org, the checklist items are always inside a
list. It can any kind of list, but they are inside a list.

This commit breaks the old syntax and most of the tests. It also
requires a custom version of markd :/
This commit is contained in:
Vishesh Handa
2020-04-06 01:10:44 +02:00
parent 7683064ba1
commit de2c82f4bd
4 changed files with 281 additions and 135 deletions

View File

@ -1,27 +1,62 @@
import 'package:markdown/markdown.dart' as md; import 'package:markd/markdown.dart' as md;
import 'package:gitjournal/core/note.dart'; import 'package:gitjournal/core/note.dart';
class ChecklistItem { class ChecklistItem {
md.Element parentListElement;
md.Element element; md.Element element;
bool get checked { bool get checked {
return element.attributes['checked'] != "false"; if (element.children == null || element.children.isEmpty) {
return false;
}
var inputEl = element.children[0] as md.Element;
assert(inputEl.attributes['class'] == 'todo');
return inputEl.attributes.containsKey('checked');
} }
set checked(bool val) { set checked(bool val) {
element.attributes['checked'] = val.toString(); if (element.children == null || element.children.isEmpty) {
return;
}
var inputEl = element.children[0] as md.Element;
assert(inputEl.attributes['class'] == 'todo');
if (val) {
inputEl.attributes["checked"] = "checked";
} else {
inputEl.attributes.remove('checked');
}
} }
String get text { String get text {
return element.attributes['text']; if (element.children == null || element.children.isEmpty) {
return "";
}
if (element.children.length > 1) {
return element.children[1].textContent.substring(1);
}
return "";
} }
set text(String val) { set text(String val) {
element.attributes['text'] = val; if (element.children == null || element.children.isEmpty) {
return;
}
if (element.children.length > 1) {
element.children[1] = md.Text(" $val");
}
} }
ChecklistItem.fromMarkdownElement(this.element); ChecklistItem.fromMarkdownElement(this.element, this.parentListElement) {
assert(element.children.isNotEmpty);
// FIXME: Maybe this shouldn't be allowed
if (parentListElement != null) {
assert(parentListElement.children.contains(element));
}
}
@override @override
String toString() => 'ChecklistItem: $checked $text'; String toString() => 'ChecklistItem: $checked $text';
@ -30,51 +65,49 @@ class ChecklistItem {
class Checklist { class Checklist {
Note _note; Note _note;
List<ChecklistItem> items; List<ChecklistItem> items;
List<md.Node> nodes;
List<md.Node> _nodes;
Checklist(this._note) { Checklist(this._note) {
var doc = md.Document( var doc = md.Document(
encodeHtml: false, encodeHtml: false,
inlineSyntaxes: [TaskListSyntax()], blockSyntaxes: md.BlockParser.standardBlockSyntaxes,
extensionSet: md.ExtensionSet.gitHubFlavored, extensionSet: md.ExtensionSet.gitHubWeb,
); );
nodes = doc.parseInline(_note.body); _nodes = doc.parseLines(_note.body.split('\n'));
_cleanupNodes(nodes); for (var node in _nodes) {
if (node is md.Element) {
items = ChecklistBuilder().build(nodes); var elem = node;
} _printElement(elem, "");
void _cleanupNodes(List<md.Node> nodes) {
if (nodes.length <= 1) {
return;
}
var last = nodes.last;
var secLast = nodes[nodes.length - 2];
if (last is! md.Text) {
return;
}
if (secLast is! md.Element) {
return;
}
var elem = secLast as md.Element;
if (elem.tag != 'input' || elem.attributes['type'] != 'checkbox') {
return;
}
// Some times we get an extra \n in the end, not sure why.
if (last.textContent == '\n') {
nodes.length = nodes.length - 1;
if (!elem.attributes["text"].endsWith('\n')) {
elem.attributes["text"] += '\n';
} }
} }
print('---------');
var builder = ChecklistBuilder();
items = builder.build(_nodes);
}
void _printElement(md.Element elem, String indent) {
print("$indent Begin ${elem.toString()}");
print("$indent E TAG ${elem.tag}");
print("$indent E ATTRIBUTES ${elem.attributes}");
print("$indent E generatedId ${elem.generatedId}");
print("$indent E children ${elem.children}");
if (elem.children != null) {
for (var child in elem.children) {
if (child is md.Element) {
_printElement(child, indent + " ");
} else {
print("$indent $child - ${child.textContent}");
}
}
}
print("$indent End ${elem.toString()}");
} }
Note get note { Note get note {
if (nodes.isEmpty) return _note; if (_nodes.isEmpty) return _note;
// Remove empty trailing items // Remove empty trailing items
while (true) { while (true) {
@ -89,8 +122,8 @@ class Checklist {
} }
} }
var renderer = CustomRenderer(); var renderer = MarkdownRenderer();
_note.body = renderer.render(nodes); _note.body = renderer.render(_nodes);
return _note; return _note;
} }
@ -105,109 +138,103 @@ class Checklist {
} }
ChecklistItem buildItem(bool value, String text) { ChecklistItem buildItem(bool value, String text) {
var elem = md.Element.withTag("input"); var inputElement = md.Element.withTag('input');
elem.attributes["type"] = "checkbox"; inputElement.attributes['class'] = 'todo';
elem.attributes["checked"] = value.toString(); inputElement.attributes['type'] = 'checkbox';
elem.attributes["xUpperCase"] = "false"; inputElement.attributes['disabled'] = 'disabled';
elem.attributes["text"] = text; if (value) {
inputElement.attributes['checked'] = 'checked';
}
return ChecklistItem.fromMarkdownElement(elem); var liElement = md.Element('li', [inputElement, md.Text(' $text')]);
liElement.attributes['class'] = 'todo';
// FIXME: Come on, there must be a simpler way
return ChecklistItem.fromMarkdownElement(liElement, null);
} }
void removeItem(ChecklistItem item) { void removeItem(ChecklistItem item) {
assert(nodes.contains(item.element));
assert(items.contains(item)); assert(items.contains(item));
nodes.remove(item.element);
items.remove(item); items.remove(item);
bool foundChild = false;
var parentList = item.parentListElement;
for (var i = 0; i < parentList.children.length; i++) {
var child = parentList.children[i];
if (child == item.element) {
foundChild = true;
parentList.children.removeAt(i);
break;
}
}
assert(foundChild);
} }
ChecklistItem removeAt(int index) { ChecklistItem removeAt(int index) {
assert(index >= 0 && index <= items.length); assert(index >= 0 && index <= items.length);
var item = items[index]; var item = items[index];
assert(nodes.contains(item.element)); removeItem(item);
nodes.remove(item.element);
items.removeAt(index);
return item; return item;
} }
void addItem(ChecklistItem item) { void addItem(ChecklistItem item) {
_insertNewLineIfRequired(nodes.length - 1); if (items.isEmpty) {
var listElement = md.Element.withTag('ul');
_nodes.add(listElement);
item.parentListElement = listElement;
} else {
var prevItem = items.last;
item.parentListElement = prevItem.parentListElement;
}
items.add(item); items.add(item);
nodes.add(item.element); item.parentListElement.children.add(item.element);
} }
void insertItem(int index, ChecklistItem item) { void insertItem(int index, ChecklistItem item) {
assert(index <= items.length, "Trying to insert beyond the end"); if (index == 0 && items.isEmpty) {
if (index == 0) { addItem(item);
items.insert(0, item);
nodes.insert(0, item.element);
return; return;
} }
assert(index <= items.length, "Trying to insert beyond the end");
if (index == items.length) { if (index == items.length) {
addItem(item); addItem(item);
return; return;
} }
var prevItem = items[index]; var prevItem = index - 1 > 0 ? items[index - 1] : items[index];
var nodeIndex = nodes.indexOf(prevItem.element); item.parentListElement = prevItem.parentListElement;
var parentList = item.parentListElement;
_insertNewLineIfRequired(nodeIndex); // Insert in correct place
bool foundChild = false;
nodes.insert(nodeIndex, item.element); for (var i = 0; i < parentList.children.length; i++) {
items.insert(index, item); var child = parentList.children[i];
} if (child == prevItem.element) {
foundChild = true;
void _insertNewLineIfRequired(int pos) { parentList.children.insert(i, item.element);
if (nodes.isEmpty) return; break;
var node = nodes[pos];
if (node is md.Text) {
if (!node.text.endsWith('\n')) {
nodes.add(md.Text("\n"));
} }
} }
} assert(foundChild);
}
/// Copied from flutter-markdown - cannot be merged as we added xUpperCase and changed the regexp items.insert(index, item);
/// Parse [task list items](https://github.github.com/gfm/#task-list-items-extension-).
class TaskListSyntax extends md.InlineSyntax {
// FIXME: Waiting for dart-lang/markdown#269 to land
static final String _pattern = r'^ *\[([ xX])\] +(.*)';
TaskListSyntax() : super(_pattern, startCharacter: '['.codeUnitAt(0));
@override
bool onMatch(md.InlineParser parser, Match match) {
md.Element el = md.Element.withTag('input');
el.attributes['type'] = 'checkbox';
el.attributes['checked'] = '${match[1].trim().isNotEmpty}';
var m = match[1].trim();
if (m.isNotEmpty) {
el.attributes['xUpperCase'] = (m[0] == 'X').toString();
}
el.attributes['text'] = '${match[2]}';
parser.addNode(el);
var lenToConsume = match[0].length;
if (match.end + 1 < match.input.length) {
lenToConsume += 1; // Consume \n
}
parser.consume(lenToConsume);
return false; // We are advancing manually
} }
} }
class ChecklistBuilder implements md.NodeVisitor { class ChecklistBuilder implements md.NodeVisitor {
List<ChecklistItem> list; List<ChecklistItem> list;
md.Element listElement;
md.Element parent;
@override @override
bool visitElementBefore(md.Element element) { bool visitElementBefore(md.Element element) {
if (element.tag == 'ul' || element.tag == 'ol') {
listElement = element;
}
return true; return true;
} }
@ -220,9 +247,15 @@ class ChecklistBuilder implements md.NodeVisitor {
void visitElementAfter(md.Element el) { void visitElementAfter(md.Element el) {
final String tag = el.tag; final String tag = el.tag;
if (tag == 'input') { if (tag == 'ul' || tag == 'ol') {
if (el is md.Element && el.attributes['type'] == 'checkbox') { listElement = null;
list.add(ChecklistItem.fromMarkdownElement(el)); return;
}
if (tag == 'li') {
if (el.attributes['class'] == 'todo') {
list.add(ChecklistItem.fromMarkdownElement(el, listElement));
return;
} }
} }
//print("builder tag: $tag"); //print("builder tag: $tag");
@ -238,11 +271,45 @@ class ChecklistBuilder implements md.NodeVisitor {
} }
} }
class CustomRenderer implements md.NodeVisitor { class MarkdownRenderer implements md.NodeVisitor {
StringBuffer buffer; StringBuffer buffer;
@override @override
bool visitElementBefore(md.Element element) { bool visitElementBefore(md.Element element) {
switch (element.tag) {
case 'h1':
buffer.write('# ');
break;
case 'h2':
buffer.write('## ');
break;
case 'h3':
buffer.write('### ');
break;
case 'h4':
buffer.write('#### ');
break;
case 'h5':
buffer.write('##### ');
break;
case 'h6':
buffer.write('###### ');
break;
case 'li':
buffer.write('- ');
break;
case 'p':
case 'ul':
buffer.write('\n');
break;
}
return true; return true;
} }
@ -257,26 +324,30 @@ class CustomRenderer implements md.NodeVisitor {
final String tag = element.tag; final String tag = element.tag;
if (tag == 'input') { if (tag == 'input') {
var el = element; var attr = element.attributes;
if (el is md.Element && el.attributes['type'] == 'checkbox') { print(attr);
bool val = el.attributes['checked'] != 'false'; if (attr['class'] == 'todo' && attr['type'] == 'checkbox') {
bool val = attr.containsKey('checked');
if (val) { if (val) {
if (el.attributes['xUpperCase'] != 'false') { buffer.write('[x]');
buffer.write('[x] ');
} else {
buffer.write('[X] ');
}
} else { } else {
buffer.write('[ ] '); buffer.write('[ ]');
}
var text = el.attributes['text'];
buffer.write(text);
//print("writeElem $text#");
if (!text.endsWith('\n')) {
//print("writeElem newLine#");
buffer.write('\n');
} }
} }
return;
}
switch (tag) {
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
case 'p':
case 'li':
buffer.write('\n');
break;
} }
} }

View File

@ -376,6 +376,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.11.3+2" version: "0.11.3+2"
markd:
dependency: "direct main"
description:
path: "/Users/vishesh/src/gitjournal/markd"
relative: false
source: path
version: "2.1.3+6"
markdown: markdown:
dependency: transitive dependency: transitive
description: description:

View File

@ -41,6 +41,8 @@ dependencies:
font_awesome_flutter: ^8.7.0 font_awesome_flutter: ^8.7.0
sentry: ">=3.0.0 <4.0.0" sentry: ">=3.0.0 <4.0.0"
equatable: ^1.1.0 equatable: ^1.1.0
markd:
path: /Users/vishesh/src/gitjournal/markd
dev_dependencies: dev_dependencies:
flutter_launcher_icons: "^0.7.2" flutter_launcher_icons: "^0.7.2"

View File

@ -59,17 +59,6 @@ Booga Wooga
expect(items[3].text, "item 4"); expect(items[3].text, "item 4");
expect(items[4].text, "item 5"); expect(items[4].text, "item 5");
// Nodes
var nodes = checklist.nodes;
expect(nodes.length, equals(7));
expect(nodes[0].textContent, "# Title 1\n\nHow are you doing?\n\n");
expect(nodes[1], items[0].element);
expect(nodes[2], items[1].element);
expect(nodes[3], items[2].element);
expect(nodes[4], items[3].element);
expect(nodes[5], items[4].element);
expect(nodes[6].textContent, "\nBooga Wooga\n");
// //
// Serialization // Serialization
// //
@ -123,11 +112,13 @@ Booga Wooga
expect(items.length, equals(3)); expect(items.length, equals(3));
// Nodes // Nodes
/*
var nodes = checklist.nodes; var nodes = checklist.nodes;
expect(nodes.length, equals(3)); expect(nodes.length, equals(3));
expect(nodes[0], items[0].element); expect(nodes[0], items[0].element);
expect(nodes[1], items[1].element); expect(nodes[1], items[1].element);
expect(nodes[2], items[2].element); expect(nodes[2], items[2].element);
*/
}); });
test('Should add \\n before item when adding', () async { test('Should add \\n before item when adding', () async {
@ -226,5 +217,80 @@ Booga Wooga
note = checklist.note; note = checklist.note;
expect(note.body, "[ ] One\n[ ]Two\n[ ] Three\n[ ] Four\n[ ] Five\n"); expect(note.body, "[ ] One\n[ ]Two\n[ ] Three\n[ ] Four\n[ ] Five\n");
}); });
test('Should parse simple checklists in a list', () async {
var content = """---
title: Foo
---
# Title 1
How are you doing?
- [ ] item 1
- [x] item 2
- [x] item 3
- [ ] item 4
- [ ] item 5
Booga Wooga
""";
var notePath = p.join(tempDir.path, "note.md");
File(notePath).writeAsString(content);
var parentFolder = NotesFolderFS(null, tempDir.path);
var note = Note(parentFolder, notePath);
await note.load();
var checklist = Checklist(note);
var items = checklist.items;
expect(items.length, equals(5));
expect(items[0].checked, false);
expect(items[1].checked, true);
expect(items[2].checked, true);
expect(items[3].checked, false);
expect(items[4].checked, false);
expect(items[0].text, "item 1");
expect(items[1].text, "item 2");
expect(items[2].text, "item 3");
expect(items[3].text, "item 4");
expect(items[4].text, "item 5");
//
// Serialization
//
checklist.items[0].checked = true;
checklist.items[1].checked = false;
checklist.items[1].text = "Foo";
var item = checklist.buildItem(false, "Howdy");
checklist.addItem(item);
checklist.removeItem(checklist.items[4]);
await checklist.note.save();
var expectedContent = """---
title: Foo
---
# Title 1
How are you doing?
- [x] item 1
- [ ] Foo
- [x] item 3
- [ ] item 4
- [ ] Howdy
Booga Wooga
""";
var actualContent = File(notePath).readAsStringSync();
expect(actualContent, equals(expectedContent));
});
}); });
} }