Overhaul Link parsing

We were mixing up header links with wiki links and the alt text. It was
a bit messy. We currently do not support linking to a particular part of
a note. Nor do we support wiki links as link references.

Fixes APP-A0
This commit is contained in:
Vishesh Handa
2020-09-14 12:20:15 +02:00
parent a58c27ed1c
commit c94ff85804
8 changed files with 181 additions and 50 deletions

View File

@ -2,10 +2,22 @@ import 'package:markdown/markdown.dart' as md;
import 'package:meta/meta.dart';
class Link {
String term;
String filePath;
String publicTerm = "";
String filePath = "";
String headingID = "";
String alt = "";
Link({@required this.term, @required this.filePath});
String wikiTerm = "";
Link({
@required this.publicTerm,
@required this.filePath,
this.headingID = "",
this.alt = "",
});
Link.wiki(this.wikiTerm);
bool get isWikiLink => wikiTerm.isNotEmpty;
@override
int get hashCode => filePath.hashCode;
@ -15,17 +27,26 @@ class Link {
identical(this, other) ||
other is Link &&
runtimeType == other.runtimeType &&
filePath == other.filePath;
filePath == other.filePath &&
publicTerm == other.publicTerm &&
wikiTerm == other.wikiTerm &&
headingID == other.headingID &&
alt == other.alt;
@override
String toString() {
return 'Link{term: $term, filePath: $filePath}';
return wikiTerm.isNotEmpty
? 'WikiLink($wikiTerm)'
: 'Link{publicTerm: $publicTerm, filePath: $filePath, headingID: $headingID}';
}
}
class LinkExtractor implements md.NodeVisitor {
final String filePath;
List<Link> links = [];
LinkExtractor(this.filePath);
@override
bool visitElementBefore(md.Element element) {
return true;
@ -42,33 +63,69 @@ class LinkExtractor implements md.NodeVisitor {
var type = el.attributes['type'] ?? "";
if (type == "wiki") {
var term = el.attributes['term'];
var link = Link(term: term, filePath: null);
var link = Link.wiki(term);
assert(link.filePath.isEmpty);
assert(link.publicTerm.isEmpty);
assert(link.alt.isEmpty);
assert(link.headingID.isEmpty);
links.add(link);
return;
}
var title = el.attributes['title'] ?? "";
if (title.isEmpty) {
for (var child in el.children) {
if (child is md.Text) {
title += child.text;
}
}
}
var alt = el.attributes['title'] ?? "";
var title = _getText(el.children);
var url = el.attributes['href'];
var link = Link(term: title, filePath: url);
if (isExternalLink(url)) {
return;
}
if (url.startsWith('#')) {
var link = Link(
publicTerm: title,
filePath: filePath,
alt: alt,
headingID: url,
);
links.add(link);
return;
}
var link = Link(publicTerm: title, filePath: url, alt: alt);
links.add(link);
return;
}
}
static bool isExternalLink(String url) {
return url.startsWith(RegExp(r'[A-Za-z]{2,5}:\/\/'));
}
List<Link> visit(List<md.Node> nodes) {
for (final node in nodes) {
node.accept(this);
}
return links;
}
String _getText(List<md.Node> nodes) {
if (nodes == null) {
return "";
}
var text = "";
for (final node in nodes) {
if (node is md.Text) {
text += node.text;
} else if (node is md.Element) {
text += _getText(node.children);
}
}
return text;
}
}
/// Parse [[term]]

View File

@ -1,6 +1,7 @@
import 'dart:io';
import 'dart:isolate';
import 'package:flutter/material.dart';
import 'package:markdown/markdown.dart' as md;
import 'package:path/path.dart' as p;
import 'package:synchronized/synchronized.dart';
@ -31,11 +32,11 @@ class LinksLoader {
});
}
Future<List<Link>> parseLinks(String body, String parentFolderPath) async {
Future<List<Link>> parseLinks({String body, String filePath}) async {
await _initIsolate();
var rec = ReceivePort();
_sendPort.send(_LoadingMessage(body, parentFolderPath, rec.sendPort));
_sendPort.send(_LoadingMessage(body, filePath, rec.sendPort));
var data = await rec.first;
assert(data is List<Link>);
@ -46,10 +47,10 @@ class LinksLoader {
class _LoadingMessage {
String body;
String parentFolderPath;
String filePath;
SendPort sendPort;
_LoadingMessage(this.body, this.parentFolderPath, this.sendPort);
_LoadingMessage(this.body, this.filePath, this.sendPort);
}
void _isolateMain(SendPort toMainSender) {
@ -60,12 +61,15 @@ void _isolateMain(SendPort toMainSender) {
assert(data is _LoadingMessage);
var msg = data as _LoadingMessage;
var links = _parseLinks(msg.body, msg.parentFolderPath);
var links = parseLinks(msg.body, msg.filePath);
msg.sendPort.send(links);
});
}
List<Link> _parseLinks(String body, String parentFolderPath) {
@visibleForTesting
List<Link> parseLinks(String body, String filePath) {
var parentFolderPath = p.dirname(filePath);
final doc = md.Document(
encodeHtml: false,
extensionSet: md.ExtensionSet.gitHubFlavored,
@ -74,29 +78,34 @@ List<Link> _parseLinks(String body, String parentFolderPath) {
var lines = body.replaceAll('\r\n', '\n').split('\n');
var nodes = doc.parseLines(lines);
var possibleLinks = LinkExtractor().visit(nodes);
var possibleLinks = LinkExtractor(filePath).visit(nodes);
var links = <Link>[];
for (var l in possibleLinks) {
var path = l.filePath;
if (path == null) {
if (l.isWikiLink) {
links.add(l);
continue;
}
var isLocal = !path.contains('://');
if (isLocal) {
l.filePath = p.join(parentFolderPath, p.normalize(l.filePath));
links.add(l);
}
l.filePath = p.join(parentFolderPath, p.normalize(l.filePath));
links.add(l);
}
doc.linkReferences.forEach((key, value) {
var path = value.destination;
var isLocal = !path.contains('://');
if (isLocal) {
links.add(Link(term: key, filePath: path));
if (LinkExtractor.isExternalLink(path)) {
return;
}
var l = Link(publicTerm: value.label, filePath: "", alt: value.title);
if (path.startsWith('#') || path.startsWith('//')) {
l.headingID = path;
l.filePath = filePath;
} else {
l.filePath = p.join(parentFolderPath, p.normalize(path));
}
links.add(l);
});
return links;

View File

@ -512,7 +512,7 @@ class Note with NotesNotifier {
return _links;
}
_links = await _linksLoader.parseLinks(_body, parent.folderPath);
_links = await _linksLoader.parseLinks(body: _body, filePath: _filePath);
return _links;
}

View File

@ -10,11 +10,12 @@ class LinkResolver {
LinkResolver(this.inputNote);
Note resolveLink(Link l) {
if (l.filePath == null) {
return resolveWikiLink(l.term);
if (l.isWikiLink) {
return resolveWikiLink(l.publicTerm);
}
var rootFolder = inputNote.parent.rootFolder;
assert(l.filePath.startsWith(rootFolder.folderPath));
var spec = l.filePath.substring(rootFolder.folderPath.length + 1);
return rootFolder.getNoteWithSpec(spec);

View File

@ -173,7 +173,11 @@ class NoteSnippet extends StatelessWidget {
var body = note.body.split('\n');
var paragraph = body.firstWhere(
(line) => line.contains('[${link.term}]'),
(String line) {
return link.isWikiLink
? line.contains('[[${link.wikiTerm}}]]')
: line.contains('[${link.publicTerm}]');
},
orElse: () => "",
);

View File

@ -158,7 +158,7 @@ void main() {
var note = rootFolder.notes[0];
var linkResolver = LinkResolver(note);
var link = Link(filePath: note.filePath, term: 'foo');
var link = Link(filePath: note.filePath, publicTerm: 'foo');
var resolvedNote = linkResolver.resolveLink(link);
expect(resolvedNote.filePath, note.filePath);

View File

@ -8,7 +8,7 @@ void main() {
[GitJournal](./gitjournal.md)
[GitJournal](gitjournal.md)
[GitJournal](gitjournal)
[GitJournal](gitjournal "alt-text")
[Google](https://google.com)
@ -19,21 +19,81 @@ void main() {
test('Should load links', () async {
var loader = LinksLoader();
var links = await loader.parseLinks(contents, "/tmp/foo");
var links = await loader.parseLinks(
body: contents,
filePath: "/tmp/foo/file.md",
);
expect(links[0].filePath, null);
expect(links[0].term, "GitJournal");
expect(links[0].filePath.isEmpty, true);
expect(links[0].headingID.isEmpty, true);
expect(links[0].alt.isEmpty, true);
expect(links[0].publicTerm.isEmpty, true);
expect(links[0].wikiTerm, "GitJournal");
expect(links[0].isWikiLink, true);
expect(links[1].filePath, "/tmp/foo/gitjournal.md");
expect(links[1].term, "GitJournal");
expect(links[1].publicTerm, "GitJournal");
expect(links[1].alt.isEmpty, true);
expect(links[1].wikiTerm.isEmpty, true);
expect(links[2].filePath, "/tmp/foo/gitjournal.md");
expect(links[2].term, "GitJournal");
expect(links[2].publicTerm, "GitJournal");
expect(links[2].alt.isEmpty, true);
expect(links[2].wikiTerm.isEmpty, true);
expect(links[3].filePath, "/tmp/foo/gitjournal");
expect(links[3].term, "GitJournal");
expect(links[3].publicTerm, "GitJournal");
expect(links[3].alt, "alt-text");
expect(links[3].wikiTerm.isEmpty, true);
expect(links.length, 4);
});
test('Foam Documentation', () async {
var contents = """
[![All Contributors](https://img.shields.io/badge/all_contributors-38-orange.svg?style=flat-square)](#contributors-)
3. Use Foam's shortcuts and autocompletions to link your thoughts together with `[[wiki-links]]`, and navigate between them to explore your knowledge graph.
4. the [[Graph Visualisation](https://foambubble.github.io/foam/graph-visualisation)], of [[Backlinking](https://foambubble.github.io/foam/backlinking)].
![Foam kitchen sink, showing a few of the key features](docs/assets/images/foam-features-dark-mode-demo.png)
Foam is licensed under the [MIT license](license).
[//begin]: # "Autogenerated link references for markdown compatibility"
[wiki-links]: wiki-links "Wiki Links"
[//end]: # "Autogenerated link references"
""";
var links = parseLinks(contents, "/tmp/foo.md");
expect(links.length, 5);
expect(links[0].filePath, "/tmp/foo.md");
expect(links[0].alt.isEmpty, true);
expect(links[0].headingID, "#contributors-");
expect(links[0].publicTerm.isEmpty, true);
expect(links[1].filePath, "/tmp/license");
expect(links[1].alt.isEmpty, true);
expect(links[1].headingID.isEmpty, true);
expect(links[1].publicTerm, "MIT license");
expect(links[2].filePath, "/tmp/foo.md");
expect(links[2].publicTerm, '//begin');
expect(links[2].headingID, "#");
expect(links[2].alt,
"Autogenerated link references for markdown compatibility");
// FIXME: link-references for wiki Links
// expect(links[3].filePath.isEmpty, true);
// expect(links[3].isWikiLink, true);
expect(links[3].headingID.isEmpty, true);
expect(links[3].alt, "Wiki Links");
expect(links[4].filePath, "/tmp/foo.md");
expect(links[4].publicTerm, '//end');
expect(links[4].headingID, "#");
expect(links[4].alt, "Autogenerated link references");
});
});
}

View File

@ -133,10 +133,10 @@ bar: Foo
var links = await note.fetchLinks();
expect(links[0].filePath, p.join(tempDir.path, "foo.md"));
expect(links[0].term, "Hi");
expect(links[0].publicTerm, "Hi");
expect(links[1].filePath, p.join(tempDir.path, "food.md"));
expect(links[1].term, "Hi2");
expect(links[1].publicTerm, "Hi2");
expect(links.length, 2);
});
@ -152,11 +152,11 @@ bar: Foo
await note.load();
var links = await note.fetchLinks();
expect(links[0].filePath, null);
expect(links[0].term, "GitJournal");
expect(links[0].isWikiLink, true);
expect(links[0].wikiTerm, "GitJournal");
expect(links[1].filePath, null);
expect(links[1].term, "Wild Fire");
expect(links[1].isWikiLink, true);
expect(links[1].wikiTerm, "Wild Fire");
expect(links.length, 2);
});