diff --git a/addon/chrome/content/diff.html b/addon/chrome/content/diff.html new file mode 100644 index 0000000..a66f76b --- /dev/null +++ b/addon/chrome/content/diff.html @@ -0,0 +1,415 @@ + + + + + + + + + +
+
+

Diff-Merger

+
+
+
+ [Note]  +
+
+ [MarkDown]  +
+
+
+
+ +
+
+ +
+
+
+
+ Last Modified: + +
+
+ Last Modified: + +
+
+ Last Synced: + +
+
+
+
+
+
Conflicted Changes
+
Raw Note
+
Rendered Note
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + + + diff --git a/addon/chrome/content/syncList.xul b/addon/chrome/content/syncList.xul index 390aef5..3442b6c 100644 --- a/addon/chrome/content/syncList.xul +++ b/addon/chrome/content/syncList.xul @@ -22,12 +22,14 @@ + + - + @@ -44,7 +46,6 @@ - diff --git a/addon/chrome/locale/en-US/overlay.dtd b/addon/chrome/locale/en-US/overlay.dtd index 9f37b6f..7ceab1f 100644 --- a/addon/chrome/locale/en-US/overlay.dtd +++ b/addon/chrome/locale/en-US/overlay.dtd @@ -69,7 +69,7 @@ - + diff --git a/addon/chrome/skin/default/Knowledge4Zotero/workspace.css b/addon/chrome/skin/default/Knowledge4Zotero/workspace.css index ad5406c..a192ed9 100644 --- a/addon/chrome/skin/default/Knowledge4Zotero/workspace.css +++ b/addon/chrome/skin/default/Knowledge4Zotero/workspace.css @@ -35,6 +35,8 @@ background: #fbfbfb; border: solid #c9c9c9 1px; text-decoration: none; + user-select: none; + -moz-user-select: none; } .tool-button:hover { diff --git a/addon/defaults/preferences/defaults.js b/addon/defaults/preferences/defaults.js index bd07af1..dc6aab0 100644 --- a/addon/defaults/preferences/defaults.js +++ b/addon/defaults/preferences/defaults.js @@ -1,6 +1,6 @@ pref("extensions.zotero.Knowledge4Zotero.recentMainNoteIds", ""); pref("extensions.zotero.Knowledge4Zotero.syncNoteIds", ""); -pref("extensions.zotero.Knowledge4Zotero.syncPeriod", 10000); +pref("extensions.zotero.Knowledge4Zotero.syncPeriod", 30000); pref("extensions.zotero.Knowledge4Zotero.autoAnnotation", false); pref("extensions.zotero.Knowledge4Zotero.exportMD", true); pref("extensions.zotero.Knowledge4Zotero.exportSubMD", false); diff --git a/package.json b/package.json index 3993377..0eecd73 100644 --- a/package.json +++ b/package.json @@ -26,25 +26,40 @@ }, "homepage": "https://github.com/windingwind/zotero-better-notes#readme", "dependencies": { - "@syncfusion/ej2-base": "^20.1.50", - "@syncfusion/ej2-navigations": "^20.1.51", "asciidoctor": "^2.2.6", - "compressing": "^1.5.1", "crypto-js": "^4.1.1", - "esbuild": "^0.14.34", + "diff": "^5.1.0", + "hast-util-to-html": "^8.0.3", + "hast-util-to-mdast": "^8.4.1", + "hast-util-to-text": "^3.1.1", + "hastscript": "^7.1.0", "html-docx-js-typescript": "^0.1.5", + "prosemirror-model": "^1.18.3", "prosemirror-transform": "^1.7.0", + "rehype-format": "^4.0.1", + "rehype-parse": "^8.0.4", + "rehype-remark": "^9.1.2", + "rehype-stringify": "^9.0.3", + "remark-gfm": "^3.0.1", + "remark-math": "^5.1.1", + "remark-parse": "^10.0.1", + "remark-rehype": "^10.1.0", + "remark-stringify": "^10.0.2", "replace-in-file": "^6.3.2", "seedrandom": "^3.0.5", "tree-model": "^1.0.7", - "turndown": "^7.1.1", - "turndown-plugin-gfm": "^1.0.2" + "unified": "^10.1.2", + "unist-util-visit": "^4.1.1", + "unist-util-visit-parents": "^5.1.1", + "yamljs": "^0.3.0" }, "devDependencies": { + "@types/diff": "^5.0.2", "@types/jquery": "^3.5.14", "@types/node": "^17.0.31", - "@types/turndown": "^5.0.1", + "esbuild": "^0.14.34", + "compressing": "^1.5.1", "release-it": "^14.14.0", - "zotero-types": "^0.0.4" + "zotero-types": "^0.0.8" } } diff --git a/src/addon.ts b/src/addon.ts index 3592a9f..3c8ed02 100644 --- a/src/addon.ts +++ b/src/addon.ts @@ -17,10 +17,13 @@ import NoteUtils from "./note/noteUtils"; import NoteParse from "./note/noteParse"; import NoteExportWindow from "./note/noteExportWindow"; import NoteExport from "./note/noteExportController"; +import NoteImport from "./note/noteImportController"; +import SyncDiffWindow from "./sync/syncDiffWindow"; import EditorViews from "./editor/editorViews"; import EditorController from "./editor/editorController"; import EditorImageViewer from "./editor/imageViewerWindow"; import TemplateWindow from "./template/templateWindow"; +import { SyncUtils } from "./sync/syncUtils"; class Knowledge4Zotero { public ZoteroEvents: ZoteroEvents; @@ -35,6 +38,7 @@ class Knowledge4Zotero { // First-run wizard public WizardWindow: WizardWindow; // Sync tools + public SyncUtils: SyncUtils; public SyncInfoWindow: SyncInfoWindow; public SyncListWindow: SyncListWindow; public SyncController: SyncController; @@ -46,6 +50,8 @@ class Knowledge4Zotero { // Note tools public NoteUtils: NoteUtils; public NoteExport: NoteExport; + public NoteImport: NoteImport; + public SyncDiffWindow: SyncDiffWindow; public NoteExportWindow: NoteExportWindow; public NoteParse: NoteParse; public EditorViews: EditorViews; @@ -63,13 +69,16 @@ class Knowledge4Zotero { this.EditorController = new EditorController(this); this.EditorImageViewer = new EditorImageViewer(this); this.WizardWindow = new WizardWindow(this); + this.SyncUtils = new SyncUtils(this); this.SyncInfoWindow = new SyncInfoWindow(this); this.SyncListWindow = new SyncListWindow(this); this.SyncController = new SyncController(this); + this.SyncDiffWindow = new SyncDiffWindow(this); this.TemplateWindow = new TemplateWindow(this); this.TemplateController = new TemplateController(this); this.NoteUtils = new NoteUtils(this); this.NoteExport = new NoteExport(this); + this.NoteImport = new NoteImport(this); this.NoteExportWindow = new NoteExportWindow(this); this.NoteParse = new NoteParse(this); this.knowledge = new TemplateAPI(this); diff --git a/src/editor/editorController.ts b/src/editor/editorController.ts index d0eccfb..5e114cf 100644 --- a/src/editor/editorController.ts +++ b/src/editor/editorController.ts @@ -10,7 +10,7 @@ class EditorController extends AddonBase { instance: Zotero.EditorInstance; time: number; }>; - editorPromise: ZoteroPromise; + editorPromise: _ZoteroPromise; activeEditor: Zotero.EditorInstance; constructor(parent: Knowledge4Zotero) { diff --git a/src/editor/editorViews.ts b/src/editor/editorViews.ts index 6f87a73..790588a 100644 --- a/src/editor/editorViews.ts +++ b/src/editor/editorViews.ts @@ -341,6 +341,13 @@ class EditorViews extends AddonBase { refreshButton.classList.add("option"); refreshButton.innerText = "Refresh Editor"; refreshButton.addEventListener("click", (e) => { + if ( + !confirm( + "Refresh before content is saved may cause note data loss. Only do refresh if tables are uneditable.\nAre you sure to continue?" + ) + ) { + return; + } instance.init({ item: instance._item, viewMode: instance._viewMode, @@ -407,11 +414,21 @@ class EditorViews extends AddonBase { } Copied` ); }); + const importButton = _window.document.createElement("button"); + importButton.classList.add("option"); + importButton.innerText = "Import from MarkDown"; + importButton.addEventListener("click", async (e) => { + await this._Addon.NoteImport.doImport(noteItem, { + ignoreVersion: true, + append: true, + }); + }); dropdownPopup.append( previewButton, refreshButton, copyLinkButton, - copyLinkAtLineButton + copyLinkAtLineButton, + importButton ); } } @@ -611,7 +628,7 @@ class EditorViews extends AddonBase { ); newLines.push(templateText); const newLineString = newLines.join("\n"); - const notifyFlag: ZoteroPromise = Zotero.Promise.defer(); + const notifyFlag: _ZoteroPromise = Zotero.Promise.defer(); const notifierName = "insertLinkWait"; this._Addon.ZoteroEvents.addNotifyListener( notifierName, diff --git a/src/note/convertMD.js b/src/note/convertMD.js deleted file mode 100644 index 2af7e31..0000000 --- a/src/note/convertMD.js +++ /dev/null @@ -1,1632 +0,0 @@ -// Based on -// -// Showdown -- A JavaScript port of Markdown https://github.com/coreyti/showdown -// html2markdown -- Convert an HTML document to Markdown https://bitbucket.org/tim_heap/html2markdown/ -// -// -// Usage: -// -// var text = "Markdown *rocks*."; -// -// var converter = new Markdown.Converter(); -// var html = converter.makeHtml(text); -// -// alert(html); -// -// var markdown = converter.makeMarkdown(html); -// - -var Markdown; - -if (typeof exports === "object" && typeof require === "function") - // we're in a CommonJS (e.g. Node.js) module - Markdown = exports; -else Markdown = {}; - -// The following text is included for historical reasons, but should -// be taken with a pinch of salt; it's not all true anymore. - -// -// Wherever possible, Showdown is a straight, line-by-line port -// of the Perl version of Markdown. -// -// This is not a normal parser design; it's basically just a -// series of string substitutions. It's hard to read and -// maintain this way, but keeping Showdown close to the original -// design makes it easier to port new features. -// -// More importantly, Showdown behaves like markdown.pl in most -// edge cases. So web applications can do client-side preview -// in Javascript, and then build identical HTML on the server. -// -// This port needs the new RegExp functionality of ECMA 262, -// 3rd Edition (i.e. Javascript 1.5). Most modern web browsers -// should do fine. Even with the new regular expression features, -// We do a lot of work to emulate Perl's regex functionality. -// The tricky changes in this file mostly have the "attacklab:" -// label. Major or self-explanatory changes don't. -// -// Smart diff tools like Araxis Merge will be able to match up -// this file with markdown.pl in a useful way. A little tweaking -// helps: in a copy of markdown.pl, replace "#" with "//" and -// replace "$text" with "text". Be sure to ignore whitespace -// and line endings. -// - -// -// Usage: -// -// var text = "Markdown *rocks*."; -// -// var converter = new Markdown.Converter(); -// var html = converter.makeHtml(text); -// -// alert(html); -// -// var markdown = converter.makeMarkdown(html); -// -// alert(markdown); -// -// Note: move the sample code to the bottom of this -// file before uncommenting it. -// - -(function () { - function identity(x) { - return x; - } - function returnFalse(x) { - return false; - } - - function HookCollection() {} - - HookCollection.prototype = { - chain: function (hookname, func) { - var original = this[hookname]; - if (!original) throw new Error("unknown hook " + hookname); - - if (original === identity) this[hookname] = func; - else - this[hookname] = function (x) { - return func(original(x)); - }; - }, - set: function (hookname, func) { - if (!this[hookname]) throw new Error("unknown hook " + hookname); - this[hookname] = func; - }, - addNoop: function (hookname) { - this[hookname] = identity; - }, - addFalse: function (hookname) { - this[hookname] = returnFalse; - }, - }; - - Markdown.HookCollection = HookCollection; - - // g_urls and g_titles allow arbitrary user-entered strings as keys. This - // caused an exception (and hence stopped the rendering) when the user entered - // e.g. [push] or [__proto__]. Adding a prefix to the actual key prevents this - // (since no builtin property starts with "s_"). See - // http://meta.stackoverflow.com/questions/64655/strange-wmd-bug - // (granted, switching from Array() to Object() alone would have left only __proto__ - // to be a problem) - function SaveHash() {} - SaveHash.prototype = { - set: function (key, value) { - this["s_" + key] = value; - }, - get: function (key) { - return this["s_" + key]; - }, - }; - - Markdown.Converter = function () { - var pluginHooks = (this.hooks = new HookCollection()); - pluginHooks.addNoop("plainLinkText"); // given a URL that was encountered by itself (without markup), should return the link text that's to be given to this link - pluginHooks.addNoop("preConversion"); // called with the orignal text as given to makeHtml. The result of this plugin hook is the actual markdown source that will be cooked - pluginHooks.addNoop("postConversion"); // called with the final cooked HTML code. The result of this plugin hook is the actual output of makeHtml - - // - // Private state of the converter instance: - // - - // Global hashes, used by various utility routines - var g_urls; - var g_titles; - var g_html_blocks; - - // Used to track when we're inside an ordered or unordered list - // (see _ProcessListItems() for details): - var g_list_level; - - this.makeHtml = function (text) { - // - // Main function. The order in which other subs are called here is - // essential. Link and image substitutions need to happen before - // _EscapeSpecialCharsWithinTagAttributes(), so that any *'s or _'s in the - // and tags get encoded. - // - - // This will only happen if makeHtml on the same converter instance is called from a plugin hook. - // Don't do that. - if (g_urls) throw new Error("Recursive call to converter.makeHtml"); - - // Create the private state objects. - g_urls = new SaveHash(); - g_titles = new SaveHash(); - g_html_blocks = []; - g_list_level = 0; - - text = pluginHooks.preConversion(text); - - // attacklab: Replace ~ with ~T - // This lets us use tilde as an escape char to avoid md5 hashes - // The choice of character is arbitray; anything that isn't - // magic in Markdown will work. - text = text.replace(/~/g, "~T"); - - // attacklab: Replace $ with ~D - // RegExp interprets $ as a special character - // when it's in a replacement string - text = text.replace(/\$/g, "~D"); - - // Standardize line endings - text = text.replace(/\r\n/g, "\n"); // DOS to Unix - text = text.replace(/\r/g, "\n"); // Mac to Unix - - // Make sure text begins and ends with a couple of newlines: - text = "\n\n" + text + "\n\n"; - - // Convert all tabs to spaces. - text = _Detab(text); - - // Strip any lines consisting only of spaces and tabs. - // This makes subsequent regexen easier to write, because we can - // match consecutive blank lines with /\n+/ instead of something - // contorted like /[ \t]*\n+/ . - text = text.replace(/^[ \t]+$/gm, ""); - - // Turn block-level HTML blocks into hash entries - text = _HashHTMLBlocks(text); - - // Strip link definitions, store in hashes. - text = _StripLinkDefinitions(text); - - text = _RunBlockGamut(text); - - text = _UnescapeSpecialChars(text); - - // attacklab: Restore dollar signs - text = text.replace(/~D/g, "$$"); - - // attacklab: Restore tildes - text = text.replace(/~T/g, "~"); - - text = pluginHooks.postConversion(text); - - g_html_blocks = g_titles = g_urls = null; - - return text; - }; - - function _StripLinkDefinitions(text) { - // - // Strips link definitions from text, stores the URLs and titles in - // hash references. - // - - // Link defs are in the form: ^[id]: url "optional title" - - /* - text = text.replace(/ - ^[ ]{0,3}\[(.+)\]: // id = $1 attacklab: g_tab_width - 1 - [ \t]* - \n? // maybe *one* newline - [ \t]* - ? // url = $2 - (?=\s|$) // lookahead for whitespace instead of the lookbehind removed below - [ \t]* - \n? // maybe one newline - [ \t]* - ( // (potential) title = $3 - (\n*) // any lines skipped = $4 attacklab: lookbehind removed - [ \t]+ - ["(] - (.+?) // title = $5 - [")] - [ \t]* - )? // title is optional - (?:\n+|$) - /gm, function(){...}); - */ - - text = text.replace( - /^[ ]{0,3}\[(.+)\]:[ \t]*\n?[ \t]*?(?=\s|$)[ \t]*\n?[ \t]*((\n*)["(](.+?)[")][ \t]*)?(?:\n+)/gm, - function (wholeMatch, m1, m2, m3, m4, m5) { - m1 = m1.toLowerCase(); - g_urls.set(m1, _EncodeAmpsAndAngles(m2)); // Link IDs are case-insensitive - if (m4) { - // Oops, found blank lines, so it's not a title. - // Put back the parenthetical statement we stole. - return m3; - } else if (m5) { - g_titles.set(m1, m5.replace(/"/g, """)); - } - - // Completely remove the definition from the text - return ""; - } - ); - - return text; - } - - function _HashHTMLBlocks(text) { - // Hashify HTML blocks: - // We only want to do this for block-level HTML tags, such as headers, - // lists, and tables. That's because we still want to wrap

s around - // "paragraphs" that are wrapped in non-block-level tags, such as anchors, - // phrase emphasis, and spans. The list of tags we're looking for is - // hard-coded: - var block_tags_a = - "p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math|ins|del"; - var block_tags_b = - "p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math"; - - // First, look for nested blocks, e.g.: - //

