diff --git a/lib/core/checklist.dart b/lib/core/checklist.dart index 41dacfbf..d06efb4e 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,111 +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(); - } else { - el.attributes['xUpperCase'] = "false"; - } - 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; } @@ -222,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"); @@ -240,11 +271,44 @@ 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': + buffer.write('\n'); + break; + } return true; } @@ -259,27 +323,33 @@ 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') { - assert(el.attributes.containsKey('xUpperCase')); - bool val = el.attributes['checked'] != 'false'; - if (val) { - if (el.attributes['xUpperCase'] != 'false') { - buffer.write('[X] '); + var attr = element.attributes; + print(attr); + if (attr['class'] == 'todo' && attr['type'] == 'checkbox') { + if (attr.containsKey('checked')) { + if (attr.containsKey('uppercase')) { + 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; } } @@ -289,6 +359,6 @@ class CustomRenderer implements md.NodeVisitor { for (final node in nodes) { node.accept(this); } - return buffer.toString(); + return buffer.toString().trimLeft(); } } diff --git a/lib/core/checklist_legacy_migrator.dart b/lib/core/checklist_legacy_migrator.dart new file mode 100644 index 00000000..8228f641 --- /dev/null +++ b/lib/core/checklist_legacy_migrator.dart @@ -0,0 +1,137 @@ +import 'package:markdown/markdown.dart' as md; + +import 'package:gitjournal/core/note.dart'; + +class ChecklistLegacyMigrator { + Note _note; + List nodes; + + ChecklistLegacyMigrator(this._note) { + var doc = md.Document( + encodeHtml: false, + inlineSyntaxes: [TaskListSyntax()], + extensionSet: md.ExtensionSet.gitHubFlavored, + ); + + nodes = doc.parseInline(_note.body); + _cleanupNodes(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'; + } + } + } + + Note get note { + if (nodes.isEmpty) return _note; + + var renderer = CustomRenderer(); + _note.body = renderer.render(nodes); + + return _note; + } +} + +/// 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 + } +} + +class CustomRenderer implements md.NodeVisitor { + StringBuffer buffer; + + @override + bool visitElementBefore(md.Element element) { + return true; + } + + @override + void visitText(md.Text text) { + //print("visitText ${text.text}#"); + buffer.write(text.text); + } + + @override + void visitElementAfter(md.Element element) { + 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'; + if (val) { + if (el.attributes['xUpperCase'] != 'false') { + buffer.write('- [x] '); + } else { + 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'); + } + } + } + } + + String render(List nodes) { + buffer = StringBuffer(); + + for (final node in nodes) { + node.accept(this); + } + return buffer.toString(); + } +} diff --git a/pubspec.lock b/pubspec.lock index 83902105..eba74c23 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -413,6 +413,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 19e91b6f..7a196d41 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,6 +46,8 @@ dependencies: equatable: ^1.1.0 purchases_flutter: ^1.1.0 cached_network_image: ^2.1.0+1 + 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 55d54bae..6a6c875a 100644 --- a/test/checklist_test.dart +++ b/test/checklist_test.dart @@ -27,11 +27,11 @@ title: Foo How are you doing? -[ ] item 1 -[x] item 2 -[x] item 3 -[ ] item 4 -[ ] item 5 +- [ ] item 1 +- [x] item 2 +- [X] item 3 +- [ ] item 4 +- [ ] item 5 Booga Wooga """; @@ -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 // @@ -92,13 +81,13 @@ title: Foo How are you doing? -[x] item 1 -[ ] Foo -[x] item 3 -[ ] item 4 +- [x] item 1 +- [ ] Foo +- [X] item 3 +- [ ] item 4 +- [ ] Howdy Booga Wooga -[ ] Howdy """; var actualContent = File(notePath).readAsStringSync(); @@ -107,9 +96,9 @@ Booga Wooga test('Should not add line breaks', () async { var content = """ -[ ] item 1 -[x] item 2 -[x] item 3"""; +- [ ] item 1 +- [x] item 2 +- [x] item 3"""; var notePath = p.join(tempDir.path, "note2.md"); await File(notePath).writeAsString(content); @@ -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 { @@ -147,11 +138,11 @@ Booga Wooga checklist.addItem(checklist.buildItem(false, "item")); note = checklist.note; - expect(note.body, "Hi.\n[ ] item\n"); + expect(note.body, "Hi.\n- [ ] item\n"); }); test('Should not add \\n when adding after item', () async { - var content = "[ ] one"; + var content = "- [ ] one"; var notePath = p.join(tempDir.path, "note13.md"); await File(notePath).writeAsString(content); @@ -167,11 +158,11 @@ Booga Wooga checklist.addItem(checklist.buildItem(false, "item")); note = checklist.note; - expect(note.body, "[ ] one\n[ ] item\n"); + expect(note.body, "- [ ] one\n- [ ] item\n"); }); test('insertItem works', () async { - var content = "Hi.\n[ ] One\nTwo\n[ ] Three"; + var content = "Hi.\n- [ ] One\n- Two\n- [ ] Three"; var notePath = p.join(tempDir.path, "note4.md"); await File(notePath).writeAsString(content); @@ -187,11 +178,11 @@ Booga Wooga checklist.insertItem(1, checklist.buildItem(false, "item")); note = checklist.note; - expect(note.body, "Hi.\n[ ] One\nTwo\n[ ] item\n[ ] Three\n"); + expect(note.body, "Hi.\n- [ ] One\n- Two\n- [ ] item\n- [ ] Three\n"); }); - test('Removes empty trailing items', () async { - var content = "Hi.\n[ ] One\nTwo\n[ ] \n[ ] "; + test('Does not Remove empty trailing items', () async { + var content = "Hi.\n- [ ] One\n- Two\n- [ ] \n- [ ] "; var notePath = p.join(tempDir.path, "note4.md"); await File(notePath).writeAsString(content); @@ -203,11 +194,11 @@ Booga Wooga var checklist = Checklist(note); note = checklist.note; - expect(note.body, "Hi.\n[ ] One\nTwo\n"); + expect(note.body, "Hi.\n- [ ] One\n- Two\n- [ ] \n- [ ] \n"); }); test('Does not add extra new line', () async { - var content = "[ ] One\n[ ]Two\n[ ] Three\n[ ] Four\n"; + var content = "- [ ] One\n- [ ]Two\n- [ ] Three\n- [ ] Four\n"; var notePath = p.join(tempDir.path, "note449.md"); await File(notePath).writeAsString(content); @@ -217,14 +208,11 @@ Booga Wooga await note.load(); var checklist = Checklist(note); - /* - for (var node in checklist.nodes) { - print("node $node - '${node.textContent}'"); - }*/ checklist.addItem(checklist.buildItem(false, "Five")); 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('Maintain x case', () async {