Follow GitHub's Checklist format

Fixes #71

It's easier to not use the Markdown parser and just go line by line and
do it myself. This does make it a bit less extensible though.
This commit is contained in:
Vishesh Handa
2020-05-01 18:12:41 +02:00
parent 1bae5c7a19
commit b919f1f2ba
4 changed files with 122 additions and 315 deletions

View File

@ -1,130 +1,94 @@
import 'package:markd/markdown.dart' as md; import 'dart:convert';
import 'package:gitjournal/error_reporting.dart';
import 'package:meta/meta.dart';
import 'package:gitjournal/core/note.dart'; import 'package:gitjournal/core/note.dart';
class ChecklistItem { class ChecklistItem {
md.Element parentListElement; bool checked;
md.Element element; String text;
bool get checked { String pre;
if (element.children == null || element.children.isEmpty) { bool upperCase;
return false; int lineNo;
}
var inputEl = element.children[0] as md.Element; ChecklistItem({
assert(inputEl.attributes['class'] == 'todo'); @required this.checked,
return inputEl.attributes.containsKey('checked'); @required this.text,
} this.pre = '',
this.upperCase = false,
set checked(bool val) { this.lineNo = -1,
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 {
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) {
if (element.children == null || element.children.isEmpty) {
return;
}
if (element.children.length > 1) {
element.children[1] = md.Text(" $val");
}
}
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() => '$pre- [$_x] $text';
String get _x => checked ? upperCase ? 'X' : 'x' : ' ';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ChecklistItem &&
runtimeType == other.runtimeType &&
checked == other.checked &&
text == other.text &&
pre == other.pre &&
upperCase == other.upperCase &&
lineNo == other.lineNo;
@override
int get hashCode => text.hashCode ^ pre.hashCode ^ checked.hashCode;
} }
class Checklist { class Checklist {
Note _note; static final _pattern = RegExp(
List<ChecklistItem> items; r'^(.*)- \[([ xX])\] +(.*)$',
multiLine: false,
List<md.Node> _nodes;
Checklist(this._note) {
var doc = md.Document(
encodeHtml: false,
blockSyntaxes: md.BlockParser.standardBlockSyntaxes,
extensionSet: md.ExtensionSet.gitHubWeb,
); );
_nodes = doc.parseLines(_note.body.split('\n')); Note _note;
for (var node in _nodes) { List<ChecklistItem> items = [];
if (node is md.Element) {
var elem = node;
_printElement(elem, "");
}
}
print('---------');
var builder = ChecklistBuilder(); List<String> _lines;
items = builder.build(_nodes); bool endsWithNewLine;
Checklist(this._note) {
_lines = LineSplitter.split(_note.body).toList();
endsWithNewLine = _note.body.endsWith('\n');
for (var i = 0; i < _lines.length; i++) {
var line = _lines[i];
var match = _pattern.firstMatch(line);
if (match == null) {
continue;
} }
void _printElement(md.Element elem, String indent) { var pre = match.group(1);
print("$indent Begin ${elem.toString()}"); var state = match.group(2);
print("$indent E TAG ${elem.tag}"); var post = match.group(3);
print("$indent E ATTRIBUTES ${elem.attributes}");
print("$indent E generatedId ${elem.generatedId}"); var item = ChecklistItem(
print("$indent E children ${elem.children}"); pre: pre,
if (elem.children != null) { checked: state != ' ',
for (var child in elem.children) { upperCase: state == 'X',
if (child is md.Element) { text: post,
_printElement(child, indent + " "); lineNo: i,
} else { );
print("$indent $child - ${child.textContent}"); items.add(item);
} }
} }
}
print("$indent End ${elem.toString()}");
}
Note get note { Note get note {
if (_nodes.isEmpty) return _note; if (_lines.isEmpty) return _note;
// Remove empty trailing items for (var item in items) {
while (true) { _lines[item.lineNo] = item.toString();
if (items.isEmpty) {
break;
} }
var item = items.last; _note.body = _lines.join('\n');
if (item.checked == false && item.text.trim().isEmpty) { if (endsWithNewLine) {
removeAt(items.length - 1); _note.body += '\n';
} else {
break;
} }
}
var renderer = MarkdownRenderer();
_note.body = renderer.render(_nodes);
return _note; return _note;
} }
@ -138,227 +102,87 @@ class Checklist {
} }
ChecklistItem buildItem(bool value, String text) { ChecklistItem buildItem(bool value, String text) {
var inputElement = md.Element.withTag('input'); var item = ChecklistItem(checked: value, text: text);
inputElement.attributes['class'] = 'todo'; return item;
inputElement.attributes['type'] = 'checkbox';
inputElement.attributes['disabled'] = 'disabled';
if (value) {
inputElement.attributes['checked'] = 'checked';
}
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(items.contains(item)); assert(items.contains(item));
items.remove(item);
bool foundChild = false; var i = items.indexOf(item);
var parentList = item.parentListElement; assert(i != -1);
for (var i = 0; i < parentList.children.length; i++) { if (i == -1) {
var child = parentList.children[i]; logException(
if (child == item.element) { Exception('Checklist removeItem does not exist'),
foundChild = true; StackTrace.current,
parentList.children.removeAt(i); );
break; return;
} }
}
assert(foundChild); removeAt(i);
} }
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];
removeItem(item); items.removeAt(index);
_lines.removeAt(item.lineNo);
for (var j = index; j < items.length; j++) {
items[j].lineNo -= 1;
}
return item; return item;
} }
void addItem(ChecklistItem item) { void addItem(ChecklistItem item) {
assert(item.lineNo == -1);
if (items.isEmpty) { if (items.isEmpty) {
var listElement = md.Element.withTag('ul'); item.lineNo = _lines.length;
_nodes.add(listElement); items.add(item);
item.parentListElement = listElement; _lines.add(item.toString());
} else { return;
var prevItem = items.last;
item.parentListElement = prevItem.parentListElement;
} }
items.add(item); var prevItem = items.last;
item.parentListElement.children.add(item.element); item.lineNo = prevItem.lineNo + 1;
_lines.insert(item.lineNo, item.toString());
} }
void insertItem(int index, ChecklistItem item) { void insertItem(int index, ChecklistItem item) {
assert(index <= items.length);
if (index == 0 && items.isEmpty) { if (index == 0 && items.isEmpty) {
addItem(item); addItem(item);
return; return;
} }
assert(index <= items.length, "Trying to insert beyond the end"); if (index == 0) {
if (index == items.length) { var nextItem = items[0];
addItem(item); item.lineNo = nextItem.lineNo;
_lines.insert(item.lineNo, item.toString());
for (var item in items) {
item.lineNo++;
}
items.insert(0, item);
return; return;
} }
var prevItem = index - 1 > 0 ? items[index - 1] : items[index]; if (index == items.length) {
item.parentListElement = prevItem.parentListElement; var prevItem = items.last;
var parentList = item.parentListElement; item.lineNo = prevItem.lineNo + 1;
_lines.insert(item.lineNo, item.toString());
// Insert in correct place return;
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);
var prevItem = items[index];
item.lineNo = prevItem.lineNo;
_lines.insert(item.lineNo, item.toString());
for (var i = index; i < items.length; i++) {
items[i].lineNo++;
}
items.insert(index, item); items.insert(index, item);
} }
} }
class ChecklistBuilder implements md.NodeVisitor {
List<ChecklistItem> list;
md.Element listElement;
md.Element parent;
@override
bool visitElementBefore(md.Element element) {
if (element.tag == 'ul' || element.tag == 'ol') {
listElement = element;
}
return true;
}
@override
void visitText(md.Text text) {
//print("builder text: ${text.text}#");
}
@override
void visitElementAfter(md.Element el) {
final String tag = el.tag;
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");
}
List<ChecklistItem> build(List<md.Node> nodes) {
list = <ChecklistItem>[];
for (md.Node node in nodes) {
node.accept(this);
}
return list;
}
}
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;
}
@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 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]');
}
} else {
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;
}
}
String render(List<md.Node> nodes) {
buffer = StringBuffer();
for (final node in nodes) {
node.accept(this);
}
return buffer.toString().trimLeft();
}
}