- //
- // tags for inner block must be indented. - //
- //
- // - // The outermost tags must start at the left margin for this to match, and - // the inner nested divs must be indented. - // We need to do this before the next, more liberal match, because the next - // match will start at the first `
` and stop at the first `
`. - - // attacklab: This regex can be expensive when it fails. - - /* - text = text.replace(/ - ( // save in $1 - ^ // start of line (with /m) - <($block_tags_a) // start tag = $2 - \b // word break - // attacklab: hack around khtml/pcre bug... - [^\r]*?\n // any number of lines, minimally matching - // the matching end tag - [ \t]* // trailing spaces/tabs - (?=\n+) // followed by a newline - ) // attacklab: there are sentinel newlines at end of document - /gm,function(){...}}; - */ - text = text.replace( - /^(<(p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math|ins|del)\b[^\r]*?\n<\/\2>[ \t]*(?=\n+))/gm, - hashElement - ); - - // - // Now match more liberally, simply from `\n` to `\n` - // - - /* - text = text.replace(/ - ( // save in $1 - ^ // start of line (with /m) - <($block_tags_b) // start tag = $2 - \b // word break - // attacklab: hack around khtml/pcre bug... - [^\r]*? // any number of lines, minimally matching - .* // the matching end tag - [ \t]* // trailing spaces/tabs - (?=\n+) // followed by a newline - ) // attacklab: there are sentinel newlines at end of document - /gm,function(){...}}; - */ - text = text.replace( - /^(<(p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math)\b[^\r]*?.*<\/\2>[ \t]*(?=\n+)\n)/gm, - hashElement - ); - - // Special case just for
. It was easier to make a special case than - // to make the other regex more complicated. - - /* - text = text.replace(/ - \n // Starting after a blank line - [ ]{0,3} - ( // save in $1 - (<(hr) // start tag = $2 - \b // word break - ([^<>])*? - \/?>) // the matching end tag - [ \t]* - (?=\n{2,}) // followed by a blank line - ) - /g,hashElement); - */ - text = text.replace( - /\n[ ]{0,3}((<(hr)\b([^<>])*?\/?>)[ \t]*(?=\n{2,}))/g, - hashElement - ); - - // Special case for standalone HTML comments: - - /* - text = text.replace(/ - \n\n // Starting after a blank line - [ ]{0,3} // attacklab: g_tab_width - 1 - ( // save in $1 - -]|-[^>])(?:[^-]|-[^-])*)--) // see http://www.w3.org/TR/html-markup/syntax.html#comments and http://meta.stackoverflow.com/q/95256 - > - [ \t]* - (?=\n{2,}) // followed by a blank line - ) - /g,hashElement); - */ - text = text.replace( - /\n\n[ ]{0,3}(-]|-[^>])(?:[^-]|-[^-])*)--)>[ \t]*(?=\n{2,}))/g, - hashElement - ); - - // PHP and ASP-style processor instructions ( and <%...%>) - - /* - text = text.replace(/ - (?: - \n\n // Starting after a blank line - ) - ( // save in $1 - [ ]{0,3} // attacklab: g_tab_width - 1 - (?: - <([?%]) // $2 - [^\r]*? - \2> - ) - [ \t]* - (?=\n{2,}) // followed by a blank line - ) - /g,hashElement); - */ - text = text.replace( - /(?:\n\n)([ ]{0,3}(?:<([?%])[^\r]*?\2>)[ \t]*(?=\n{2,}))/g, - hashElement - ); - - return text; - } - - function hashElement(wholeMatch, m1) { - var blockText = m1; - - // Undo double lines - blockText = blockText.replace(/^\n+/, ""); - - // strip trailing blank lines - blockText = blockText.replace(/\n+$/g, ""); - - // Replace the element text with a marker ("~KxK" where x is its key) - blockText = "\n\n~K" + (g_html_blocks.push(blockText) - 1) + "K\n\n"; - - return blockText; - } - - function _RunBlockGamut(text, doNotUnhash) { - // - // These are all the transformations that form block-level - // tags like paragraphs, headers, and list items. - // - text = _DoHeaders(text); - - // Do Horizontal Rules: - var replacement = "
\n"; - text = text.replace(/^[ ]{0,2}([ ]?\*[ ]?){3,}[ \t]*$/gm, replacement); - text = text.replace(/^[ ]{0,2}([ ]?-[ ]?){3,}[ \t]*$/gm, replacement); - text = text.replace(/^[ ]{0,2}([ ]?_[ ]?){3,}[ \t]*$/gm, replacement); - - text = _DoLists(text); - text = _DoCodeBlocks(text); - text = _DoBlockQuotes(text); - - // We already ran _HashHTMLBlocks() before, in Markdown(), but that - // was to escape raw HTML in the original Markdown source. This time, - // we're escaping the markup we've just created, so that we don't wrap - //

tags around block-level tags. - text = _HashHTMLBlocks(text); - text = _FormParagraphs(text, doNotUnhash); - - return text; - } - - function _RunSpanGamut(text) { - // - // These are all the transformations that occur *within* block-level - // tags like paragraphs, headers, and list items. - // - - text = _DoCodeSpans(text); - text = _EscapeSpecialCharsWithinTagAttributes(text); - text = _EncodeBackslashEscapes(text); - - // Process anchor and image tags. Images must come first, - // because ![foo][f] looks like an anchor. - text = _DoImages(text); - text = _DoAnchors(text); - - // Make links out of things like `` - // Must come after _DoAnchors(), because you can use < and > - // delimiters in inline links like [this](). - text = _DoAutoLinks(text); - - text = text.replace(/~P/g, "://"); // put in place to prevent autolinking; reset now - - text = _EncodeAmpsAndAngles(text); - text = _DoItalicsAndBold(text); - - // Do hard breaks: - text = text.replace(/ +\n/g, "
\n"); - - return text; - } - - function _EscapeSpecialCharsWithinTagAttributes(text) { - // - // Within tags -- meaning between < and > -- encode [\ ` * _] so they - // don't conflict with their use in Markdown for code, italics and strong. - // - - // Build a regex to find HTML tags and comments. See Friedl's - // "Mastering Regular Expressions", 2nd Ed., pp. 200-201. - - // SE: changed the comment part of the regex - - var regex = - /(<[a-z\/!$]("[^"]*"|'[^']*'|[^'">])*>|-]|-[^>])(?:[^-]|-[^-])*)--)>)/gi; - - text = text.replace(regex, function (wholeMatch) { - var tag = wholeMatch.replace(/(.)<\/?code>(?=.)/g, "$1`"); - tag = escapeCharacters( - tag, - wholeMatch.charAt(1) == "!" ? "\\`*_/" : "\\`*_" - ); // also escape slashes in comments to prevent autolinking there -- http://meta.stackoverflow.com/questions/95987 - return tag; - }); - - return text; - } - - function _DoAnchors(text) { - // - // Turn Markdown link shortcuts into XHTML
tags. - // - // - // First, handle reference-style links: [link text] [id] - // - - /* - text = text.replace(/ - ( // wrap whole match in $1 - \[ - ( - (?: - \[[^\]]*\] // allow brackets nested one level - | - [^\[] // or anything else - )* - ) - \] - [ ]? // one optional space - (?:\n[ ]*)? // one optional newline followed by spaces - \[ - (.*?) // id = $3 - \] - ) - ()()()() // pad remaining backreferences - /g, writeAnchorTag); - */ - text = text.replace( - /(\[((?:\[[^\]]*\]|[^\[\]])*)\][ ]?(?:\n[ ]*)?\[(.*?)\])()()()()/g, - writeAnchorTag - ); - - // - // Next, inline-style links: [link text](url "optional title") - // - - /* - text = text.replace(/ - ( // wrap whole match in $1 - \[ - ( - (?: - \[[^\]]*\] // allow brackets nested one level - | - [^\[\]] // or anything else - )* - ) - \] - \( // literal paren - [ \t]* - () // no id, so leave $3 empty - ? - [ \t]* - ( // $5 - (['"]) // quote char = $6 - (.*?) // Title = $7 - \6 // matching quote - [ \t]* // ignore any spaces/tabs between closing quote and ) - )? // title is optional - \) - ) - /g, writeAnchorTag); - */ - - text = text.replace( - /(\[((?:\[[^\]]*\]|[^\[\]])*)\]\([ \t]*()?[ \t]*((['"])(.*?)\6[ \t]*)?\))/g, - writeAnchorTag - ); - - // - // Last, handle reference-style shortcuts: [link text] - // These must come last in case you've also got [link test][1] - // or [link test](/foo) - // - - /* - text = text.replace(/ - ( // wrap whole match in $1 - \[ - ([^\[\]]+) // link text = $2; can't contain '[' or ']' - \] - ) - ()()()()() // pad rest of backreferences - /g, writeAnchorTag); - */ - text = text.replace(/(\[([^\[\]]+)\])()()()()()/g, writeAnchorTag); - - return text; - } - - function writeAnchorTag(wholeMatch, m1, m2, m3, m4, m5, m6, m7) { - if (m7 == undefined) m7 = ""; - var whole_match = m1; - var link_text = m2.replace(/:\/\//g, "~P"); // to prevent auto-linking withing the link. will be converted back after the auto-linker runs - var link_id = m3.toLowerCase(); - var url = m4; - var title = m7; - - if (url == "") { - if (link_id == "") { - // lower-case and turn embedded newlines into spaces - link_id = link_text.toLowerCase().replace(/ ?\n/g, " "); - } - url = "#" + link_id; - - if (g_urls.get(link_id) != undefined) { - url = g_urls.get(link_id); - if (g_titles.get(link_id) != undefined) { - title = g_titles.get(link_id); - } - } else { - if (whole_match.search(/\(\s*\)$/m) > -1) { - // Special case for explicit empty url - url = ""; - } else { - return whole_match; - } - } - } - url = encodeProblemUrlChars(url); - url = escapeCharacters(url, "*_"); - var result = '"; - - return result; - } - - function _DoImages(text) { - // - // Turn Markdown image shortcuts into tags. - // - - // - // First, handle reference-style labeled images: ![alt text][id] - // - - /* - text = text.replace(/ - ( // wrap whole match in $1 - !\[ - (.*?) // alt text = $2 - \] - [ ]? // one optional space - (?:\n[ ]*)? // one optional newline followed by spaces - \[ - (.*?) // id = $3 - \] - ) - ()()()() // pad rest of backreferences - /g, writeImageTag); - */ - text = text.replace( - /(!\[(.*?)\][ ]?(?:\n[ ]*)?\[(.*?)\])()()()()/g, - writeImageTag - ); - - // - // Next, handle inline images: ![alt text](url "optional title") - // Don't forget: encode * and _ - - /* - text = text.replace(/ - ( // wrap whole match in $1 - !\[ - (.*?) // alt text = $2 - \] - \s? // One optional whitespace character - \( // literal paren - [ \t]* - () // no id, so leave $3 empty - ? // src url = $4 - [ \t]* - ( // $5 - (['"]) // quote char = $6 - (.*?) // title = $7 - \6 // matching quote - [ \t]* - )? // title is optional - \) - ) - /g, writeImageTag); - */ - text = text.replace( - /(!\[(.*?)\]\s?\([ \t]*()?[ \t]*((['"])(.*?)\6[ \t]*)?\))/g, - writeImageTag - ); - - return text; - } - - function attributeEncode(text) { - // unconditionally replace angle brackets here -- what ends up in an attribute (e.g. alt or title) - // never makes sense to have verbatim HTML in it (and the sanitizer would totally break it) - return text - .replace(/>/g, ">") - .replace(/" + _RunSpanGamut(m1) + "\n\n"; - } - ); - - text = text.replace( - /^(.+)[ \t]*\n-+[ \t]*\n+/gm, - function (matchFound, m1) { - return "

" + _RunSpanGamut(m1) + "

