diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 97f223607..7e8f944f5 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -52,4 +52,5 @@ Where: * **[Li Guanglin](https://github.com/guanglinn)**
~° Added line numbers support * **[bigger124](https://github.com/bigger124)**
~° Added OrgMode-Support * **[Ayowel](https://github.com/ayowel)**
~° Mermaid update +* **[Matthew White](https://github.com/mehw)**
~° Zim-Wiki link/attachment conformance. * **[Markus Paintner](https://github.com/goli4thus)**
~° Added duplicate lines action diff --git a/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextActionButtons.java b/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextActionButtons.java index af5fb11e8..f5a6bff11 100644 --- a/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextActionButtons.java +++ b/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextActionButtons.java @@ -62,6 +62,7 @@ public List getFormatActionList() { new ActionItem(R.string.abid_common_deindent, R.drawable.ic_format_indent_decrease_black_24dp, R.string.deindent), new ActionItem(R.string.abid_wikitext_h4, R.drawable.format_header_4, R.string.heading_4), new ActionItem(R.string.abid_wikitext_h5, R.drawable.format_header_5, R.string.heading_5), + new ActionItem(R.string.abid_common_insert_audio, R.drawable.ic_keyboard_voice_black_24dp, R.string.audio), new ActionItem(R.string.abid_common_insert_image, R.drawable.ic_image_black_24dp, R.string.insert_image), new ActionItem(R.string.abid_common_insert_link, R.drawable.ic_link_black_24dp, R.string.insert_link) ); diff --git a/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextLinkResolver.java b/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextLinkResolver.java index a20fdc06b..cfc32effc 100644 --- a/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextLinkResolver.java +++ b/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextLinkResolver.java @@ -6,6 +6,7 @@ import org.apache.commons.io.FilenameUtils; import java.io.File; +import java.io.IOException; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -90,7 +91,8 @@ private String resolveWikitextPath(String wikitextPath) { if (_shouldDynamicallyDetermineRoot) { _notebookRootDir = findNotebookRootDir(_currentPage); if (_notebookRootDir == null) { - return null; + // try the current directory as a possible notebook root dir + _notebookRootDir = _currentPage.getParentFile(); } } @@ -107,7 +109,10 @@ private String resolveWikitextPath(String wikitextPath) { return findFirstPageTraversingUpToRoot(_currentPage, relativeLinkToCheck); } - return wikitextPath; // just return the original path in case the link cannot be resolved (might be a URL) + // Try to resolve the path as relative to the wiki page's attachment directory. + // Return an absolute path, or the original path in case it cannot be resolved, + // it might be a URL. + return resolveAttachmentPath(wikitextPath, _notebookRootDir, _currentPage, _shouldDynamicallyDetermineRoot); } private String stripInnerPageReference(String wikitextPath) { @@ -119,7 +124,7 @@ private String stripInnerPageReference(String wikitextPath) { return wikitextPath; } - private File findNotebookRootDir(File currentPage) { + private static File findNotebookRootDir(File currentPage) { if (currentPage != null && currentPage.exists()) { if (GsFileUtils.join(currentPage, "notebook.zim").exists()) { return currentPage; @@ -176,4 +181,148 @@ public boolean isWebLink() { public File getNotebookRootDir() { return _notebookRootDir; } + + /** + * Return a wiki file's Attachment Directory.

+ * + *

By design, a Zim wiki file's Attachment Directory should + * have the same path of the wiki file itself, without the .txt + * suffix.

+ * + *

Here, a difference from Zim is that any file can derive a + * properly named Attachment Directory, as long as that file is + * with a suffix.

+ * + * @param currentPage Current wiki file's path. + * @return {@code currentPage}'s path without suffix. + * + * @see GsFileUtils#getFilenameWithoutExtension(File) + */ + public static File findAttachmentDir(File currentPage) { + return GsFileUtils.join(currentPage.getParentFile(), GsFileUtils.getFilenameWithoutExtension(currentPage)); + } + + /** + * Return a Notebook's Root Directory.

+ * + *

By design ({@code shouldDynamicallyDetermineRoot = true}), + * a Zim Notebook's Root Directory is the closest ancestor of the + * {@code currentPage} with a notebook.zim file in it.

+ * + *

Here, a difference from Zim is that if a notebook.zim file + * can't be located, a {@code currentPage}'s current directory is + * taken as Root Directory.

+ * + *

WARNING: Removing a notebook.zim file from the Notebook or + * changing the value of {@code notebookRootDir}, may switch to a + * Root Directory different than where the Notebook was organized + * originally.

+ * + * @param notebookRootDir Root Directory when {@code shouldDynamicallyDetermineRoot == false}. + * @param currentPage Current wiki file's path used to determine the current directory. + * @param shouldDynamicallyDetermineRoot If {@code true}, return the closest ancestor of the + * {@code currentPage} with a notebook.zim file in it, or fall back to the current directory + * on failure. If {@code false}, return {@code notebookRootDir}. + * @return Identified Notebook's Root Directory. + * + * @see WikitextLinkResolver#findNotebookRootDir(File) + */ + public static File findNotebookRootDir(File notebookRootDir, File currentPage, boolean shouldDynamicallyDetermineRoot) { + if (shouldDynamicallyDetermineRoot) { + notebookRootDir = findNotebookRootDir(currentPage); + if (notebookRootDir == null) { + notebookRootDir = currentPage.getParentFile(); + } + } + return notebookRootDir; + } + + /** + * Return a system file's path as wiki attachment's path.

+ * + *

If both {@code file} and {@code currentPage} are children + * of the same Notebook's Root Directory, return a path relative + * to the {@code currentPage}'s Attachment Directory, otherwise, + * return the original {@code file}'s path.

+ * + *

Here, a difference from Zim is to always consider as Root + * Directory the {@code currentPage}'s directory before anything + * else.

+ * + * @param file System file's path to resolve as wiki attachment's path. + * @param notebookRootDir Root Directory when {@code shouldDynamicallyDetermineRoot == false}. + * @param currentPage Current wiki file's path used to determine the current Attachment Directory. + * @param shouldDynamicallyDetermineRoot If {@code true}, the Root Directory is the closest ancestor + * of the {@code currentPage} with a notebook.zim file in it, or the {@code currentPage}'s directory + * on failure. If {@code false}, the Root Directory is {@code notebookRootDir}. In either cases, a + * {@code currentPage}'s directory is always considered as Root Directory before anything else. + * @return {@code file}'s path relative to the {@code currentPage}'s Attachment Directory, when both + * {@code file} and {@code currentPage} are children of the identified Notebook's Root Directory, or + * the original {@code file}'s path otherwise. + * + * @see WikitextLinkResolver#findAttachmentDir(File) + * @see WikitextLinkResolver#findNotebookRootDir(File, File, boolean) + */ + public static String resolveSystemFilePath(File file, File notebookRootDir, File currentPage, boolean shouldDynamicallyDetermineRoot) { + final File currentDir = currentPage.getParentFile(); + notebookRootDir = findNotebookRootDir(notebookRootDir, currentPage, shouldDynamicallyDetermineRoot); + + if (GsFileUtils.isChild(currentDir, file) || + (GsFileUtils.isChild(notebookRootDir, file) && GsFileUtils.isChild(notebookRootDir, currentPage))) { + final File attachmentDir = findAttachmentDir(currentPage); + String path = GsFileUtils.relativePath(attachmentDir, file); + + // Zim prefixes also children of the Attachment Directory. + if (file.toString().endsWith("/" + path)) { + path = "./" + path; + } + + return path; + } + + return file.toString(); + } + + /** + * Return a wiki attachment's path as system absolute path.

+ * + *

If {@code path} can be resolved as a relative path to the + * {@code currentPage}'s Attachment Directory, return the result + * of {@code path} as a system absolute path. Otherwise, return + * the original {@code path}.

+ * + *

Here, a difference from Zim is to always consider as Root + * Directory the {@code currentPage}'s directory before anything + * else.

+ * + * @param path Path that might be relative to the {@code currentPage}'s Attachment Directory. + * @param notebookRootDir Root Directory when {@code shouldDynamicallyDetermineRoot == false}. + * @param currentPage Current wiki file's path used to determine the current Attachment Directory. + * @param shouldDynamicallyDetermineRoot If {@code true}, the Root Directory is the closest ancestor + * of the {@code currentPage} with a notebook.zim file in it, or the {@code currentPage}'s directory + * on failure. If {@code false}, the Root Directory is {@code notebookRootDir}. In either cases, a + * {@code currentPage}'s directory is always considered as Root Directory before anything else. + * @return {@code path} as a system absolute path, if it can be resolved as a relative path to the + * {@code currentPage}'s Attachment Directory, or the original {@code path} otherwise. + * + * @see WikitextLinkResolver#findAttachmentDir(File) + * @see WikitextLinkResolver#resolveSystemFilePath(File, File, File, boolean) + */ + public static String resolveAttachmentPath(String path, File notebookRootDir, File currentPage, boolean shouldDynamicallyDetermineRoot) { + if (path.startsWith("./") || path.startsWith("../")) { + final File attachmentDir = findAttachmentDir(currentPage); + final File file = new File(attachmentDir, path).getAbsoluteFile(); + final String resolved = resolveSystemFilePath(file, notebookRootDir, currentPage, shouldDynamicallyDetermineRoot); + + if (path.equals(resolved)) { + try { + return file.getCanonicalPath(); + } catch (IOException e) { + return file.toString(); + } + } + } + + return path; + } } diff --git a/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextTextConverter.java b/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextTextConverter.java index 82a3716ad..9b1e8247e 100644 --- a/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextTextConverter.java +++ b/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextTextConverter.java @@ -22,7 +22,10 @@ import java.io.BufferedReader; import java.io.File; import java.io.FileReader; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; import java.util.Arrays; +import java.util.Objects; import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -155,7 +158,49 @@ private String convertImage(File file, String fullMatch) { String currentPageFileName = file.getName(); String currentPageFolderName = currentPageFileName.replaceFirst(".txt$", ""); String markdownPathToImage = FilenameUtils.concat(currentPageFolderName, imagePathFromPageFolder); - return "![" + file.getName() + "](" + markdownPathToImage + ")"; + + // Zim may insert in the image link, after a '?' character, the 'id', 'width', + // 'height', 'type', and 'href' tags, separating them with a '&' character, so + // you may not want to use '?' and '&' as directory or file name: + // https://github.com/zim-desktop-wiki/zim-desktop-wiki/blob/c88cf3cb53896bf272e87704826b77e82eddb3ef/zim/formats/__init__.py#L903 + final int pos = markdownPathToImage.indexOf("?"); + if (pos != -1) { + final String image = markdownPathToImage.substring(0, pos); + final String[] options = markdownPathToImage.substring(pos + 1).split("&"); + String link = null; // or [![name](image)](link) + StringBuilder attributes = new StringBuilder(); // + // The 'type' tag is for backward compatibility of image generators before + // Zim version 0.70. Here, it probably may be ignored: + // https://github.com/zim-desktop-wiki/zim-desktop-wiki/blob/c88cf3cb53896bf272e87704826b77e82eddb3ef/zim/formats/wiki.py#586 + final Pattern tags = Pattern.compile("(id|width|height|href)=(.+)", Pattern.CASE_INSENSITIVE); + for (String item : options) { + final Matcher data = tags.matcher(item); + if (data.matches()) { + final String key = Objects.requireNonNull(data.group(1)).toLowerCase(); + String value = data.group(2); + try { + value = URLDecoder.decode(value, "UTF-8"); + } catch (UnsupportedEncodingException e) { + e.printStackTrace(); + } + if (key.equals("href")) { + link = value; + } else { + attributes.append(String.format("%s=\"%s\" ", key, value)); + } + } + } + String html = String.format("\"%s\"", image, currentPageFileName, attributes); + if (link != null) { + AppSettings settings = ApplicationObject.settings(); + File notebookDir = settings.getNotebookDirectory(); + link = WikitextLinkResolver.resolveAttachmentPath(link, notebookDir, file, settings.isWikitextDynamicNotebookRootEnabled()); + html = String.format("%s", link, html); + } + return html; + } + + return String.format("![%s](%s)", currentPageFileName, markdownPathToImage); } @Override diff --git a/app/src/main/java/net/gsantner/markor/frontend/AttachLinkOrFileDialog.java b/app/src/main/java/net/gsantner/markor/frontend/AttachLinkOrFileDialog.java index 7e3316a13..ff7a0cf2c 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/AttachLinkOrFileDialog.java +++ b/app/src/main/java/net/gsantner/markor/frontend/AttachLinkOrFileDialog.java @@ -27,6 +27,8 @@ import net.gsantner.markor.R; import net.gsantner.markor.format.FormatRegistry; import net.gsantner.markor.format.markdown.MarkdownActionButtons; +import net.gsantner.markor.format.markdown.MarkdownSyntaxHighlighter; +import net.gsantner.markor.format.wikitext.WikitextLinkResolver; import net.gsantner.markor.frontend.filebrowser.MarkorFileBrowserFactory; import net.gsantner.markor.frontend.filesearch.FileSearchDialog; import net.gsantner.markor.frontend.filesearch.FileSearchEngine; @@ -61,7 +63,7 @@ private static String getLinkFormat(final int textFormatId) { if (textFormatId == FormatRegistry.FORMAT_MARKDOWN) { return "[%TITLE%](%LINK%)"; } else if (textFormatId == FormatRegistry.FORMAT_WIKITEXT) { - return "{{%LINK%|%TITLE%}}"; + return "[[LINK|TITLE]]"; } else if (textFormatId == FormatRegistry.FORMAT_ASCIIDOC) { return "link:%LINK%[%TITLE%]"; } else if (textFormatId == FormatRegistry.FORMAT_TODOTXT) { @@ -72,6 +74,9 @@ private static String getLinkFormat(final int textFormatId) { } private static String getAudioFormat(final int textFormatId) { + if (textFormatId == FormatRegistry.FORMAT_WIKITEXT) { + return "[[LINK|TITLE]]"; + } return ""; } @@ -304,35 +309,72 @@ private static void insertItem( // If path is not under notebook, copy it to the res folder File file = new File(path); - if (!GsFileUtils.isChild(_appSettings.getNotebookDirectory(), file)) { - final File local = GsFileUtils.findNonConflictingDest(attachmentDir, file.getName()); - attachmentDir.mkdirs(); - GsFileUtils.copyFile(file, local); - file = local; - } - // Pull the appropriate title - String title = ""; - if (nameEdit != null) { - title = nameEdit.getText().toString(); - } + if (textFormatId == FormatRegistry.FORMAT_WIKITEXT) { + final File notebookDir = _appSettings.getNotebookDirectory(); + final boolean shouldDynamicallyDetermineRoot = _appSettings.isWikitextDynamicNotebookRootEnabled(); + path = WikitextLinkResolver.resolveSystemFilePath(file, notebookDir, currentFile, shouldDynamicallyDetermineRoot); + if (path.startsWith("/")) { + final File zimAttachmentDir = WikitextLinkResolver.findAttachmentDir(currentFile); + final File local = GsFileUtils.findNonConflictingDest(zimAttachmentDir, file.getName()); + zimAttachmentDir.mkdirs(); + GsFileUtils.copyFile(file, local); + file = local; + path = WikitextLinkResolver.resolveSystemFilePath(file, notebookDir, currentFile, shouldDynamicallyDetermineRoot); + } + insertLink.callback(path, path); + } else { + if (!GsFileUtils.isChild(_appSettings.getNotebookDirectory(), file)) { + final File local = GsFileUtils.findNonConflictingDest(attachmentDir, file.getName()); + attachmentDir.mkdirs(); + GsFileUtils.copyFile(file, local); + file = local; + } - if (GsTextUtils.isNullOrEmpty(title)) { - title = GsFileUtils.getFilenameWithoutExtension(file); - } + // Pull the appropriate title + String title = ""; + if (nameEdit != null) { + title = nameEdit.getText().toString(); + } - insertLink.callback(title, GsFileUtils.relativePath(currentFile, file)); + if (GsTextUtils.isNullOrEmpty(title)) { + title = GsFileUtils.getFilenameWithoutExtension(file); + } + insertLink.callback(title, GsFileUtils.relativePath(currentFile, file)); + } }; final MarkorContextUtils cu = new MarkorContextUtils(activity); final GsCallback.a1 setFields = file -> { - if (pathEdit != null) { - pathEdit.setText(GsFileUtils.relativePath(currentFile, file)); - } + if (textFormatId == FormatRegistry.FORMAT_WIKITEXT) { + // About the Zim's window 'Insert Link', where it is possible to browse for a file to select, + // Zim defaults, for the first time, to the file link's path to set the description when it's + // considered empty. Then, Zim will automatically replace a description with the path of the + // next selection only if the description had been already automatically set, or manually set + // before switching the file, to the path of the current selection. Zim will not replace the + // description that had been manually set to the path of a future selection, after exchanging + // that file. Nor Zim will replace an empty description if this happens after the first time + // a link is inserted. Here, for clarity, always replace an empty description, or one set to + // the path of the current selection, with the path of the next selection. + if (nameEdit.getText().toString().equals(pathEdit.getText().toString())) { + nameEdit.setText(""); + } + + final File notebookDir = _appSettings.getNotebookDirectory(); + final boolean shouldDynamicallyDetermineRoot = _appSettings.isWikitextDynamicNotebookRootEnabled(); + pathEdit.setText(WikitextLinkResolver.resolveSystemFilePath(file, notebookDir, currentFile, shouldDynamicallyDetermineRoot)); - if (nameEdit != null && GsTextUtils.isNullOrEmpty(nameEdit.getText())) { - nameEdit.setText(GsFileUtils.getNameWithoutExtension(file.getName())); + if (GsTextUtils.isNullOrEmpty(nameEdit.getText())) { + nameEdit.setText(pathEdit.getText()); + } + } else { + if (pathEdit != null) { + pathEdit.setText(GsFileUtils.relativePath(currentFile, file)); + } + if (nameEdit != null && GsTextUtils.isNullOrEmpty(nameEdit.getText())) { + nameEdit.setText(GsFileUtils.getNameWithoutExtension(file.getName())); + } } }; @@ -356,7 +398,8 @@ private static void insertItem( break; } - final File rel = new File(currentFile.getParentFile(), path).getAbsoluteFile(); + final File currentDir = (textFormatId == FormatRegistry.FORMAT_WIKITEXT) ? WikitextLinkResolver.findAttachmentDir(currentFile) : currentFile.getParentFile(); + final File rel = new File(currentDir, path).getAbsoluteFile(); if (rel.isFile()) { cu.requestFileEdit(activity, rel); } diff --git a/app/src/test/java/net/gsantner/markor/format/wikitext/WikitextLinkResolverTests.java b/app/src/test/java/net/gsantner/markor/format/wikitext/WikitextLinkResolverTests.java index 496b9dccf..47582fc8a 100644 --- a/app/src/test/java/net/gsantner/markor/format/wikitext/WikitextLinkResolverTests.java +++ b/app/src/test/java/net/gsantner/markor/format/wikitext/WikitextLinkResolverTests.java @@ -171,10 +171,12 @@ public void resolvesTopLevelLinkWithDynamicallyDeterminedRoot() throws IOExcepti } @Test - public void doesNotResolveTopLevelLinkIfRootCannotBeDetermined() { + public void assumesCurrentDirAsTopLevelLinkIfRootCannotBeDetermined() { WikitextLinkResolver resolver = WikitextLinkResolver.resolve("[[:Your page:The coolest page]]", null, notebookRoot.resolve("My_page/Yet_another_page.txt").toFile(), true); - assertNull(resolver.getNotebookRootDir()); - assertNull(resolver.getResolvedLink()); + Path currentDir = notebookRoot.resolve("My_page"); + assertEquals(currentDir.toFile(), resolver.getNotebookRootDir()); + Path expectedLink = currentDir.resolve("Your_page/The_coolest_page.txt"); + assertEquals(expectedLink.toString(), resolver.getResolvedLink()); } @Test