diff --git a/lib/core/link.dart b/lib/core/link.dart index b039782d..994c0b3b 100644 --- a/lib/core/link.dart +++ b/lib/core/link.dart @@ -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 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 visit(List nodes) { for (final node in nodes) { node.accept(this); } return links; } + + String _getText(List 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]] diff --git a/lib/core/links_loader.dart b/lib/core/links_loader.dart index 1a99da8c..269a0283 100644 --- a/lib/core/links_loader.dart +++ b/lib/core/links_loader.dart @@ -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> parseLinks(String body, String parentFolderPath) async { + Future> 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); @@ -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 _parseLinks(String body, String parentFolderPath) { +@visibleForTesting +List 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 _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 = []; 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; diff --git a/lib/core/note.dart b/lib/core/note.dart index b8fe8ee2..879d269f 100644 --- a/lib/core/note.dart +++ b/lib/core/note.dart @@ -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; } diff --git a/lib/utils/link_resolver.dart b/lib/utils/link_resolver.dart index 7fcee2b0..a1332815 100644 --- a/lib/utils/link_resolver.dart +++ b/lib/utils/link_resolver.dart @@ -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); diff --git a/lib/widgets/notes_backlinks.dart b/lib/widgets/notes_backlinks.dart index d9fabd03..e1d1f82b 100644 --- a/lib/widgets/notes_backlinks.dart +++ b/lib/widgets/notes_backlinks.dart @@ -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: () => "", ); diff --git a/test/link_resolver_test.dart b/test/link_resolver_test.dart index 8340d024..7021dba9 100644 --- a/test/link_resolver_test.dart +++ b/test/link_resolver_test.dart @@ -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); diff --git a/test/links_loader_test.dart b/test/links_loader_test.dart index b4e32fb1..55bbab06 100644 --- a/test/links_loader_test.dart +++ b/test/links_loader_test.dart @@ -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"); + }); }); } diff --git a/test/note_test.dart b/test/note_test.dart index 16e71c1d..292fefc9 100644 --- a/test/note_test.dart +++ b/test/note_test.dart @@ -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); });