\n\n"; - } - ); - - // atx-style headers: - // # Header 1 - // ## Header 2 - // ## Header 2 with closing hashes ## - // ... - // ###### Header 6 - // - - /* - text = text.replace(/ - ^(\#{1,6}) // $1 = string of #'s - [ \t]* - (.+?) // $2 = Header text - [ \t]* - \#* // optional closing #'s (not counted) - \n+ - /gm, function() {...}); - */ - - text = text.replace( - /^(\#{1,6})[ \t]*(.+?)[ \t]*\#*\n+/gm, - function (wholeMatch, m1, m2) { - var h_level = m1.length; - return ( - "" + _RunSpanGamut(m2) + "\n\n" - ); - } - ); - - return text; - } - - function _DoLists(text) { - // - // Form HTML ordered (numbered) and unordered (bulleted) lists. - // - - // attacklab: add sentinel to hack around khtml/safari bug: - // http://bugs.webkit.org/show_bug.cgi?id=11231 - text += "~0"; - - // Re-usable pattern to match any entirel ul or ol list: - - /* - var whole_list = / - ( // $1 = whole list - ( // $2 - [ ]{0,3} // attacklab: g_tab_width - 1 - ([*+-]|\d+[.]) // $3 = first list item marker - [ \t]+ - ) - [^\r]+? - ( // $4 - ~0 // sentinel for workaround; should be $ - | - \n{2,} - (?=\S) - (?! // Negative lookahead for another list item marker - [ \t]* - (?:[*+-]|\d+[.])[ \t]+ - ) - ) - ) - /g - */ - var whole_list = - /^(([ ]{0,3}([*+-]|\d+[.])[ \t]+)[^\r]+?(~0|\n{2,}(?=\S)(?![ \t]*(?:[*+-]|\d+[.])[ \t]+)))/gm; - - if (g_list_level) { - text = text.replace(whole_list, function (wholeMatch, m1, m2) { - var list = m1; - var list_type = m2.search(/[*+-]/g) > -1 ? "ul" : "ol"; - - var result = _ProcessListItems(list, list_type); - - // Trim any trailing whitespace, to put the closing `` - // up on the preceding line, to get it past the current stupid - // HTML block parser. This is a hack to work around the terrible - // hack that is the HTML block parser. - result = result.replace(/\s+$/, ""); - result = "<" + list_type + ">" + result + "\n"; - return result; - }); - } else { - whole_list = - /(\n\n|^\n?)(([ ]{0,3}([*+-]|\d+[.])[ \t]+)[^\r]+?(~0|\n{2,}(?=\S)(?![ \t]*(?:[*+-]|\d+[.])[ \t]+)))/g; - text = text.replace(whole_list, function (wholeMatch, m1, m2, m3) { - var runup = m1; - var list = m2; - - var list_type = m3.search(/[*+-]/g) > -1 ? "ul" : "ol"; - var result = _ProcessListItems(list, list_type); - result = - runup + "<" + list_type + ">\n" + result + "\n"; - return result; - }); - } - - // attacklab: strip sentinel - text = text.replace(/~0/, ""); - - return text; - } - - var _listItemMarkers = { ol: "\\d+[.]", ul: "[*+-]" }; - - function _ProcessListItems(list_str, list_type) { - // - // Process the contents of a single ordered or unordered list, splitting it - // into individual list items. - // - // list_type is either "ul" or "ol". - - // The $g_list_level global keeps track of when we're inside a list. - // Each time we enter a list, we increment it; when we leave a list, - // we decrement. If it's zero, we're not in a list anymore. - // - // We do this because when we're not inside a list, we want to treat - // something like this: - // - // I recommend upgrading to version - // 8. Oops, now this line is treated - // as a sub-list. - // - // As a single paragraph, despite the fact that the second line starts - // with a digit-period-space sequence. - // - // Whereas when we're inside a list (or sub-list), that line will be - // treated as the start of a sub-list. What a kludge, huh? This is - // an aspect of Markdown's syntax that's hard to parse perfectly - // without resorting to mind-reading. Perhaps the solution is to - // change the syntax rules such that sub-lists must start with a - // starting cardinal number; e.g. "1." or "a.". - - g_list_level++; - - // trim trailing blank lines: - list_str = list_str.replace(/\n{2,}$/, "\n"); - - // attacklab: add sentinel to emulate \z - list_str += "~0"; - - // In the original attacklab showdown, list_type was not given to this function, and anything - // that matched /[*+-]|\d+[.]/ would just create the next
  • , causing this mismatch: - // - // Markdown rendered by WMD rendered by MarkdownSharp - // ------------------------------------------------------------------ - // 1. first 1. first 1. first - // 2. second 2. second 2. second - // - third 3. third * third - // - // We changed this to behave identical to MarkdownSharp. This is the constructed RegEx, - // with {MARKER} being one of \d+[.] or [*+-], depending on list_type: - - /* - list_str = list_str.replace(/ - (^[ \t]*) // leading whitespace = $1 - ({MARKER}) [ \t]+ // list marker = $2 - ([^\r]+? // list item text = $3 - (\n+) - ) - (?= - (~0 | \2 ({MARKER}) [ \t]+) - ) - /gm, function(){...}); - */ - - var marker = _listItemMarkers[list_type]; - var re = new RegExp( - "(^[ \\t]*)(" + - marker + - ")[ \\t]+([^\\r]+?(\\n+))(?=(~0|\\1(" + - marker + - ")[ \\t]+))", - "gm" - ); - var last_item_had_a_double_newline = false; - list_str = list_str.replace(re, function (wholeMatch, m1, m2, m3) { - var item = m3; - var leading_space = m1; - var ends_with_double_newline = /\n\n$/.test(item); - var contains_double_newline = - ends_with_double_newline || item.search(/\n{2,}/) > -1; - - if (contains_double_newline || last_item_had_a_double_newline) { - item = _RunBlockGamut(_Outdent(item), /* doNotUnhash = */ true); - } else { - // Recursion for sub-lists: - item = _DoLists(_Outdent(item)); - item = item.replace(/\n$/, ""); // chomp(item) - item = _RunSpanGamut(item); - } - last_item_had_a_double_newline = ends_with_double_newline; - return "
  • " + item + "
  • \n"; - }); - - // attacklab: strip sentinel - list_str = list_str.replace(/~0/g, ""); - - g_list_level--; - return list_str; - } - - function _DoCodeBlocks(text) { - // - // Process Markdown `
    ` blocks.
    -      //
    -
    -      /*
    -             text = text.replace(/
    -             (?:\n\n|^)
    -             (                               // $1 = the code block -- one or more lines, starting with a space/tab
    -             (?:
    -             (?:[ ]{4}|\t)           // Lines must start with a tab or a tab-width of spaces - attacklab: g_tab_width
    -             .*\n+
    -             )+
    -             )
    -             (\n*[ ]{0,3}[^ \t\n]|(?=~0))    // attacklab: g_tab_width
    -             /g ,function(){...});
    -             */
    -
    -      // attacklab: sentinel workarounds for lack of \A and \Z, safari\khtml bug
    -      text += "~0";
    -
    -      text = text.replace(
    -        /(?:\n\n|^)((?:(?:[ ]{4}|\t).*\n+)+)(\n*[ ]{0,3}[^ \t\n]|(?=~0))/g,
    -        function (wholeMatch, m1, m2) {
    -          var codeblock = m1;
    -          var nextChar = m2;
    -
    -          codeblock = _EncodeCode(_Outdent(codeblock));
    -          codeblock = _Detab(codeblock);
    -          codeblock = codeblock.replace(/^\n+/g, ""); // trim leading newlines
    -          codeblock = codeblock.replace(/\n+$/g, ""); // trim trailing whitespace
    -
    -          codeblock = "
    " + codeblock + "\n
    "; - - return "\n\n" + codeblock + "\n\n" + nextChar; - } - ); - - // attacklab: strip sentinel - text = text.replace(/~0/, ""); - - return text; - } - - function hashBlock(text) { - text = text.replace(/(^\n+|\n+$)/g, ""); - return "\n\n~K" + (g_html_blocks.push(text) - 1) + "K\n\n"; - } - - function _DoCodeSpans(text) { - // - // * Backtick quotes are used for spans. - // - // * You can use multiple backticks as the delimiters if you want to - // include literal backticks in the code span. So, this input: - // - // Just type ``foo `bar` baz`` at the prompt. - // - // Will translate to: - // - //

    Just type foo `bar` baz at the prompt.

    - // - // There's no arbitrary limit to the number of backticks you - // can use as delimters. If you need three consecutive backticks - // in your code, use four for delimiters, etc. - // - // * You can use spaces to get literal backticks at the edges: - // - // ... type `` `bar` `` ... - // - // Turns to: - // - // ... type `bar` ... - // - - /* - text = text.replace(/ - (^|[^\\]) // Character before opening ` can't be a backslash - (`+) // $2 = Opening run of ` - ( // $3 = The code block - [^\r]*? - [^`] // attacklab: work around lack of lookbehind - ) - \2 // Matching closer - (?!`) - /gm, function(){...}); - */ - - text = text.replace( - /(^|[^\\])(`+)([^\r]*?[^`])\2(?!`)/gm, - function (wholeMatch, m1, m2, m3, m4) { - var c = m3; - c = c.replace(/^([ \t]*)/g, ""); // leading whitespace - c = c.replace(/[ \t]*$/g, ""); // trailing whitespace - c = _EncodeCode(c); - c = c.replace(/:\/\//g, "~P"); // to prevent auto-linking. Not necessary in code *blocks*, but in code spans. Will be converted back after the auto-linker runs. - return m1 + "" + c + ""; - } - ); - - return text; - } - - function _EncodeCode(text) { - // - // Encode/escape certain characters inside Markdown code runs. - // The point is that in code, these characters are literals, - // and lose their special Markdown meanings. - // - // Encode all ampersands; HTML entities are not - // entities within a Markdown code span. - text = text.replace(/&/g, "&"); - - // Do the angle bracket song and dance: - text = text.replace(//g, ">"); - - // Now, escape characters that are magic in Markdown: - text = escapeCharacters(text, "*_{}[]\\", false); - - // jj the line above breaks this: - //--- - - //* Item - - // 1. Subitem - - // special char: * - //--- - - return text; - } - - function _DoItalicsAndBold(text) { - // must go first: - text = text.replace( - /([\W_]|^)(\*\*|__)(?=\S)([^\r]*?\S[\*_]*)\2([\W_]|$)/g, - "$1$3$4" - ); - - text = text.replace( - /([\W_]|^)(\*|_)(?=\S)([^\r\*_]*?\S)\2([\W_]|$)/g, - "$1$3$4" - ); - - return text; - } - - function _DoBlockQuotes(text) { - /* - text = text.replace(/ - ( // Wrap whole match in $1 - ( - ^[ \t]*>[ \t]? // '>' at the start of a line - .+\n // rest of the first line - (.+\n)* // subsequent consecutive lines - \n* // blanks - )+ - ) - /gm, function(){...}); - */ - - text = text.replace( - /((^[ \t]*>[ \t]?.+\n(.+\n)*\n*)+)/gm, - function (wholeMatch, m1) { - var bq = m1; - - // attacklab: hack around Konqueror 3.5.4 bug: - // "----------bug".replace(/^-/g,"") == "bug" - - bq = bq.replace(/^[ \t]*>[ \t]?/gm, "~0"); // trim one level of quoting - - // attacklab: clean up hack - bq = bq.replace(/~0/g, ""); - - bq = bq.replace(/^[ \t]+$/gm, ""); // trim whitespace-only lines - bq = _RunBlockGamut(bq); // recurse - - bq = bq.replace(/(^|\n)/g, "$1 "); - // These leading spaces screw with
     content, so we need to fix that:
    -          bq = bq.replace(
    -            /(\s*
    [^\r]+?<\/pre>)/gm,
    -            function (wholeMatch, m1) {
    -              var pre = m1;
    -              // attacklab: hack around Konqueror 3.5.4 bug:
    -              pre = pre.replace(/^  /gm, "~0");
    -              pre = pre.replace(/~0/g, "");
    -              return pre;
    -            }
    -          );
    -
    -          return hashBlock("
    \n" + bq + "\n
    "); - } - ); - return text; - } - - function _FormParagraphs(text, doNotUnhash) { - // - // Params: - // $text - string to process with html

    tags - // - - // Strip leading and trailing lines: - text = text.replace(/^\n+/g, ""); - text = text.replace(/\n+$/g, ""); - - var grafs = text.split(/\n{2,}/g); - var grafsOut = []; - - var markerRe = /~K(\d+)K/; - - // - // Wrap

    tags. - // - var end = grafs.length; - for (var i = 0; i < end; i++) { - var str = grafs[i]; - - // if this is an HTML marker, copy it - if (markerRe.test(str)) { - grafsOut.push(str); - } else if (/\S/.test(str)) { - str = _RunSpanGamut(str); - str = str.replace(/^([ \t]*)/g, "

    "); - str += "

    "; - grafsOut.push(str); - } - } - // - // Unhashify HTML blocks - // - if (!doNotUnhash) { - end = grafsOut.length; - for (var i = 0; i < end; i++) { - var foundAny = true; - while (foundAny) { - // we may need several runs, since the data may be nested - foundAny = false; - grafsOut[i] = grafsOut[i].replace( - /~K(\d+)K/g, - function (wholeMatch, id) { - foundAny = true; - return g_html_blocks[id]; - } - ); - } - } - } - return grafsOut.join("\n\n"); - } - - function _EncodeAmpsAndAngles(text) { - // Smart processing for ampersands and angle brackets that need to be encoded. - - // Ampersand-encoding based entirely on Nat Irons's Amputator MT plugin: - // http://bumppo.net/projects/amputator/ - text = text.replace(/&(?!#?[xX]?(?:[0-9a-fA-F]+|\w+);)/g, "&"); - - // Encode naked <'s - text = text.replace(/<(?![a-z\/?\$!])/gi, "<"); - - return text; - } - - function _EncodeBackslashEscapes(text) { - // - // Parameter: String. - // Returns: The string, with after processing the following backslash - // escape sequences. - // - - // attacklab: The polite way to do this is with the new - // escapeCharacters() function: - // - // text = escapeCharacters(text,"\\",true); - // text = escapeCharacters(text,"`*_{}[]()>#+-.!",true); - // - // ...but we're sidestepping its use of the (slow) RegExp constructor - // as an optimization for Firefox. This function gets called a LOT. - - text = text.replace(/\\(\\)/g, escapeCharacters_callback); - text = text.replace( - /\\([`*_{}\[\]()>#+-.!])/g, - escapeCharacters_callback - ); - return text; - } - - function _DoAutoLinks(text) { - // note that at this point, all other URL in the text are already hyperlinked as
    - // *except* for the case - - // automatically add < and > around unadorned raw hyperlinks - // must be preceded by space/BOF and followed by non-word/EOF character - text = text.replace( - /(^|\s)(https?|ftp)(:\/\/[-A-Z0-9+&@#\/%?=~_|\[\]\(\)!:,\.;]*[-A-Z0-9+&@#\/%=~_|\[\]])($|\W)/gi, - "$1<$2$3>$4" - ); - - // autolink anything like - - var replacer = function (wholematch, m1) { - return '' + pluginHooks.plainLinkText(m1) + ""; - }; - text = text.replace(/<((https?|ftp):[^'">\s]+)>/gi, replacer); - - // Email addresses: - /* - text = text.replace(/ - < - (?:mailto:)? - ( - [-.\w]+ - \@ - [-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+ - ) - > - /gi, _DoAutoLinks_callback()); - */ - - /* disabling email autolinking, since we don't do that on the server, either - text = text.replace(/<(?:mailto:)?([-.\w]+\@[-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+)>/gi, - function(wholeMatch,m1) { - return _EncodeEmailAddress( _UnescapeSpecialChars(m1) ); - } - ); - */ - return text; - } - - function _UnescapeSpecialChars(text) { - // - // Swap back in all the special characters we've hidden. - // - text = text.replace(/~E(\d+)E/g, function (wholeMatch, m1) { - var charCodeToReplace = parseInt(m1); - return String.fromCharCode(charCodeToReplace); - }); - return text; - } - - function _Outdent(text) { - // - // Remove one level of line-leading tabs or spaces - // - - // attacklab: hack around Konqueror 3.5.4 bug: - // "----------bug".replace(/^-/g,"") == "bug" - - text = text.replace(/^(\t|[ ]{1,4})/gm, "~0"); // attacklab: g_tab_width - - // attacklab: clean up hack - text = text.replace(/~0/g, ""); - - return text; - } - - function _Detab(text) { - if (!/\t/.test(text)) return text; - - var spaces = [" ", " ", " ", " "], - skew = 0, - v; - - return text.replace(/[\n\t]/g, function (match, offset) { - if (match === "\n") { - skew = offset + 1; - return match; - } - v = (offset - skew) % 4; - skew = offset + 1; - return spaces[v]; - }); - } - - // - // attacklab: Utility functions - // - - var _problemUrlChars = /(?:["'*()[\]:]|~D)/g; - - // hex-encodes some unusual "problem" chars in URLs to avoid URL detection problems - function encodeProblemUrlChars(url) { - if (!url) return ""; - - var len = url.length; - - return url.replace(_problemUrlChars, function (match, offset) { - if (match == "~D") - // escape for dollar - return "%24"; - if (match == ":") { - if (offset == len - 1 || /[0-9\/]/.test(url.charAt(offset + 1))) - return ":"; - } - return "%" + match.charCodeAt(0).toString(16); - }); - } - - function escapeCharacters(text, charsToEscape, afterBackslash) { - // First we have to escape the escape characters so that - // we can build a character class out of them - var regexString = - "([" + charsToEscape.replace(/([\[\]\\])/g, "\\$1") + "])"; - - if (afterBackslash) { - regexString = "\\\\" + regexString; - } - - var regex = new RegExp(regexString, "g"); - text = text.replace(regex, escapeCharacters_callback); - - return text; - } - - function escapeCharacters_callback(wholeMatch, m1) { - var charCodeToEscape = m1.charCodeAt(0); - return "~E" + charCodeToEscape + "E"; - } - }; // end of the Markdown.Converter constructor -})(); - -Markdown.Converter.prototype.makeMarkdown = function (string) { - var ELEMENTS = [ - { - patterns: "p", - replacement: function (str, attrs, innerHTML) { - return innerHTML ? "\n\n" + innerHTML + "\n" : ""; - }, - }, - { - patterns: "br", - type: "void", - replacement: "\n", - }, - { - patterns: "h([1-6])", - replacement: function (str, hLevel, attrs, innerHTML) { - var hPrefix = ""; - for (var i = 0; i < hLevel; i++) { - hPrefix += "#"; - } - return "\n\n" + hPrefix + " " + innerHTML + "\n"; - }, - }, - { - patterns: "hr", - type: "void", - replacement: "\n\n* * *\n", - }, - { - patterns: "a", - replacement: function (str, attrs, innerHTML) { - var href = attrs.match(attrRegExp("href")), - title = attrs.match(attrRegExp("title")); - return href - ? "[" + - innerHTML + - "]" + - "(" + - href[1] + - (title && title[1] ? ' "' + title[1] + '"' : "") + - ")" - : str; - }, - }, - { - patterns: ["b", "strong"], - replacement: function (str, attrs, innerHTML) { - return innerHTML ? "**" + innerHTML + "**" : ""; - }, - }, - { - patterns: ["i", "em"], - replacement: function (str, attrs, innerHTML) { - return innerHTML ? "_" + innerHTML + "_" : ""; - }, - }, - { - patterns: "code", - replacement: function (str, attrs, innerHTML) { - return innerHTML ? "`" + innerHTML + "`" : ""; - }, - }, - { - patterns: "img", - type: "void", - replacement: function (str, attrs, innerHTML) { - var src = attrs.match(attrRegExp("src")), - alt = attrs.match(attrRegExp("alt")), - title = attrs.match(attrRegExp("title")); - return ( - "![" + - (alt && alt[1] ? alt[1] : "") + - "]" + - "(" + - src[1] + - (title && title[1] ? ' "' + title[1] + '"' : "") + - ")" - ); - }, - }, - ]; - - for (var i = 0, len = ELEMENTS.length; i < len; i++) { - if (typeof ELEMENTS[i].patterns === "string") { - string = replaceEls(string, { - tag: ELEMENTS[i].patterns, - replacement: ELEMENTS[i].replacement, - type: ELEMENTS[i].type, - }); - } else { - for (var j = 0, pLen = ELEMENTS[i].patterns.length; j < pLen; j++) { - string = replaceEls(string, { - tag: ELEMENTS[i].patterns[j], - replacement: ELEMENTS[i].replacement, - type: ELEMENTS[i].type, - }); - } - } - } - - function replaceEls(html, elProperties) { - var pattern = - elProperties.type === "void" - ? "<" + elProperties.tag + "\\b([^>]*)\\/?>" - : "<" + - elProperties.tag + - "\\b([^>]*)>([\\s\\S]*?)<\\/" + - elProperties.tag + - ">", - regex = new RegExp(pattern, "gi"), - markdown = ""; - if (typeof elProperties.replacement === "string") { - markdown = html.replace(regex, elProperties.replacement); - } else { - markdown = html.replace(regex, function (str, p1, p2, p3) { - return elProperties.replacement.call(this, str, p1, p2, p3); - }); - } - return markdown; - } - - function attrRegExp(attr) { - return new RegExp(attr + "\\s*=\\s*[\"']?([^\"']*)[\"']?", "i"); - } - - // Pre code blocks - - string = string.replace( - /]*>`([\s\S]*)`<\/pre>/gi, - function (str, innerHTML) { - //innerHTML = innerHTML.replace(/^\t+/g, ' '); // convert tabs to spaces (you know it makes sense) - innerHTML = innerHTML.replace(/\n/g, "\n "); - return "\n\n " + innerHTML + "\n"; - } - ); - - // Lists - - // Escape numbers that could trigger an ol - string = string.replace(/(\d+). /g, "$1\\. "); - - // Converts lists that have no child lists (of same type) first, then works it's way up - var noChildrenRegex = /<(ul|ol)\b[^>]*>(?:(?!/gi; - while (string.match(noChildrenRegex)) { - string = string.replace(noChildrenRegex, function (str) { - return replaceLists(str); - }); - } - - function replaceLists(html) { - html = html.replace( - /<(ul|ol)\b[^>]*>([\s\S]*?)<\/\1>/gi, - function (str, listType, innerHTML) { - var lis = innerHTML.split(""); - lis.splice(lis.length - 1, 1); - - for (i = 0, len = lis.length; i < len; i++) { - if (lis[i]) { - var prefix = listType === "ol" ? i + 1 + ". " : "* "; - lis[i] = lis[i].replace( - /\s*]*>([\s\S]*)/i, - function (str, innerHTML) { - innerHTML = innerHTML.replace(/^\s+/, ""); - innerHTML = innerHTML.replace(/\n\n/g, "\n\n "); - // indent nested lists - innerHTML = innerHTML.replace( - /\n([ ]*)+(\*|\d+\.) /g, - "\n$1 $2 " - ); - return prefix + innerHTML; - } - ); - } - } - return lis.join("\n"); - } - ); - return "\n\n" + html.replace(/[ \t]+\n|\s+$/g, ""); - } - - // Blockquotes - var deepest = - /]*>((?:(?!/gi; - while (string.match(deepest)) { - string = string.replace(deepest, function (str) { - return replaceBlockquotes(str); - }); - } - - function replaceBlockquotes(html) { - html = html.replace( - /]*>([\s\S]*?)<\/blockquote>/gi, - function (str, inner) { - inner = inner.replace(/^\s+|\s+$/g, ""); - inner = cleanUp(inner); - inner = inner.replace(/^/gm, "> "); - inner = inner.replace(/^(>([ \t]{2,}>)+)/gm, "> >"); - return inner; - } - ); - return html; - } - - function cleanUp(string) { - string = string.replace(/^[\t\r\n]+|[\t\r\n]+$/g, ""); // trim leading/trailing whitespace - string = string.replace(/\n\s+\n/g, "\n\n"); - string = string.replace(/\n{3,}/g, "\n\n"); // limit consecutive linebreaks to 2 - return string; - } - - return cleanUp(string); -}; - -var converter = new Markdown.Converter(); - -var Markdown2HTML = function (data) { - return converter.makeHtml(data); -}; - -var HTML2Markdown = function (data) { - return converter.makeMarkdown(data); -}; - -export { Markdown2HTML, HTML2Markdown }; diff --git a/src/note/noteExportController.ts b/src/note/noteExportController.ts index 678d46e..c781b16 100644 --- a/src/note/noteExportController.ts +++ b/src/note/noteExportController.ts @@ -14,8 +14,8 @@ class NoteExport extends AddonBase { note: Zotero.Item; filename: string; }>; - _pdfPrintPromise: ZoteroPromise; - _docxPromise: ZoteroPromise; + _pdfPrintPromise: _ZoteroPromise; + _docxPromise: _ZoteroPromise; _docxBlob: Blob; constructor(parent: Knowledge4Zotero) { @@ -182,28 +182,37 @@ class NoteExport extends AddonBase { async exportNotesToMDFiles( notes: Zotero.Item[], - useEmbed: boolean, - useSync: boolean = false + options: { + useEmbed?: boolean; + useSync?: boolean; + filedir?: string; + } = {} ) { Components.utils.import("resource://gre/modules/osfile.jsm"); this._exportFileInfo = []; - const filepath = await pick( - Zotero.getString(useSync ? "sync.sync" : "fileInterface.export") + - " MarkDown", - "folder" - ); + let filedir = + options.filedir || + (await pick( + Zotero.getString( + options.useSync ? "sync.sync" : "fileInterface.export" + ) + " MarkDown", + "folder" + )); - if (!filepath) { + filedir = Zotero.File.normalizeToUnix(filedir); + + if (!filedir) { + Zotero.debug("BN:export, filepath invalid"); return; } this._exportPath = this._Addon.NoteUtils.formatPath( - Zotero.File.pathToFile(filepath).path + "/attachments" + OS.Path.join(filedir, "attachments") ); notes = notes.filter((n) => n && n.getNote); - if (useEmbed) { + if (options.useEmbed) { for (const note of notes) { let newNote: Zotero.Item; if (this._Addon.NoteParse.parseLinkInText(note.getNote())) { @@ -233,9 +242,7 @@ class NoteExport extends AddonBase { newNote = note; } - let filename = `${ - Zotero.File.pathToFile(filepath).path - }/${await this._getFileName(note)}`; + let filename = OS.Path.join(filedir, await this._getFileName(note)); filename = filename.replace(/\\/g, "/"); await this._exportMD(newNote, filename, newNote.id !== note.id); @@ -243,6 +250,7 @@ class NoteExport extends AddonBase { } else { // Export every linked note as a markdown file // Find all linked notes that need to be exported + const inputIds = notes.map((n) => n.id); let allNoteIds: number[] = notes.map((n) => n.id); for (const note of notes) { const linkMatches = note @@ -272,77 +280,38 @@ class NoteExport extends AddonBase { link: this._Addon.NoteUtils.getNoteLink(_note), id: _note.id, note: _note, - filename: await this._getFileName(_note), + filename: await this._getFileName(_note, filedir), }); } this._exportFileInfo = noteLinkDict; for (const noteInfo of noteLinkDict) { - let exportPath = `${Zotero.File.pathToFile(filepath).path}/${ - noteInfo.filename - }`; - await this._exportMD(noteInfo.note, exportPath, false); - if (useSync) { - this._Addon.SyncController.updateNoteSyncStatus( - noteInfo.note, - Zotero.File.pathToFile(filepath).path, - noteInfo.filename - ); + let exportPath = OS.Path.join(filedir, noteInfo.filename); + if ( + options.useSync && + !inputIds.includes(noteInfo.id) && + (await OS.File.exists(exportPath)) + ) { + // Avoid overwrite existing notes that are waiting to be synced. + continue; + } + const content = await this._exportMD(noteInfo.note, exportPath, false); + if (options.useSync) { + this._Addon.SyncController.updateNoteSyncStatus(noteInfo.note, { + path: filedir, + filename: noteInfo.filename, + md5: Zotero.Utilities.Internal.md5( + this._Addon.SyncUtils.getMDStatusFromContent(content).content, + false + ), + lastsync: new Date().getTime(), + itemID: noteInfo.id, + }); } } } } - async syncNotesToMDFiles(notes: Zotero.Item[], filepath: string) { - this._exportPath = this._Addon.NoteUtils.formatPath( - Zotero.File.pathToFile(filepath).path + "/attachments" - ); - - // Export every linked note as a markdown file - // Find all linked notes that need to be exported - let allNoteIds: number[] = notes.map((n) => n.id); - for (const note of notes) { - const linkMatches = note.getNote().match(/zotero:\/\/note\/\w+\/\w+\//g); - if (!linkMatches) { - continue; - } - const subNoteIds = ( - await Promise.all( - linkMatches.map(async (link) => - this._Addon.NoteUtils.getNoteFromLink(link) - ) - ) - ) - .filter((res) => res.item) - .map((res) => res.item.id); - allNoteIds = allNoteIds.concat(subNoteIds); - } - allNoteIds = new Array(...new Set(allNoteIds)); - // console.log(allNoteIds); - const allNoteItems: Zotero.Item[] = Zotero.Items.get( - allNoteIds - ) as Zotero.Item[]; - const noteLinkDict = []; - for (const _note of allNoteItems) { - noteLinkDict.push({ - link: this._Addon.NoteUtils.getNoteLink(_note), - id: _note.id, - note: _note, - filename: await this._getFileName(_note), - }); - } - this._exportFileInfo = noteLinkDict; - - for (const note of notes) { - const syncInfo = this._Addon.SyncController.getNoteSyncStatus(note); - let exportPath = `${decodeURIComponent( - syncInfo.path - )}/${decodeURIComponent(syncInfo.filename)}`; - await this._exportMD(note, exportPath, false); - this._Addon.SyncController.updateNoteSyncStatus(note); - } - } - private async _exportDocx(filename: string) { await Zotero.File.putContentsAsync(filename, this._docxBlob); this._Addon.ZoteroViews.showProgressWindow( @@ -362,7 +331,9 @@ class NoteExport extends AddonBase { } filename = this._Addon.NoteUtils.formatPath(filename); - const content: string = await this._Addon.NoteParse.parseNoteToMD(note); + const content: string = await this._Addon.NoteParse.parseNoteToMD(note, { + withMeta: true, + }); console.log( `Exporting MD file: ${filename}, content length: ${content.length}` ); @@ -378,6 +349,7 @@ class NoteExport extends AddonBase { } await Zotero.Items.erase(note.id); } + return content; } private async _exportFreeMind(noteItem: Zotero.Item, filename: string) { @@ -392,7 +364,35 @@ class NoteExport extends AddonBase { ); } - private async _getFileName(noteItem: Zotero.Item) { + private async _getFileName( + noteItem: Zotero.Item, + filedir: string = undefined + ) { + if (filedir !== undefined && (await OS.File.exists(filedir))) { + const mdRegex = /\.(md|MD|Md|mD)$/; + let matchedFileName = null; + let matchedDate = new Date(0); + await Zotero.File.iterateDirectory( + filedir, + async (entry: OS.File.Entry) => { + if (entry.isDir) return; + if (mdRegex.test(entry.name)) { + if ( + entry.name.split(".").shift().split("-").pop() === noteItem.key + ) { + const stat = await OS.File.stat(entry.path); + if (stat.lastModificationDate > matchedDate) { + matchedFileName = entry.name; + matchedDate = stat.lastModificationDate; + } + } + } + } + ); + if (matchedFileName) { + return matchedFileName; + } + } return ( (await this._Addon.TemplateController.renderTemplateAsync( "[ExportMDFileName]", diff --git a/src/note/noteImportController.ts b/src/note/noteImportController.ts new file mode 100644 index 0000000..c078b70 --- /dev/null +++ b/src/note/noteImportController.ts @@ -0,0 +1,99 @@ +/* + * This file realizes md import. + */ + +import Knowledge4Zotero from "../addon"; +import AddonBase from "../module"; +import { pick } from "../utils"; + +class NoteImport extends AddonBase { + constructor(parent: Knowledge4Zotero) { + super(parent); + } + + async doImport( + noteItem: Zotero.Item = undefined, + options: { + ignoreVersion?: boolean; + append?: boolean; + } = {} + ) { + const filepath = await pick( + `${Zotero.getString("fileInterface.import")} MarkDown Document`, + "open", + [["MarkDown File(*.md)", "*.md"]] + ); + if (filepath) { + await this.importMDFileToNote(filepath, noteItem, options); + } + } + + async importMDFileToNote( + file: string, + noteItem: Zotero.Item = undefined, + options: { + ignoreVersion?: boolean; + append?: boolean; + } = {} + ) { + let mdStatus: MDStatus; + try { + mdStatus = await this._Addon.SyncUtils.getMDStatus(file); + } catch (e) { + Zotero.debug(`BN Import: ${String(e)}`); + } + if (!options.ignoreVersion && mdStatus.meta?.version < noteItem?._version) { + if ( + !confirm( + `The target note seems to be newer than the file ${file}. Are you sure you want to import it anyway?` + ) + ) { + return; + } + } + const noteStatus = noteItem + ? this._Addon.SyncUtils.getNoteStatus(noteItem) + : { + meta: '
    ', + content: "", + tail: "
    ", + }; + + if (!noteItem) { + noteItem = new Zotero.Item("note"); + noteItem.libraryID = ZoteroPane.getSelectedLibraryID(); + if (ZoteroPane.getCollectionTreeRow().isCollection()) { + noteItem.addToCollection(ZoteroPane.getCollectionTreeRow().ref.id); + } + await noteItem.saveTx({ + notifierData: { + autoSyncDelay: Zotero.Notes.AUTO_SYNC_DELAY, + }, + }); + } + const parsedContent = await this._Addon.NoteParse.parseMDToNote( + mdStatus, + noteItem, + true + ); + console.log("bn import", noteStatus); + + if (options.append) { + await this._Addon.NoteUtils.addLineToNote( + noteItem, + parsedContent, + Number.MAX_VALUE + ); + } else { + noteItem.setNote(noteStatus.meta + parsedContent + noteStatus.tail); + await noteItem.saveTx({ + notifierData: { + autoSyncDelay: Zotero.Notes.AUTO_SYNC_DELAY, + }, + }); + } + return noteItem; + } +} + +export default NoteImport; diff --git a/src/note/noteParse.ts b/src/note/noteParse.ts index 05bf095..a316ef3 100644 --- a/src/note/noteParse.ts +++ b/src/note/noteParse.ts @@ -2,40 +2,18 @@ * This file realizes note parse (md, html, rich-text). */ -import AddonBase from "../module"; -import { HTML2Markdown, Markdown2HTML } from "./convertMD"; -import TurndownService = require("turndown"); -const turndownPluginGfm = require("turndown-plugin-gfm"); import TreeModel = require("tree-model"); const asciidoctor = require("asciidoctor")(); -const seedrandom = require("seedrandom"); +import YAML = require("yamljs"); +import AddonBase from "../module"; +import Knowledge4Zotero from "../addon"; +import { getDOMParser } from "../utils"; +import { NodeMode } from "../sync/syncUtils"; class NoteParse extends AddonBase { - private getDOMParser(): DOMParser { - if (Zotero.platformMajorVersion > 60) { - return new DOMParser(); - } else { - return Components.classes[ - "@mozilla.org/xmlextras/domparser;1" - ].createInstance(Components.interfaces.nsIDOMParser); - } - } - - // A seedable version of Zotero.Utilities.randomString - private randomString(len: number, chars: string, seed: string) { - if (!chars) { - chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; - } - if (!len) { - len = 8; - } - let randomstring = ""; - const random: Function = seedrandom(seed); - for (let i = 0; i < len; i++) { - const rnum = Math.floor(random() * chars.length); - randomstring += chars.substring(rnum, rnum + 1); - } - return randomstring; + tools: any; + constructor(parent: Knowledge4Zotero) { + super(parent); } public parseNoteTree( @@ -107,7 +85,7 @@ class NoteParse extends AddonBase { return root; } public parseHTMLLines(html: string): string[] { - let containerIndex = html.search(/data-schema-version="8">/g); + let containerIndex = html.search(/data-schema-version="[0-9]*">/g); if (containerIndex != -1) { html = html.substring( containerIndex + 'data-schema-version="8">'.length, @@ -256,7 +234,9 @@ class NoteParse extends AddonBase { if (!annotationItem || !annotationItem.isAnnotation()) { return null; } - let json = await Zotero.Annotations.toJSON(annotationItem); + let json: AnnotationJson = await Zotero.Annotations.toJSON( + annotationItem + ); json.id = annotationItem.key; json.attachmentItemID = annotationItem.parentItem.id; delete json.key; @@ -271,15 +251,180 @@ class NoteParse extends AddonBase { } } - async parseAnnotationHTML( - note: Zotero.Item, - annotations: Zotero.Item[], - ignoreComment: boolean = false + // Zotero.EditorInstanceUtilities.serializeAnnotations + serializeAnnotations( + annotations: AnnotationJson[], + skipEmbeddingItemData: boolean = false, + skipCitation: boolean = false ) { - if (!note) { - return; + let storedCitationItems = []; + let html = ""; + for (let annotation of annotations) { + let attachmentItem = Zotero.Items.get(annotation.attachmentItemID); + if (!attachmentItem) { + continue; + } + + if ( + (!annotation.text && + !annotation.comment && + !annotation.imageAttachmentKey) || + annotation.type === "ink" + ) { + continue; + } + + let citationHTML = ""; + let imageHTML = ""; + let highlightHTML = ""; + let quotedHighlightHTML = ""; + let commentHTML = ""; + + let storedAnnotation: any = { + attachmentURI: Zotero.URI.getItemURI(attachmentItem), + annotationKey: annotation.id, + color: annotation.color, + pageLabel: annotation.pageLabel, + position: annotation.position, + }; + + // Citation + let parentItem = skipCitation + ? undefined + : attachmentItem.parentID && Zotero.Items.get(attachmentItem.parentID); + if (parentItem) { + let uris = [Zotero.URI.getItemURI(parentItem)]; + let citationItem: any = { + uris, + locator: annotation.pageLabel, + }; + + // Note: integration.js` uses `Zotero.Cite.System.prototype.retrieveItem`, + // which produces a little bit different CSL JSON + let itemData = Zotero.Utilities.Item.itemToCSLJSON(parentItem); + if (!skipEmbeddingItemData) { + citationItem.itemData = itemData; + } + + let item = storedCitationItems.find((item) => + item.uris.some((uri) => uris.includes(uri)) + ); + if (!item) { + storedCitationItems.push({ uris, itemData }); + } + + storedAnnotation.citationItem = citationItem; + let citation = { + citationItems: [citationItem], + properties: {}, + }; + + let citationWithData = JSON.parse(JSON.stringify(citation)); + citationWithData.citationItems[0].itemData = itemData; + let formatted = + Zotero.EditorInstanceUtilities.formatCitation(citationWithData); + citationHTML = `${formatted}`; + } + + // Image + if (annotation.imageAttachmentKey) { + // // let imageAttachmentKey = await this._importImage(annotation.image); + // delete annotation.image; + + // Normalize image dimensions to 1.25 of the print size + let rect = annotation.position.rects[0]; + let rectWidth = rect[2] - rect[0]; + let rectHeight = rect[3] - rect[1]; + // Constants from pdf.js + const CSS_UNITS = 96.0 / 72.0; + const PDFJS_DEFAULT_SCALE = 1.25; + let width = Math.round(rectWidth * CSS_UNITS * PDFJS_DEFAULT_SCALE); + let height = Math.round((rectHeight * width) / rectWidth); + imageHTML = ``; + } + + // Text + if (annotation.text) { + let text = Zotero.EditorInstanceUtilities._transformTextToHTML.call( + Zotero.EditorInstanceUtilities, + annotation.text.trim() + ); + highlightHTML = `${text}`; + quotedHighlightHTML = `${Zotero.getString( + "punctuation.openingQMark" + )}${text}${Zotero.getString("punctuation.closingQMark")}`; + } + + // Note + if (annotation.comment) { + commentHTML = Zotero.EditorInstanceUtilities._transformTextToHTML.call( + Zotero.EditorInstanceUtilities, + annotation.comment.trim() + ); + } + + let template; + if (annotation.type === "highlight") { + template = Zotero.Prefs.get("annotations.noteTemplates.highlight"); + } else if (annotation.type === "note") { + template = Zotero.Prefs.get("annotations.noteTemplates.note"); + } else if (annotation.type === "image") { + template = "

    {{image}}
    {{citation}} {{comment}}

    "; + } + + Zotero.debug("Using note template:"); + Zotero.debug(template); + + template = template.replace( + /(
    [^<>]*?)({{highlight}})([\s\S]*?<\/blockquote>)/g, + (match, p1, p2, p3) => p1 + "{{highlight quotes='false'}}" + p3 + ); + + let vars = { + color: annotation.color || "", + // Include quotation marks by default, but allow to disable with `quotes='false'` + highlight: (attrs) => + attrs.quotes === "false" ? highlightHTML : quotedHighlightHTML, + comment: commentHTML, + citation: citationHTML, + image: imageHTML, + tags: (attrs) => + ( + (annotation.tags && annotation.tags.map((tag) => tag.name)) || + [] + ).join(attrs.join || " "), + }; + + let templateHTML = Zotero.Utilities.Internal.generateHTMLFromTemplate( + template, + vars + ); + // Remove some spaces at the end of paragraph + templateHTML = templateHTML.replace(/([\s]*)(<\/p)/g, "$2"); + // Remove multiple spaces + templateHTML = templateHTML.replace(/\s\s+/g, " "); + html += templateHTML; } - let annotationJSONList = []; + return { html, citationItems: storedCitationItems }; + } + + async parseAnnotationHTML( + note: Zotero.Item, // If you are sure there are no image annotations, note is not required. + annotations: Zotero.Item[], + ignoreComment: boolean = false, + skipCitation: boolean = false + ) { + let annotationJSONList: AnnotationJson[] = []; for (const annot of annotations) { const annotJson = await this._Addon.NoteParse.parseAnnotation(annot); if (ignoreComment && annotJson.comment) { @@ -288,10 +433,45 @@ class NoteParse extends AddonBase { annotationJSONList.push(annotJson); } await this._Addon.NoteUtils.importImagesToNote(note, annotationJSONList); - const html = - Zotero.EditorInstanceUtilities.serializeAnnotations( - annotationJSONList - ).html; + const html = this.serializeAnnotations( + annotationJSONList, + false, + skipCitation + ).html; + return html; + } + + async parseCitationHTML(citationIds: number[]) { + let html = ""; + let items = await Zotero.Items.getAsync(citationIds); + for (let item of items) { + if ( + item.isNote() && + !(await Zotero.Notes.ensureEmbeddedImagesAreAvailable(item)) && + !Zotero.Notes.promptToIgnoreMissingImage() + ) { + return null; + } + } + + for (let item of items) { + if (item.isRegularItem()) { + let itemData = Zotero.Utilities.Item.itemToCSLJSON(item); + let citation = { + citationItems: [ + { + uris: [Zotero.URI.getItemURI(item)], + itemData, + }, + ], + properties: {}, + }; + let formatted = Zotero.EditorInstanceUtilities.formatCitation(citation); + html += `

    ${formatted}

    `; + } + } return html; } @@ -306,7 +486,7 @@ class NoteParse extends AddonBase { .join("\n")}`; console.log(this.parseHTMLLines(item.getNote()).slice(0, lineCount)); - let parser = this.getDOMParser(); + let parser = getDOMParser(); let doc = parser.parseFromString(note, "text/html"); // Make sure this is the new note @@ -419,7 +599,7 @@ class NoteParse extends AddonBase { if (noteText.search(/data-schema-version/g) === -1) { noteText = `
    ${noteText}\n
    `; } - let parser = this.getDOMParser(); + let parser = getDOMParser(); let doc = parser.parseFromString(noteText, "text/html"); let metadataContainer: HTMLElement = doc.querySelector( @@ -429,7 +609,7 @@ class NoteParse extends AddonBase { } parseLineText(line: string): string { - const parser = this.getDOMParser(); + const parser = getDOMParser(); try { if (line.search(/data-schema-version/g) === -1) { line = `
    ${line}
    `; @@ -444,12 +624,12 @@ class NoteParse extends AddonBase { } } - parseMDToHTML(str: string): string { - return Markdown2HTML(str.replace(/\u00A0/gu, " ")); + async parseMDToHTML(str: string): Promise { + return await this._Addon.SyncUtils.md2note(str.replace(/\u00A0/gu, " ")); } - parseHTMLToMD(str: string): string { - return HTML2Markdown(str); + async parseHTMLToMD(str: string): Promise { + return await this._Addon.SyncUtils.note2md(str); } parseAsciiDocToHTML(str: string): string { @@ -537,280 +717,172 @@ class NoteParse extends AddonBase { return mmXML; } - // A realization of Markdown Note.js translator async parseNoteToMD( noteItem: Zotero.Item, - options: { wrapCitation?: boolean } = {} + options: { + withMeta?: boolean; + skipSavingImages?: boolean; + backend?: "turndown" | "unified"; + } = {} ) { - const parser = this.getDOMParser(); - const doc = parser.parseFromString(noteItem.getNote() || "", "text/html"); - Components.utils.import("resource://gre/modules/osfile.jsm"); - doc.querySelectorAll("span").forEach(function (span) { - if (span.style.textDecoration === "line-through") { - let s = doc.createElement("s"); - s.append(...span.childNodes); - span.replaceWith(s); - } - }); + const noteStatus = this._Addon.SyncUtils.getNoteStatus(noteItem); + const rehype = this._Addon.SyncUtils.note2rehype(noteStatus.content); + console.log(rehype); + this._Addon.SyncUtils.processN2MRehypeHighlightNodes( + this._Addon.SyncUtils.getN2MRehypeHighlightNodes(rehype), + NodeMode.direct + ); + this._Addon.SyncUtils.processN2MRehypeCitationNodes( + this._Addon.SyncUtils.getN2MRehypeCitationNodes(rehype), + NodeMode.direct + ); + this._Addon.SyncUtils.processN2MRehypeNoteLinkNodes( + this._Addon.SyncUtils.getN2MRehypeNoteLinkNodes(rehype), + this._Addon.NoteExport._exportFileInfo, + NodeMode.direct + ); + await this._Addon.SyncUtils.processN2MRehypeImageNodes( + this._Addon.SyncUtils.getN2MRehypeImageNodes(rehype), + noteItem.libraryID, + this._Addon.NoteExport._exportPath, + options.skipSavingImages, + true, + NodeMode.direct + ); + console.log("rehype", rehype); + const remark = await this._Addon.SyncUtils.rehype2remark(rehype); + console.log("remark", remark); + let md = this._Addon.SyncUtils.remark2md(remark); - // Turndown wants pre content inside additional code block - doc.querySelectorAll("pre").forEach(function (pre) { - let code = doc.createElement("code"); - code.append(...pre.childNodes); - pre.append(code); - }); - - // Insert a PDF link for highlight and image annotation nodes - doc - .querySelectorAll('span[class="highlight"], img[data-annotation]') - .forEach((node) => { - Zotero.debug(node.outerHTML); - try { - var annotation = JSON.parse( - decodeURIComponent(node.getAttribute("data-annotation")) - ); - } catch (e) { - Zotero.debug(e); - } - - if (annotation) { - // annotation.uri was used before note-editor v4 - let uri = annotation.attachmentURI || annotation.uri; - let position = annotation.position; - Zotero.debug("----Debug Link----"); - Zotero.debug(annotation); - if (typeof uri === "string" && typeof position === "object") { - Zotero.debug(uri); - let openURI; - let uriParts = uri.split("/"); - let libraryType = uriParts[3]; - let key = uriParts[uriParts.length - 1]; - Zotero.debug(key); - if (libraryType === "users") { - openURI = "zotero://open-pdf/library/items/" + key; - } - // groups - else { - let groupID = uriParts[4]; - openURI = "zotero://open-pdf/groups/" + groupID + "/items/" + key; - } - - openURI += - "?page=" + - (position.pageIndex + 1) + - (annotation.annotationKey - ? "&annotation=" + annotation.annotationKey - : ""); - - let a = doc.createElement("a"); - a.href = openURI; - a.append("pdf"); - let fragment = doc.createDocumentFragment(); - fragment.append(" (", a, ") "); - - if (options.wrapCitation) { - const citationKey = annotation.annotationKey - ? annotation.annotationKey - : this.randomString( - 8, - Zotero.Utilities.allowedKeyChars, - Zotero.Utilities.Internal.md5( - node.getAttribute("data-annotation") - ) - ); - Zotero.Utilities.Internal.md5( - node.getAttribute("data-annotation") - ); - const beforeCitationDecorator = doc.createElement("span"); - beforeCitationDecorator.innerHTML = `<!-- bn::${citationKey} -->`; - const afterCitationDecorator = doc.createElement("span"); - afterCitationDecorator.innerHTML = `<!-- bn::${citationKey} -->`; - node.before(beforeCitationDecorator); - fragment.append(afterCitationDecorator); - } - - let nextNode = node.nextElementSibling; - if (nextNode && nextNode.classList.contains("citation")) { - nextNode.parentNode.insertBefore(fragment, nextNode.nextSibling); - } else { - node.parentNode.insertBefore(fragment, node.nextSibling); - } - } - } - }); - - console.log(doc); - - for (const img of doc.querySelectorAll("img[data-attachment-key]")) { - let imgKey = img.getAttribute("data-attachment-key"); - - const attachmentItem = await Zotero.Items.getByLibraryAndKeyAsync( - noteItem.libraryID, - imgKey - ); - Zotero.debug(attachmentItem); - - let oldFile = String(await attachmentItem.getFilePathAsync()); - Zotero.debug(oldFile); - let ext = oldFile.split(".").pop(); - let newAbsPath = this._Addon.NoteUtils.formatPath( - `${this._Addon.NoteExport._exportPath}/${imgKey}.${ext}` - ); - Zotero.debug(newAbsPath); - let newFile = oldFile; - try { - // Don't overwrite - if (await OS.File.exists(newAbsPath)) { - newFile = newAbsPath.replace(/\\/g, "/"); - } else { - newFile = Zotero.File.copyToUnique(oldFile, newAbsPath).path; - newFile = newFile.replace(/\\/g, "/"); - } - newFile = `attachments/${newFile.split(/\//).pop()}`; - } catch (e) { - Zotero.debug(e); - } - Zotero.debug(newFile); - - img.setAttribute("src", newFile ? newFile : oldFile); - img.setAttribute("alt", "image"); - } - - // Transform citations to links - doc.querySelectorAll('span[class="citation"]').forEach(function (span) { - try { - var citation = JSON.parse( - decodeURIComponent(span.getAttribute("data-citation")) - ); - } catch (e) {} - - if (citation && citation.citationItems && citation.citationItems.length) { - let uris = []; - for (let citationItem of citation.citationItems) { - let uri = citationItem.uris[0]; - if (typeof uri === "string") { - let uriParts = uri.split("/"); - let libraryType = uriParts[3]; - let key = uriParts[uriParts.length - 1]; - Zotero.debug(key); - if (libraryType === "users") { - uris.push("zotero://select/library/items/" + key); - } - // groups - else { - let groupID = uriParts[4]; - uris.push("zotero://select/groups/" + groupID + "/items/" + key); - } - } - } - - let items = Array.from(span.querySelectorAll(".citation-item")).map( - (x) => x.textContent - ); - // Fallback to pre v5 note-editor schema that was serializing citations as plain text i.e.: - // (Jang et al., 2005, p. 14; Kongsgaard et al., 2009, p. 790) - if (!items.length) { - items = span.textContent.slice(1, -1).split("; "); - } - - span.innerHTML = - "(" + - items - .map((item, i) => { - return `${item}`; - }) - .join("; ") + - ")"; - } - }); - // Overwrite escapes - const escapes: [RegExp, string][] = [ - // [/\\/g, '\\\\'], - // [/\*/g, '\\*'], - // [/^-/g, "\\-"], - [/^\+ /g, "\\+ "], - [/^(=+)/g, "\\$1"], - [/^(#{1,6}) /g, "\\$1 "], - [/`/g, "\\`"], - [/^~~~/g, "\\~~~"], - // [/^>/g, "\\>"], - // [/_/g, "\\_"], - [/^(\d+)\. /g, "$1\\. "], - ]; - if (Zotero.Prefs.get("Knowledge4Zotero.convertSquare")) { - escapes.push([/\[/g, "\\["]); - escapes.push([/\]/g, "\\]"]); - } - TurndownService.prototype.escape = function (string) { - return escapes.reduce(function (accumulator, escape) { - return accumulator.replace(escape[0], escape[1]); - }, string); - }; - // Initialize Turndown Service - let turndownService = new TurndownService({ - headingStyle: "atx", - bulletListMarker: "-", - emDelimiter: "*", - codeBlockStyle: "fenced", - }); - turndownService.use(turndownPluginGfm.gfm); - // Add math block rule - turndownService.addRule("mathBlock", { - filter: function (node) { - return node.nodeName === "PRE" && node.className === "math"; - }, - - replacement: function (content, node, options) { - return ( - "\n\n$$\n" + node.firstChild.textContent.slice(2, -2) + "\n$$\n\n" - ); - }, - }); - turndownService.addRule("inlineLinkCustom", { - filter: function (node, options) { - return ( - options.linkStyle === "inlined" && - node.nodeName === "A" && - node.getAttribute("href").length > 0 - ); - }, - - replacement: (content, node: HTMLElement, options) => { - var href = node.getAttribute("href"); - const cleanAttribute = (attribute) => - attribute ? attribute.replace(/(\n+\s*)+/g, "\n") : ""; - var title = cleanAttribute(node.getAttribute("title")); - if (title) title = ' "' + title + '"'; - if (href.search(/zotero:\/\/note\/\w+\/\w+\//g) !== -1) { - // A note link should be converted if it is in the _exportFileDict - const noteInfo = this._Addon.NoteExport._exportFileInfo.find((i) => - href.includes(i.link) - ); - if (noteInfo) { - href = `./${noteInfo.filename}`; - } - } - return "[" + content + "](" + href + title + ")"; - }, - }); - - if (Zotero.Prefs.get("Knowledge4Zotero.exportHighlight")) { - turndownService.addRule("backgroundColor", { - filter: function (node, options) { - return node.nodeName === "SPAN" && node.style["background-color"]; + if (options.withMeta) { + let yamlFrontMatter = `---\n${YAML.stringify( + { + version: noteItem._version, + // "data-citation-items": JSON.parse( + // decodeURIComponent( + // doc + // .querySelector("div[data-citation-items]") + // .getAttribute("data-citation-items") + // ) + // ), }, - - replacement: function (content, node) { - return `${content}`; - }, - }); + 10 + )}\n---`; + md = `${yamlFrontMatter}\n${md}`; } + console.log(md); + return md; + } - const parsedMD = turndownService.turndown(doc.body); - console.log(parsedMD); - return parsedMD; + async parseMDToNote( + mdStatus: MDStatus, + noteItem: Zotero.Item, + isImport: boolean = false + ) { + // let editorInstance = + // this._Addon.WorkspaceWindow.getEditorInstance(noteItem); + // if (!editorInstance) { + // ZoteroPane.openNoteWindow(noteItem.id); + // editorInstance = this._Addon.WorkspaceWindow.getEditorInstance(noteItem); + // let t = 0; + // // Wait for editor instance + // while (t < 10 && !editorInstance) { + // await Zotero.Promise.delay(500); + // t += 1; + // editorInstance = + // this._Addon.WorkspaceWindow.getEditorInstance(noteItem); + // } + // } + // if (!editorInstance) { + // Zotero.debug("BN:Import: failed to open note."); + // return; + // } + console.log("md", mdStatus); + const remark = this._Addon.SyncUtils.md2remark(mdStatus.content); + console.log("remark", remark); + const _rehype = await this._Addon.SyncUtils.remark2rehype(remark); + console.log("_rehype", _rehype); + const _note = this._Addon.SyncUtils.rehype2note(_rehype); + console.log("_note", _note); + const rehype = this._Addon.SyncUtils.note2rehype(_note); + console.log("rehype", rehype); + // Import highlight to note meta + // Annotations don't need to be processed. + // Image annotations are imported with normal images. + // const annotationNodes = getM2NRehypeAnnotationNodes(mdRehype); + // for (const node of annotationNodes) { + // try { + // // { + // // "attachmentURI": "http://zotero.org/users/uid/items/itemkey", + // // "annotationKey": "4FLVQRDG", + // // "color": "#5fb236", + // // "pageLabel": "2503", + // // "position": { + // // "pageIndex": 0, + // // "rects": [ + // // [ + // // 101.716, + // // 298.162, + // // 135.469, + // // 307.069 + // // ] + // // ] + // // }, + // // "citationItem": { + // // "uris": [ + // // "http://zotero.org/users/uid/items/itemkey" + // // ], + // // "locator": "2503" + // // } + // // } + // const dataAnnotation = JSON.parse( + // decodeURIComponent(node.properties.dataAnnotation) + // ); + // const id = dataAnnotation.citationItems.map((c) => + // Zotero.URI.getURIItemID(dataAnnotation.attachmentURI) + // ); + // const html = await this.parseAnnotationHTML(noteItem, []); + // const newNode = note2rehype(html); + // // root -> p -> span(cite, this is what we actually want) + // replace(node, (newNode.children[0] as any).children[0]); + // } catch (e) { + // Zotero.debug(e); + // console.log(e); + // continue; + // } + // } + // Check if image already belongs to note + + this._Addon.SyncUtils.processM2NRehypeHighlightNodes( + this._Addon.SyncUtils.getM2NRehypeHighlightNodes(rehype) + ); + await this._Addon.SyncUtils.processM2NRehypeCitationNodes( + this._Addon.SyncUtils.getM2NRehypeCitationNodes(rehype), + isImport + ); + this._Addon.SyncUtils.processM2NRehypeNoteLinkNodes( + this._Addon.SyncUtils.getM2NRehypeNoteLinkNodes(rehype) + ); + await this._Addon.SyncUtils.processM2NRehypeImageNodes( + this._Addon.SyncUtils.getM2NRehypeImageNodes(rehype), + noteItem, + mdStatus.filedir, + isImport + ); + console.log(rehype); + const noteContent = this._Addon.SyncUtils.rehype2note(rehype); + return noteContent; + } + + async parseNoteForDiff(noteItem: Zotero.Item) { + const noteStatus = this._Addon.SyncUtils.getNoteStatus(noteItem); + const rehype = this._Addon.SyncUtils.note2rehype(noteStatus.content); + await this._Addon.SyncUtils.processM2NRehypeCitationNodes( + this._Addon.SyncUtils.getM2NRehypeCitationNodes(rehype), + true + ); + // Prse content like ciations + return this._Addon.SyncUtils.rehype2note(rehype); } } diff --git a/src/note/noteUtils.ts b/src/note/noteUtils.ts index bd4bab9..c76fd0a 100644 --- a/src/note/noteUtils.ts +++ b/src/note/noteUtils.ts @@ -10,7 +10,7 @@ class NoteUtils extends AddonBase { public currentLine: any; constructor(parent: Knowledge4Zotero) { super(parent); - this.currentLine = []; + this.currentLine = {}; } public getLinesInNote(note: Zotero.Item): string[] { @@ -26,7 +26,7 @@ class NoteUtils extends AddonBase { return []; } let noteText: string = note.getNote(); - let containerIndex = noteText.search(/data-schema-version="8">/g); + let containerIndex = noteText.search(/data-schema-version="[0-9]*/g); if (containerIndex === -1) { note.setNote( `
    ${noteLines.join("\n")}
    ` @@ -78,9 +78,28 @@ class NoteUtils extends AddonBase { while (temp.firstChild) { frag.appendChild(temp.firstChild); } + const defer = Zotero.Promise.defer(); + const notifyName = `addLineToNote-${note.id}`; + this._Addon.ZoteroEvents.addNotifyListener( + notifyName, + ( + event: string, + type: string, + ids: Array, + extraData: object + ) => { + if (event === "modify" && type === "item" && ids.includes(note.id)) { + this._Addon.ZoteroEvents.removeNotifyListener(notifyName); + defer.resolve(); + } + } + ); position === "after" ? currentElement.after(frag) : currentElement.before(frag); + + await defer.promise; + this._Addon.EditorViews.scrollToPosition( editorInstance, currentElement.offsetTop @@ -121,19 +140,52 @@ class NoteUtils extends AddonBase { return null; } - private async _importImage(note: Zotero.Item, src, download = false) { - let blob; + public async getAttachmentKeyFromFileName( + libraryID: number, + path: string + ): Promise { + return await Zotero.Items.getByLibraryAndKeyAsync( + libraryID, + Zotero.File.normalizeToUnix(path).split("/").pop().split(".").shift() + ); + } + + public async _importImage( + note: Zotero.Item, + src: string, + type: "b64" | "url" | "file" = "b64" + ): Promise { + if (!note || !note.isNote()) { + return ""; + } + let blob: Blob; if (src.startsWith("data:")) { blob = this._dataURLtoBlob(src); - } else if (download) { + } else if (type === "url") { let res; - try { res = await Zotero.HTTP.request("GET", src, { responseType: "blob" }); } catch (e) { return; } blob = res.response; + } else if (type === "file") { + src = Zotero.File.normalizeToUnix(src); + const noteAttachmentKeys = Zotero.Items.get(note.getAttachments()).map( + (_i) => _i.key + ); + const filename = src.split("/").pop().split(".").shift(); + // The exported image is KEY.png by default. + // If it is already an attachment, just keep it. + if (noteAttachmentKeys.includes(filename)) { + return filename; + } + const imageData = await Zotero.File.getBinaryContentsAsync(src); + const array = new Uint8Array(imageData.length); + for (let i = 0; i < imageData.length; i++) { + array[i] = imageData.charCodeAt(i); + } + blob = new Blob([array], { type: "image/png" }); } else { return; } @@ -150,10 +202,8 @@ class NoteUtils extends AddonBase { public async importImagesToNote(note: Zotero.Item, annotations: any) { for (let annotation of annotations) { if (annotation.image) { - annotation.imageAttachmentKey = await this._importImage( - note, - annotation.image - ); + annotation.imageAttachmentKey = + (await this._importImage(note, annotation.image)) || ""; } delete annotation.image; } @@ -320,7 +370,24 @@ class NoteUtils extends AddonBase { while (temp.firstChild) { frag.appendChild(temp.firstChild); } + const defer = Zotero.Promise.defer(); + const notifyName = `modifyLineInNote-${note.id}`; + this._Addon.ZoteroEvents.addNotifyListener( + notifyName, + ( + event: string, + type: string, + ids: Array, + extraData: object + ) => { + if (event === "modify" && type === "item" && ids.includes(note.id)) { + this._Addon.ZoteroEvents.removeNotifyListener(notifyName); + defer.resolve(); + } + } + ); currentElement.replaceWith(frag); + await defer.promise; this._Addon.EditorViews.scrollToPosition( editorInstance, currentElement.offsetTop @@ -713,7 +780,7 @@ class NoteUtils extends AddonBase { // `Current Element: ${focusNode.outerHTML}; Real Element: ${realElement.outerHTML}` // ); this.currentLine[editor._item.id] = currentLineIndex; - console.log(realElement); + // console.log(realElement); if (realElement.tagName === "A") { let link = (realElement as HTMLLinkElement).href; let linkedNote = (await this.getNoteFromLink(link)).item; diff --git a/src/sync/syncController.ts b/src/sync/syncController.ts index f2d49c8..2d54619 100644 --- a/src/sync/syncController.ts +++ b/src/sync/syncController.ts @@ -4,12 +4,14 @@ import Knowledge4Zotero from "../addon"; import AddonBase from "../module"; +import { SyncCode } from "../utils"; class SyncController extends AddonBase { - triggerTime: number; + sycnLock: boolean; constructor(parent: Knowledge4Zotero) { super(parent); + this.sycnLock = false; } getSyncNoteIds(): number[] { @@ -65,103 +67,208 @@ class SyncController extends AddonBase { "Knowledge4Zotero.syncNoteIds", ids.filter((id) => id !== noteItem.id).join(",") ); - const sycnTag = noteItem.getTags().find((t) => t.tag.includes("sync://")); - if (sycnTag) { - noteItem.removeTag(sycnTag.tag); - } - await noteItem.saveTx(); + Zotero.Prefs.clear(`Knowledge4Zotero.syncDetail-${noteItem.id}`); } - getNoteSyncStatus(noteItem: Zotero.Item): any { - const sycnInfo = noteItem.getTags().find((t) => t.tag.includes("sync://")); - if (!sycnInfo) { - return false; + async doCompare(noteItem: Zotero.Item): Promise { + const syncStatus = this._Addon.SyncUtils.getSyncStatus(noteItem); + const MDStatus = await this._Addon.SyncUtils.getMDStatus(noteItem); + // No file found + if (!MDStatus.meta) { + return SyncCode.NoteAhead; + } + // File meta is unavailable + if (MDStatus.meta.version < 0) { + return SyncCode.NeedDiff; + } + let MDAhead = false; + let noteAhead = false; + const md5 = Zotero.Utilities.Internal.md5(MDStatus.content, false); + // MD5 doesn't match (md side change) + if (md5 !== syncStatus.md5) { + MDAhead = true; + } + // Note version doesn't match (note side change) + if (Number(MDStatus.meta.version) !== noteItem._version) { + noteAhead = true; + } + if (noteAhead && MDAhead) { + return SyncCode.NeedDiff; + } else if (noteAhead) { + return SyncCode.NoteAhead; + } else if (MDAhead) { + return SyncCode.MDAhead; + } else { + return SyncCode.UpToDate; } - const params = {}; - sycnInfo.tag - .split("?") - .pop() - .split("&") - .forEach((p) => { - params[p.split("=")[0]] = p.split("=")[1]; - }); - return params; } - async updateNoteSyncStatus( - noteItem: Zotero.Item, - path: string = "", - filename: string = "" - ) { + async updateNoteSyncStatus(noteItem: Zotero.Item, status: SyncStatus) { this.addSyncNote(noteItem); - const syncInfo = this.getNoteSyncStatus(noteItem); - const sycnTag = noteItem.getTags().find((t) => t.tag.includes("sync://")); - if (sycnTag) { - noteItem.removeTag(sycnTag.tag); - } - noteItem.addTag( - `sync://note/?version=${noteItem._version + 1}&path=${ - path ? encodeURIComponent(path) : syncInfo["path"] - }&filename=${ - filename ? encodeURIComponent(filename) : syncInfo["filename"] - }&lastsync=${new Date().getTime()}`, - undefined + Zotero.Prefs.set( + `Knowledge4Zotero.syncDetail-${noteItem.id}`, + JSON.stringify(status) ); - await noteItem.saveTx(); } setSync() { - const _t = new Date().getTime(); - this.triggerTime = _t; - const syncPeriod = Number(Zotero.Prefs.get("Knowledge4Zotero.syncPeriod")); + const syncPeriod = Zotero.Prefs.get( + "Knowledge4Zotero.syncPeriod" + ) as number; if (syncPeriod > 0) { - setTimeout(() => { - if (this.triggerTime === _t) { + setInterval(() => { + // Only when Zotero is active and focused + if (document.hasFocus()) { this.doSync(); } }, syncPeriod); } } - async doSync( - items: Zotero.Item[] = null, - force: boolean = false, - useIO: boolean = true - ) { - Zotero.debug("Better Notes: sync start"); - items = items || (Zotero.Items.get(this.getSyncNoteIds()) as Zotero.Item[]); - const toExport = {}; - const forceNoteIds = force - ? await this.getRelatedNoteIdsFromNotes( - useIO ? [this._Addon.SyncInfoWindow.io.dataIn] : items - ) - : []; - for (const item of items) { - const syncInfo = this.getNoteSyncStatus(item); - const filepath = decodeURIComponent(syncInfo.path); - const filename = decodeURIComponent(syncInfo.filename); - if ( - Number(syncInfo.version) < item._version || - !(await OS.File.exists(`${filepath}/${filename}`)) || - forceNoteIds.includes(item.id) - ) { - if (Object.keys(toExport).includes(filepath)) { - toExport[filepath].push(item); - } else { - toExport[filepath] = [item]; - } + // We set quiet false by default in pre-releases + // to test the syncing + async doSync(items: Zotero.Item[] = null, quiet: boolean = false) { + if (this.sycnLock) { + // Only allow one task + return; + } + // Wrap the code in try...catch so that the lock can be released anyway + try { + Zotero.debug("Better Notes: sync start"); + this.sycnLock = true; + if (!items || !items.length) { + items = Zotero.Items.get(this.getSyncNoteIds()); } + console.log("BN:Sync", items); + let progress; + if (!quiet) { + progress = this._Addon.ZoteroViews.showProgressWindow( + "[Syncing] Better Notes", + `[Check Status] 0/${items.length} ...`, + "default", + -1 + ); + progress.progress.setProgress(1); + await this._Addon.ZoteroViews.waitProgressWindow(progress); + } + // Export items of same dir in batch + const toExport = {}; + const toImport: SyncStatus[] = []; + const toDiff: SyncStatus[] = []; + let i = 1; + for (const item of items) { + const syncStatus = this._Addon.SyncUtils.getSyncStatus(item); + const filepath = decodeURIComponent(syncStatus.path); + let compareResult = await this.doCompare(item); + switch (compareResult) { + case SyncCode.NoteAhead: + if (Object.keys(toExport).includes(filepath)) { + toExport[filepath].push(item); + } else { + toExport[filepath] = [item]; + } + break; + case SyncCode.MDAhead: + toImport.push(syncStatus); + break; + case SyncCode.NeedDiff: + toDiff.push(syncStatus); + break; + default: + break; + } + if (progress) { + this._Addon.ZoteroViews.changeProgressWindowDescription( + progress, + `[Check Status] ${i}/${items.length} ...` + ); + progress.progress.setProgress((i / items.length) * 100); + } + i += 1; + } + console.log(toExport, toImport, toDiff); + i = 1; + let totalCount = Object.keys(toExport).length; + for (const filepath of Object.keys(toExport)) { + if (progress) { + this._Addon.ZoteroViews.changeProgressWindowDescription( + progress, + `[Update MD] ${i}/${totalCount}, ${ + toImport.length + toDiff.length + } queuing...` + ); + progress.progress.setProgress(((i - 1) / totalCount) * 100); + } + + await this._Addon.NoteExport.exportNotesToMDFiles(toExport[filepath], { + useEmbed: false, + useSync: true, + filedir: filepath, + }); + i += 1; + } + i = 1; + totalCount = toImport.length; + for (const syncStatus of toImport) { + if (progress) { + this._Addon.ZoteroViews.changeProgressWindowDescription( + progress, + `[Update Note] ${i}/${totalCount}, ${toDiff.length} queuing...` + ); + progress.progress.setProgress(((i - 1) / totalCount) * 100); + } + const item = Zotero.Items.get(syncStatus.itemID); + const filepath = OS.Path.join(syncStatus.path, syncStatus.filename); + await this._Addon.NoteImport.importMDFileToNote(filepath, item, {}); + await this._Addon.NoteExport.exportNotesToMDFiles([item], { + useEmbed: false, + useSync: true, + filedir: syncStatus.path, + }); + i += 1; + } + i = 1; + totalCount = toDiff.length; + for (const syncStatus of toDiff) { + if (progress) { + this._Addon.ZoteroViews.changeProgressWindowDescription( + progress, + `[Compare Diff] ${i}/${totalCount}...` + ); + progress.progress.setProgress(((i - 1) / totalCount) * 100); + } + + const item = Zotero.Items.get(syncStatus.itemID); + await this._Addon.SyncDiffWindow.doDiff( + item, + OS.Path.join(syncStatus.path, syncStatus.filename) + ); + i += 1; + } + if ( + this._Addon.SyncInfoWindow._window && + !this._Addon.SyncInfoWindow._window.closed + ) { + this._Addon.SyncInfoWindow.doUpdate(); + } + if (progress) { + const syncCount = + Object.keys(toExport).length + toImport.length + toDiff.length; + + this._Addon.ZoteroViews.changeProgressWindowDescription( + progress, + syncCount + ? `[Finish] Sync ${syncCount} notes successfully` + : "[Finish] Already up to date" + ); + progress.progress.setProgress(100); + progress.startCloseTimer(5000); + } + } catch (e) { + Zotero.debug(e); + console.log(e); } - console.log(toExport); - for (const filepath of Object.keys(toExport)) { - await this._Addon.NoteExport.syncNotesToMDFiles( - toExport[filepath], - filepath - ); - } - if (this._Addon.SyncInfoWindow._window && !this._Addon.SyncInfoWindow._window.closed) { - this._Addon.SyncInfoWindow.doUpdate(); - } + this.sycnLock = false; } } diff --git a/src/sync/syncDiffWindow.ts b/src/sync/syncDiffWindow.ts new file mode 100644 index 0000000..21ee15e --- /dev/null +++ b/src/sync/syncDiffWindow.ts @@ -0,0 +1,164 @@ +/* + * This file realizes note diff with markdown file. + */ + +import Knowledge4Zotero from "../addon"; +import AddonBase from "../module"; + +import { diffChars } from "diff"; + +class SyncDiffWindow extends AddonBase { + _window: any | Window; + constructor(parent: Knowledge4Zotero) { + super(parent); + } + + async doDiff(noteItem: Zotero.Item, mdPath: string) { + const noteStatus = this._Addon.SyncUtils.getNoteStatus(noteItem); + mdPath = Zotero.File.normalizeToUnix(mdPath); + if (!noteItem || !noteItem.isNote() || !(await OS.File.exists(mdPath))) { + return; + } + const mdStatus = await this._Addon.SyncUtils.getMDStatus(mdPath); + if (!mdStatus.meta) { + return; + } + const mdNoteContent = await this._Addon.NoteParse.parseMDToNote( + mdStatus, + noteItem, + true + ); + const noteContent = await this._Addon.NoteParse.parseNoteForDiff(noteItem); + console.log(mdNoteContent, noteContent); + const changes = diffChars(noteContent, mdNoteContent); + console.log("changes", changes); + + const io = { + defer: Zotero.Promise.defer(), + result: "", + type: "skip", + }; + + const addedCount = changes.filter((c) => c.added).length; + const removedCount = changes.filter((c) => c.removed).length; + if (addedCount === 0 || removedCount === 0) { + // If only one kind of changes, merge automatically + if (noteStatus.lastmodify >= mdStatus.lastmodify) { + // refuse all, keep note + io.result = changes + .filter((diff) => (!diff.added && !diff.removed) || diff.removed) + .map((diff) => diff.value) + .join(""); + } else { + // accept all, keep md + io.result = changes + .filter((diff) => (!diff.added && !diff.removed) || diff.added) + .map((diff) => diff.value) + .join(""); + } + io.type = "finish"; + } else { + // Otherwise, merge manually + const imageAttachemnts = Zotero.Items.get( + noteItem.getAttachments() + ).filter((attch) => attch.isEmbeddedImageAttachment()); + const imageData = {}; + for (const image of imageAttachemnts) { + try { + const b64 = await this._Addon.SyncUtils._getDataURL(image); + imageData[image.key] = b64; + } catch (e) { + Zotero.debug(e); + } + } + + if (!this._window || this._window.closed) { + this._window = window.open( + "chrome://Knowledge4Zotero/content/diff.html", + "betternotes-note-syncdiff", + `chrome,centerscreen,resizable,status,width=900,height=550` + ); + const defer = Zotero.Promise.defer(); + this._window.addEventListener("DOMContentLoaded", (e) => { + defer.resolve(); + }); + // Incase we missed the content loaded event + setTimeout(() => { + if (this._window.document.readyState === "complete") { + defer.resolve(); + } + }, 500); + await defer.promise; + } + + this._window.document.title = `[Better Notes Sycing] Diff Merge of ${noteItem.getNoteTitle()}`; + this._window.syncInfo = { + noteName: noteItem.getNoteTitle(), + noteModify: noteStatus.lastmodify.toISOString(), + mdName: mdPath, + mdModify: mdStatus.lastmodify.toISOString(), + syncTime: new Date( + this._Addon.SyncUtils.getSyncStatus(noteItem).lastsync + ).toISOString(), + }; + this._window.diffData = changes.map((change, id) => + Object.assign(change, { + id: id, + text: change.value, + }) + ); + this._window.imageData = imageData; + + this._window.io = io; + this._window.initSyncInfo(); + this._window.initList(); + this._window.initDiffViewer(); + this._window.updateDiffRender([]); + const abort = () => { + console.log("unloaded"); + io.defer.resolve(); + }; + // If closed by user, abort syncing + this._window.addEventListener("beforeunload", abort); + this._window.addEventListener("unload", abort); + this._window.addEventListener("close", abort); + this._window.onclose = abort; + this._window.onbeforeunload = abort; + this._window.onunload = abort; + await io.defer.promise; + } + + switch (io.type) { + case "skip": + alert( + `Syncing of "${noteItem.getNoteTitle()}" is skipped.\nTo sync manually, go to File->Better Notes Sync Manager.` + ); + this._window.closed || this._window.close(); + break; + case "unsync": + Zotero.debug("remove synce" + noteItem.getNote()); + await this._Addon.SyncController.removeSyncNote(noteItem); + break; + case "finish": + Zotero.debug("Diff result:" + io.result); + console.log("Diff result:", io.result); + // return io.result; + noteItem.setNote(noteStatus.meta + io.result + noteStatus.tail); + await noteItem.saveTx({ + notifierData: { + autoSyncDelay: Zotero.Notes.AUTO_SYNC_DELAY, + }, + }); + await this._Addon.NoteExport.exportNotesToMDFiles([noteItem], { + useEmbed: false, + useSync: true, + filedir: mdStatus.filedir, + }); + break; + default: + break; + } + } +} + +export default SyncDiffWindow; diff --git a/src/sync/syncInfoWindow.ts b/src/sync/syncInfoWindow.ts index cacb2be..3bea2bc 100644 --- a/src/sync/syncInfoWindow.ts +++ b/src/sync/syncInfoWindow.ts @@ -27,9 +27,7 @@ class SyncInfoWindow extends AddonBase { } doUpdate() { - const syncInfo = this._Addon.SyncController.getNoteSyncStatus( - this.io.dataIn - ); + const syncInfo = this._Addon.SyncUtils.getSyncStatus(this.io.dataIn); const syncPathLable = this._window.document.getElementById( "Knowledge4Zotero-sync-path" ); diff --git a/src/sync/syncListWindow.ts b/src/sync/syncListWindow.ts index ee7d041..afd500b 100644 --- a/src/sync/syncListWindow.ts +++ b/src/sync/syncListWindow.ts @@ -44,7 +44,7 @@ class SyncListWindow extends AddonBase { e.parentElement.removeChild(e); } for (const note of notes) { - const syncInfo = this._Addon.SyncController.getNoteSyncStatus(note); + const syncInfo = this._Addon.SyncUtils.getSyncStatus(note); const listitem: XUL.ListItem = this._window.document.createElement("listitem"); listitem.setAttribute("id", note.id); @@ -101,6 +101,7 @@ class SyncListWindow extends AddonBase { (period > 0 ? period + "s" : "disabled") ); this._window.focus(); + this.onSelect(); } getSelectedItems(): Zotero.Item[] { @@ -109,21 +110,39 @@ class SyncListWindow extends AddonBase { (this._window.document.getElementById("sync-list") as any) .selectedItems, (node) => node.id - ) - ) as Zotero.Item[]; + ) as string[] + ); + } + + onSelect() { + const selected = + (this._window.document.getElementById("sync-list") as any).selectedItems + .length > 0; + if (selected) { + this._window.document + .querySelector("#changesync") + .removeAttribute("disabled"); + this._window.document + .querySelector("#removesync") + .removeAttribute("disabled"); + } else { + this._window.document + .querySelector("#changesync") + .setAttribute("disabled", "true"); + this._window.document + .querySelector("#removesync") + .setAttribute("disabled", "true"); + } } useRelated(): Boolean { - return (this._window.document.getElementById("related") as XUL.Checkbox) - .checked; + return confirm( + "Apply changes to:\n[Yes] Selected note and it's linked notes\n[No] Only selected note" + ); } async doSync() { - const selectedItems = this.getSelectedItems(); - if (selectedItems.length === 0) { - return; - } - await this._Addon.SyncController.doSync(selectedItems, true, false); + await this._Addon.SyncController.doSync(this.getSelectedItems(), false); this.doUpdate(); } @@ -132,7 +151,10 @@ class SyncListWindow extends AddonBase { if (selectedItems.length === 0) { return; } - await this._Addon.NoteExport.exportNotesToMDFiles(selectedItems, false, true); + await this._Addon.NoteExport.exportNotesToMDFiles(selectedItems, { + useEmbed: false, + useSync: true, + }); this.doUpdate(); } diff --git a/src/sync/syncUtils.ts b/src/sync/syncUtils.ts new file mode 100644 index 0000000..adec557 --- /dev/null +++ b/src/sync/syncUtils.ts @@ -0,0 +1,1084 @@ +import { unified } from "unified"; +import rehypeParse from "rehype-parse"; +import rehypeRemark from "rehype-remark"; +import remarkRehype from "remark-rehype"; +import rehypeStringify from "rehype-stringify"; +import remarkParse from "remark-parse"; +import remarkStringify from "remark-stringify"; +import { all, defaultHandlers } from "hast-util-to-mdast"; +import { toHtml } from "hast-util-to-html"; +import { toText } from "hast-util-to-text"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import { visit } from "unist-util-visit"; +import { visitParents } from "unist-util-visit-parents"; +import rehypeFormat from "rehype-format"; +import { h } from "hastscript"; +import seedrandom = require("seedrandom"); +import YAML = require("yamljs"); + +import Knowledge4Zotero from "../addon"; +import AddonBase from "../module"; +import { NodeMode } from "../utils"; + +class SyncUtils extends AddonBase { + constructor(parent: Knowledge4Zotero) { + super(parent); + } + + // A seedable version of Zotero.Utilities.randomString + randomString(len: number, chars: string, seed: string) { + if (!chars) { + chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + } + if (!len) { + len = 8; + } + let randomstring = ""; + const random: Function = seedrandom(seed); + for (let i = 0; i < len; i++) { + const rnum = Math.floor(random() * chars.length); + randomstring += chars.substring(rnum, rnum + 1); + } + return randomstring; + } + + async _getDataURL(item: Zotero.Item) { + let path = await item.getFilePathAsync(); + let buf = new Uint8Array((await OS.File.read(path, {})) as Uint8Array) + .buffer; + return ( + "data:" + + item.attachmentContentType + + ";base64," + + this._arrayBufferToBase64(buf) + ); + } + + _arrayBufferToBase64(buffer) { + var binary = ""; + var bytes = new Uint8Array(buffer); + var len = bytes.byteLength; + for (var i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } + + getNoteStatus(noteItem: Zotero.Item): NoteStatus { + if (!noteItem.isNote()) { + return; + } + const fullContent = noteItem.getNote(); + const ret = { + meta: "", + content: "", + tail: "", + lastmodify: Zotero.Date.sqlToDate(noteItem.dateModified, true), + }; + const metaRegex = /"?data-schema-version"?="[0-9]*">/; + const match = fullContent?.match(metaRegex); + if (!match || match.length == 0) { + ret.meta = '
    '; + ret.content = fullContent || ""; + return ret; + } + const idx = fullContent.search(metaRegex); + if (idx != -1) { + ret.content = fullContent.substring( + idx + match[0].length, + fullContent.length - ret.tail.length + ); + } + return ret; + } + + getEmptySyncStatus(): SyncStatus { + return { + path: "", + filename: "", + md5: "", + lastsync: new Date().getTime(), + itemID: -1, + }; + } + + getSyncStatus(noteItem: Zotero.Item): SyncStatus { + return JSON.parse( + (Zotero.Prefs.get( + `Knowledge4Zotero.syncDetail-${noteItem.id}` + ) as string) || JSON.stringify(this.getEmptySyncStatus()) + ); + } + + getMDStatusFromContent(contentRaw: string): MDStatus { + const result = contentRaw.match(/^---([\s\S]*)---\n/); + const ret: MDStatus = { + meta: { version: -1 }, + content: contentRaw, + filedir: "", + filename: "", + lastmodify: new Date(0), + }; + if (result) { + const yaml = result[0].replace(/---/g, ""); + ret.content = contentRaw.slice(result[0].length); + try { + ret.meta = YAML.parse(yaml); + } catch (e) { + Zotero.debug(e); + console.log(e); + } + } + return ret; + } + + async getMDStatus(source: Zotero.Item | string): Promise { + let ret: MDStatus = { + meta: null, + content: "", + filedir: "", + filename: "", + lastmodify: new Date(0), + }; + try { + let filepath = ""; + if (typeof source === "string") { + filepath = source; + } else if (source.isNote && source.isNote()) { + const syncStatus = this.getSyncStatus(source); + filepath = `${syncStatus.path}/${syncStatus.filename}`; + } + filepath = Zotero.File.normalizeToUnix(filepath); + if (await OS.File.exists(filepath)) { + let contentRaw = (await OS.File.read(filepath, { + encoding: "utf-8", + })) as string; + ret = this.getMDStatusFromContent(contentRaw); + const pathSplit = filepath.split("/"); + ret.filedir = Zotero.File.normalizeToUnix( + pathSplit.slice(0, -1).join("/") + ); + ret.filename = filepath.split("/").pop(); + const stat = await OS.File.stat(filepath); + ret.lastmodify = stat.lastModificationDate; + } + } catch (e) { + Zotero.debug(e); + console.log(e); + } + return ret; + } + + note2rehype(str) { + const rehype = unified() + .use(remarkGfm) + .use(remarkMath) + .use(rehypeParse, { fragment: true }) + .parse(str); + + // Make sure
    is inline break. Remove \n before/after
    + const removeBlank = (node, parentNode, offset) => { + const idx = parentNode.children.indexOf(node); + const target = parentNode.children[idx + offset]; + if ( + target && + target.type === "text" && + !target.value.replace(/[\r\n]/g, "") + ) { + (parentNode.children as any[]).splice(idx + offset, 1); + } + }; + visitParents( + rehype, + (_n: any) => _n.type === "element" && _n.tagName === "br", + (_n: any, ancestors) => { + if (ancestors.length) { + const parentNode = ancestors[ancestors.length - 1]; + removeBlank(_n, parentNode, -1); + removeBlank(_n, parentNode, 1); + } + } + ); + + // Make sure and wrapped by

    + visitParents( + rehype, + (_n: any) => + _n.type === "element" && + (_n.tagName === "span" || _n.tagName === "img"), + (_n: any, ancestors) => { + if (ancestors.length) { + const parentNode = ancestors[ancestors.length - 1]; + if (parentNode === rehype) { + const newChild = h("span"); + this.replace(newChild, _n); + const p = h("p", [newChild]); + this.replace(_n, p); + } + } + } + ); + + // Make sure empty

    under root node is removed + visitParents( + rehype, + (_n: any) => _n.type === "element" && _n.tagName === "p", + (_n: any, ancestors) => { + if (ancestors.length) { + const parentNode = ancestors[ancestors.length - 1]; + if (parentNode === rehype && !_n.children.length && !toText(_n)) { + parentNode.children.splice(parentNode.children.indexOf(_n), 1); + } + } + } + ); + return rehype; + } + + async rehype2remark(rehype) { + return await unified() + .use(rehypeRemark, { + handlers: { + span: (h, node) => { + if ( + node.properties?.style?.includes("text-decoration: line-through") + ) { + return h(node, "delete", all(h, node)); + } else if (node.properties?.style?.includes("background-color")) { + return h(node, "html", toHtml(node)); + } else if (node.properties?.className?.includes("math")) { + return h(node, "inlineMath", toText(node).slice(1, -1)); + } else { + return h(node, "paragraph", all(h, node)); + } + }, + pre: (h, node) => { + if (node.properties?.className?.includes("math")) { + return h(node, "math", toText(node).slice(2, -2)); + } else { + return h(node, "code", toText(node)); + } + }, + u: (h, node) => { + return h(node, "u", toText(node)); + }, + sub: (h, node) => { + return h(node, "sub", toText(node)); + }, + sup: (h, node) => { + return h(node, "sup", toText(node)); + }, + table: (h, node) => { + let hasStyle = false; + visit( + node, + (_n) => + _n.type === "element" && + ["tr", "td", "th"].includes((_n as any).tagName), + (node) => { + if (node.properties.style) { + hasStyle = true; + } + } + ); + if (0 && hasStyle) { + return h(node, "styleTable", toHtml(node)); + } else { + return defaultHandlers.table(h, node); + } + }, + wrapper: (h, node) => { + return h(node, "wrapper", toText(node)); + }, + wrapperleft: (h, node) => { + return h(node, "wrapperleft", toText(node)); + }, + wrapperright: (h, node) => { + return h(node, "wrapperright", toText(node)); + }, + zhighlight: (h, node) => { + return h(node, "zhighlight", toHtml(node)); + }, + zcitation: (h, node) => { + return h(node, "zcitation", toHtml(node)); + }, + znotelink: (h, node) => { + return h(node, "znotelink", toHtml(node)); + }, + zimage: (h, node) => { + return h(node, "zimage", toHtml(node)); + }, + }, + }) + .run(rehype); + } + + remark2md(remark) { + return String( + unified() + .use(remarkGfm) + .use(remarkMath) + .use(remarkStringify, { + handlers: { + pre: (node) => { + return "```\n" + node.value + "\n```"; + }, + u: (node) => { + return "" + node.value + ""; + }, + sub: (node) => { + return "" + node.value + ""; + }, + sup: (node) => { + return "" + node.value + ""; + }, + styleTable: (node) => { + return node.value; + }, + wrapper: (node) => { + return "\n\n"; + }, + wrapperleft: (node) => { + return "\n"; + }, + wrapperright: (node) => { + return "\n"; + }, + zhighlight: (node) => { + return node.value.replace(/(^|<\/zhighlight>$)/g, ""); + }, + zcitation: (node) => { + return node.value.replace(/(^|<\/zcitation>$)/g, ""); + }, + znotelink: (node) => { + return node.value.replace(/(^|<\/znotelink>$)/g, ""); + }, + zimage: (node) => { + return node.value.replace(/(^|<\/zimage>$)/g, ""); + }, + }, + }) + .stringify(remark) + ); + } + + md2remark(str) { + // Parse Obsidian-style image ![[xxx.png]] + // Encode spaces in link, otherwise it cannot be parsed to image node + str = str + .replace(/!\[\[(.*)\]\]/g, (s: string) => `![](${s.slice(3, -2)})`) + .replace( + /!\[.*\]\((.*)\)/g, + (s: string) => + `![](${encodeURIComponent(s.match(/\(.*\)/g)[0].slice(1, -1))})` + ); + const remark = unified() + .use(remarkGfm) + .use(remarkMath) + .use(remarkParse) + .parse(str); + // visit( + // remark, + // (_n) => _n.type === "image", + // (_n: any) => { + // _n.type = "html"; + // _n.value = toHtml( + // h("img", { + // src: _n.url, + // }) + // ); + // } + // ); + return remark; + } + + async remark2rehype(remark) { + return await unified() + .use(remarkRehype, { + allowDangerousHtml: true, + }) + .run(remark); + } + + rehype2note(rehype) { + // Del node + visit( + rehype, + (node) => node.type === "element" && (node as any).tagName === "del", + (node) => { + node.tagName = "span"; + node.properties.style = "text-decoration: line-through"; + } + ); + + // Code node + visitParents( + rehype, + (node) => node.type === "element" && (node as any).tagName === "code", + (node, ancestors) => { + const parent = ancestors.length + ? ancestors[ancestors.length - 1] + : undefined; + if (parent?.type == "element" && parent?.tagName === "pre") { + node.value = toText(node); + node.type = "text"; + } + } + ); + + // Table node with style + visit( + rehype, + (node) => node.type === "element" && (node as any).tagName === "table", + (node) => { + let hasStyle = false; + visit( + node, + (_n) => + _n.type === "element" && + ["tr", "td", "th"].includes((_n as any).tagName), + (node) => { + if (node.properties.style) { + hasStyle = true; + } + } + ); + if (hasStyle) { + node.value = toHtml(node).replace(/[\r\n]/g, ""); + node.children = []; + node.type = "raw"; + } + } + ); + + // Convert thead to tbody + visit( + rehype, + (node) => node.type === "element" && (node as any).tagName === "thead", + (node) => { + node.value = toHtml(node).slice(7, -8); + node.children = []; + node.type = "raw"; + } + ); + + // Wrap lines in list with

    (for diff) + visitParents(rehype, "text", (node, ancestors) => { + const parent = ancestors.length + ? ancestors[ancestors.length - 1] + : undefined; + if ( + node.value.replace(/[\r\n]/g, "") && + parent?.type == "element" && + ["li", "td"].includes(parent?.tagName) + ) { + node.type = "element"; + node.tagName = "p"; + node.children = [ + { type: "text", value: node.value.replace(/[\r\n]/g, "") }, + ]; + node.value = undefined; + } + }); + + // No empty breakline text node in list (for diff) + visit( + rehype, + (node) => + node.type === "element" && + ((node as any).tagName === "li" || (node as any).tagName === "td"), + (node) => { + node.children = node.children.filter( + (_n) => + _n.type === "element" || + (_n.type === "text" && _n.value.replace(/[\r\n]/g, "")) + ); + } + ); + + // Math node + visit( + rehype, + (node) => + node.type === "element" && + ((node as any).properties?.className?.includes("math-inline") || + (node as any).properties?.className?.includes("math-display")), + (node) => { + if (node.properties.className.includes("math-inline")) { + node.children = [ + { type: "text", value: "$" }, + ...node.children, + { type: "text", value: "$" }, + ]; + } else if (node.properties.className.includes("math-display")) { + node.children = [ + { type: "text", value: "$$" }, + ...node.children, + { type: "text", value: "$$" }, + ]; + node.tagName = "pre"; + } + node.properties.className = "math"; + } + ); + + // Ignore link rel attribute, which exists in note + visit( + rehype, + (node) => node.type === "element" && (node as any).tagName === "a", + (node) => { + node.properties.rel = undefined; + } + ); + + // Ignore empty lines, as they are not parsed to md + const tempChildren = []; + const isEmptyNode = (_n) => + (_n.type === "text" && !_n.value.trim()) || + (_n.type === "element" && + _n.tagName === "p" && + !_n.children.length && + !toText(_n).trim()); + for (const child of rehype.children) { + if ( + tempChildren.length && + isEmptyNode(tempChildren[tempChildren.length - 1]) && + isEmptyNode(child) + ) { + continue; + } + tempChildren.push(child); + } + + rehype.children = tempChildren; + + return unified() + .use(rehypeStringify, { + allowDangerousCharacters: true, + allowDangerousHtml: true, + }) + .stringify(rehype); + } + + async rehype2rehype(rehype) { + return await unified().use(rehypeFormat).run(rehype); + } + + async note2md(str) { + const rehype = this.note2rehype(str); + const remark = await this.rehype2remark(rehype); + const md = this.remark2md(remark); + return md; + } + + async md2note(str) { + const remark = this.md2remark(str); + let rehype = await this.remark2rehype(remark); + const html = this.rehype2note(rehype); + return html; + } + + async note2note(str) { + let rehype = this.note2rehype(str); + const html = this.rehype2note(rehype); + return html; + } + + replace(targetNode, sourceNode) { + targetNode.type = sourceNode.type; + targetNode.tagName = sourceNode.tagName; + targetNode.properties = sourceNode.properties; + targetNode.value = sourceNode.value; + targetNode.children = sourceNode.children; + } + + getN2MRehypeHighlightNodes(rehype) { + const nodes = []; + visit( + rehype, + (node: any) => + node.type === "element" && + node.properties?.className?.includes("highlight"), + (node) => nodes.push(node) + ); + return nodes; + } + + getN2MRehypeCitationNodes(rehype) { + const nodes = []; + visit( + rehype, + (node: any) => + node.type === "element" && + node.properties?.className?.includes("citation"), + (node) => nodes.push(node) + ); + return nodes; + } + + getN2MRehypeNoteLinkNodes(rehype) { + const nodes = []; + visit( + rehype, + (node: any) => + node.type === "element" && + node.tagName === "a" && + node.properties?.href && + /zotero:\/\/note\/\w+\/\w+\//.test(node.properties?.href), + (node) => nodes.push(node) + ); + return nodes; + } + + getN2MRehypeImageNodes(rehype) { + const nodes = []; + visit( + rehype, + (node: any) => + node.type === "element" && + node.tagName === "img" && + node.properties?.dataAttachmentKey, + (node) => nodes.push(node) + ); + return nodes; + } + + processN2MRehypeHighlightNodes(nodes, mode: NodeMode = NodeMode.default) { + if (!nodes.length) { + return; + } + for (const node of nodes) { + let annotation; + try { + annotation = JSON.parse( + decodeURIComponent(node.properties.dataAnnotation) + ); + } catch (e) { + continue; + } + if (!annotation) { + continue; + } + // annotation.uri was used before note-editor v4 + let uri = annotation.attachmentURI || annotation.uri; + let position = annotation.position; + Zotero.debug("----Debug Link----"); + Zotero.debug(annotation); + console.log("convertAnnotations", node); + + if (typeof uri === "string" && typeof position === "object") { + Zotero.debug(uri); + let openURI; + let uriParts = uri.split("/"); + let libraryType = uriParts[3]; + let key = uriParts[uriParts.length - 1]; + Zotero.debug(key); + if (libraryType === "users") { + openURI = "zotero://open-pdf/library/items/" + key; + } + // groups + else { + let groupID = uriParts[4]; + openURI = "zotero://open-pdf/groups/" + groupID + "/items/" + key; + } + + openURI += + "?page=" + + (position.pageIndex + 1) + + (annotation.annotationKey + ? "&annotation=" + annotation.annotationKey + : ""); + + let newNode = h("span", [ + h(node.tagName, node.properties, node.children), + h("span", " ("), + h("a", { href: openURI }, ["pdf"]), + h("span", ") "), + ]); + const annotKey = + annotation.annotationKey || + this.randomString( + 8, + Zotero.Utilities.allowedKeyChars, + Zotero.Utilities.Internal.md5(node.properties.dataAnnotation) + ); + + if (mode === NodeMode.wrap) { + newNode.children.splice(0, 0, h("wrapperleft", `annot:${annotKey}`)); + newNode.children.push(h("wrapperright", `annot:${annotKey}`)); + } else if (mode === NodeMode.replace) { + newNode = h("placeholder", `annot:${annotKey}`); + } else if (mode === NodeMode.direct) { + const newChild = h("span"); + this.replace(newChild, node); + newChild.children = [h("a", { href: openURI }, toText(node))]; + newChild.properties.ztype = "zhighlight"; + newNode = h("zhighlight", [newChild]); + } + console.log(newNode, node); + this.replace(node, newNode); + console.log("converted", newNode, node); + } + } + } + + processN2MRehypeCitationNodes(nodes, mode: NodeMode = NodeMode.default) { + if (!nodes.length) { + return; + } + for (const node of nodes) { + let citation; + try { + citation = JSON.parse(decodeURIComponent(node.properties.dataCitation)); + } catch (e) { + continue; + } + if (!citation?.citationItems?.length) { + continue; + } + + let uris = []; + for (let citationItem of citation.citationItems) { + let uri = citationItem.uris[0]; + if (typeof uri === "string") { + let uriParts = uri.split("/"); + let libraryType = uriParts[3]; + let key = uriParts[uriParts.length - 1]; + Zotero.debug(key); + if (libraryType === "users") { + uris.push("zotero://select/library/items/" + key); + } + // groups + else { + let groupID = uriParts[4]; + uris.push("zotero://select/groups/" + groupID + "/items/" + key); + } + } + } + + let childNodes = []; + + visit( + node, + (_n: any) => _n.properties?.className.includes("citation-item"), + (_n) => { + return childNodes.push(_n); + } + ); + + // For unknown reasons, the element will be duplicated. Remove them. + childNodes = new Array(...new Set(childNodes)); + + // Fallback to pre v5 note-editor schema that was serializing citations as plain text i.e.: + // (Jang et al., 2005, p. 14; Kongsgaard et al., 2009, p. 790) + if (!childNodes.length) { + childNodes = toText(node).slice(1, -1).split("; "); + } + + let newNode = h("span", node.properties, [ + { type: "text", value: "(" }, + ...childNodes.map((child, i) => { + const newNode = h("span"); + this.replace(newNode, child); + console.log("cite child", child, newNode); + newNode.children = [h("a", { href: uris[i] }, child.children)]; + return newNode; + }), + { type: "text", value: ")" }, + ]); + console.log("cite", newNode); + const citationKey = this.randomString( + 8, + Zotero.Utilities.allowedKeyChars, + Zotero.Utilities.Internal.md5(node.properties.dataCitation) + ); + if (mode === NodeMode.wrap) { + newNode.children.splice(0, 0, h("wrapperleft", `cite:${citationKey}`)); + newNode.children.push(h("wrapperright", `cite:${citationKey}`)); + } else if (mode === NodeMode.replace) { + newNode = h("placeholder", `cite:${citationKey}`); + } else if (mode === NodeMode.direct) { + const newChild = h("span"); + this.replace(newChild, newNode); + newChild.properties.ztype = "zcitation"; + newNode = h("zcitation", [newChild]); + } + this.replace(node, newNode); + } + } + + processN2MRehypeNoteLinkNodes( + nodes, + Info: any, + mode: NodeMode = NodeMode.default + ) { + if (!nodes.length) { + return; + } + for (const node of nodes) { + console.log("note link", node); + const noteInfo = Info.find((i) => node.properties.href.includes(i.link)); + const link = `./${noteInfo.filename}`; + if (!noteInfo) { + continue; + } + + const linkKey = this.randomString( + 8, + Zotero.Utilities.allowedKeyChars, + Zotero.Utilities.Internal.md5(node.properties.href) + ); + if (mode === NodeMode.wrap) { + const newNode = h("span", [ + h("wrapperleft", `note:${linkKey}`), + h( + node.tagName, + Object.assign(node.properties, { href: link }), + node.children + ), + h("wrapperright", `note:${linkKey}`), + ]); + this.replace(node, newNode); + } else if (mode === NodeMode.replace) { + const newNode = h("placeholder", `note:${linkKey}`); + this.replace(node, newNode); + } else if (mode === NodeMode.direct) { + const newChild = h("a", node.properties, node.children); + newChild.properties.zhref = node.properties.href; + newChild.properties.href = link; + newChild.properties.ztype = "znotelink"; + const newNode = h("znotelink", [newChild]); + this.replace(node, newNode); + console.log("direct link", node, newNode, newChild); + } + console.log("note link parsed", node); + } + } + + async processN2MRehypeImageNodes( + nodes, + libraryID: number, + Path: string, + skipSavingImages: boolean = false, + absolutePath: boolean = false, + mode: NodeMode = NodeMode.default + ) { + if (!nodes.length) { + return; + } + for (const node of nodes) { + let imgKey = node.properties.dataAttachmentKey; + + const attachmentItem = await Zotero.Items.getByLibraryAndKeyAsync( + libraryID, + imgKey + ); + Zotero.debug(attachmentItem); + console.log("image", libraryID, imgKey, attachmentItem, node); + if (!attachmentItem) { + continue; + } + + let oldFile = String(await attachmentItem.getFilePathAsync()); + Zotero.debug(oldFile); + let ext = oldFile.split(".").pop(); + let newAbsPath = Zotero.Knowledge4Zotero.NoteUtils.formatPath( + `${Path}/${imgKey}.${ext}` + ); + Zotero.debug(newAbsPath); + let newFile = oldFile; + try { + // Don't overwrite + if (skipSavingImages || (await OS.File.exists(newAbsPath))) { + newFile = newAbsPath.replace(/\\/g, "/"); + } else { + newFile = (await Zotero.File.copyToUnique(oldFile, newAbsPath)).path; + newFile = newFile.replace(/\\/g, "/"); + } + newFile = Zotero.File.normalizeToUnix( + absolutePath ? newFile : `$attachments/${newFile.split(/\//).pop()}` + ); + } catch (e) { + Zotero.debug(e); + } + Zotero.debug(newFile); + + node.properties.src = newFile ? newFile : oldFile; + + if (mode === NodeMode.direct) { + const newChild = h("span"); + this.replace(newChild, node); + newChild.properties.ztype = "zimage"; + const newNode = h("zimage", [newChild]); + this.replace(node, newNode); + } + console.log("zimage", node); + } + } + + getM2NRehypeAnnotationNodes(rehype) { + const nodes = []; + visit( + rehype, + (node: any) => node.type === "element" && node.properties?.dataAnnotation, + (node) => nodes.push(node) + ); + return nodes; + } + + getM2NRehypeHighlightNodes(rehype) { + const nodes = []; + visit( + rehype, + (node: any) => + node.type === "element" && node.properties?.ztype === "zhighlight", + (node) => nodes.push(node) + ); + console.log("N2M:highlight", nodes); + return nodes; + } + + getM2NRehypeCitationNodes(rehype) { + const nodes = []; + visit( + rehype, + (node: any) => + node.type === "element" && + (node.properties?.ztype === "zcitation" || + node.properties?.dataCitation), + (node) => nodes.push(node) + ); + return nodes; + } + + getM2NRehypeNoteLinkNodes(rehype) { + const nodes = []; + visit( + rehype, + (node: any) => + node.type === "element" && node.properties?.ztype === "znotelink", + (node) => nodes.push(node) + ); + return nodes; + } + + getM2NRehypeImageNodes(rehype) { + const nodes = []; + visit( + rehype, + (node: any) => node.type === "element" && node.tagName === "img", + (node) => nodes.push(node) + ); + return nodes; + } + + processM2NRehypeHighlightNodes(nodes) { + if (!nodes.length) { + return; + } + for (const node of nodes) { + node.children = [{ type: "text", value: toText(node) }]; + delete node.properties.ztype; + } + } + + async processM2NRehypeCitationNodes(nodes, isImport: boolean = false) { + if (!nodes.length) { + return; + } + for (const node of nodes) { + if (isImport) { + try { + // { + // "citationItems": [ + // { + // "uris": [ + // "http://zotero.org/users/uid/items/itemkey" + // ] + // } + // ], + // "properties": {} + // } + const dataCitation = JSON.parse( + decodeURIComponent(node.properties.dataCitation) + ); + const ids = dataCitation.citationItems.map((c) => + Zotero.URI.getURIItemID(c.uris[0]) + ); + const html = await this._Addon.NoteParse.parseCitationHTML(ids); + const newNode = this.note2rehype(html); + // root -> p -> span(cite, this is what we actually want) + this.replace(node, (newNode.children[0] as any).children[0]); + } catch (e) { + Zotero.debug(e); + console.log(e); + continue; + } + } else { + visit( + node, + (_n: any) => _n.properties?.className.includes("citation-item"), + (_n) => { + _n.children = [{ type: "text", value: toText(_n) }]; + } + ); + delete node.properties?.ztype; + } + } + } + + processM2NRehypeNoteLinkNodes(nodes) { + if (!nodes.length) { + return; + } + for (const node of nodes) { + node.properties.href = node.properties.zhref; + delete node.properties.zhref; + delete node.properties.ztype; + } + } + + async processM2NRehypeImageNodes( + nodes: any[], + noteItem: Zotero.Item, + fileDir: string, + isImport: boolean = false + ) { + if (!nodes.length || (isImport && !noteItem)) { + return; + } + + console.log("processing M2N images", nodes); + for (const node of nodes) { + if (isImport) { + // We encode the src in md2remark and decode it here. + let src = Zotero.File.normalizeToUnix( + decodeURIComponent(node.properties.src) + ); + const srcType = (src as string).startsWith("data:") + ? "b64" + : (src as string).startsWith("http") + ? "url" + : "file"; + if (srcType === "file") { + if (!(await OS.File.exists(src))) { + src = OS.Path.join(fileDir, src); + if (!(await OS.File.exists(src))) { + Zotero.debug("BN:parse image, path invalid"); + continue; + } + } + } + const key = await ( + Zotero.Knowledge4Zotero as Knowledge4Zotero + ).NoteUtils._importImage(noteItem, src, srcType); + node.properties.dataAttachmentKey = key; + } + delete node.properties.src; + node.properties.ztype && delete node.properties.ztype; + } + } +} + +export { SyncUtils, NodeMode }; diff --git a/src/template/templateWindow.ts b/src/template/templateWindow.ts index 9d47562..1b455fe 100644 --- a/src/template/templateWindow.ts +++ b/src/template/templateWindow.ts @@ -137,8 +137,8 @@ class TemplateWindow extends AddonBase { ); await io.deferred.promise; - const ids = io.dataOut; - const note: Zotero.Item = (Zotero.Items.get(ids) as Zotero.Item[]).filter( + const ids = io.dataOut as number[]; + const note: Zotero.Item = Zotero.Items.get(ids).filter( (item: Zotero.Item) => item.isNote() )[0]; if (!note) { diff --git a/src/utils.ts b/src/utils.ts index e62d6c2..b96b9b1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -109,7 +109,7 @@ async function pick( } // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return new Zotero.Promise((resolve) => { + return new Promise((resolve) => { fp.open((userChoice) => { switch (userChoice) { case Components.interfaces.nsIFilePicker.returnOK: @@ -124,4 +124,38 @@ async function pick( }); }); } -export { EditorMessage, OutlineType, NoteTemplate, CopyHelper, pick }; + +enum SyncCode { + UpToDate = 0, + NoteAhead, + MDAhead, + NeedDiff, +} + +enum NodeMode { + default = 0, + wrap, + replace, + direct, +} + +function getDOMParser(): DOMParser { + if (Zotero.platformMajorVersion > 60) { + return new DOMParser(); + } else { + return Components.classes[ + "@mozilla.org/xmlextras/domparser;1" + ].createInstance(Components.interfaces.nsIDOMParser); + } +} + +export { + EditorMessage, + OutlineType, + NoteTemplate, + CopyHelper, + pick, + SyncCode, + NodeMode, + getDOMParser, +}; diff --git a/src/workspace/workspaceWindow.ts b/src/workspace/workspaceWindow.ts index 1a29f20..c262888 100644 --- a/src/workspace/workspaceWindow.ts +++ b/src/workspace/workspaceWindow.ts @@ -7,13 +7,13 @@ import { EditorMessage, OutlineType, pick } from "../utils"; import AddonBase from "../module"; class WorkspaceWindow extends AddonBase { - private _initIframe: ZoteroPromise; + private _initIframe: _ZoteroPromise; public workspaceWindow: Window; public workspaceTabId: string; public workspaceNoteEditor: Zotero.EditorInstance | undefined; public previewItemID: number; private _firstInit: boolean; - public _workspacePromise: ZoteroPromise; + public _workspacePromise: _ZoteroPromise; private _DOMParser: any; constructor(parent: Knowledge4Zotero) { diff --git a/src/zotero/events.ts b/src/zotero/events.ts index 0219e11..7485abe 100644 --- a/src/zotero/events.ts +++ b/src/zotero/events.ts @@ -30,10 +30,14 @@ class ZoteroEvents extends AddonBase { this._Addon.ZoteroViews.updateWordCount(); } // Check Note Sync - const syncIds = - this._Addon.SyncController.getSyncNoteIds() as number[]; - if (ids.filter((id) => syncIds.includes(id as number)).length > 0) { - this._Addon.SyncController.setSync(); + const syncIds = this._Addon.SyncController.getSyncNoteIds(); + const modifiedSyncIds = ids.filter((id) => + syncIds.includes(id as number) + ) as number[]; + if (modifiedSyncIds.length > 0) { + this._Addon.SyncController.doSync( + Zotero.Items.get(modifiedSyncIds) + ); Zotero.debug("Better Notes: sync planned."); } } @@ -323,6 +327,7 @@ class ZoteroEvents extends AddonBase { instance._iframeWindow.document.addEventListener( "selectionchange", async (e) => { + e.stopPropagation(); await this._Addon.NoteUtils.onSelectionChange(instance); } ); @@ -924,7 +929,10 @@ class ZoteroEvents extends AddonBase { console.log(html); new CopyHelper() .addText(html, "text/html") - .addText(this._Addon.NoteParse.parseHTMLToMD(html), "text/unicode") + .addText( + await this._Addon.NoteParse.parseHTMLToMD(html), + "text/unicode" + ) .copy(); progressWindow.changeHeadline("Template Copied"); } else { @@ -1040,7 +1048,10 @@ class ZoteroEvents extends AddonBase { new CopyHelper() .addText(html, "text/html") - .addText(this._Addon.NoteParse.parseHTMLToMD(html), "text/unicode") + .addText( + await this._Addon.NoteParse.parseHTMLToMD(html), + "text/unicode" + ) .copy(); progressWindow.changeHeadline("Template Copied"); } else { @@ -1119,11 +1130,10 @@ class ZoteroEvents extends AddonBase { return; } if (options.exportMD && options.exportSubMD) { - await this._Addon.NoteExport.exportNotesToMDFiles( - [item], - false, - options.exportAutoSync - ); + await this._Addon.NoteExport.exportNotesToMDFiles([item], { + useEmbed: false, + useSync: options.exportAutoSync, + }); } else { await this._Addon.NoteExport.exportNote(item, options); } @@ -1156,10 +1166,9 @@ class ZoteroEvents extends AddonBase { ); } else { const useSingleFile = confirm("Export linked notes to markdown files?"); - await this._Addon.NoteExport.exportNotesToMDFiles( - noteItems, - !useSingleFile - ); + await this._Addon.NoteExport.exportNotesToMDFiles(noteItems, { + useEmbed: !useSingleFile, + }); } } else if (message.type === "sync") { /* @@ -1169,9 +1178,12 @@ class ZoteroEvents extends AddonBase { */ const note = this._Addon.WorkspaceWindow.getWorkspaceNote(); if (this._Addon.SyncController.isSyncNote(note)) { - this._Addon.SyncController.doSync([note], true, false); + this._Addon.SyncController.doSync([note]); } else { - await this._Addon.NoteExport.exportNotesToMDFiles([note], false, true); + await this._Addon.NoteExport.exportNotesToMDFiles([note], { + useEmbed: false, + useSync: true, + }); } } else if (message.type === "openAttachment") { /* @@ -1262,7 +1274,7 @@ class ZoteroEvents extends AddonBase { ); return; } - const html = this._Addon.NoteParse.parseMDToHTML(source); + const html = await this._Addon.NoteParse.parseMDToHTML(source); console.log(source, html); new CopyHelper().addText(html, "text/html").copy(); diff --git a/src/zotero/views.ts b/src/zotero/views.ts index 2a9e90d..999434c 100644 --- a/src/zotero/views.ts +++ b/src/zotero/views.ts @@ -68,20 +68,56 @@ class ZoteroViews extends AddonBase { let addNoteItem = document .getElementById("zotero-tb-note-add") .getElementsByTagName("menuitem")[1]; - let button = document.createElement("menuitem"); - button.setAttribute("id", "zotero-tb-knowledge-openwindow"); - button.setAttribute("label", "New Main Note"); - button.addEventListener("click", (e) => { - this._Addon.ZoteroEvents.onEditorEvent( - new EditorMessage("createWorkspace", {}) - ); + let buttons = this.createXULElement(document, { + tag: "fragment", + subElementOptions: [ + { + tag: "menuitem", + id: "zotero-tb-knowledge-create-mainnote", + attributes: [ + ["label", "New Main Note"], + ["class", "menuitem-iconic"], + [ + "style", + "list-style-image: url('chrome://Knowledge4Zotero/skin/favicon.png');", + ], + ], + listeners: [ + [ + "click", + (e) => { + this._Addon.ZoteroEvents.onEditorEvent( + new EditorMessage("createWorkspace", {}) + ); + }, + false, + ], + ], + }, + { + tag: "menuitem", + id: "zotero-tb-knowledge-import-md", + attributes: [ + ["label", "Import MarkDown as Note"], + ["class", "menuitem-iconic"], + [ + "style", + "list-style-image: url('chrome://Knowledge4Zotero/skin/favicon.png');", + ], + ], + listeners: [ + [ + "click", + async (e) => { + await this._Addon.NoteImport.doImport(); + }, + false, + ], + ], + }, + ], }); - button.setAttribute("class", "menuitem-iconic"); - button.setAttribute( - "style", - "list-style-image: url('chrome://Knowledge4Zotero/skin/favicon.png');" - ); - addNoteItem.after(button); + addNoteItem.after(buttons); } public addOpenWorkspaceButton() { @@ -111,10 +147,12 @@ class ZoteroViews extends AddonBase { : "Open Workspace"; span1.append(span2, span3, span4); treeRow.append(span1); - treeRow.addEventListener("click", (e) => { - this._Addon.ZoteroEvents.onEditorEvent( - new EditorMessage("openWorkspace", { event: e }) - ); + treeRow.addEventListener("click", async (e) => { + if (e.shiftKey) { + await this._Addon.WorkspaceWindow.openWorkspaceWindow("window", true); + } else { + await this._Addon.WorkspaceWindow.openWorkspaceWindow(); + } }); treeRow.addEventListener("mouseover", (e: XUL.XULEvent) => { treeRow.setAttribute( @@ -361,6 +399,16 @@ class ZoteroViews extends AddonBase { progressWindow.progress._itemText.innerHTML = context; } + public async waitProgressWindow(progressWindow) { + let t = 0; + // Wait for ready + while (!progressWindow.progress._itemText && t < 100) { + t += 1; + await Zotero.Promise.delay(10); + } + return; + } + public createXULElement(doc: Document, options: XULElementOptions) { const createElement: () => XUL.Element = options.tag === "fragment" diff --git a/typing/global.d.ts b/typing/global.d.ts index dba0b8f..064f6e9 100644 --- a/typing/global.d.ts +++ b/typing/global.d.ts @@ -1,8 +1,3 @@ -declare interface ZoteroPromise { - promise: Promise; - resolve: () => void; -} - declare interface XULElementOptions { tag: string; id?: string; @@ -22,3 +17,52 @@ declare interface XULElementOptions { customCheck?: () => boolean; subElementOptions?: Array; } + +declare interface SyncStatus { + path: string; + filename: string; + md5: string; + lastsync: number; + itemID: number; +} + +declare interface MDStatus { + meta: { + version: number; + } | null; + content: string; + filedir: string; + filename: string; + lastmodify: Date; +} + +declare interface NoteStatus { + meta: string; + content: string; + tail: string; + lastmodify: Date; +} + +declare interface AnnotationJson { + authorName: string; + color: string; + comment: string; + dateModified: string; + image: string; + imageAttachmentKey: string; + isAuthorNameAuthoritative: boolean; + isExternal: boolean; + id: string; + key: string; + lastModifiedByUser: string; + pageLabel: string; + position: { + rects: number[]; + }; + readOnly: boolean; + sortIndex: any; + tags: { name: string }[]; + text: string; + type: string; + attachmentItemID: number; +}