diff --git a/lib/core/checklist.dart b/lib/core/checklist.dart index c80b3f26..23d305c9 100644 --- a/lib/core/checklist.dart +++ b/lib/core/checklist.dart @@ -1,27 +1,62 @@ -import 'package:markdown/markdown.dart' as md; +import 'package:markd/markdown.dart' as md; import 'package:gitjournal/core/note.dart'; class ChecklistItem { + md.Element parentListElement; md.Element element; 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) { - 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 { - 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) { - 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 String toString() => 'ChecklistItem: $checked $text'; @@ -30,51 +65,49 @@ class ChecklistItem { class Checklist { Note _note; List items; - List nodes; + + List _nodes; Checklist(this._note) { var doc = md.Document( encodeHtml: false, - inlineSyntaxes: [TaskListSyntax()], - extensionSet: md.ExtensionSet.gitHubFlavored, + blockSyntaxes: md.BlockParser.standardBlockSyntaxes, + extensionSet: md.ExtensionSet.gitHubWeb, ); - nodes = doc.parseInline(_note.body); - _cleanupNodes(nodes); - - items = ChecklistBuilder().build(nodes); - } - - void _cleanupNodes(List 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'; + _nodes = doc.parseLines(_note.body.split('\n')); + for (var node in _nodes) { + if (node is md.Element) { + var elem = node; + _printElement(elem, ""); } } + 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 { - if (nodes.isEmpty) return _note; + if (_nodes.isEmpty) return _note; // Remove empty trailing items while (true) { @@ -89,8 +122,8 @@ class Checklist { } } - var renderer = CustomRenderer(); - _note.body = renderer.render(nodes); + var renderer = MarkdownRenderer(); + _note.body = renderer.render(_nodes); return _note; } @@ -105,109 +138,103 @@ class Checklist { } ChecklistItem buildItem(bool value, String text) { - var elem = md.Element.withTag("input"); - elem.attributes["type"] = "checkbox"; - elem.attributes["checked"] = value.toString(); - elem.attributes["xUpperCase"] = "false"; - elem.attributes["text"] = text; + var inputElement = md.Element.withTag('input'); + inputElement.attributes['class'] = 'todo'; + inputElement.attributes['type'] = 'checkbox'; + inputElement.attributes['disabled'] = 'disabled'; + 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) { - assert(nodes.contains(item.element)); assert(items.contains(item)); - - nodes.remove(item.element); 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) { assert(index >= 0 && index <= items.length); var item = items[index]; - assert(nodes.contains(item.element)); - - nodes.remove(item.element); - items.removeAt(index); + removeItem(item); return 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); - nodes.add(item.element); + item.parentListElement.children.add(item.element); } void insertItem(int index, ChecklistItem item) { - assert(index <= items.length, "Trying to insert beyond the end"); - if (index == 0) { - items.insert(0, item); - nodes.insert(0, item.element); + if (index == 0 && items.isEmpty) { + addItem(item); return; } + + assert(index <= items.length, "Trying to insert beyond the end"); if (index == items.length) { addItem(item); return; } - var prevItem = items[index]; - var nodeIndex = nodes.indexOf(prevItem.element); + var prevItem = index - 1 > 0 ? items[index - 1] : items[index]; + item.parentListElement = prevItem.parentListElement; + var parentList = item.parentListElement; - _insertNewLineIfRequired(nodeIndex); - - nodes.insert(nodeIndex, item.element); - items.insert(index, item); - } - - void _insertNewLineIfRequired(int pos) { - if (nodes.isEmpty) return; - - var node = nodes[pos]; - if (node is md.Text) { - if (!node.text.endsWith('\n')) { - nodes.add(md.Text("\n")); + // Insert in correct place + bool foundChild = false; + for (var i = 0; i < parentList.children.length; i++) { + var child = parentList.children[i]; + if (child == prevItem.element) { + foundChild = true; + parentList.children.insert(i, item.element); + break; } } - } -} + assert(foundChild); -/// Copied from flutter-markdown - cannot be merged as we added xUpperCase and changed the regexp -/// 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 + items.insert(index, item); } } class ChecklistBuilder implements md.NodeVisitor { List list; + md.Element listElement; + md.Element parent; @override bool visitElementBefore(md.Element element) { + if (element.tag == 'ul' || element.tag == 'ol') { + listElement = element; + } return true; } @@ -220,9 +247,15 @@ class ChecklistBuilder implements md.NodeVisitor { void visitElementAfter(md.Element el) { final String tag = el.tag; - if (tag == 'input') { - if (el is md.Element && el.attributes['type'] == 'checkbox') { - list.add(ChecklistItem.fromMarkdownElement(el)); + if (tag == 'ul' || tag == 'ol') { + listElement = null; + return; + } + + if (tag == 'li') { + if (el.attributes['class'] == 'todo') { + list.add(ChecklistItem.fromMarkdownElement(el, listElement)); + return; } } //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; @override 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; } @@ -257,26 +324,30 @@ class CustomRenderer implements md.NodeVisitor { final String tag = element.tag; if (tag == 'input') { - var el = element; - if (el is md.Element && el.attributes['type'] == 'checkbox') { - bool val = el.attributes['checked'] != 'false'; + var attr = element.attributes; + print(attr); + if (attr['class'] == 'todo' && attr['type'] == 'checkbox') { + bool val = attr.containsKey('checked'); if (val) { - if (el.attributes['xUpperCase'] != 'false') { - buffer.write('[x] '); - } else { - buffer.write('[X] '); - } + buffer.write('[x]'); } else { - buffer.write('[ ] '); - } - var text = el.attributes['text']; - buffer.write(text); - //print("writeElem $text#"); - if (!text.endsWith('\n')) { - //print("writeElem newLine#"); - buffer.write('\n'); + buffer.write('[ ]'); } } + return; + } + + switch (tag) { + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': + case 'p': + case 'li': + buffer.write('\n'); + break; } } diff --git a/pubspec.lock b/pubspec.lock index f6e71bc2..3acd67ef 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -376,6 +376,13 @@ packages: url: "https://pub.dartlang.org" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1a2745db..33a5b8bf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,8 @@ dependencies: font_awesome_flutter: ^8.7.0 sentry: ">=3.0.0 <4.0.0" equatable: ^1.1.0 + markd: + path: /Users/vishesh/src/gitjournal/markd dev_dependencies: flutter_launcher_icons: "^0.7.2" diff --git a/test/checklist_test.dart b/test/checklist_test.dart index cc24b351..3d078ff2 100644 --- a/test/checklist_test.dart +++ b/test/checklist_test.dart @@ -59,17 +59,6 @@ Booga Wooga expect(items[3].text, "item 4"); 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 // @@ -123,11 +112,13 @@ Booga Wooga expect(items.length, equals(3)); // Nodes + /* var nodes = checklist.nodes; expect(nodes.length, equals(3)); expect(nodes[0], items[0].element); expect(nodes[1], items[1].element); expect(nodes[2], items[2].element); + */ }); test('Should add \\n before item when adding', () async { @@ -226,5 +217,80 @@ Booga Wooga note = checklist.note; 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)); + }); }); }