mirror of
https://github.com/GitJournal/GitJournal.git
synced 2025-06-29 02:07:39 +08:00
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:
@ -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<ChecklistItem> items;
|
||||
List<md.Node> nodes;
|
||||
|
||||
List<md.Node> _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<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';
|
||||
_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<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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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"
|
||||
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
Reference in New Issue
Block a user