View File

@ -413,13 +413,6 @@ 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

@ -46,8 +46,6 @@ dependencies:
equatable: ^1.1.0 equatable: ^1.1.0
purchases_flutter: ^1.1.0 purchases_flutter: ^1.1.0
cached_network_image: ^2.1.0+1 cached_network_image: ^2.1.0+1
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

@ -110,15 +110,6 @@ Booga Wooga
var checklist = Checklist(note); var checklist = Checklist(note);
var items = checklist.items; var items = checklist.items;
expect(items.length, equals(3)); 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 { test('Should add \\n before item when adding', () async {
@ -136,9 +127,10 @@ Booga Wooga
expect(items.length, equals(0)); expect(items.length, equals(0));
checklist.addItem(checklist.buildItem(false, "item")); checklist.addItem(checklist.buildItem(false, "item"));
expect(items.length, 1);
note = checklist.note; note = checklist.note;
expect(note.body, "Hi.\n- [ ] item\n"); expect(note.body, "Hi.\n- [ ] item");
}); });
test('Should not add \\n when adding after item', () async { test('Should not add \\n when adding after item', () async {
@ -158,7 +150,7 @@ Booga Wooga
checklist.addItem(checklist.buildItem(false, "item")); checklist.addItem(checklist.buildItem(false, "item"));
note = checklist.note; note = checklist.note;
expect(note.body, "- [ ] one\n- [ ] item\n"); expect(note.body, "- [ ] one\n- [ ] item");
}); });
test('insertItem works', () async { test('insertItem works', () async {
@ -173,12 +165,12 @@ Booga Wooga
var checklist = Checklist(note); var checklist = Checklist(note);
var items = checklist.items; var items = checklist.items;
expect(items.length, equals(2)); expect(items.length, 2);
checklist.insertItem(1, checklist.buildItem(false, "item")); checklist.insertItem(1, checklist.buildItem(false, "item"));
note = checklist.note; note = checklist.note;
expect(note.body, "Hi.\n- [ ] One\n- Two\n- [ ] item\n- [ ] Three\n"); expect(note.body, "Hi.\n- [ ] One\n- Two\n- [ ] item\n- [ ] Three");
}); });
test('Does not Remove empty trailing items', () async { test('Does not Remove empty trailing items', () async {
@ -194,7 +186,7 @@ Booga Wooga
var checklist = Checklist(note); var checklist = Checklist(note);
note = checklist.note; note = checklist.note;
expect(note.body, "Hi.\n- [ ] One\n- Two\n- [ ] \n- [ ] \n"); expect(note.body, "Hi.\n- [ ] One\n- Two\n- [ ] \n- [ ] ");
}); });
test('Does not add extra new line', () async { test('Does not add extra new line', () async {
@ -216,7 +208,7 @@ Booga Wooga
}); });
test('Maintain x case', () async { test('Maintain x case', () async {
var content = "[X] One\n[ ]Two"; var content = "- [X] One\n- [ ]Two";
var notePath = p.join(tempDir.path, "note448.md"); var notePath = p.join(tempDir.path, "note448.md");
await File(notePath).writeAsString(content); await File(notePath).writeAsString(content);