diff --git a/.gitmodules b/.gitmodules index f70921805c..32ff4587a9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -29,3 +29,15 @@ [submodule "resource/SingleFile"] path = resource/SingleFile url = https://github.com/gildas-lormeau/SingleFile.git +[submodule "pdf-reader"] + path = pdf-reader + url = ssh://git@github.com/zotero/pdf-reader.git + branch = master +[submodule "pdf-worker"] + path = pdf-worker + url = ssh://git@github.com/zotero/pdf-worker.git + branch = master +[submodule "note-editor"] + path = note-editor + url = ssh://git@github.com/zotero/note-editor.git + branch = master diff --git a/chrome/content/zotero-platform/mac-big-sur/style.css b/chrome/content/zotero-platform/mac-big-sur/style.css index e206c02b41..d15aec7a87 100644 --- a/chrome/content/zotero-platform/mac-big-sur/style.css +++ b/chrome/content/zotero-platform/mac-big-sur/style.css @@ -1,10 +1,10 @@ /* Use standard tab appearance for item pane tabs */ -#zotero-pane #zotero-view-tabbox > tabs > tab { +.zotero-view-tabbox > tabs > tab { -moz-appearance: tab; } /* Active tab label color in item pane and elsewhere */ -#zotero-pane tabs#zotero-editpane-tabs > tab[visuallyselected="true"][selected="true"] hbox > .tab-text, +tabs.zotero-editpane-tabs > tab[visuallyselected="true"][selected="true"] hbox > .tab-text, #zotero-prefs tab[visuallyselected="true"]:not(:-moz-window-inactive), tabs > tab[visuallyselected="true"] hbox > .tab-text { color: black !important; @@ -12,10 +12,6 @@ tabs > tab[visuallyselected="true"] hbox > .tab-text { } /* Quick Format dialog, which is based on window corners, which are different on Big Sur */ -#quick-format-dialog #zotero-icon { - margin-top: 2px; -} - #quick-format-iframe { margin-top: 2px; } \ No newline at end of file diff --git a/chrome/content/zotero-platform/mac/integration.css b/chrome/content/zotero-platform/mac/integration.css index f8287e5007..9d62382760 100644 --- a/chrome/content/zotero-platform/mac/integration.css +++ b/chrome/content/zotero-platform/mac/integration.css @@ -4,7 +4,7 @@ html > body { } body { - line-height: 1.45em; + line-height: 1.6em; font-size: 15px; } @@ -12,45 +12,42 @@ body[multiline="true"] { line-height: 26px; } -#quick-format-dialog { +window.citation-dialog { background: transparent; -moz-appearance: none; padding: 0; width: 800px; } -#quick-format-dialog.progress-bar #quick-format-deck { +.citation-dialog.progress-bar .citation-dialog.deck { height: 37px; } -#quick-format-search { +.citation-dialog.search { background: white; - -moz-appearance: searchfield; -} - -#quick-format-search[multiline="true"] { - padding: 2px 2px 0 19.5px; + padding: 2px 2px 0 2px; margin: 2.5px 3.5px; border: 1px solid rgba(0, 0, 0, 0.5); -moz-appearance: none; + border-radius: 10px; } -#quick-format-search:not([multiline="true"]) { - padding-top: 3.5px; - height: 37px !important; +.citation-dialog.search:not([multiline="true"]) { + height: 32px !important; } -#quick-format-entry { +.citation-dialog.entry { background: -moz-linear-gradient(-90deg, rgb(243,123,119) 0, rgb(180,47,38) 50%, rgb(156,36,27) 50%); padding: 12px; } -#zotero-icon { - margin: -2px 0 3px -6px; +.note-dialog .citation-dialog.entry { + background: -moz-linear-gradient(-90deg, rgb(249, 231, 179) 0, rgb(228, 193, 94) 50%, rgb(221, 184, 81) 50%); } -#quick-format-search[multiline="true"] #zotero-icon { - margin: 0 0 1px -13px; +#zotero-icon { + margin: -1px 0 0 4px; + -moz-appearance: none; } #citation-properties menulist { @@ -106,7 +103,6 @@ panel button:-moz-focusring { box-shadow: 0 0 1px -moz-mac-focusring inset, 0 0 4px 1px -moz-mac-focusring, 0 0 2px 1px -moz-mac-focusring; } -.quick-format-bubble { - margin-top: 0; +.citation-dialog.bubble { padding: 1px 6px 1px 6px; } \ No newline at end of file diff --git a/chrome/content/zotero-platform/mac/overlay.css b/chrome/content/zotero-platform/mac/overlay.css index acb230d456..0553db9230 100644 --- a/chrome/content/zotero-platform/mac/overlay.css +++ b/chrome/content/zotero-platform/mac/overlay.css @@ -107,12 +107,12 @@ input { margin: 0; } -#zotero-view-tabbox { +.zotero-view-tabbox { background-color: #fff; padding: 0; } -#zotero-item-pane-content .groupbox-body { +.zotero-item-pane-content .groupbox-body { -moz-appearance: none; background-color: #ffffff; } @@ -121,13 +121,13 @@ input { color: #7f7f7f; } -#zotero-view-tabbox > tabpanels { +.zotero-view-tabbox > tabpanels { margin: 12px 0 0 0; padding: 0; -moz-appearance: none; } -#zotero-view-tabbox > tabs { +.zotero-editpane-tabs { -moz-appearance: none; background: -moz-linear-gradient(top, #ededed, #cccccc); border-style: solid; @@ -136,15 +136,15 @@ input { padding: 2px 0 2px 0; } -#zotero-view-tabbox > tabs > tab > hbox { +.zotero-editpane-tabs > tab > hbox { padding: 0; } -#zotero-view-tabbox > tabs > tab > hbox > .tab-icon { +.zotero-editpane-tabs > tab > hbox > .tab-icon { display: none; } -#zotero-pane #zotero-view-tabbox > tabs > tab { +.zotero-editpane-tabs > tab { -moz-box-orient: vertical; -moz-box-align: center; -moz-appearance: toolbarbutton; @@ -153,7 +153,7 @@ input { padding: 3px 1px 3px 1px; } -#zotero-pane #zotero-view-tabbox > tabs > tab > hbox .tab-text { +.zotero-editpane-tabs > tab > hbox .tab-text { font-size: 11px; font-weight: bold; margin: 2px 7px 2px 9px !important; @@ -161,11 +161,11 @@ input { } /* This seems to be necessary to center the tabs. Not sure why. */ -#zotero-pane #zotero-view-tabbox > tabs > tab:last-of-type > hbox .tab-text { +.zotero-editpane-tabs > tab:last-of-type > hbox .tab-text { margin: 2px 9px 2px 9px !important; } -#zotero-pane #zotero-view-tabbox > tabs > tab[selected=true] > hbox .tab-text { +.zotero-editpane-tabs > tab[selected=true] > hbox .tab-text { color: #FFF !important; text-shadow: rgba(0, 0, 0, 0.4) 0 1px; } @@ -217,18 +217,21 @@ input { } #zotero-collections-splitter:not([state=collapsed]), -#zotero-items-splitter:not([state=collapsed])[orient=horizontal] +#zotero-items-splitter:not([state=collapsed])[orient=horizontal], +#zotero-context-splitter:not([state=collapsed])[orient=horizontal] { -moz-appearance: none; border-inline-start: 1px solid #bdbdbd; margin-inline-end: -4px; width: 5px !important; min-width: 5px; - position: relative; + /* Create a separate stacking context to be on top */ + opacity: 0.99; background-image: none; } -#zotero-items-splitter[orient=vertical] +#zotero-items-splitter[orient=vertical], +#zotero-context-splitter-stacked { -moz-border-start: none !important; -moz-border-end: none !important; @@ -239,17 +242,25 @@ input { } #zotero-collections-splitter:not([state=collapsed]) > grippy, -#zotero-items-splitter:not([state=collapsed]) > grippy +#zotero-items-splitter:not([state=collapsed]) > grippy, +#zotero-context-splitter:not([state=collapsed]) > grippy, +#zotero-context-splitter-stacked:not([state=collapsed]) > grippy { display: none; } -#zotero-collections-splitter[state=collapsed], #zotero-items-splitter[state=collapsed] { +#zotero-collections-splitter[state=collapsed], +#zotero-items-splitter[state=collapsed], +#zotero-context-splitter[state=collapsed], +#zotero-context-splitter-stacked[state=collapsed] +{ border: 0 solid #d6d6d6 !important; padding: 0; } -#zotero-collections-splitter[state=collapsed], #zotero-items-splitter[state=collapsed][orient=horizontal] +#zotero-collections-splitter[state=collapsed], +#zotero-items-splitter[state=collapsed][orient=horizontal], +#zotero-context-splitter[state=collapsed][orient=horizontal] { background-image: url("chrome://zotero/skin/mac/vsplitter.png"); background-repeat: repeat-y; @@ -258,7 +269,8 @@ input { width: 8px !important; } -#zotero-items-splitter[state=collapsed][orient=vertical] +#zotero-items-splitter[state=collapsed][orient=vertical], +#zotero-context-splitter-stacked[state=collapsed][orient=vertical] { background-image: url("chrome://zotero/skin/mac/hsplitter.png"); background-repeat: repeat-x; @@ -271,11 +283,17 @@ input { border-right-width: 1px !important; } -#zotero-items-splitter[state=collapsed] { +#zotero-items-splitter[state=collapsed], +#zotero-context-splitter[state=collapsed], +#zotero-context-splitter-stacked[state=collapsed] +{ border-left-width: 1px !important; } -#zotero-collections-splitter[state=collapsed] > grippy, #zotero-items-splitter[state=collapsed] > grippy +#zotero-collections-splitter[state=collapsed] > grippy, +#zotero-items-splitter[state=collapsed] > grippy, +#zotero-context-splitter[state=collapsed] > grippy, +#zotero-context-splitter-stacked[state=collapsed] > grippy { -moz-appearance: none; background: url(chrome://zotero/skin/mac/vgrippy.png) center/auto 8px no-repeat; @@ -301,11 +319,20 @@ input { height: 8px; } -#zotero-tags-splitter > grippy:hover, #zotero-collections-splitter > grippy:hover, #zotero-items-splitter > grippy:hover +#zotero-tags-splitter > grippy:hover, +#zotero-collections-splitter > grippy:hover, +#zotero-items-splitter > grippy:hover, +#zotero-context-splitter > grippy:hover, +#zotero-context-splitter-stacked > grippy:hover { background-color:transparent; } +#zotero-context-toolbar-extension { + /* To cover #zotero-context-splitter 1px border */ + margin-inline-start: -1px; +} + #zotero-items-tree { -moz-appearance: none; diff --git a/chrome/content/zotero-platform/unix/integration.css b/chrome/content/zotero-platform/unix/integration.css index d6ab27d2e8..d297f55d66 100644 --- a/chrome/content/zotero-platform/unix/integration.css +++ b/chrome/content/zotero-platform/unix/integration.css @@ -2,18 +2,18 @@ body { line-height: 1.5em; } -#quick-format-search:not([multiline="true"]) { +.citation-dialog.search:not([multiline="true"]) { height: 29px !important; } -#quick-format-search { +.citation-dialog.search { background: white; padding: 0 2px 0 0; border: 1px solid rgba(0, 0, 0, 0.5); -moz-appearance: textfield; } -#quick-format-dialog { +window.citation-dialog { -moz-appearance: none; padding: 5px; } diff --git a/chrome/content/zotero-platform/unix/itemPane.css b/chrome/content/zotero-platform/unix/itemPane.css index 999e699e3d..82e8f78040 100644 --- a/chrome/content/zotero-platform/unix/itemPane.css +++ b/chrome/content/zotero-platform/unix/itemPane.css @@ -11,20 +11,20 @@ visibility: visible; } -#zotero-item-pane-content { +.zotero-item-pane-content { margin-right: 6px; } /* Make the item pane appear white (same colour as treeview), making the UI more consistent */ -#zotero-item-pane-content tab, #zotero-item-pane-content tabpanels { +.zotero-item-pane-content tab, .zotero-item-pane-content tabpanels { background-color: -moz-Field; /* Same as background colour for treeview */ } /* Possibly irrelevant if mozilla fixes https://bugzilla.mozilla.org/show_bug.cgi?id=1306425 */ -#zotero-view-tabbox tabs tab[visuallyselected=true] { +.zotero-view-tabbox tabs tab[visuallyselected=true] { margin-top: 0px !important; /* Importanter than ./itemPane.css:20 */ margin-bottom: -2px !important; /* Importanter than skin/itemPane.css:12 */ } -#zotero-view-tabbox tabs tab { +.zotero-view-tabbox tabs tab { margin-top: 2px !important; /* Importanter than skin/itemPane.css:11 */ } diff --git a/chrome/content/zotero-platform/unix/overlay.css b/chrome/content/zotero-platform/unix/overlay.css index cc19958220..50aee11669 100644 --- a/chrome/content/zotero-platform/unix/overlay.css +++ b/chrome/content/zotero-platform/unix/overlay.css @@ -76,3 +76,9 @@ tab { background-color: transparent; background-image: none; } + +#zotero-context-splitter-stacked { + -moz-appearance: none; + background-color: #ececec; + border-top: 1px solid hsla(0, 0%, 0%, 0.2); +} diff --git a/chrome/content/zotero-platform/win/integration.css b/chrome/content/zotero-platform/win/integration.css index 05bb899e8c..252ff62dbf 100644 --- a/chrome/content/zotero-platform/win/integration.css +++ b/chrome/content/zotero-platform/win/integration.css @@ -1,9 +1,10 @@ -#quick-format-dialog { +window.citation-dialog { background: transparent; padding: 0; + -moz-appearance: none; } -#quick-format-search { +.citation-dialog.search { background: white; padding: 2px 2px 2px 0; border: 1px solid rgba(0, 0, 0, 0.5); @@ -11,22 +12,20 @@ -moz-appearance: none; } -#quick-format-dialog { - background: transparent; - -moz-appearance: none; - padding: 0; -} - -#quick-format-search:not([multiline="true"]) { +.citation-dialog.search:not([multiline="true"]) { height: 28px !important; } -#quick-format-entry { +.citation-dialog.entry { background: -moz-linear-gradient(-90deg, rgb(243,123,119) 0, rgb(180,47,38) 50%, rgb(156,36,27) 50%); padding: 10px; } -#quick-format-entry:not([square="true"]) { +.note-dialog .citation-dialog.entry { + background: -moz-linear-gradient(-90deg, rgb(249, 231, 179) 0, rgb(228, 193, 94) 50%, rgb(221, 184, 81) 50%); +} + +.citation-dialog.entry:not([square="true"]) { -moz-border-radius: 15px; border-radius: 15px; } diff --git a/chrome/content/zotero-platform/win/overlay.css b/chrome/content/zotero-platform/win/overlay.css index 591d8ed3df..7f2044a975 100644 --- a/chrome/content/zotero-platform/win/overlay.css +++ b/chrome/content/zotero-platform/win/overlay.css @@ -49,7 +49,7 @@ padding-top: 4px; } -#zotero-view-tabbox tab { +.zotero-view-tabbox tab { padding-left: .7em; padding-right: .7em; } @@ -61,7 +61,9 @@ #zotero-collections-splitter:not([state=collapsed]), #zotero-items-splitter:not([state=collapsed]), -#zotero-tags-splitter:not([state=collapsed]) { +#zotero-tags-splitter:not([state=collapsed]), +#zotero-context-splitter:not([state=collapsed]), +#zotero-context-splitter-stacked:not([state=collapsed]) { border: 0; background-color: transparent; position: relative; @@ -70,9 +72,15 @@ z-index: 1; } +#zotero-context-splitter:not([state=collapsed]), +#zotero-context-splitter-stacked:not([state=collapsed]) { + z-index: 0; +} + #zotero-collections-splitter:not([state=collapsed]), #zotero-items-splitter:not([state=collapsed]):not([orient=vertical]), -#zotero-tags-splitter:not([state=collapsed]) { +#zotero-tags-splitter:not([state=collapsed]), +#zotero-context-splitter:not([state=collapsed]) { border-inline-end: 1px solid var(--theme-border-color); min-width: 0; width: 3px; @@ -80,7 +88,8 @@ } #zotero-tags-splitter:not([state=collapsed]), -#zotero-items-splitter:not([state=collapsed])[orient=vertical] { +#zotero-items-splitter:not([state=collapsed])[orient=vertical], +#zotero-context-splitter-stacked:not([state=collapsed]) { border-block-end: 1px solid var(--theme-border-color); min-height: 0; height: 3px; @@ -89,17 +98,20 @@ #zotero-collections-splitter > grippy, #zotero-items-splitter > grippy, -#zotero-tags-splitter > grippy { +#zotero-tags-splitter > grippy, +#zotero-context-splitter > grippy { border: 0; } #zotero-collections-splitter:not([state=collapsed]) > grippy, #zotero-items-splitter:not([state=collapsed]) > grippy, -#zotero-tags-splitter:not([state=collapsed]) > grippy { +#zotero-tags-splitter:not([state=collapsed]) > grippy, +#zotero-context-splitter:not([state=collapsed]) > grippy, +#zotero-context-splitter-stacked:not([state=collapsed]) > grippy { display: none; } -#zotero-collections-tree, #zotero-items-tree, #zotero-view-item { +#zotero-collections-tree, #zotero-items-tree, .zotero-view-item { -moz-appearance: none; border-style: solid; border-color: #818790; @@ -142,11 +154,11 @@ tree { margin: .04em 0 0 .15em !important; } -#zotero-editpane-tabs spacer { +.zotero-editpane-tabs spacer { border: 0; } -#zotero-view-item { +.zotero-view-item { padding: 0 !important; -moz-appearance: none; background-color: -moz-field; @@ -154,7 +166,7 @@ tree { border-color: var(--theme-border-color); } -#zotero-view-tabbox > tabs { +.zotero-editpane-tabs { margin-top: 2px; } @@ -163,8 +175,8 @@ tree { border-width: 0; } -#zotero-editpane-item-box > scrollbox, #zotero-view-item > tabpanel > vbox, -#zotero-editpane-tags > scrollbox, #zotero-editpane-related { +.zotero-editpane-item-box > scrollbox, .zotero-view-item > tabpanel > vbox, +#zotero-editpane-tags > scrollbox, .zotero-editpane-related { padding-top: 5px; } @@ -172,6 +184,6 @@ tree { padding-left: 5px; } -#zotero-view-item > tabpanel > vbox { +.zotero-view-item > tabpanel > vbox { padding-left: 5px; } \ No newline at end of file diff --git a/chrome/content/zotero/bindings/attachmentbox.xml b/chrome/content/zotero/bindings/attachmentbox.xml index 4604c9567a..917c99e2a6 100644 --- a/chrome/content/zotero/bindings/attachmentbox.xml +++ b/chrome/content/zotero/bindings/attachmentbox.xml @@ -360,9 +360,19 @@ indexStatusRow.hidden = true; } + var type = Zotero.Libraries.get(this.item.libraryID).libraryType; + var switherDeck = this._id('attachment-note-editor-switcher'); // Note editor - var noteEditor = this._id('attachment-note-editor'); - if (this.displayNote && (this.displayNoteIfEmpty || this.item.getNote() != '')) { + if (type == 'group' || !Zotero.isPDFBuild) { + var noteEditor = this._id('attachment-note-editor-old'); + switherDeck.selectedIndex = 0; + } + else { + var noteEditor = this._id('attachment-note-editor'); + switherDeck.selectedIndex = 1; + } + + if (this.displayNote && (this.displayNoteIfEmpty || this.item.note != '')) { noteEditor.linksOnTop = true; noteEditor.hidden = false; @@ -624,7 +634,10 @@ - + + + + + + {!childNotes.length &&
{Zotero.getString('pane.context.noNotes')}
} + {childNotes.map(note => onClick(note.id)}/>)} + } +
+
+

{Zotero.getString('pane.context.allNotes')}

+ +
+ {!allNotes.length &&
{Zotero.getString('pane.context.noNotes')}
} + {(expanded ? allNotes : allNotes.slice(0, MAX_ALL_NOTES)).map(note => onClick(note.id)}/>)} + {!expanded && allNotes.length > MAX_ALL_NOTES + &&
{ + Zotero.getString('general.numMore', Zotero.Utilities.numberFormat([allNotes.length - MAX_ALL_NOTES], 0)) + }
+ } +
+ + ); +}); + +export default NotesList; diff --git a/chrome/content/zotero/components/tabBar.jsx b/chrome/content/zotero/components/tabBar.jsx new file mode 100644 index 0000000000..6d23923a8a --- /dev/null +++ b/chrome/content/zotero/components/tabBar.jsx @@ -0,0 +1,129 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2020 Corporation for Digital Scholarship + Vienna, Virginia, USA + https://www.zotero.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + +'use strict'; + +import React, { forwardRef, useState, useRef, useImperativeHandle, useEffect } from 'react'; +import cx from 'classnames'; +const { IconXmark } = require('./icons'); + +const TabBar = forwardRef(function (props, ref) { + const [tabs, setTabs] = useState([]); + const draggingID = useRef(null); + const tabsRef = useRef(); + const mouseMoveWaitUntil = useRef(0); + + useEffect(() => { + window.addEventListener('mouseup', handleMouseUp); + return () => { + window.removeEventListener('mouseup', handleMouseUp); + }; + }, []); + + useImperativeHandle(ref, () => ({ setTabs })); + + function handleMouseDown(event, id, index) { + if (event.target.closest('.tab-close')) { + return; + } + if (index != 0) { + draggingID.current = id; + } + props.onTabSelect(id); + event.stopPropagation(); + } + + function handleMouseMove(event) { + if (!draggingID.current || mouseMoveWaitUntil.current > Date.now()) { + return; + } + let points = Array.from(tabsRef.current.children).map((child) => { + let rect = child.getBoundingClientRect(); + return rect.left + rect.width / 2; + }); + let index = null; + for (let i = 0; i < points.length - 1; i++) { + let point1 = points[i]; + let point2 = points[i + 1]; + if (event.clientX > Math.min(point1, point2) + && event.clientX < Math.max(point1, point2)) { + index = i + 1; + break; + } + } + if (index === null) { + let point1 = points[0]; + let point2 = points[points.length - 1]; + if ((point1 < point2 && event.clientX < point1 + || point1 > point2 && event.clientX > point1)) { + index = 0; + } + else { + index = points.length; + } + } + if (index == 0) { + index = 1; + } + props.onTabMove(draggingID.current, index); + mouseMoveWaitUntil.current = Date.now() + 100; + } + + function handleMouseUp(event) { + draggingID.current = null; + event.stopPropagation(); + } + + function handleTabClose(event, id) { + props.onTabClose(id); + event.stopPropagation(); + } + + function renderTab({ id, title, selected }, index) { + return ( +
handleMouseDown(event, id, index)} + > +
{title}
+
handleTabClose(event, id)} + > + +
+
+ ); + } + + return ( +
+ {tabs.map((tab, index) => renderTab(tab, index))} +
+ ); +}); + +export default TabBar; diff --git a/chrome/content/zotero/contextPane.js b/chrome/content/zotero/contextPane.js new file mode 100644 index 0000000000..6e3c669356 --- /dev/null +++ b/chrome/content/zotero/contextPane.js @@ -0,0 +1,718 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2020 Corporation for Digital Scholarship + Vienna, Virginia, USA + https://digitalscholar.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + +// TODO: Fix import/require related isues that might be +// related with `require` not reusing the context +var React = require('react'); +var ReactDOM = require('react-dom'); +var TagsBoxContainer = require('containers/tagsBoxContainer').default; +var NotesList = require('components/itemPane/notesList').default; + +var ZoteroContextPane = new function () { + const HTML_NS = 'http://www.w3.org/1999/xhtml'; + + var _tabCover; + var _contextPane; + var _contextPaneInner; + var _contextPaneSplitter; + var _contextPaneSplitterStacked; + var _itemToggle; + var _notesToggle; + var _panesDeck; + var _itemPaneDeck; + var _notesPaneDeck; + + var _itemToolbar; + var _splitButton; + var _itemPaneToggle; + var _notesPaneToggle; + var _toolbar; + var _tabToolbarContainer; + + var _itemContexts = []; + var _notesContexts = []; + + // Using attribute instead of propery to set 'selectedIndex' + // is more reliable + + this.update = _update; + this.getActiveEditor = _getActiveEditor; + + this.onLoad = function () { + if (!Zotero) { + return; + } + + _tabCover = document.getElementById('zotero-tab-cover'); + _itemToggle = document.getElementById('zotero-tb-toggle-item-pane'); + _notesToggle = document.getElementById('zotero-tb-toggle-notes-pane'); + _contextPane = document.getElementById('zotero-context-pane'); + _contextPaneInner = document.getElementById('zotero-context-pane-inner'); + _contextPaneSplitter = document.getElementById('zotero-context-splitter'); + _contextPaneSplitterStacked = document.getElementById('zotero-context-splitter-stacked'); + + _itemToolbar = document.getElementById('zotero-item-toolbar'); + _splitButton = document.getElementById('zotero-tb-split'); + _itemPaneToggle = document.getElementById('zotero-tb-toggle-item-pane'); + _notesPaneToggle = document.getElementById('zotero-tb-toggle-notes-pane'); + _toolbar = document.getElementById('zotero-toolbar'); + _tabToolbarContainer = document.getElementById('zotero-tab-toolbar-container'); + + _init(); + + this._notifierID = Zotero.Notifier.registerObserver(this, ['item', 'tab'], 'contextPane'); + window.addEventListener('resize', _update); + _itemToggle.addEventListener('click', _toggleItemButton); + _notesToggle.addEventListener('click', _toggleNotesButton); + Zotero.Reader.onChangeSidebarWidth = _updatePaneWidth; + Zotero.Reader.onChangeSidebarOpen = _updatePaneWidth; + + this._mutationObserver = new MutationObserver(() => { + _updateToolbarWidth(); + // Sometimes XUL is late to reflow + setTimeout(_updateToolbarWidth, 100); + }); + this._mutationObserver.observe(_tabToolbarContainer, { attributes: true, childList: true, subtree: true }); + }; + + this.onUnload = function () { + _itemToggle.removeEventListener('click', _toggleItemButton); + _notesToggle.removeEventListener('click', _toggleNotesButton); + window.removeEventListener('resize', _update); + Zotero.Notifier.unregisterObserver(this._notifierID); + this._mutationObserver.disconnect(); + Zotero.Reader.onChangeSidebarWidth = () => {}; + Zotero.Reader.onChangeSidebarOpen = () => {}; + _contextPaneInner.innerHTML = ''; + _itemContexts = []; + _notesContexts = []; + }; + + this.notify = Zotero.Promise.coroutine(function* (action, type, ids, extraData) { + if (type == 'item') { + // Update, remove or re-create item panes + for (let context of _itemContexts.slice()) { + let item = Zotero.Items.get(context.itemID); + if (!item) { + _removeItemContext(context.tabID); + } + else if (item.parentID != context.parentID) { + _removeItemContext(context.tabID); + _addItemContext(context.tabID, context.itemID); + } + else { + context.update(); + } + } + + // Update notes lists for affected libraries + let libraryIDs = []; + for (let id of ids) { + let item = Zotero.Items.get(id); + if (item && item.isNote()) { + libraryIDs.push(item.libraryID); + } + else if (action == 'delete') { + libraryIDs.push(extraData[id].libraryID); + } + } + for (let context of _notesContexts) { + if (libraryIDs.includes(context.libraryID)) { + context.update(); + } + } + } + else if (type == 'tab') { + if (action == 'add') { + _addItemContext(ids[0], extraData[ids[0]].itemID); + } + else if (action == 'close') { + _removeItemContext(ids[0]); + if (Zotero_Tabs.deck.children.length == 1) { + _notesContexts.forEach(x => x.notesListRef.current.setExpanded(false)); + } + } + else if (action == 'select') { + if (Zotero_Tabs.selectedIndex == 0) { + _contextPaneSplitter.setAttribute('hidden', true); + _contextPane.setAttribute('collapsed', true); + _toolbar.append(_itemToolbar); + _itemToolbar.classList.remove('tab-mode'); + _splitButton.classList.add('hidden'); + _tabCover.hidden = true; + } + else { + var reader = Zotero.Reader.getByTabID(Zotero_Tabs.selectedID); + if (reader) { + _tabCover.hidden = false; + (async () => { + await reader._initPromise; + _tabCover.hidden = true; + })(); + + var attachment = Zotero.Items.get(reader.itemID); + _selectNotesContext(attachment.libraryID); + var notesContext = _getNotesContext(attachment.libraryID); + notesContext.updateFromCache(); + } + + _contextPaneSplitter.setAttribute('hidden', false); + _contextPane.setAttribute('collapsed', !(_contextPaneSplitter.getAttribute('state') != 'collapsed')); + _tabToolbarContainer.append(_itemToolbar); + _itemToolbar.classList.add('tab-mode'); + _splitButton.classList.remove('hidden'); + } + + _selectItemContext(ids[0]); + _update(); + } + } + }); + + function _toggleItemButton() { + _togglePane(0); + } + + function _toggleNotesButton() { + _togglePane(1); + } + + function _removeNote(id) { + var ps = Components.classes['@mozilla.org/embedcomp/prompt-service;1'] + .getService(Components.interfaces.nsIPromptService); + if (ps.confirm(null, '', Zotero.getString('pane.item.notes.delete.confirm'))) { + Zotero.Items.trashTx(id); + } + } + + function _getActiveEditor() { + var splitter; + if (Zotero.Prefs.get('layout') == 'stacked') { + splitter = _contextPaneSplitterStacked; + } + else { + splitter = _contextPaneSplitter; + } + + if (splitter.getAttribute('state') != 'collapsed') { + if (_panesDeck.selectedIndex == 0) { + let child = _itemPaneDeck.selectedPanel; + if (child) { + var tabPanels = child.querySelector('tabpanels'); + if (tabPanels && tabPanels.selectedIndex == 1) { + var notesDeck = child.querySelector('.notes-deck'); + if (notesDeck.selectedIndex == 1) { + return child.querySelector('zoteronoteeditor'); + } + } + } + } + else { + var node = _notesPaneDeck.selectedPanel; + if (node.selectedIndex == 1) { + return node.querySelector('zoteronoteeditor'); + } + } + } + } + + function _updateAddToNote() { + var reader = Zotero.Reader.getByTabID(Zotero_Tabs.selectedID); + if (reader) { + var editor = _getActiveEditor(); + reader.enableAddToNote(!!editor); + } + } + + function _updatePaneWidth() { + var stacked = Zotero.Prefs.get('layout') == 'stacked'; + var width = Zotero.Reader.getSidebarWidth() + 'px'; + if (!Zotero.Reader.getSidebarOpen()) { + width = 0; + } + _contextPane.style.left = stacked ? width : 'unset'; + } + + function _updateToolbarWidth() { + var stacked = Zotero.Prefs.get('layout') == 'stacked'; + var reader = Zotero.Reader.getByTabID(Zotero_Tabs.selectedID); + if (reader) { + if ((stacked || _contextPaneSplitter.getAttribute('state') == 'collapsed')) { + reader.setToolbarPlaceholderWidth(_tabToolbarContainer.boxObject.width); + } + else { + reader.setToolbarPlaceholderWidth(0); + } + } + } + + function _update() { + if (Zotero_Tabs.selectedIndex == 0) { + return; + } + + var splitter; + var stacked = Zotero.Prefs.get('layout') == 'stacked'; + if (stacked) { + _contextPaneSplitterStacked.setAttribute('hidden', false); + _contextPaneSplitter.setAttribute('state', 'open'); + _contextPaneSplitter.setAttribute('hidden', true); + _contextPane.classList.add('stacked'); + _contextPane.classList.remove('standard'); + splitter = _contextPaneSplitterStacked; + } + else { + _contextPaneSplitter.setAttribute('hidden', false); + _contextPaneSplitterStacked.setAttribute('hidden', true); + _contextPaneSplitterStacked.setAttribute('state', 'open'); + _contextPane.classList.add('standard'); + _contextPane.classList.remove('stacked'); + splitter = _contextPaneSplitter; + } + + var collapsed = splitter.getAttribute('state') == 'collapsed'; + + var selectedIndex = _panesDeck.selectedIndex; + if (!collapsed && selectedIndex == 0) { + _itemPaneToggle.classList.add('toggled'); + } + else { + _itemPaneToggle.classList.remove('toggled'); + } + + if (!collapsed && selectedIndex == 1) { + _notesPaneToggle.classList.add('toggled'); + } + else { + _notesPaneToggle.classList.remove('toggled'); + } + + if (Zotero_Tabs.selectedIndex > 0) { + var height = 0; + if (Zotero.Prefs.get('layout') == 'stacked' + && _contextPane.getAttribute('collapsed') != 'true') { + height = _contextPaneInner.boxObject.height; + } + Zotero.Reader.setBottomPlaceholderHeight(height); + } + + _updatePaneWidth(); + _updateToolbarWidth(); + _updateAddToNote(); + } + + function _togglePane(paneIndex) { + var splitter = Zotero.Prefs.get('layout') == 'stacked' + ? _contextPaneSplitterStacked : _contextPaneSplitter; + + var isOpen = splitter.getAttribute('state') != 'collapsed'; + var hide = false; + var currentPane = _panesDeck.selectedIndex; + if (isOpen && currentPane == paneIndex) { + hide = true; + } + else { + _panesDeck.setAttribute('selectedIndex', paneIndex); + } + + splitter.setAttribute('state', hide ? 'collapsed' : 'open'); + _update(); + } + + function _init() { + // vbox + var vbox = document.createElement('vbox'); + vbox.setAttribute('flex', '1'); + + _contextPaneInner.append(vbox); + + // Toolbar extension + var toolbarExtension = document.createElement('box'); + toolbarExtension.style.height = '32px'; + toolbarExtension.id = 'zotero-context-toolbar-extension'; + + _panesDeck = document.createElement('deck'); + _panesDeck.setAttribute('flex', 1); + _panesDeck.setAttribute('selectedIndex', 0); + + vbox.append(toolbarExtension, _panesDeck); + + // Item pane deck + _itemPaneDeck = document.createElement('deck'); + // Notes pane deck + _notesPaneDeck = document.createElement('deck'); + _notesPaneDeck.style.backgroundColor = 'white'; + _notesPaneDeck.setAttribute('flex', 1); + _notesPaneDeck.className = 'notes-pane-deck'; + + _panesDeck.append(_itemPaneDeck, _notesPaneDeck); + } + + function _getCurrentAttachment() { + var reader = Zotero.Reader.getByTabID(Zotero_Tabs.selectedID); + if (reader) { + return Zotero.Items.get(reader.itemID); + } + } + + function _addNotesContext(libraryID) { + var list = document.createElement('vbox'); + list.setAttribute('flex', 1); + list.className = 'zotero-context-notes-list'; + + var noteContainer = document.createElement('vbox'); + var title = document.createElement('vbox'); + title.className = 'zotero-context-pane-editor-parent-line'; + var editor = document.createElement('zoteronoteeditor'); + editor.className = 'zotero-context-pane-pinned-note'; + editor.setAttribute('flex', 1); + noteContainer.append(title, editor); + + let contextNode = document.createElement('deck'); + contextNode.append(list, noteContainer); + _notesPaneDeck.append(contextNode); + + contextNode.className = 'context-node'; + contextNode.setAttribute('selectedIndex', 0); + + editor.returnHandler = () => { + contextNode.setAttribute('selectedIndex', 0); + _updateAddToNote(); + }; + + var head = document.createElement('hbox'); + head.style.display = 'flex'; + + async function _createNoteFromAnnotations(child) { + var attachment = _getCurrentAttachment(); + if (!attachment) { + return; + } + var note = await Zotero.EditorInstance.createNoteFromAnnotations( + attachment.getAnnotations(), child && attachment.parentID + ); + + _updateAddToNote(); + + input.value = ''; + _updateNotesList(); + + _setPinnedNote(note.id); + } + + function _createNote(child) { + contextNode.setAttribute('selectedIndex', 1); + var item = new Zotero.Item('note'); + item.libraryID = libraryID; + if (child) { + var attachment = _getCurrentAttachment(); + if (!attachment) { + return; + } + item.parentID = attachment.parentID; + } + editor.mode = 'edit'; + editor.item = item; + editor.parentItem = null; + editor.focus(); + _updateAddToNote(); + + input.value = ''; + _updateNotesList(); + } + + var vbox = document.createElement('vbox'); + vbox.style.flex = '1'; + var input = document.createElement('textbox'); + input.style.width = 'calc(100% - 42px)'; + input.style.marginLeft = '12px'; + input.setAttribute('type', 'search'); + input.setAttribute('timeout', '250'); + input.addEventListener('command', () => { + notesListRef.current.setExpanded(false); + _updateNotesList(); + }); + vbox.append(input); + + head.append(vbox); + + var listBox = document.createElement('vbox'); + listBox.style.display = 'flex'; + listBox.setAttribute('flex', '1'); + var listInner = document.createElementNS(HTML_NS, 'div'); + listInner.className = 'notes-list-container'; + listBox.append(listInner); + + list.append(head, listBox); + + var notesListRef = React.createRef(); + + async function _updateNotesList(useCached) { + var query = input.value; + var notes; + if (useCached && context.cachedNotes.length) { + notes = context.cachedNotes; + } + else { + await Zotero.Schema.schemaUpdatePromise; + var s = new Zotero.Search(); + s.addCondition('libraryID', 'is', libraryID); + s.addCondition('itemType', 'is', 'note'); + if (query) { + let parts = Zotero.SearchConditions.parseSearchString(query); + for (let part of parts) { + s.addCondition('note', 'contains', part.text); + } + } + notes = await s.search(); + notes = Zotero.Items.get(notes); + notes.sort((a, b) => { + a = a.getField('dateModified'); + b = b.getField('dateModified'); + return b.localeCompare(a); + }); + + notes = notes.map(note => { + var parentItem = note.parentItem; + var text = note.note; + text = Zotero.Utilities.unescapeHTML(text); + text = text.trim(); + text = text.slice(0, 500); + var parts = text.split('\n').map(x => x.trim()).filter(x => x.length); + var title = parts[0] && parts[0].slice(0, Zotero.Notes.MAX_TITLE_LENGTH); + var date = Zotero.Date.sqlToDate(note.dateModified); + date = Zotero.Date.toFriendlyDate(date); + + return { + id: note.id, + title: title || Zotero.getString('pane.item.notes.untitled'), + body: parts[1] || '', + date, + parentID: note.parentID, + parentItemType: parentItem && parentItem.itemType, + parentTitle: parentItem && parentItem.getDisplayTitle() + }; + }); + context.cachedNotes = notes; + } + + var attachment = _getCurrentAttachment(); + var parentID = attachment && attachment.parentID; + notesListRef.current.setHasParent(!!parentID); + notesListRef.current.setNotes(notes.map(note => ({ + ...note, + isCurrentChild: parentID && note.parentID == parentID + }))); + } + + var context = { + libraryID, + node: contextNode, + editor, + notesListRef, + cachedNotes: [], + update: Zotero.Utilities.throttle(_updateNotesList, 1000, { leading: false }), + updateFromCache: () => _updateNotesList(true) + }; + + function _handleAddChildNotePopupClick(event) { + switch (event.originalTarget.id) { + case 'context-pane-add-child-note': + _createNote(true); + break; + + case 'context-pane-add-child-note-from-annotations': + _createNoteFromAnnotations(true); + break; + + default: + } + } + + function _handleAddStandaloneNotePopupClick(event) { + switch (event.originalTarget.id) { + case 'context-pane-add-standalone-note': + _createNote(); + break; + + case 'context-pane-add-standalone-note-from-annotations': + _createNoteFromAnnotations(); + break; + + default: + } + } + + ReactDOM.render( + { + _setPinnedNote(id); + }} + onAddChildButtonDown={(event) => { + var popup = document.getElementById('context-pane-add-child-note-button-popup'); + popup.onclick = _handleAddChildNotePopupClick; + popup.openPopup(event.target, 'after_end'); + }} + onAddStandaloneButtonDown={(event) => { + var popup = document.getElementById('context-pane-add-standalone-note-button-popup'); + popup.onclick = _handleAddStandaloneNotePopupClick; + popup.openPopup(event.target, 'after_end'); + }} + />, + listInner, + () => { + _updateNotesList(); + } + ); + _notesContexts.push(context); + return context; + } + + function _getNotesContext(libraryID) { + var context = _notesContexts.find(x => x.libraryID == libraryID); + if (!context) { + context = _addNotesContext(libraryID); + } + return context; + } + + function _selectNotesContext(libraryID) { + let context = _getNotesContext(libraryID); + _notesPaneDeck.setAttribute('selectedIndex', Array.from(_notesPaneDeck.children).findIndex(x => x == context.node)); + } + + function _removeNotesContext(libraryID) { + var context = _notesContexts.find(x => x.libraryID == libraryID); + context.node.remove(); + _notesContexts = _notesContexts.filter(x => x.libraryID != libraryID); + } + + function _isLibraryEditable(libraryID) { + var type = Zotero.Libraries.get(libraryID).libraryType; + if (type == 'group') { + var groupID = Zotero.Groups.getGroupIDFromLibraryID(libraryID); + var group = Zotero.Groups.get(groupID); + return group.editable; + } + return true; + } + + function _setPinnedNote(itemID) { + var item = Zotero.Items.get(itemID); + if (!item) { + return; + } + var editable = _isLibraryEditable(item.libraryID); + var context = _getNotesContext(item.libraryID); + if (context) { + var { editor, node } = context; + node.setAttribute('selectedIndex', 1); + editor.mode = editable ? 'edit' : 'view'; + editor.item = item; + editor.parentItem = null; + editor.hideLinksContainer = true; + + node.querySelector('.zotero-context-pane-editor-parent-line').innerHTML = ''; + var parentItem = item.parentItem; + if (parentItem) { + var container = document.createElementNS(HTML_NS, 'div'); + var img = document.createElementNS(HTML_NS, 'img'); + img.src = Zotero.ItemTypes.getImageSrc(parentItem.itemType); + img.className = 'parent-item-type'; + var title = document.createElementNS(HTML_NS, 'div'); + title.append(parentItem.getDisplayTitle()); + title.className = 'parent-title'; + container.append(img, title); + node.querySelector('.zotero-context-pane-editor-parent-line').append(container); + } + _updateAddToNote(); + } + } + + function _removeItemContext(tabID) { + document.getElementById(tabID + '-context').remove(); + _itemContexts = _itemContexts.filter(x => x.tabID != tabID); + } + + function _selectItemContext(tabID) { + let selectedIndex = Array.from(_itemPaneDeck.children).findIndex(x => x.id == tabID + '-context'); + if (selectedIndex != -1) { + _itemPaneDeck.setAttribute('selectedIndex', selectedIndex); + } + } + + function _addItemContext(tabID, itemID) { + var item = Zotero.Items.get(itemID); + if (!item) { + return; + } + var libraryID = item.libraryID; + var editable = _isLibraryEditable(libraryID); + var parentID = item.parentID; + + var container = document.createElement('vbox'); + container.id = tabID + '-context'; + container.className = 'zotero-item-pane-content'; + _itemPaneDeck.appendChild(container); + + var context = { + tabID, + itemID, + parentID, + libraryID, + update: () => {} + }; + _itemContexts.push(context); + + if (!parentID) { + var vbox = document.createElement('vbox'); + vbox.setAttribute('flex', '1'); + vbox.setAttribute('align', 'center'); + vbox.setAttribute('pack', 'center'); + var description = document.createElement('description'); + vbox.append(description); + description.append(Zotero.getString('pane.context.noParent')); + container.append(vbox); + return; + } + var parentItem = Zotero.Items.get(item.parentID); + + // Info pane + var panelInfo = document.createElement('vbox'); + panelInfo.setAttribute('flex', '1'); + panelInfo.className = 'zotero-editpane-item-box'; + var itemBox = document.createElement('zoteroitembox'); + itemBox.setAttribute('flex', '1'); + panelInfo.append(itemBox); + container.append(panelInfo); + + itemBox.mode = editable ? 'edit' : 'view'; + itemBox.item = parentItem; + } +}; + +addEventListener('load', function (e) { ZoteroContextPane.onLoad(e); }, false); +addEventListener('unload', function (e) { ZoteroContextPane.onUnload(e); }, false); diff --git a/chrome/content/zotero/exportOptions.js b/chrome/content/zotero/exportOptions.js index cfe66baea9..9919f9915f 100644 --- a/chrome/content/zotero/exportOptions.js +++ b/chrome/content/zotero/exportOptions.js @@ -34,8 +34,6 @@ const OPTION_PREFIX = "export-option-"; // Class to provide options for export var Zotero_File_Interface_Export = new function() { - this.init = init; - this.updateOptions = updateOptions; this.accept = accept; this.cancel = cancel; @@ -44,7 +42,7 @@ var Zotero_File_Interface_Export = new function() { /* * add options to export */ - function init() { + this.init = function () { // Set font size from pref var sbc = document.getElementById('zotero-export-options-container'); Zotero.setFontSize(sbc); @@ -82,10 +80,25 @@ var Zotero_File_Interface_Export = new function() { // right now, option interface supports only boolean values, which // it interprets as checkboxes if(typeof(translators[i].displayOptions[option]) == "boolean") { - var checkbox = document.createElement("checkbox"); + let checkbox = document.createElement("checkbox"); checkbox.setAttribute("id", OPTION_PREFIX+option); checkbox.setAttribute("label", optionLabel); optionsBox.insertBefore(checkbox, charsetBox); + + // Add "Include Annotations" after "Export Files" + if (option == 'exportFileData') { + checkbox.onclick = () => { + setTimeout(() => this.updateAnnotationsCheckbox()); + }; + + checkbox = document.createElement("checkbox"); + checkbox.setAttribute("id", OPTION_PREFIX + 'includeAnnotations'); + checkbox.setAttribute( + "label", + Zotero.getString('exportOptions.includeAnnotations') + ); + optionsBox.insertBefore(checkbox, charsetBox); + } } addedOptions[option] = true; @@ -108,13 +121,13 @@ var Zotero_File_Interface_Export = new function() { _charsets = Zotero_Charset_Menu.populate(document.getElementById(OPTION_PREFIX+"exportCharset"), true); } - updateOptions(Zotero.Prefs.get("export.translatorSettings")); + this.updateOptions(Zotero.Prefs.get("export.translatorSettings")); } /* * update translator-specific options */ - function updateOptions(optionString) { + this.updateOptions = function (optionString) { // get selected translator var index = document.getElementById("format-menu").selectedIndex; var translatorOptions = window.arguments[0].translators[index].displayOptions; @@ -133,7 +146,9 @@ var Zotero_File_Interface_Export = new function() { var node = optionsBox.childNodes[i]; // skip non-options if(node.id.length <= OPTION_PREFIX.length - || node.id.substr(0, OPTION_PREFIX.length) != OPTION_PREFIX) { + || node.id.substr(0, OPTION_PREFIX.length) != OPTION_PREFIX + // Handled separately by updateAnnotationsCheckbox() + || node.id == 'export-option-includeAnnotations') { continue; } @@ -161,6 +176,10 @@ var Zotero_File_Interface_Export = new function() { } } + this.updateAnnotationsCheckbox( + (options && options.includeAnnotations) ? options.includeAnnotations : false + ); + // handle charset popup if(_charsets && translatorOptions && translatorOptions.exportCharset) { optionsBox.hidden = undefined; @@ -181,6 +200,21 @@ var Zotero_File_Interface_Export = new function() { window.sizeToContent(); } + this.updateAnnotationsCheckbox = function (defaultValue) { + var filesCheckbox = document.getElementById(OPTION_PREFIX + 'exportFileData'); + var annotationsCheckbox = document.getElementById(OPTION_PREFIX + 'includeAnnotations'); + if (filesCheckbox.hidden) { + annotationsCheckbox.hidden = true; + annotationsCheckbox.checked = false; + return; + } + annotationsCheckbox.hidden = false; + annotationsCheckbox.disabled = !filesCheckbox.checked; + if (defaultValue !== undefined) { + annotationsCheckbox.checked = defaultValue; + } + }; + /* * make option array reflect status */ @@ -210,6 +244,13 @@ var Zotero_File_Interface_Export = new function() { } } + // If "Export Files" is shown, add "Include Annotations" checkbox value + if (optionsAvailable && optionsAvailable.exportFileData !== undefined) { + let elem1 = document.getElementById(OPTION_PREFIX + 'exportFileData'); + let elem2 = document.getElementById(OPTION_PREFIX + 'includeAnnotations'); + displayOptions.includeAnnotations = elem1.checked && elem2.checked; + } + // save options var optionString = JSON.stringify(displayOptions); Zotero.Prefs.set("export.translatorSettings", optionString); diff --git a/chrome/content/zotero/exportOptions.xul b/chrome/content/zotero/exportOptions.xul index 8584fa6164..28d21a18e9 100644 --- a/chrome/content/zotero/exportOptions.xul +++ b/chrome/content/zotero/exportOptions.xul @@ -1,5 +1,7 @@ + + %zoteroDTD; diff --git a/chrome/content/zotero/integration/insertNoteDialog.js b/chrome/content/zotero/integration/insertNoteDialog.js new file mode 100644 index 0000000000..7a3e64b67d --- /dev/null +++ b/chrome/content/zotero/integration/insertNoteDialog.js @@ -0,0 +1,31 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2021 Center for History and New Media + George Mason University, Fairfax, Virginia, USA + http://zotero.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + +var defaultBubbleizeSelected = Zotero_QuickFormat._bubbleizeSelected; +Zotero_QuickFormat.citingNotes = true; +Zotero_QuickFormat._bubbleizeSelected = async function () { + await defaultBubbleizeSelected(); + await Zotero_QuickFormat._accept(); +} \ No newline at end of file diff --git a/chrome/content/zotero/integration/insertNoteDialog.xul b/chrome/content/zotero/integration/insertNoteDialog.xul new file mode 100644 index 0000000000..c57b33d6fd --- /dev/null +++ b/chrome/content/zotero/integration/insertNoteDialog.xul @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + diff --git a/resource/pdf-renderer/renderer.js b/resource/pdf-renderer/renderer.js new file mode 100644 index 0000000000..1cef87f91a --- /dev/null +++ b/resource/pdf-renderer/renderer.js @@ -0,0 +1,141 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2021 Corporation for Digital Scholarship + Vienna, Virginia, USA + http://digitalscholar.org/ + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + +const SCALE_FACTOR = 4; + +window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'resource://zotero/pdf-reader/pdf.worker.js'; + +function errObject(err) { + return JSON.parse(JSON.stringify(err, Object.getOwnPropertyNames(err))); +} + +let lastPromiseID = 0; +let waitingPromises = {}; + +async function query(action, data, transfer) { + return new Promise((resolve, reject) => { + lastPromiseID++; + waitingPromises[lastPromiseID] = { resolve, reject }; + parent.postMessage({ + id: lastPromiseID, + action, + data + }, parent.origin, transfer); + }); +} + +async function renderAnnotations(buf, annotations) { + let num = 0; + let pdfDocument = await window.pdfjsLib.getDocument({ data: buf }).promise; + let pages = new Map(); + for (let annotation of annotations) { + let pageIndex = annotation.position.pageIndex; + let page = pages.get(pageIndex) || []; + page.push(annotation); + pages.set(pageIndex, page); + } + for (let [pageIndex, annotations] of pages) { + let { canvas, viewport } = await renderPage(pdfDocument, pageIndex); + for (let annotation of annotations) { + let position = p2v(annotation.position, viewport); + let rect = position.rects[0]; + let [left, top, right, bottom] = rect; + let width = right - left; + let height = bottom - top; + let newCanvas = document.createElement('canvas'); + newCanvas.width = width; + newCanvas.height = height; + let newCanvasContext = newCanvas.getContext('2d'); + newCanvasContext.drawImage(canvas, left, top, width, height, 0, 0, width, height); + let blob = await new Promise(resolve => newCanvas.toBlob(resolve, 'image/png')); + let image = await new Response(blob).arrayBuffer(); + await query('renderedAnnotation', { annotation: { id: annotation.id, image } }, [image]); + num++; + } + } + return num; +} + +function p2v(position, viewport) { + return { + pageIndex: position.pageIndex, + rects: position.rects.map(rect => { + let [x1, y2] = viewport.convertToViewportPoint(rect[0], rect[1]); + let [x2, y1] = viewport.convertToViewportPoint(rect[2], rect[3]); + return [Math.min(x1, x2), Math.min(y1, y2), Math.max(x1, x2), Math.max(y1, y2)]; + }) + }; +} + +async function renderPage(pdfDocument, pageIndex) { + let page = await pdfDocument.getPage(pageIndex + 1); + var canvas = document.createElement('canvas'); + var viewport = page.getViewport({ scale: SCALE_FACTOR }); + var context = canvas.getContext('2d', { alpha: false }); + canvas.height = viewport.height; + canvas.width = viewport.width; + await page.render({ canvasContext: context, viewport: viewport }).promise; + return { canvas, viewport }; +} + +window.addEventListener('message', async (event) => { + if (event.source === parent) { + return; + } + let message = event.data; + + if (message.responseID) { + let { resolve, reject } = waitingPromises[message.responseID]; + delete waitingPromises[message.responseID]; + if (message.data) { + resolve(message.data); + } + else { + let err = new Error(message.error.message); + Object.assign(err, message.error); + reject(err); + } + return; + } + + if (message.action === 'renderAnnotations') { + try { + let { buf, annotations } = message.data; + let num = await renderAnnotations(buf, annotations); + parent.postMessage({ responseID: message.id, data: num }, parent.origin); + } + catch (e) { + console.log(e); + parent.postMessage({ + responseID: message.id, + error: errObject(e) + }, parent.origin); + } + } +}); + +setTimeout(() => { + query('initialized', {}); +}, 100); diff --git a/resource/schema/global b/resource/schema/global index 68ad875567..7a5019893a 160000 --- a/resource/schema/global +++ b/resource/schema/global @@ -1 +1 @@ -Subproject commit 68ad875567b6bed4b6ebb2b5ee08bc6a272c2302 +Subproject commit 7a5019893a1af246f9ea53d9ceb927aee4d95c6c diff --git a/resource/schema/userdata.sql b/resource/schema/userdata.sql index 3ddc70f30a..620525b1de 100644 --- a/resource/schema/userdata.sql +++ b/resource/schema/userdata.sql @@ -1,4 +1,4 @@ --- 111 +-- 112 -- Copyright (c) 2009 Center for History and New Media -- George Mason University, Fairfax, Virginia, USA @@ -104,6 +104,7 @@ CREATE TABLE itemAttachments ( syncState INT DEFAULT 0, storageModTime INT, storageHash TEXT, + lastProcessedModificationTime INT, FOREIGN KEY (itemID) REFERENCES items(itemID) ON DELETE CASCADE, FOREIGN KEY (parentItemID) REFERENCES items(itemID) ON DELETE CASCADE, FOREIGN KEY (charsetID) REFERENCES charsets(charsetID) ON DELETE SET NULL @@ -112,6 +113,23 @@ CREATE INDEX itemAttachments_parentItemID ON itemAttachments(parentItemID); CREATE INDEX itemAttachments_charsetID ON itemAttachments(charsetID); CREATE INDEX itemAttachments_contentType ON itemAttachments(contentType); CREATE INDEX itemAttachments_syncState ON itemAttachments(syncState); +CREATE INDEX itemAttachments_lastProcessedModificationTime ON itemAttachments(lastProcessedModificationTime); + +CREATE TABLE itemAnnotations ( + itemID INTEGER PRIMARY KEY, + parentItemID INT NOT NULL, + type INTEGER NOT NULL, + text TEXT, + comment TEXT, + color TEXT, + pageLabel TEXT, + sortIndex TEXT NOT NULL, + position TEXT NOT NULL, + isExternal INT NOT NULL, + FOREIGN KEY (itemID) REFERENCES items(itemID) ON DELETE CASCADE, + FOREIGN KEY (parentItemID) REFERENCES itemAttachments(itemID) ON DELETE CASCADE +); +CREATE INDEX itemAnnotations_parentItemID ON itemAnnotations(parentItemID); CREATE TABLE tags ( tagID INTEGER PRIMARY KEY, @@ -275,7 +293,7 @@ CREATE TABLE libraries ( CREATE TABLE users ( userID INTEGER PRIMARY KEY, - username TEXT NOT NULL + name TEXT NOT NULL ); CREATE TABLE groups ( @@ -374,37 +392,6 @@ CREATE TABLE storageDeleteLog ( FOREIGN KEY (libraryID) REFERENCES libraries(libraryID) ON DELETE CASCADE ); -CREATE TABLE annotations ( - annotationID INTEGER PRIMARY KEY, - itemID INT NOT NULL, - parent TEXT, - textNode INT, - offset INT, - x INT, - y INT, - cols INT, - rows INT, - text TEXT, - collapsed BOOL, - dateModified DATE, - FOREIGN KEY (itemID) REFERENCES itemAttachments(itemID) ON DELETE CASCADE -); -CREATE INDEX annotations_itemID ON annotations(itemID); - -CREATE TABLE highlights ( - highlightID INTEGER PRIMARY KEY, - itemID INT NOT NULL, - startParent TEXT, - startTextNode INT, - startOffset INT, - endParent TEXT, - endTextNode INT, - endOffset INT, - dateModified DATE, - FOREIGN KEY (itemID) REFERENCES itemAttachments(itemID) ON DELETE CASCADE -); -CREATE INDEX highlights_itemID ON highlights(itemID); - CREATE TABLE proxies ( proxyID INTEGER PRIMARY KEY, multiHost INT, diff --git a/resource/tinymce/noteview.html b/resource/tinymce/noteview.html index 50b0960d20..4a9c987cb7 100644 --- a/resource/tinymce/noteview.html +++ b/resource/tinymce/noteview.html @@ -16,14 +16,28 @@ menubar: false, resize: false, statusbar: false, - + // Hack in text selection. More info: https://stackoverflow.com/a/52272322 + readonly: 1, + setup: function (editor) { + editor.on('SwitchMode', function hack() { + if (editor.readonly) { + editor.readonly = 1; + } + }); + }, init_instance_callback: function (ed) { ed.setMode('readonly'); setLocale(ed); zoteroInit(ed); - + ed.getContainer().classList.add('readonly'); + ed.dom.doc.addEventListener('click', (event) => { + if (event.target.nodeName === 'A') { + zoteroHandleEvent({ type: 'ZoteroLinkClick', value: event.target.href }); + event.preventDefault(); + } + }, true); }, // More restrictive version of default set, with JS/etc. removed diff --git a/scripts/build.js b/scripts/build.js index 07fe74dbf7..bce9e73c7a 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -5,6 +5,9 @@ const getCopy = require('./copy'); const getJS = require('./js'); const getSass = require('./sass'); const getSymlinks = require('./symlinks'); +const getPDFReader = require('./pdf-reader'); +const getPDFWorker = require('./pdf-worker'); +const getZoteroNoteEditor = require('./note-editor'); const { formatDirsForMatcher, getSignatures, writeSignatures, cleanUp, onSuccess, onError} = require('./utils'); const { dirs, symlinkDirs, copyDirs, symlinkFiles, jsFiles, scssFiles, ignoreMask } = require('./config'); @@ -27,7 +30,10 @@ if (require.main === module) { getSass(scssFiles, { ignore: ignoreMask }, signatures), getSymlinks(symlinks, { nodir: true, ignore: ignoreMask }, signatures), getSymlinks(symlinkDirs, { ignore: ignoreMask }, signatures), - cleanUp(signatures) + cleanUp(signatures), + getPDFReader(signatures), + getPDFWorker(signatures), + getZoteroNoteEditor(signatures) ]); await writeSignatures(signatures); diff --git a/scripts/clean.js b/scripts/clean.js index 09a2f938ae..5840342019 100644 --- a/scripts/clean.js +++ b/scripts/clean.js @@ -17,6 +17,9 @@ if (require.main === module) { try { await getClean(path.join(ROOT, 'build')); await getClean(path.join(ROOT, '.signatures.json')); + await getClean(path.join(ROOT, 'pdf-reader/build')); + await getClean(path.join(ROOT, 'pdf-worker/build')); + await getClean(path.join(ROOT, 'note-editor/build')); } catch (err) { process.exitCode = 1; global.isError = true; diff --git a/scripts/config.js b/scripts/config.js index 920efaa561..723a360c4c 100644 --- a/scripts/config.js +++ b/scripts/config.js @@ -110,6 +110,8 @@ const scssFiles = [ 'chrome/skin/default/zotero/**/*.scss' ]; +const buildsURL = 'https://zotero-download.s3.amazonaws.com/ci/'; + module.exports = { - dirs, symlinkDirs, copyDirs, symlinkFiles, browserifyConfigs, jsFiles, scssFiles, ignoreMask + dirs, symlinkDirs, copyDirs, symlinkFiles, browserifyConfigs, jsFiles, scssFiles, ignoreMask, buildsURL }; diff --git a/scripts/note-editor.js b/scripts/note-editor.js new file mode 100644 index 0000000000..5a7351b3ad --- /dev/null +++ b/scripts/note-editor.js @@ -0,0 +1,62 @@ +'use strict'; + +const fs = require('fs-extra'); +const util = require('util'); +const exec = util.promisify(require('child_process').exec); +const { getSignatures, writeSignatures, onSuccess, onError } = require('./utils'); +const { buildsURL } = require('./config'); + +async function getZoteroNoteEditor(signatures) { + const t1 = Date.now(); + + const { stdout } = await exec('git rev-parse HEAD', { cwd: './note-editor' }); + const hash = stdout.trim(); + + if (!('note-editor' in signatures) || signatures['note-editor'].hash !== hash) { + const targetDir = 'build/resource/note-editor/'; + try { + const filename = hash + '.zip'; + const tmpDir = 'tmp/builds/note-editor/'; + const url = buildsURL + 'client-note-editor/' + filename; + await exec( + `mkdir -p ${tmpDir}` + + `&& cd ${tmpDir}` + + `&& (test -f ${filename} || curl -f ${url} -o ${filename})` + + `&& rm -rf ../../../${targetDir}` + + `&& mkdir -p ../../../${targetDir}` + + `&& unzip -o ${filename} -d ../../../${targetDir}` + ); + } + catch (e) { + await exec('npm ci;npm run build', { cwd: 'note-editor' }); + await fs.copy('note-editor/build/zotero', targetDir); + } + signatures['note-editor'] = { hash }; + } + + const t2 = Date.now(); + + return { + action: 'note-editor', + count: 1, + totalCount: 1, + processingTime: t2 - t1 + }; +} + +module.exports = getZoteroNoteEditor; + +if (require.main === module) { + (async () => { + try { + const signatures = await getSignatures(); + onSuccess(await getZoteroNoteEditor(signatures)); + await writeSignatures(signatures); + } + catch (err) { + process.exitCode = 1; + global.isError = true; + onError(err); + } + })(); +} diff --git a/scripts/pdf-reader.js b/scripts/pdf-reader.js new file mode 100644 index 0000000000..957a708d3a --- /dev/null +++ b/scripts/pdf-reader.js @@ -0,0 +1,62 @@ +'use strict'; + +const fs = require('fs-extra'); +const util = require('util'); +const exec = util.promisify(require('child_process').exec); +const { getSignatures, writeSignatures, onSuccess, onError } = require('./utils'); +const { buildsURL } = require('./config'); + +async function getPDFReader(signatures) { + const t1 = Date.now(); + + const { stdout } = await exec('git rev-parse HEAD', { cwd: './pdf-reader' }); + const hash = stdout.trim(); + + if (!('pdf-reader' in signatures) || signatures['pdf-reader'].hash !== hash) { + const targetDir = 'build/resource/pdf-reader/'; + try { + const filename = hash + '.zip'; + const tmpDir = 'tmp/builds/pdf-reader/'; + const url = buildsURL + 'client-pdf-reader/' + filename; + await exec( + `mkdir -p ${tmpDir}` + + `&& cd ${tmpDir}` + + `&& (test -f ${filename} || curl -f ${url} -o ${filename})` + + `&& rm -rf ../../../${targetDir}` + + `&& mkdir -p ../../../${targetDir}` + + `&& unzip -o ${filename} -d ../../../${targetDir}` + ); + } + catch (e) { + await exec('npm ci;npm run build', { cwd: 'pdf-reader' }); + await fs.copy('pdf-reader/build/zotero', targetDir); + } + signatures['pdf-reader'] = { hash }; + } + + const t2 = Date.now(); + + return { + action: 'pdf-reader', + count: 1, + totalCount: 1, + processingTime: t2 - t1 + }; +} + +module.exports = getPDFReader; + +if (require.main === module) { + (async () => { + try { + const signatures = await getSignatures(); + onSuccess(await getPDFReader(signatures)); + await writeSignatures(signatures); + } + catch (err) { + process.exitCode = 1; + global.isError = true; + onError(err); + } + })(); +} diff --git a/scripts/pdf-worker.js b/scripts/pdf-worker.js new file mode 100644 index 0000000000..7342dd274f --- /dev/null +++ b/scripts/pdf-worker.js @@ -0,0 +1,61 @@ +'use strict'; + +const fs = require('fs-extra'); +const util = require('util'); +const exec = util.promisify(require('child_process').exec); +const { getSignatures, writeSignatures, onSuccess, onError } = require('./utils'); +const { buildsURL } = require('./config'); + +async function getPDFWorker(signatures) { + const t1 = Date.now(); + + const { stdout } = await exec('git rev-parse HEAD', { cwd: 'pdf-worker' }); + const hash = stdout.trim(); + + if (!('pdf-worker' in signatures) || signatures['pdf-worker'].hash !== hash) { + const targetDir = 'build/chrome/content/zotero/xpcom/pdfWorker/'; + try { + const filename = hash + '.zip'; + const tmpDir = 'tmp/builds/pdf-worker'; + const url = buildsURL + 'client-pdf-worker/' + filename; + await exec( + `mkdir -p ${tmpDir}` + + `&& cd ${tmpDir}` + + `&& (test -f ${filename} || curl -f ${url} -o ${filename})` + + `&& mkdir -p ../../../${targetDir}` + + `&& unzip -o ${filename} -d ../../../${targetDir}` + ); + } + catch (e) { + await exec('npm ci;npm run build', { cwd: 'pdf-worker' }); + await fs.copy('pdf-worker/build/pdf-worker.js', targetDir + 'worker.js'); + } + signatures['pdf-worker'] = { hash }; + } + + const t2 = Date.now(); + + return { + action: 'pdf-worker', + count: 1, + totalCount: 1, + processingTime: t2 - t1 + }; +} + +module.exports = getPDFWorker; + +if (require.main === module) { + (async () => { + try { + const signatures = await getSignatures(); + onSuccess(await getPDFWorker(signatures)); + await writeSignatures(signatures); + } + catch (err) { + process.exitCode = 1; + global.isError = true; + onError(err); + } + })(); +} diff --git a/scss/_zotero-react-client.scss b/scss/_zotero-react-client.scss index c99c1f191f..6bc27557d2 100644 --- a/scss/_zotero-react-client.scss +++ b/scss/_zotero-react-client.scss @@ -11,6 +11,7 @@ @import "abstracts/mixins"; @import "abstracts/placeholders"; @import "abstracts/utilities"; +@import "abstracts/split-button"; // Theme // -------------------------------------------------- @@ -24,9 +25,13 @@ @import "components/button"; @import "components/createParent"; @import "components/editable"; +@import "components/exportOptions"; @import "components/icons"; +@import "components/mainWindow"; +@import "components/notesList"; @import "components/progressMeter"; @import "components/search"; @import "components/syncButtonTooltip"; +@import "components/tabBar"; @import "components/tagsBox"; @import "components/tagSelector"; diff --git a/scss/abstracts/_split-button.scss b/scss/abstracts/_split-button.scss new file mode 100644 index 0000000000..3e44b5a679 --- /dev/null +++ b/scss/abstracts/_split-button.scss @@ -0,0 +1,489 @@ + +// The split button was originally created by flachware for pdf-reader: +// https://github.com/zotero/pdf-reader/blob/master/src/stylesheets/abstracts/mixins/_split-button.scss + +$accent-color: #0a6cf5; // 215° 96 96 +$accent-color-darken-6: darken($accent-color, 6%); + +$blue-btn: #90c8f6; + +$toolbar-btn-height: 22px; +$toolbar-btn-bg: linear-gradient(to bottom, #fafafa, #f2f2f2); +$toolbar-btn-padding: 3px 11px; +$toolbar-btn-border: 0; +$toolbar-btn-border-radius: 3.75px; +$toolbar-btn-margin-x: 4px; +$toolbar-btn-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.33), 0 0 0 1px rgba(0, 0, 0, 0.08), 0 1px 0 rgba(0, 0, 0, 0.1); +$toolbar-btn-box-shadow-2x: inset 0 0.5px 0.5px rgba(255, 255, 255, 1), 0 0 0 0.5px rgba(0, 0, 0, 0.1), 0 0.75px 0 rgba(0, 0, 0, 0.125); +$toolbar-btn-focus-box-shadow: 0 0 0 4px rgba($accent-color, 0.5), $toolbar-btn-box-shadow; +$toolbar-btn-focus-box-shadow-2x: 0 0 0 4px rgba($accent-color, 0.5), $toolbar-btn-box-shadow-2x; +$toolbar-btn-hover-bg: null; +$toolbar-btn-border-hover-color: null; +$toolbar-btn-active-bg: linear-gradient(to bottom, #e4e4e4, #ddd); +$toolbar-btn-blurred-bg: transparent; +$toolbar-btn-blurred-box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.11); +$toolbar-btn-border-active-color: $blue-btn; +$toolbar-btn-icon-active-offset: 0; + + +// +// String functions +// + +@function str-replace($string, $token, $replace: "") { + $i: str-index($string, $token); + + @while (str-index($string, $token) != null) { + $first-part: str-slice($string, 1, ($i - 1)); + $last-part: str-slice($string, ($i + str-length($token))); + $string: str-slice($string, 1, ($i - 1)) + $replace + $last-part; + + $i: str-index($string, $token); + } + + @return $string; +} + +@function str-parse($string, $token) { + $i: str-index($string, $token); + + @if $i { + $first-part: str-slice($string, 1, ($i - 1)); + $string: str-parse(str-slice($string, ($i + str-length($token))), $token); + + @return join(simple-selectors($first-part), $string); + } + + @return simple-selectors($string); +} + + + +// State mixin +@mixin state($states...) { + + @each $state in $states { + $string: $state; + + @each $token in ">", "+", "~" { + $string: str-replace($string, $token, " "); + } + + $string: str-replace($string, " ", " "); + + $selectors: str-parse($string, " "); + + @each $sel in $selectors { + @if str-index("#{&}", $sel) != null { + @at-root #{selector-replace(&, $sel, $state)} { + @content; + } + } + } + } +} + +// Variant mixin (alias) +@mixin variant($args...) { + @include state($args...) { + @content; + } +} + + +// +// Media queries +// + +@mixin retina { + @media screen and (min-resolution: 1.25dppx) { + @content; + } +} + + +// +// Asset URLs +// + +@function asset-url($type, $path) { + @return url(unquote("#{$type}/#{$path}")); +} + +@function icon-url($path) { + @return asset-url("chrome://zotero/skin", $path); +} + +@function image-url($path) { + @return asset-url("chrome://zotero/skin", $path); +} + + +// +// Icon +// + +@mixin icon($file-name, $size: 16px) { + &::before { + content: ""; + display: inline-block; + vertical-align: top; + width: $size; + height: $size; + background-image: icon-url("#{$file-name}.png"); + background-size: 100%; + + @include retina { + background-image: icon-url("#{$file-name}@2x.png"); + } + } +} + + +.toolbarButton::before { + position: relative; + z-index: 2; +} + +.toolbarButton > span:first-child { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.toolbarButton { + height: $toolbar-btn-height; + background: $toolbar-btn-bg; + padding: $toolbar-btn-padding; + border: $toolbar-btn-border; + border-radius: $toolbar-btn-border-radius; + margin: 0 $toolbar-btn-margin-x; + position: relative; + box-shadow: $toolbar-btn-box-shadow; + + @include retina { + box-shadow: $toolbar-btn-box-shadow-2x; + } + + /*&:focus-visible { + box-shadow: $toolbar-btn-focus-box-shadow; + z-index: 1; + + @include retina { + box-shadow: $toolbar-btn-focus-box-shadow-2x; + } + }*/ + + &:-moz-focusring { + box-shadow: $toolbar-btn-focus-box-shadow; + z-index: 1; + + @include retina { + box-shadow: $toolbar-btn-focus-box-shadow-2x; + } + } + + &:hover { + background: $toolbar-btn-hover-bg; + border-color: $toolbar-btn-border-hover-color; + } + + &:active, + &.active { + background: $toolbar-btn-active-bg; + border-color: $toolbar-btn-border-active-color; + } + + &[disabled] { + pointer-events: none; + + &::before { + opacity: 0.5; + } + } + + &::before { + position: relative; + z-index: 2; + + @include state(".toolbarButton:active", ".toolbarButton.active") { + top: $toolbar-btn-icon-active-offset; + } + } + + // Label for screen readers + > span:first-child { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } +} + +.split-button { + display: flex; +} + + +// +// Split button +// + +@mixin split-button($height, $button, $padding-x, $padding-y) { + height: $height; + padding: $padding-y $padding-x $padding-y ($padding-x + 1px); + background: none; + border-radius: 0; + box-shadow: none; + position: relative; + + /*&:focus-visible { + z-index: 2; // Chrome + + &::after { + content: ""; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + border-radius: 0; + box-shadow: inset 0 0 0 1px rgba($accent-color, 0.5), 0 0 0 3px rgba($accent-color, 0.5); + + @include retina { + box-shadow: inset 0 0 0 1px rgba($accent-color, 0.5), 0 0 0 2.5px rgba($accent-color, 0.5); + } + } + }*/ + + &:-moz-focusring { + &::after { + content: ""; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + border-radius: 0; + box-shadow: inset 0 0 0 1px rgba($accent-color, 0.5), 0 0 0 3px rgba($accent-color, 0.5); + z-index: 2; // Firefox + + @include retina { + box-shadow: inset 0 0 0 1px rgba($accent-color, 0.5), 0 0 0 2.5px rgba($accent-color, 0.5); + } + } + } + + &:first-child { + margin-right: 0; + background: none; + + /*&:focus-visible { + &::after { + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + } + }*/ + + &:-moz-focusring { + &::after { + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + } + } + } + + &:last-child { + padding: $padding-y ($padding-x + 1px); + margin-left: 0; + background: image-url("mac/#{$button}-end.png") no-repeat right top; + + @include retina { + background: image-url("mac/#{$button}-end@2x.png") no-repeat right top / auto $height; + } + + &:active { + background: image-url("mac/#{$button}-end-pressed.png") no-repeat right top; + + @include retina { + background: image-url("mac/#{$button}-end-pressed@2x.png") no-repeat right top / auto $height; + } + } + + @include state(".toolbarButton.toggled") { + background: image-url("mac/#{$button}-end-active.png") no-repeat right top; + + @include retina { + background: image-url("mac/#{$button}-end-active@2x.png") no-repeat right top / auto $height; + } + + @include state(".toolbarButton:active") { + background: image-url("mac/#{$button}-end-active-pressed.png") no-repeat right top; + + @include retina { + background: image-url("mac/#{$button}-end-active-pressed@2x.png") no-repeat right top / auto $height; + } + } + } + + /*&:focus-visible { + &::after { + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + } + }*/ + + &:-moz-focusring { + &::after { + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + } + } + } + + &:not(:first-child) { + margin-left: 0; + } + + &:not(:last-child) { + margin-right: 0; + } + + > span:first-child { + clip: initial; + margin: initial; + left: 0; + top: 0; + width: 100%; + height: 100%; + text-indent: -99em; + background: + linear-gradient(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.1)) no-repeat left center / 1px ($height - 2px), + image-url("mac/#{$button}-start.png") no-repeat center top; + + @include retina { + background: + linear-gradient(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.1)) no-repeat left center / 1px ($height - 2px), + image-url("mac/#{$button}-start@2x.png") no-repeat center top / auto $height; + } + + @include state(".toolbarButton:active") { + background: image-url("mac/#{$button}-start-pressed.png") no-repeat center top; + + @include retina { + background: image-url("mac/#{$button}-start-pressed@2x.png") no-repeat center top / auto $height; + } + } + + @include state(".toolbarButton.toggled") { + background: image-url("mac/#{$button}-start-active.png") no-repeat center top; + + @include retina { + background: image-url("mac/#{$button}-start-active@2x.png") no-repeat center top / auto $height; + } + + @include state(".toolbarButton:active") { + background: image-url("mac/#{$button}-start-active-pressed.png") no-repeat center top; + + @include retina { + background: image-url("mac/#{$button}-start-active-pressed@2x.png") no-repeat center top / auto $height; + } + } + } + + @include state(".toolbarButton.toggled + .toolbarButton:not(:active)") { + background: image-url("mac/#{$button}-start.png") no-repeat center top; + + @include retina { + background: image-url("mac/#{$button}-start@2x.png") no-repeat center top / auto $height; + } + } + + @include variant(".toolbarButton:first-child") { + background: image-url("mac/#{$button}-start.png") no-repeat left top; + + @include retina { + background: image-url("mac/#{$button}-start@2x.png") no-repeat left top / auto $height; + } + + @include state(".toolbarButton:active") { + background: image-url("mac/#{$button}-start-pressed.png") no-repeat left top; + + @include retina { + background: image-url("mac/#{$button}-start-pressed@2x.png") no-repeat left top / auto $height; + } + } + + @include state(".toolbarButton.toggled") { + background: image-url("mac/#{$button}-start-active.png") no-repeat left top; + + @include retina { + background: image-url("mac/#{$button}-start-active@2x.png") no-repeat left top / auto $height; + } + + @include state(".toolbarButton:active") { + background: image-url("mac/#{$button}-start-active-pressed.png") no-repeat left top; + + @include retina { + background: image-url("mac/#{$button}-start-active-pressed@2x.png") no-repeat left top / auto $height; + } + } + } + } + + @include variant(".toolbarButton:last-child") { + width: calc(100% - 17px); + } + } +} + + +// Move this out from mixins +#zotero-tb-split { + $item-tool-icon: "mac/item"; + $item-tool-icon-active: "mac/item-white"; + $notes-tool-icon: "mac/notes"; + $notes-tool-icon-active: "mac/notes-white"; + + &.hidden { + display: none; + } + + .item { + @include icon($item-tool-icon); + + &.toggled { + @include icon($item-tool-icon-active); + } + } + + .notes { + @include icon($notes-tool-icon); + + &.toggled { + @include icon($notes-tool-icon-active); + } + } + + .toolbarButton { + box-sizing: border-box; + + @include split-button( + $height: 24px, + $button: "menubutton", + $padding-x: 11px, + $padding-y: 4px + ); + } +} diff --git a/scss/components/_exportOptions.scss b/scss/components/_exportOptions.scss new file mode 100644 index 0000000000..f78b1716e4 --- /dev/null +++ b/scss/components/_exportOptions.scss @@ -0,0 +1,10 @@ +#zotero-export-options { + #export-option-includeAnnotations { + margin-top: 2px; + margin-left: 17px; + } + + checkbox[disabled=true] label { + opacity: .5; + } +} diff --git a/scss/components/_mainWindow.scss b/scss/components/_mainWindow.scss new file mode 100644 index 0000000000..1be6361fa4 --- /dev/null +++ b/scss/components/_mainWindow.scss @@ -0,0 +1,174 @@ +// Styling for displaying tabs in the title bar +:root:not([legacytoolbar="true"]) { + & { + --tab-min-height: 30px; + --tabs-border-color: rgba(0,0,0,.3); + --tabline-color: #0a84ff; + + --toolbar-non-lwt-bgcolor: #f9f9fa; + --toolbar-non-lwt-textcolor: #0c0c0d; + --toolbar-non-lwt-bgimage: none; + --toolbar-bgcolor: var(--toolbar-non-lwt-bgcolor); + --toolbar-bgimage: var(--toolbar-non-lwt-bgimage); + --chrome-content-separator-color: hsl(0, 0%, 60%); + + --toolbarbutton-border-radius: 3px; + --toolbarbutton-icon-fill-opacity: .85; + --toolbarbutton-hover-background: hsla(0, 0%, 100%, .1) + linear-gradient(hsla(0, 0%, 100%, .3), + hsla(0, 0%, 100%, .1)) no-repeat; + --toolbarbutton-hover-bordercolor: hsla(0, 0%, 0%, .2); + --toolbarbutton-header-bordercolor: hsla(0, 0%, 0%, .2); + --toolbarbutton-hover-boxshadow: 0 1px 0 hsla(0, 0%, 100%, .5), + 0 1px 0 hsla(0, 0%, 100%, .5) inset; + + --toolbarbutton-active-background: hsla(0, 0%, 0%, .02) + linear-gradient(hsla(0, 0%, 0%, .12), + transparent) border-box; + --toolbarbutton-active-bordercolor: hsla(0, 0%, 0%, .3); + --toolbarbutton-active-boxshadow: 0 1px 0 hsla(0, 0%, 100%, .5), + 0 1px 0 hsla(0, 0%, 0%, .05) inset, + 0 1px 1px hsla(0, 0%, 0%, .2) inset; + --toolbarbutton-inactive-bordercolor: rgba(0, 0, 0, 0.1); + --toolbarbutton-inactive-boxshadow: 0 1px 0 hsla(0, 0%, 0%, .05) inset; + --toolbarbutton-checkedhover-backgroundcolor: hsla(0, 0%, 0%, .09); + --toolbarbutton-icon-fill-attention: var(--lwt-toolbarbutton-icon-fill-attention, #0a84ff); + + --lwt-header-image: none; + --row-grouped-header-bg-color: #d5d5d5; + --row-grouped-header-bg-color-selected: #3874d1; + --panel-separator-color: hsla(210, 4%, 10%, .14); + --arrowpanel-dimmed: hsla(0, 0%, 80%, .3); + --arrowpanel-dimmed-further: hsla(0, 0%, 80%, .45); + --splitter-color: #bdbdbd; + --urlbar-popup-url-color: hsl(210, 77%, 47%); + --urlbar-popup-action-color: hsl(178, 100%, 28%); + + --autocomplete-popup-background: -moz-field; + --autocomplete-popup-color: -moz-fieldtext; + --autocomplete-popup-border-color: ThreeDShadow; + --autocomplete-popup-highlight-background: Highlight; + --autocomplete-popup-highlight-color: HighlightText; + /* Note: Setting this to 0 (without px) breaks CSS calculations for OSX. */ + --space-above-tabbar: 0px; + } + + &:-moz-window-inactive { + --toolbar-bgcolor: -moz-mac-chrome-inactive; + } + + + + + #titlebar { + margin-bottom: -30px; + -moz-box-pack: center; + } + + #personal-bookmarks { + -moz-window-dragging: inherit; + } + + toolbarpaletteitem { + -moz-window-dragging: no-drag; + -moz-box-pack: start; + } + + .titlebar-buttonbox-container { + -moz-box-ordinal-group: 1000; + } + + #titlebar-fullscreen-button { + -moz-appearance: -moz-mac-fullscreen-button; + } + + + + + + + + /* ::::: Tabs in Titlebar :::::: */ + + &:not([chromemargin]) > #titlebar { + display: none; + } + + /** + * For tabs in titlebar on OS X, we stretch the titlebar down so that the + * tabstrip can overlap it. + */ + &[tabsintitlebar] > #titlebar { + min-height: calc(var(--tab-min-height) + var(--space-above-tabbar)); + } + + &[tabsintitlebar="true"]:not(:-moz-lwtheme) > #titlebar { + -moz-appearance: -moz-window-titlebar; + } + + &:not([tabsintitlebar]) .titlebar-placeholder { + visibility: collapse; + } + + /* NB: these would be margin-inline-start/end if it wasn't for the fact that OS X + * doesn't reverse the order of the items in the titlebar in RTL mode. */ + .titlebar-placeholder[type="caption-buttons"], + #titlebar-buttonbox { + margin-right: 12px; + margin-left: 12px; + } + + .titlebar-placeholder[type="fullscreen-button"], + #titlebar-fullscreen-button { + margin-right: 7px; + margin-left: 7px; + } + + #titlebar-fullscreen-button { + -moz-appearance: -moz-mac-fullscreen-button; + } + + #titlebar-buttonbox { + -moz-appearance: -moz-window-button-box; + } + + /* Fullscreen and caption buttons don't move with RTL on OS X so override the automatic ordering. */ + #titlebar-fullscreen-button:-moz-locale-dir(ltr), + #titlebar-buttonbox-container:-moz-locale-dir(rtl), + .titlebar-placeholder[type="fullscreen-button"]:-moz-locale-dir(ltr), + .titlebar-placeholder[type="caption-buttons"]:-moz-locale-dir(rtl) { + -moz-box-ordinal-group: 1000; + } + + #titlebar-fullscreen-button:-moz-locale-dir(rtl), + #titlebar-buttonbox-container:-moz-locale-dir(ltr), + .titlebar-placeholder[type="caption-buttons"]:-moz-locale-dir(ltr), + .titlebar-placeholder[type="fullscreen-button"]:-moz-locale-dir(rtl) { + -moz-box-ordinal-group: 0; + } + + &[sizemode="fullscreen"] .titlebar-placeholder[type="fullscreen-button"], + &[sizemode="fullscreen"] .titlebar-placeholder[type="caption-buttons"] { + display: none; + } + + + + #tab-bar-container { + -moz-window-dragging: drag; + } + + #tab-bar-container .tab { + background: $transparent; + } + + #tab-bar-container .tab.selected { + background: $toolbar-background-color; + } + + #zotero-toolbar { + -moz-appearance: none; + background: $toolbar-background-color; + border-bottom: $toolbar-border; + } +} \ No newline at end of file diff --git a/scss/components/_notesList.scss b/scss/components/_notesList.scss new file mode 100644 index 0000000000..33f58bcf46 --- /dev/null +++ b/scss/components/_notesList.scss @@ -0,0 +1,138 @@ +.notes-list-container { + display: flex; + width: 100%; + overflow-y: auto; + flex-grow: 1; + background: #d2d8e2; + border-top: 1px solid lightgray; +} + +.notes-list { + display: flex; + flex-direction: column; + height: 0; + flex-grow: 1; + padding-top: 2px; + + & > section { + margin: 5px 0; + } +} + +.header-row { + margin: 0 7px; + display: flex; + justify-content: space-between; + height: 24px; + + h2 { + font-weight: bold; + margin: 0; + font-size: 13px; + align-self: center; + } + + button { + height: 24px; + padding-left: 4px; + padding-right: 4px; + // Necessary on linux to horizontaly center text + line-height: 0; + } +} + +.empty-row { + margin: 4px 7px 0; + text-align: center; +} + +.note-row { + border: 1px solid #bcc4d2; + border-radius: 5px; + margin: 4px 7px; + background-color: #fff; + + &:active { + background: #eef1f8; + } + + .inner { + > *:not(:first-child) { + margin-top: 3px; + } + + .parent-line { + display: flex; + width: calc(100% - 16px); + border-bottom: 1px solid #d7dad7; + align-items: center; + padding: 5px 8px 4px; + margin-bottom: 5px; + } + + .parent-item-type { + margin-right: 3px; + width: 16px; // Don't show HiDPI icons at 2x size + } + + .parent-title { + flex-grow: 1; + width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .title-line { + display: flex; + padding: 0 8px 0; + + .title { + flex-grow: 1; + width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: bold; + } + } + + .body-line { + display: flex; + padding: 0 8px 6px; + + .date { + color: $shade-6; + } + + .body { + flex-grow: 1; + width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-left: 10px; + color: $shade-6; + } + } + } +} + +.more-row { + border: 1px solid #bcc4d2; + border-radius: 5px; + margin: 4px 7px; + background-color: white; + text-align: center; + padding: 5px; + + &:active { + background: #eef1f8; + } +} + +.standalone-note-row { + .title-line { + padding-top: 6px !important; + } +} diff --git a/scss/components/_tabBar.scss b/scss/components/_tabBar.scss new file mode 100644 index 0000000000..2c4853f461 --- /dev/null +++ b/scss/components/_tabBar.scss @@ -0,0 +1,84 @@ +#tab-bar-container { + min-height: 30px; +} + +.tabs { + display: flex; + + &:before { + content: ""; + width: 0px; + min-width: 0px; + border-bottom: $tab-border; + border-inline-end: $tab-border; + } + + &:after { + content: ""; + flex: 1 0 0%; + width: 100%; + min-width: 0px; + border-bottom: $tab-border; + border-inline-start: $tab-border; + } +} + +.tab { + box-sizing: border-box; + -moz-appearance: none; + max-width: 200px; + flex: 1 1 200px; + height: 30px; + line-height: 30px; + position: relative; + background: #f9f9f9; + border-top: 2px solid transparent; + color: #000; + text-align: center; + padding: 0 30px; + + &:not(:last-child) { + border-inline-end: $tab-border; + } + + &.selected { + border-top: 2px solid $tab-background-color-selected; + } + + .tab-name { + line-height: 30px; + overflow-x: hidden; + text-overflow: ellipsis; + display: -moz-box; + position: relative; + top: -2px; + overflow-y: hidden; + } + + .tab-close { + position: absolute; + right: 6px; + top: 6px; + width: 16px; + height: 16px; + text-align: center; + line-height: 16px; + border-radius: 3px; + + &:hover { + background-color: rgba(0, 0, 0, 0.08); + } + + &:active { + background-color: rgba(0, 0, 0, 0.16); + } + } + + &:first-child .tab-close { + display: none; + } + + &:not(.selected) { + border-bottom: $tab-border; + } +} diff --git a/scss/components/_tagsBox.scss b/scss/components/_tagsBox.scss index 2a6a540b0a..be79367f72 100644 --- a/scss/components/_tagsBox.scss +++ b/scss/components/_tagsBox.scss @@ -1,8 +1,8 @@ -#tags-pane { +.tags-pane { display: flex; } -#tags-box-container { +.tags-box-container { flex-grow: 1; display: flex; } @@ -14,7 +14,11 @@ $li-side-margin: 6px; flex-grow: 1; - width: 330px; + //width: 330px; + + // This is necessary for XUL layout to prevent children + // container to force its height for the parent + height: 0; .tags-box-header { display: flex; @@ -40,7 +44,6 @@ list-style: none; margin: 0; padding: 2px 0 0; // Leave space for textbox border on top tag - overflow-y: auto; } ul.tags-box-list > li { @@ -61,7 +64,7 @@ .editable-container { flex-grow: 1; margin: 0 2px; - width: $item-pane-width - $icon-width - $delete-button-width - ($li-side-margin * 2); + //width: $item-pane-width - $icon-width - $delete-button-width - ($li-side-margin * 2); } ul.tags-box-list > li:not(.multiline) .editable-container { diff --git a/scss/mac/_mainWindow.scss b/scss/mac/_mainWindow.scss new file mode 100644 index 0000000000..eabd6d9fbc --- /dev/null +++ b/scss/mac/_mainWindow.scss @@ -0,0 +1,3 @@ +#titlebar { + //-moz-appearance: -moz-mac-vibrant-titlebar-light !important; +} diff --git a/scss/mac/_tabBar.scss b/scss/mac/_tabBar.scss new file mode 100644 index 0000000000..43fabae76f --- /dev/null +++ b/scss/mac/_tabBar.scss @@ -0,0 +1,18 @@ +.tabs { + -moz-window-dragging: drag; +} + +.tab { + -moz-window-dragging: no-drag; +} + +.tabs { + &:before { + width: 78px; + min-width: 78px; + } + + &:after { + min-width: 20px; + } +} diff --git a/scss/themes/_light.scss b/scss/themes/_light.scss index deeeb37e28..a6b7fdcab6 100644 --- a/scss/themes/_light.scss +++ b/scss/themes/_light.scss @@ -80,6 +80,15 @@ $clicky-active-color: $shade-0; // Components // -------------------------------------------------- +// Toolbar +$toolbar-background-color: #f6f6f6; +$toolbar-border-color: darkgray; +$toolbar-border: .5px solid $toolbar-border-color; + +// Tab bar +$tab-border: $toolbar-border; +$tab-background-color-selected: #678AC5; + // Icon $icon-color: $secondary; $icon-active-color: $shade-9; diff --git a/scss/zotero-react-client-mac.scss b/scss/zotero-react-client-mac.scss index 2678caa9c9..29c82fd8eb 100644 --- a/scss/zotero-react-client-mac.scss +++ b/scss/zotero-react-client-mac.scss @@ -7,5 +7,7 @@ @import "mac/button"; @import "mac/createParent"; @import "mac/editable"; +@import "mac/mainWindow"; @import "mac/search"; +@import "mac/tabBar"; @import "mac/tag-selector"; diff --git a/test/content/support.js b/test/content/support.js index 02ac0796af..0fa9f9c1ac 100644 --- a/test/content/support.js +++ b/test/content/support.js @@ -689,7 +689,7 @@ function generateAllTypesAndFieldsData() { }; // Item types that should not be included in sample data - let excludeItemTypes = ['note', 'attachment']; + let excludeItemTypes = ['note', 'attachment', 'annotation']; for (let i = 0; i < itemTypes.length; i++) { if (excludeItemTypes.indexOf(itemTypes[i].name) != -1) continue; @@ -904,7 +904,8 @@ function importFileAttachment(filename, options = {}) { filename.split('/').forEach((part) => file.append(part)); let importOptions = { file, - parentItemID: options.parentID + parentItemID: options.parentID, + title: options.title }; Object.assign(importOptions, options); return Zotero.Attachments.importFromFile(importOptions); @@ -921,6 +922,62 @@ function importHTMLAttachment() { } +async function importPDFAttachment(parentItem, options = {}) { + var attachment = await importFileAttachment( + 'test.pdf', + { + contentType: 'application/pdf', + parentID: parentItem ? parentItem.id : null, + title: options.title + } + ); + return attachment; +} + + +async function createAnnotation(type, parentItem, options = {}) { + var annotation = new Zotero.Item('annotation'); + annotation.parentID = parentItem.id; + annotation.annotationType = type; + if (type == 'highlight') { + annotation.annotationText = Zotero.Utilities.randomString(); + } + annotation.annotationComment = Zotero.Utilities.randomString(); + var page = Zotero.Utilities.rand(1, 100).toString().padStart(5, '0'); + var pos = Zotero.Utilities.rand(1, 10000).toString().padStart(6, '0'); + annotation.annotationSortIndex = `${page}|${pos}|00000`; + annotation.annotationPosition = JSON.stringify({ + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }); + if (options.isExternal) { + annotation.annotationIsExternal = options.isExternal; + } + if (options.tags) { + annotation.setTags(options.tags); + } + await annotation.saveTx(); + return annotation; +} + + +async function createEmbeddedImage(parentItem, options = {}) { + var attachment = await Zotero.Attachments.importEmbeddedImage({ + blob: await File.createFromFileName( + OS.Path.join(getTestDataDirectory().path, 'test.png') + ), + parentItemID: parentItem.id + }); + if (options.tags) { + attachment.setTags(options.tags); + await attachment.saveTx(); + } + return attachment; +} + + /** * Sets the fake XHR server to response to a given response * diff --git a/test/tests/annotationsTest.js b/test/tests/annotationsTest.js new file mode 100644 index 0000000000..6147479975 --- /dev/null +++ b/test/tests/annotationsTest.js @@ -0,0 +1,308 @@ +describe("Zotero.Annotations", function() { + var exampleHighlight = { + "libraryID": null, + "key": "92JLMCVT", + "type": "highlight", + "isExternal": false, + "isAuthor": true, + "text": "This is an extracted text with rich-text\nAnd a new line", + "comment": "This is a comment with rich-text\nAnd a new line", + "color": "#ffec00", + "pageLabel": "15", + "sortIndex": "00015|002431|00000", + "position": { + "pageIndex": 1, + "rects": [ + [231.284, 402.126, 293.107, 410.142], + [54.222, 392.164, 293.107, 400.18], + [54.222, 382.201, 293.107, 390.217], + [54.222, 372.238, 293.107, 380.254], + [54.222, 362.276, 273.955, 370.292] + ] + }, + "tags": [ + { + "name": "math", + "color": "#ff0000" + }, + { + "name": "chemistry" + } + ], + "dateModified": "2019-05-14 06:50:40" + }; + var exampleHighlightAlt = jsonPositionToString(exampleHighlight); + + var exampleNote = { + "libraryID": null, + "key": "5TKU34XX", + "type": "note", + "isExternal": false, + "isAuthor": true, + "comment": "This is a note", + "color": "#ffec00", + "pageLabel": "14", + "sortIndex": "00014|001491|00283", + "position": { + "pageIndex": 0, + "rects": [ + [371.395, 266.635, 486.075, 274.651] + ] + }, + "dateModified": "2019-05-14 06:50:54" + }; + var exampleNoteAlt = jsonPositionToString(exampleNote); + + var exampleImage = { + "libraryID": null, + "key": "QD32MQJF", + "type": "image", + "isExternal": false, + "isAuthor": true, + "image": "zotero://attachment/library/items/LB417FR4", + "comment": "This is a comment", + "color": "#ffec00", + "pageLabel": "XVI", + "sortIndex": "00016|003491|00683", + "position": { + "pageIndex": 123, + "rects": [ + [314.4, 412.8, 556.2, 609.6] + ], + "width": 400, + "height": 200 + }, + "dateModified": "2019-05-14 06:51:22" + }; + var exampleImageAlt = jsonPositionToString(exampleImage); + + var exampleGroupHighlight = { + "libraryID": null, + "key": "PE57YAYH", + "type": "highlight", + "isExternal": false, + "isAuthor": false, + "authorName": "Kate Smith", + "text": "This is an extracted text with rich-text\nAnd a new line", + "comment": "This is a comment with rich-text\nAnd a new line", + "color": "#ffec00", + "pageLabel": "15", + "sortIndex": "00015|002431|00000", + "position": { + "pageIndex": 1, + "rects": [ + [231.284, 402.126, 293.107, 410.142], + [54.222, 392.164, 293.107, 400.18], + [54.222, 382.201, 293.107, 390.217], + [54.222, 372.238, 293.107, 380.254], + [54.222, 362.276, 273.955, 370.292] + ] + }, + "dateModified": "2019-05-14 06:50:40" + }; + var exampleGroupHighlightAlt = jsonPositionToString(exampleGroupHighlight); + + // Item.position is a string, so when using the annotation JSON as input or when comparing we + // have to use a version where 'position' has been stringified + function jsonPositionToString(json) { + var o = Object.assign({}, json); + o.position = JSON.stringify(o.position); + return o; + } + + var item; + var attachment; + var group; + var groupItem; + var groupAttachment; + + before(async function () { + item = await createDataObject('item'); + attachment = await importFileAttachment('test.pdf', { parentID: item.id }); + exampleHighlight.libraryID = item.libraryID; + exampleNote.libraryID = item.libraryID; + exampleImage.libraryID = item.libraryID; + + // Disabled while group annotations are disabled + /*group = await getGroup(); + exampleGroupHighlight.libraryID = group.libraryID; + groupItem = await createDataObject('item', { libraryID: group.libraryID }); + groupAttachment = await importFileAttachment( + 'test.pdf', + { libraryID: group.libraryID, parentID: groupItem.id } + );*/ + }); + + describe("#toJSON()", function () { + it("should generate an object for a highlight", async function () { + var annotation = new Zotero.Item('annotation'); + annotation.libraryID = attachment.libraryID; + annotation.key = exampleHighlight.key; + await annotation.loadPrimaryData(); + annotation.parentID = attachment.id; + annotation.annotationType = 'highlight'; + for (let prop of ['text', 'comment', 'color', 'pageLabel', 'sortIndex', 'position']) { + let itemProp = 'annotation' + prop[0].toUpperCase() + prop.substr(1); + annotation[itemProp] = exampleHighlightAlt[prop]; + } + annotation.addTag("math"); + annotation.addTag("chemistry"); + await annotation.saveTx(); + await Zotero.Tags.setColor(annotation.libraryID, "math", "#ff0000", 0); + var json = await Zotero.Annotations.toJSON(annotation); + + assert.sameMembers(Object.keys(json), Object.keys(exampleHighlight)); + for (let prop of Object.keys(exampleHighlight)) { + if (prop == 'dateModified') { + continue; + } + assert.deepEqual(json[prop], exampleHighlight[prop], `'${prop}' doesn't match`); + } + + await annotation.eraseTx(); + }); + + it("should generate an object for a note", async function () { + var annotation = new Zotero.Item('annotation'); + annotation.libraryID = attachment.libraryID; + annotation.key = exampleNote.key; + await annotation.loadPrimaryData(); + annotation.parentID = attachment.id; + annotation.annotationType = 'note'; + for (let prop of ['comment', 'color', 'pageLabel', 'sortIndex', 'position']) { + let itemProp = 'annotation' + prop[0].toUpperCase() + prop.substr(1); + annotation[itemProp] = exampleNoteAlt[prop]; + } + await annotation.saveTx(); + var json = await Zotero.Annotations.toJSON(annotation); + + assert.sameMembers(Object.keys(json), Object.keys(exampleNote)); + for (let prop of Object.keys(exampleNote)) { + if (prop == 'dateModified') { + continue; + } + assert.deepEqual(json[prop], exampleNote[prop], `'${prop}' doesn't match`); + } + + await annotation.eraseTx(); + }); + + it("should generate an object for an image", async function () { + var annotation = new Zotero.Item('annotation'); + annotation.libraryID = attachment.libraryID; + annotation.key = exampleImage.key; + await annotation.loadPrimaryData(); + annotation.parentID = attachment.id; + annotation.annotationType = 'image'; + for (let prop of ['comment', 'color', 'pageLabel', 'sortIndex', 'position']) { + let itemProp = 'annotation' + prop[0].toUpperCase() + prop.substr(1); + annotation[itemProp] = exampleImageAlt[prop]; + } + await annotation.saveTx(); + + // Get Blob from file and attach it + var path = OS.Path.join(getTestDataDirectory().path, 'test.png'); + var imageData = await Zotero.File.getBinaryContentsAsync(path); + var array = new Uint8Array(imageData.length); + for (let i = 0; i < imageData.length; i++) { + array[i] = imageData.charCodeAt(i); + } + var blob = new Blob([array], { type: 'image/png' }); + var file = await Zotero.Annotations.saveCacheImage(annotation, blob); + + var json = await Zotero.Annotations.toJSON(annotation); + + assert.sameMembers(Object.keys(json), Object.keys(exampleImage)); + for (let prop of Object.keys(exampleImage)) { + if (prop == 'image' + || prop == 'dateModified') { + continue; + } + assert.deepEqual(json[prop], exampleImage[prop], `'${prop}' doesn't match`); + } + + var imageVal = await new Zotero.Promise((resolve) => { + var reader = new FileReader(); + reader.readAsDataURL(blob); + reader.onloadend = function() { + resolve(reader.result); + } + }); + assert.equal(json.image, imageVal); + + await annotation.eraseTx(); + }); + + it.skip("should generate an object for a highlight by another user in a group library", async function () { + await Zotero.Users.setName(12345, 'Kate Smith'); + + var annotation = new Zotero.Item('annotation'); + annotation.libraryID = group.libraryID; + annotation.key = exampleGroupHighlight.key; + await annotation.loadPrimaryData(); + annotation.createdByUserID = 12345; + annotation.parentID = groupAttachment.id; + annotation.annotationType = 'highlight'; + for (let prop of ['text', 'comment', 'color', 'pageLabel', 'sortIndex', 'position']) { + let itemProp = 'annotation' + prop[0].toUpperCase() + prop.substr(1); + annotation[itemProp] = exampleGroupHighlightAlt[prop]; + } + await annotation.saveTx(); + var json = await Zotero.Annotations.toJSON(annotation); + + assert.isFalse(json.isAuthor); + assert.equal(json.authorName, 'Kate Smith'); + + await annotation.eraseTx(); + }); + }); + + + describe("#saveFromJSON()", function () { + it("should create an item from a highlight", async function () { + var annotation = await Zotero.Annotations.saveFromJSON(attachment, exampleHighlight); + + assert.equal(annotation.key, exampleHighlight.key); + for (let prop of ['text', 'comment', 'color', 'pageLabel', 'sortIndex', 'position']) { + let itemProp = 'annotation' + prop[0].toUpperCase() + prop.substr(1); + assert.deepEqual(annotation[itemProp], exampleHighlightAlt[prop], `'${prop}' doesn't match`); + } + var itemTags = annotation.getTags().map(t => t.tag); + var jsonTags = exampleHighlight.tags.map(t => t.name); + assert.sameMembers(itemTags, jsonTags); + }); + + it("should create an item from a note", async function () { + var annotation = await Zotero.Annotations.saveFromJSON(attachment, exampleNote); + + assert.equal(annotation.key, exampleNote.key); + for (let prop of ['comment', 'color', 'pageLabel', 'sortIndex', 'position']) { + let itemProp = 'annotation' + prop[0].toUpperCase() + prop.substr(1); + assert.deepEqual(annotation[itemProp], exampleNoteAlt[prop], `'${prop}' doesn't match`); + } + }); + + it("should create an item from an image", async function () { + var annotation = await Zotero.Annotations.saveFromJSON(attachment, exampleImage); + + // Note: Image is created separately using Zotero.Annotations.saveCacheImage() + + assert.equal(annotation.key, exampleImage.key); + for (let prop of ['comment', 'color', 'pageLabel', 'sortIndex', 'position']) { + let itemProp = 'annotation' + prop[0].toUpperCase() + prop.substr(1); + assert.deepEqual(annotation[itemProp], exampleImageAlt[prop], `'${prop}' doesn't match`); + } + }); + + it("should remove empty fields", async function () { + var annotation = await Zotero.Annotations.saveFromJSON(attachment, exampleHighlight); + var json = Object.assign({}, exampleHighlight); + json.comment = ''; + json.pageLabel = ''; + await Zotero.Annotations.saveFromJSON(attachment, json); + + assert.isNull(annotation.annotationComment); + assert.isNull(annotation.annotationPageLabel); + }); + }); +}) \ No newline at end of file diff --git a/test/tests/attachmentsTest.js b/test/tests/attachmentsTest.js index 23a431665a..1bcfc60012 100644 --- a/test/tests/attachmentsTest.js +++ b/test/tests/attachmentsTest.js @@ -1327,7 +1327,7 @@ describe("Zotero.Attachments", function() { assert.equal(newAttachment.attachmentContentType, 'application/pdf'); assert.isTrue(await newAttachment.fileExists()); assert.equal(newAttachment.getField('title'), 'Title'); - assert.equal(newAttachment.getNote(), 'Note'); + assert.equal(newAttachment.note, 'Note'); assert.sameDeepMembers(newAttachment.getTags(), [{ tag: 'Tag' }]); assert.sameMembers(newAttachment.relatedItems, [relatedItem.key]); assert.sameMembers(relatedItem.relatedItems, [newAttachment.key]); diff --git a/test/tests/collectionsTest.js b/test/tests/collectionsTest.js index 8116fbcc3b..848dff6f85 100644 --- a/test/tests/collectionsTest.js +++ b/test/tests/collectionsTest.js @@ -83,48 +83,4 @@ describe("Zotero.Collections", function () { assert.notInstanceOf(collection, Zotero.Feed); }); }); - - - describe("#sortByLevel()", function () { - it("should return collections sorted from top-level to deepest", function* () { - // - A - // - B - // - C - // - D - // - E - // - F - // - G - // - H - // - I - - // Leave out B and G - // Order should be {A, E}, {D, F}, {C, I}, {H} (internal order is undefined) - - var check = function (arr) { - assert.sameMembers(arr.slice(0, 2), [c1.id, c5.id]); - assert.sameMembers(arr.slice(2, 4), [c4.id, c6.id]); - assert.sameMembers(arr.slice(4, 6), [c3.id, c9.id]); - assert.equal(arr[6], c8.id); - }; - - var c1 = yield createDataObject('collection', { "name": "A" }); - var c2 = yield createDataObject('collection', { "name": "B", parentID: c1.id }); - var c3 = yield createDataObject('collection', { "name": "C", parentID: c2.id }); - var c4 = yield createDataObject('collection', { "name": "D", parentID: c1.id }); - var c5 = yield createDataObject('collection', { "name": "E" }); - var c6 = yield createDataObject('collection', { "name": "F", parentID: c5.id }); - var c7 = yield createDataObject('collection', { "name": "G", parentID: c6.id }); - var c8 = yield createDataObject('collection', { "name": "H", parentID: c7.id }); - var c9 = yield createDataObject('collection', { "name": "I", parentID: c6.id }); - - var arr = Zotero.Collections.sortByLevel([c1, c3, c4, c5, c6, c8, c9].map(c => c.id)); - //Zotero.debug(arr.map(id => Zotero.Collections.get(id).name)); - check(arr); - - // Check reverse order - arr = Zotero.Collections.sortByLevel([c1, c3, c4, c5, c6, c8, c9].reverse().map(c => c.id)); - //Zotero.debug(arr.map(id => Zotero.Collections.get(id).name)); - check(arr); - }); - }); }) diff --git a/test/tests/dataObjectTest.js b/test/tests/dataObjectTest.js index 8b2c5a3a83..460fd3f2de 100644 --- a/test/tests/dataObjectTest.js +++ b/test/tests/dataObjectTest.js @@ -141,7 +141,7 @@ describe("Zotero.DataObject", function() { var obj = createUnsavedDataObject(type); var id = yield obj.saveTx(); - obj.synced = 1; + obj.synced = true; yield obj.saveTx(); if (type == 'item') { @@ -210,7 +210,16 @@ describe("Zotero.DataObject", function() { yield obj.loadPrimaryData(); assert.equal(obj.version, objs[type].version); } - }) + }); + + it("shouldn't overwrite item type set in constructor", async function () { + var item = new Zotero.Item('book'); + item.libraryID = Zotero.Libraries.userLibraryID; + item.key = Zotero.DataObjectUtilities.generateKey(); + await item.loadPrimaryData(); + var saved = await item.saveTx(); + assert.ok(saved); + }); }) describe("#loadAllData()", function () { @@ -225,14 +234,14 @@ describe("Zotero.DataObject", function() { var item = new Zotero.Item('attachment'); var id = yield item.saveTx(); yield item.loadAllData(); - assert.equal(item.getNote(), ''); + assert.equal(item.note, ''); }) it("should load data on a note item", function* () { var item = new Zotero.Item('note'); var id = yield item.saveTx(); yield item.loadAllData(); - assert.equal(item.getNote(), ''); + assert.equal(item.note, ''); }) }) diff --git a/test/tests/dataObjectsTest.js b/test/tests/dataObjectsTest.js index 073859be74..e5e0f06688 100644 --- a/test/tests/dataObjectsTest.js +++ b/test/tests/dataObjectsTest.js @@ -71,6 +71,127 @@ describe("Zotero.DataObjects", function () { }) }) + + describe("#sortByLevel()", function () { + it("should return collections sorted from top-level to deepest", async function () { + // - A + // - B + // - C + // - D + // - E + // - F + // - G + // - H + // - I + // + // Leave out B and G + // + // Order should be {A, E}, {D, F}, {C, I}, {H} (internal order is undefined) + + var check = function (arr) { + assert.sameMembers(arr.slice(0, 2), [c1, c5]); + assert.sameMembers(arr.slice(2, 4), [c4, c6]); + assert.sameMembers(arr.slice(4, 6), [c3, c9]); + assert.equal(arr[6], c8); + }; + + var c1 = await createDataObject('collection', { "name": "A" }); + var c2 = await createDataObject('collection', { "name": "B", parentID: c1.id }); + var c3 = await createDataObject('collection', { "name": "C", parentID: c2.id }); + var c4 = await createDataObject('collection', { "name": "D", parentID: c1.id }); + var c5 = await createDataObject('collection', { "name": "E" }); + var c6 = await createDataObject('collection', { "name": "F", parentID: c5.id }); + var c7 = await createDataObject('collection', { "name": "G", parentID: c6.id }); + var c8 = await createDataObject('collection', { "name": "H", parentID: c7.id }); + var c9 = await createDataObject('collection', { "name": "I", parentID: c6.id }); + + var arr = Zotero.Collections.sortByLevel([c1, c3, c4, c5, c6, c8, c9]); + //Zotero.debug(arr.map(id => Zotero.Collections.get(id).name)); + check(arr); + + // Check reverse order + arr = Zotero.Collections.sortByLevel([c1, c3, c4, c5, c6, c8, c9].reverse()); + //Zotero.debug(arr.map(id => Zotero.Collections.get(id).name)); + check(arr); + }); + }); + + + describe("#sortByParent", function () { + it("should return items sorted hierarchically", async function () { + // - A + // - B + // - C + // - D + // - E + // - F + // - G + // - H + // - I + // + // Leave out B and G + // + // Order should be top-down, with child items included immediately after their parents. + // The order of items at the same level is undefined. + + function check(arr) { + var str = arr.map(o => title(o)).join(''); + var possibilities = [ + 'ACDEFH', + 'ACDEFH', + + 'ADCEFH', + 'ADCEFH', + + 'EFHACD', + 'EFHADC', + + 'EFHACD', + 'EFHADC', + ]; + assert.oneOf(str, possibilities); + } + + function title(o) { + return o.getDisplayTitle() || o.getTags()[0].tag; + } + + var a = await createDataObject('item', { title: "A" }); + var b = await createDataObject('item', { note: "B", itemType: 'note', parentID: a.id }); + var c = await createEmbeddedImage(b, { tags: [{ tag: 'C' }] }); + var d = await importPDFAttachment(a, { title: 'D' }); + var e = await createDataObject('item', { title: "E" }); + var f = await importPDFAttachment(e, { title: 'F' }); + var g = await createAnnotation('image', f, { tags: [{ tag: 'G' }] }); + var h = await createAnnotation('highlight', f, { tags: [{ tag: 'H' }] }); + + var arr = Zotero.Items.sortByParent([a, c, d, e, f, h]); + Zotero.debug(arr.map(o => title(o))); + check(arr); + + // Reverse order + arr = Zotero.Items.sortByParent([a, c, d, e, f, h].reverse()); + Zotero.debug(arr.map(o => title(o))); + check(arr); + + // Top-level first + arr = Zotero.Items.sortByParent([a, e, c, d, f, h]); + Zotero.debug(arr.map(o => title(o))); + check(arr); + + // Child first + arr = Zotero.Items.sortByParent([c, h, d, f, a, e]); + Zotero.debug(arr.map(o => title(o))); + check(arr); + + // Random + arr = Zotero.Items.sortByParent([e, d, h, c, a, f]); + Zotero.debug(arr.map(o => title(o))); + check(arr); + }); + }); + + describe("#_setIdentifier", function () { it("should not allow an id change", function* () { var item = yield createDataObject('item'); diff --git a/test/tests/feedItemsTest.js b/test/tests/feedItemsTest.js index fc0eae6ff0..6d80e6e737 100644 --- a/test/tests/feedItemsTest.js +++ b/test/tests/feedItemsTest.js @@ -10,22 +10,31 @@ describe("Zotero.FeedItems", function () { describe("#getMarkedAsRead", function() { var items = []; var result; - before(function* () { + + before(async function () { for (let i = 0; i < 4; i++) { - let f = yield createDataObject('feedItem', {libraryID: feed.libraryID, guid: 'http://www.example.com/' + i}); + let f = await createDataObject( + 'feedItem', + { + libraryID: feed.libraryID, + guid: 'http://www.example.com/' + i + } + ); items.push(f); } - yield items[0].toggleRead(); - yield items[2].toggleRead(); - result = yield Zotero.FeedItems.getMarkedAsRead(feed.libraryID); + await items[0].toggleRead(); + await items[2].toggleRead(); + result = (await Zotero.FeedItems.getMarkedAsRead(feed.libraryID)).map(x => x.id); }); + it('should get all marked as read items', function() { - assert.include(result, items[0]); - assert.include(result, items[2]); + assert.include(result, items[0].id); + assert.include(result, items[2].id); }); + it('should not include items that were not marked', function() { - assert.notInclude(result, items[1]); - assert.notInclude(result, items[3]); + assert.notInclude(result, items[1].id); + assert.notInclude(result, items[3].id); }); }); diff --git a/test/tests/fileTest.js b/test/tests/fileTest.js index 04f3c8bce9..23df1953fb 100644 --- a/test/tests/fileTest.js +++ b/test/tests/fileTest.js @@ -293,6 +293,13 @@ describe("Zotero.File", function () { assert.ok(e); assert.match(e.message, /^Broken symlink/); }); + + it("should handle 'from' in options", async function () { + var tmpPath = await getTempDirectory(); + var path = OS.Path.join(tmpPath, 'a', 'b'); + await Zotero.File.createDirectoryIfMissingAsync(path, { from: tmpPath }); + assert.isTrue(await OS.File.exists(path)); + }); }); describe("#zipDirectory()", function () { diff --git a/test/tests/integrationTest.js b/test/tests/integrationTest.js index 7269ce1926..bae0be58bd 100644 --- a/test/tests/integrationTest.js +++ b/test/tests/integrationTest.js @@ -21,7 +21,10 @@ describe("Zotero.Integration", function () { this.primaryFieldType = "Field"; this.secondaryFieldType = "Bookmark"; this.supportedNotes = ['footnotes', 'endnotes']; + // Will display an option to switch word processors in the Doc Prefs this.supportsImportExport = true; + // Will allow inserting notes + this.supportsTextInsertion = true; this.fields = []; }; DocumentPluginDummy.Application.prototype = { @@ -82,25 +85,50 @@ describe("Zotero.Integration", function () { */ setDocumentData: function(data) {this.data = data}, /** - * Inserts a field at the given position and initializes the field object. + * Inserts a field at cursor position and initializes the field object. + * If Document.insertText() was called previously inserts the field + * directly after the inserted text. * @param {String} fieldType * @param {Integer} noteType * @returns {DocumentPluginDummy.Field} */ - insertField: function(fieldType, noteType) { + insertField: function(fieldType, noteType) { if (typeof noteType != "number") { throw new Error("noteType must be an integer"); } - var field = new DocumentPluginDummy.Field(this); + var field = new DocumentPluginDummy.Field(this); this.fields.push(field); return field; }, + /** + * Inserts rich text at cursor position. If Document.insertField() was called + * previously inserts the text directly after the inserted field. + * @param {String} text + */ + insertText: function (text) { return; }, + /** + * Converts placeholders (which are text with links to https://www.zotero.org/?[placeholderID]) + * to fields and sets their field codes to strings in `codes` in the reverse order of their appearance + * @param {String[]} codes + * @param {String[]} placeholderIDs - the order of placeholders to be replaced + * @param {Number} noteType - controls whether citations should be in-text or in footnotes/endnotes + * @param {Number} fieldType + * @return {Field[]} + */ + convertPlaceholdersToFields: function (codes, noteType, fieldType) { + return codes.map(code => { + let field = new DocumentPluginDummy.Field(this); + field.code = code; + this.fields.push(field); + return field; + }); + }, /** * Gets all fields present in the document. * @param {String} fieldType * @returns {DocumentPluginDummy.Field[]} */ - getFields: function(fieldType) {return Array.from(this.fields)}, + getFields: function (fieldType) {return Array.from(this.fields)}, /** * Sets the bibliography style, overwriting the current values for this document */ @@ -692,12 +720,12 @@ describe("Zotero.Integration", function () { doc.fields[1].code = doc.fields[0].code; doc.fields[1].text = doc.fields[0].text; - var originalUpdateDocument = Zotero.Integration.Fields.prototype.updateDocument; - var stubUpdateDocument = sinon.stub(Zotero.Integration.Fields.prototype, 'updateDocument'); + var originalUpdateDocument = Zotero.Integration.Session.prototype.updateDocument; + var stubUpdateDocument = sinon.stub(Zotero.Integration.Session.prototype, 'updateDocument'); try { var indicesLength; stubUpdateDocument.callsFake(function() { - indicesLength = Object.keys(Zotero.Integration.currentSession.newIndices).length; + indicesLength = Object.keys(this.newIndices).length; return originalUpdateDocument.apply(this, arguments); }); @@ -726,12 +754,12 @@ describe("Zotero.Integration", function () { `"citationID":"${newCitationID}"`); doc.fields[1].text = doc.fields[0].text; - var originalUpdateDocument = Zotero.Integration.Fields.prototype.updateDocument; - var stubUpdateDocument = sinon.stub(Zotero.Integration.Fields.prototype, 'updateDocument'); + var originalUpdateDocument = Zotero.Integration.Session.prototype.updateDocument; + var stubUpdateDocument = sinon.stub(Zotero.Integration.Session.prototype, 'updateDocument'); try { var indices; stubUpdateDocument.callsFake(function() { - indices = Object.keys(Zotero.Integration.currentSession.newIndices); + indices = Object.keys(this.newIndices); return originalUpdateDocument.apply(this, arguments); }); diff --git a/test/tests/itemPaneTest.js b/test/tests/itemPaneTest.js index c6ce5c0d10..72889cff7a 100644 --- a/test/tests/itemPaneTest.js +++ b/test/tests/itemPaneTest.js @@ -282,16 +282,18 @@ describe("Item pane", function () { // Wait for the editor yield new Zotero.Promise((resolve, reject) => { - noteEditor.noteField.onInit(() => resolve()); - }) - assert.equal(noteEditor.noteField.value, ''); - + noteEditor.onInit(() => resolve()); + }); + assert.equal(noteEditor._editorInstance._iframeWindow.wrappedJSObject.getDataSync(), null); item.setNote('

Test

'); yield item.saveTx(); - assert.equal(noteEditor.noteField.value, '

Test

'); - }) - }) + // Wait for asynchronous editor update + do { + yield Zotero.Promise.delay(10); + } while(noteEditor._editorInstance._iframeWindow.wrappedJSObject.getDataSync().html != '

Test

'); + }); + }); describe("Feed buttons", function() { describe("Mark as Read/Unread", function() { diff --git a/test/tests/itemTest.js b/test/tests/itemTest.js index 6fb0d18ca6..7dec374f50 100644 --- a/test/tests/itemTest.js +++ b/test/tests/itemTest.js @@ -709,6 +709,27 @@ describe("Zotero.Item", function () { assert.equal(attachments[0], attachment.id); }) + it("should update after an attachment is moved to the trash", async function () { + var item = await createDataObject('item'); + var attachment = new Zotero.Item("attachment"); + attachment.parentID = item.id; + attachment.attachmentLinkMode = Zotero.Attachments.LINK_MODE_IMPORTED_FILE; + await attachment.saveTx(); + + // Attachment should show up initially + var attachments = item.getAttachments(); + assert.lengthOf(attachments, 1); + assert.equal(attachments[0], attachment.id); + + // Move attachment to trash + attachment.deleted = true; + await attachment.saveTx(); + + // Attachment should not show up without includeTrashed=true + attachments = item.getAttachments(); + assert.lengthOf(attachments, 0); + }); + it("#should return an empty array for an item with no attachments", function* () { var item = yield createDataObject('item'); assert.lengthOf(item.getAttachments(), 0); @@ -833,6 +854,33 @@ describe("Zotero.Item", function () { }); }) + + describe("#getFilePath()", function () { + it("should return the absolute path for an embedded image", async function () { + var note = await createDataObject('item', { itemType: 'note' }); + + var path = OS.Path.join(getTestDataDirectory().path, 'test.png'); + var imageData = await Zotero.File.getBinaryContentsAsync(path); + var array = new Uint8Array(imageData.length); + for (let i = 0; i < imageData.length; i++) { + array[i] = imageData.charCodeAt(i); + } + + var blob = new Blob([array], { type: 'image/png' }); + var attachment = await Zotero.Attachments.importEmbeddedImage({ + blob, + parentItemID: note.id + }); + + var storageDir = Zotero.getStorageDirectory().path; + assert.equal( + OS.Path.join(storageDir, attachment.key, 'image.png'), + attachment.getFilePath() + ); + }); + }); + + describe("#attachmentCharset", function () { it("should get and set a value", function* () { var charset = 'utf-8'; @@ -1138,6 +1186,216 @@ describe("Zotero.Item", function () { }); + describe("#attachmentLastProcessedModificationTime", function () { + it("should save time in milliseconds", async function () { + var item = await createDataObject('item'); + var attachment = await importFileAttachment('test.pdf', { parentID: item.id }); + + var mtime = Math.floor(Date.now() / 1000); + attachment.attachmentLastProcessedModificationTime = mtime; + await attachment.saveTx(); + + assert.equal(attachment.attachmentLastProcessedModificationTime, mtime); + + var sql = "SELECT lastProcessedModificationTime FROM itemAttachments WHERE itemID=?"; + var dbmtime = await Zotero.DB.valueQueryAsync(sql, attachment.id); + + assert.equal(mtime, dbmtime); + }); + }); + + + describe("Attachment Page Index", function () { + describe("#getAttachmentLastPageIndex()", function () { + it("should get the page index", async function () { + var attachment = await importFileAttachment('test.pdf'); + assert.isNull(attachment.getAttachmentLastPageIndex()); + await attachment.setAttachmentLastPageIndex(2); + assert.equal(2, attachment.getAttachmentLastPageIndex()); + }); + + it("should throw an error if called on a regular item", async function () { + var item = createUnsavedDataObject('item'); + assert.throws( + () => item.getAttachmentLastPageIndex(), + "getAttachmentLastPageIndex() can only be called on file attachments" + ); + }); + + it("should discard invalid page index", async function () { + var attachment = await importFileAttachment('test.pdf'); + var id = attachment._getLastPageIndexSettingKey(); + await Zotero.SyncedSettings.set(Zotero.Libraries.userLibraryID, id, '"1"'); + assert.isNull(attachment.getAttachmentLastPageIndex()); + }); + }); + + it("should be cleared when item is deleted", async function () { + var attachment = await importFileAttachment('test.pdf'); + await attachment.setAttachmentLastPageIndex(2); + var id = attachment._getLastPageIndexSettingKey(); + assert.equal(2, Zotero.SyncedSettings.get(Zotero.Libraries.userLibraryID, id)); + await attachment.eraseTx(); + assert.isNull(Zotero.SyncedSettings.get(Zotero.Libraries.userLibraryID, id)); + }); + }); + + + describe("Annotations", function () { + var item; + var attachment; + + before(async function () { + item = await createDataObject('item'); + attachment = await importFileAttachment('test.pdf', { parentID: item.id }); + }); + + describe("#annotationText", function () { + it("should not be changeable", async function () { + var a = new Zotero.Item('annotation'); + a.annotationType = 'highlight'; + assert.doesNotThrow(() => a.annotationType = 'highlight'); + assert.throws(() => a.annotationType = 'note'); + }); + }); + + describe("#annotationText", function () { + it("should only be allowed for highlights", async function () { + var a = new Zotero.Item('annotation'); + a.annotationType = 'highlight'; + assert.doesNotThrow(() => a.annotationText = "This is highlighted text."); + + a = new Zotero.Item('annotation'); + a.annotationType = 'note'; + assert.throws(() => a.annotationText = "This is highlighted text."); + + a = new Zotero.Item('annotation'); + a.annotationType = 'image'; + assert.throws(() => a.annotationText = "This is highlighted text."); + }); + }); + + describe("#saveTx()", function () { + it("should save a highlight annotation", async function () { + var annotation = new Zotero.Item('annotation'); + annotation.parentID = attachment.id; + annotation.annotationType = 'highlight'; + annotation.annotationText = "This is highlighted text."; + annotation.annotationSortIndex = '00015|002431|00000'; + annotation.annotationPosition = JSON.stringify({ + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }); + await annotation.saveTx(); + assert.isFalse(annotation.hasChanged()); + }); + + it("should save a note annotation", async function () { + var annotation = new Zotero.Item('annotation'); + annotation.parentID = attachment.id; + annotation.annotationType = 'note'; + annotation.annotationComment = "This is a comment."; + annotation.annotationSortIndex = '00015|002431|00000'; + annotation.annotationPosition = JSON.stringify({ + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }); + await annotation.saveTx(); + assert.isFalse(annotation.hasChanged()); + }); + + it("should save an image annotation", async function () { + // Create a Blob from a PNG + var path = OS.Path.join(getTestDataDirectory().path, 'test.png'); + var imageData = await Zotero.File.getBinaryContentsAsync(path); + var array = new Uint8Array(imageData.length); + for (let i = 0; i < imageData.length; i++) { + array[i] = imageData.charCodeAt(i); + } + + var annotation = new Zotero.Item('annotation'); + annotation.parentID = attachment.id; + annotation.annotationType = 'image'; + annotation.annotationSortIndex = '00015|002431|00000'; + annotation.annotationPosition = JSON.stringify({ + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ], + width: 1, + height: 1 + }); + await annotation.saveTx(); + assert.isFalse(annotation.hasChanged()); + + var blob = new Blob([array], { type: 'image/png' }); + await Zotero.Annotations.saveCacheImage(annotation, blob); + + var imagePath = Zotero.Annotations.getCacheImagePath(annotation); + assert.ok(imagePath); + assert.equal(OS.Path.basename(imagePath), annotation.key + '.png'); + assert.equal( + await Zotero.File.getBinaryContentsAsync(imagePath), + imageData + ); + }); + + it("should remove cached image for an annotation item when position changes", async function () { + var attachment = await importFileAttachment('test.pdf'); + var annotation = await createAnnotation('image', attachment); + + // Get Blob from file and attach it + var path = OS.Path.join(getTestDataDirectory().path, 'test.png'); + var imageData = await Zotero.File.getBinaryContentsAsync(path); + var array = new Uint8Array(imageData.length); + for (let i = 0; i < imageData.length; i++) { + array[i] = imageData.charCodeAt(i); + } + var blob = new Blob([array], { type: 'image/png' }); + var file = await Zotero.Annotations.saveCacheImage(annotation, blob); + + assert.isTrue(await OS.File.exists(file)); + + var position = JSON.parse(annotation.annotationPosition); + position.rects[0][0] = position.rects[0][0] + 1; + annotation.annotationPosition = JSON.stringify(position); + await annotation.saveTx(); + assert.isFalse(await OS.File.exists(file)); + }); + }); + + describe("#getAnnotations()", function () { + var item; + var attachment; + var annotation1; + var annotation2; + + before(async function () { + item = await createDataObject('item'); + attachment = await importFileAttachment('test.pdf', { parentID: item.id }); + annotation1 = await createAnnotation('highlight', attachment); + annotation2 = await createAnnotation('highlight', attachment); + annotation2.deleted = true; + await annotation2.saveTx(); + }); + + it("should return annotations not in trash", async function () { + var items = attachment.getAnnotations(); + assert.sameMembers(items, [annotation1]); + }); + + it("should return annotations in trash if includeTrashed=true", async function () { + var items = attachment.getAnnotations(true); + assert.sameMembers(items, [annotation1, annotation2]); + }); + }); + }); + + describe("#setTags", function () { it("should save an array of tags in API JSON format", function* () { var tags = [ @@ -1286,6 +1544,41 @@ describe("Zotero.Item", function () { assert.equal(e.message, "Item type must be set before saving"); }) + describe("saving a child item", function () { + it("should throw an error if a new note is the child of another note", async function () { + var note1 = await createDataObject('item', { itemType: 'note' }); + var note2 = createUnsavedDataObject('item', { itemType: 'note', parentID: note1.id }); + var e = await getPromiseError(note2.saveTx()); + assert.ok(e); + assert.include(e.message, "must be a regular item"); + }); + + it("should throw an error if a new imported_file attachment is the child of a note", async function () { + var note = await createDataObject('item', { itemType: 'note' }); + var e = await getPromiseError(importFileAttachment('test.png', { parentItemID: note.id })); + assert.ok(e); + assert.include(e.message, "must be a regular item"); + }); + + it("should throw an error if a new note is the child of another attachment", async function () { + var attachment = await importFileAttachment('test.png'); + var note = createUnsavedDataObject('item', { itemType: 'note', parentID: attachment.id }); + var e = await getPromiseError(note.saveTx()); + assert.ok(e); + assert.include(e.message, "must be a regular item"); + }); + + it("should throw an error if an existing note is set as a child of another note", async function () { + var note1 = await createDataObject('item', { itemType: 'note' }); + var note2 = createUnsavedDataObject('item', { itemType: 'note' }); + await note2.saveTx(); + note2.parentID = note1.id; + var e = await getPromiseError(note2.saveTx()); + assert.ok(e); + assert.include(e.message, "must be a regular item"); + }); + }); + it("should reload child items for parent items", function* () { var item = yield createDataObject('item'); var attachment = yield importFileAttachment('test.png', { parentItemID: item.id }); @@ -1305,6 +1598,25 @@ describe("Zotero.Item", function () { assert.lengthOf(item.getAttachments(), 1); assert.lengthOf(item.getNotes(), 1); }); + + // Make sure we're updating annotations rather than replacing and triggering ON DELETE CASCADE + it("should update attachment without deleting child annotations", async function () { + var attachment = await importFileAttachment('test.pdf'); + var annotation = await createAnnotation('highlight', attachment); + + var annotationIDs = await Zotero.DB.columnQueryAsync( + "SELECT itemID FROM itemAnnotations WHERE parentItemID=?", attachment.id + ); + assert.lengthOf(annotationIDs, 1); + + attachment.attachmentLastProcessedModificationTime = Math.floor(Date.now() / 1000); + await attachment.saveTx(); + + annotationIDs = await Zotero.DB.columnQueryAsync( + "SELECT itemID FROM itemAnnotations WHERE parentItemID=?", attachment.id + ); + assert.lengthOf(annotationIDs, 1); + }); }) @@ -1339,6 +1651,25 @@ describe("Zotero.Item", function () { skipEditCheck: true }); }); + + it("should remove cached image for an annotation item", async function () { + var attachment = await importFileAttachment('test.pdf'); + var annotation = await createAnnotation('image', attachment); + + // Get Blob from file and attach it + var path = OS.Path.join(getTestDataDirectory().path, 'test.png'); + var imageData = await Zotero.File.getBinaryContentsAsync(path); + var array = new Uint8Array(imageData.length); + for (let i = 0; i < imageData.length; i++) { + array[i] = imageData.charCodeAt(i); + } + var blob = new Blob([array], { type: 'image/png' }); + var file = await Zotero.Annotations.saveCacheImage(annotation, blob); + + assert.isTrue(await OS.File.exists(file)); + await annotation.eraseTx(); + assert.isFalse(await OS.File.exists(file)); + }); }); @@ -1478,82 +1809,158 @@ describe("Zotero.Item", function () { assert.isUndefined(json.numPages); }) - it.skip("should output attachment fields from file", function* () { - var file = getTestDataDirectory(); - file.append('test.png'); - var item = yield Zotero.Attachments.importFromFile({ file }); + describe("Attachments", function () { + it.skip("should output attachment fields from file", function* () { + var file = getTestDataDirectory(); + file.append('test.png'); + var item = yield Zotero.Attachments.importFromFile({ file }); + + yield Zotero.DB.executeTransaction(function* () { + yield Zotero.Sync.Storage.Local.setSyncedModificationTime( + item.id, new Date().getTime() + ); + yield Zotero.Sync.Storage.Local.setSyncedHash( + item.id, 'b32e33f529942d73bea4ed112310f804' + ); + }); + + var json = item.toJSON(); + assert.equal(json.linkMode, 'imported_file'); + assert.equal(json.filename, 'test.png'); + assert.isUndefined(json.path); + assert.equal(json.mtime, (yield item.attachmentModificationTime)); + assert.equal(json.md5, (yield item.attachmentHash)); + }) - yield Zotero.DB.executeTransaction(function* () { - yield Zotero.Sync.Storage.Local.setSyncedModificationTime( - item.id, new Date().getTime() - ); - yield Zotero.Sync.Storage.Local.setSyncedHash( - item.id, 'b32e33f529942d73bea4ed112310f804' - ); + it("should omit storage values with .skipStorageProperties", function* () { + var file = getTestDataDirectory(); + file.append('test.png'); + var item = yield Zotero.Attachments.importFromFile({ file }); + + item.attachmentSyncedModificationTime = new Date().getTime(); + item.attachmentSyncedHash = 'b32e33f529942d73bea4ed112310f804'; + yield item.saveTx({ skipAll: true }); + + var json = item.toJSON({ + skipStorageProperties: true + }); + assert.isUndefined(json.mtime); + assert.isUndefined(json.md5); }); - var json = item.toJSON(); - assert.equal(json.linkMode, 'imported_file'); - assert.equal(json.filename, 'test.png'); - assert.isUndefined(json.path); - assert.equal(json.mtime, (yield item.attachmentModificationTime)); - assert.equal(json.md5, (yield item.attachmentHash)); - }) - - it("should omit storage values with .skipStorageProperties", function* () { - var file = getTestDataDirectory(); - file.append('test.png'); - var item = yield Zotero.Attachments.importFromFile({ file }); + it("should output synced storage values with .syncedStorageProperties", function* () { + var item = new Zotero.Item('attachment'); + item.attachmentLinkMode = 'imported_file'; + item.fileName = 'test.txt'; + yield item.saveTx(); + + var mtime = new Date().getTime(); + var md5 = 'b32e33f529942d73bea4ed112310f804'; + + item.attachmentSyncedModificationTime = mtime; + item.attachmentSyncedHash = md5; + yield item.saveTx({ skipAll: true }); + + var json = item.toJSON({ + syncedStorageProperties: true + }); + assert.equal(json.mtime, mtime); + assert.equal(json.md5, md5); + }) - item.attachmentSyncedModificationTime = new Date().getTime(); - item.attachmentSyncedHash = 'b32e33f529942d73bea4ed112310f804'; - yield item.saveTx({ skipAll: true }); + it.skip("should output unset storage properties as null", function* () { + var item = new Zotero.Item('attachment'); + item.attachmentLinkMode = 'imported_file'; + item.fileName = 'test.txt'; + var id = yield item.saveTx(); + var json = item.toJSON(); + + assert.isNull(json.mtime); + assert.isNull(json.md5); + }) - var json = item.toJSON({ - skipStorageProperties: true + it("shouldn't include filename, path, or PDF properties for linked_url attachments", function* () { + var item = new Zotero.Item('attachment'); + item.attachmentLinkMode = 'linked_url'; + item.url = "https://www.zotero.org/"; + var json = item.toJSON(); + assert.notProperty(json, "filename"); + assert.notProperty(json, "path"); + }); + + it("shouldn't include various properties on embedded-image attachments", async function () { + var item = await createDataObject('item', { itemType: 'note' }); + var attachment = await createEmbeddedImage(item); + var json = attachment.toJSON(); + assert.notProperty(json, 'title'); + assert.notProperty(json, 'url'); + assert.notProperty(json, 'accessDate'); + assert.notProperty(json, 'tags'); + assert.notProperty(json, 'collections'); + assert.notProperty(json, 'relations'); + assert.notProperty(json, 'note'); + assert.notProperty(json, 'charset'); + assert.notProperty(json, 'path'); }); - assert.isUndefined(json.mtime); - assert.isUndefined(json.md5); }); - it("should output synced storage values with .syncedStorageProperties", function* () { - var item = new Zotero.Item('attachment'); - item.attachmentLinkMode = 'imported_file'; - item.fileName = 'test.txt'; - yield item.saveTx(); + describe("Annotations", function () { + var attachment; - var mtime = new Date().getTime(); - var md5 = 'b32e33f529942d73bea4ed112310f804'; - - item.attachmentSyncedModificationTime = mtime; - item.attachmentSyncedHash = md5; - yield item.saveTx({ skipAll: true }); - - var json = item.toJSON({ - syncedStorageProperties: true + before(async function () { + attachment = await importFileAttachment('test.pdf'); }); - assert.equal(json.mtime, mtime); - assert.equal(json.md5, md5); - }) - - it.skip("should output unset storage properties as null", function* () { - var item = new Zotero.Item('attachment'); - item.attachmentLinkMode = 'imported_file'; - item.fileName = 'test.txt'; - var id = yield item.saveTx(); - var json = item.toJSON(); - assert.isNull(json.mtime); - assert.isNull(json.md5); - }) - - it("shouldn't include filename or path for linked_url attachments", function* () { - var item = new Zotero.Item('attachment'); - item.attachmentLinkMode = 'linked_url'; - item.url = "https://www.zotero.org/"; - var json = item.toJSON(); - assert.notProperty(json, "filename"); - assert.notProperty(json, "path"); + it("should output highlight annotation", async function () { + var item = createUnsavedDataObject( + 'item', { itemType: 'annotation', parentKey: attachment.key } + ); + item.annotationType = 'highlight'; + item.annotationText = "This is an extracted text with rich-text\nAnd a new line"; + item.annotationComment = "This is a comment with rich-text\nAnd a new line"; + item.annotationColor = "#ffec00"; + item.annotationPageLabel = "15"; + item.annotationSortIndex = "00015|002431|00000"; + item.annotationPosition = JSON.stringify({ + "pageIndex": 1, + "rects": [ + [231.284, 402.126, 293.107, 410.142], + [54.222, 392.164, 293.107, 400.18], + [54.222, 382.201, 293.107, 390.217], + [54.222, 372.238, 293.107, 380.254], + [54.222, 362.276, 273.955, 370.292] + ] + }); + var json = item.toJSON(); + + for (let prop of ['Type', 'Text', 'Comment', 'Color', 'PageLabel', 'SortIndex']) { + let name = 'annotation' + prop; + assert.propertyVal(json, name, item[name]); + } + assert.deepEqual(json.annotationPosition, item.annotationPosition); + assert.notProperty(json, 'collections'); + assert.notProperty(json, 'relations'); + assert.notProperty(json, 'annotationIsExternal'); + }); + + describe("#annotationIsExternal", function () { + it("should be false if not set", async function () { + var item = await createAnnotation('highlight', attachment); + assert.isFalse(item.annotationIsExternal); + }); + + it("should be true if set", async function () { + var item = await createAnnotation('highlight', attachment, { isExternal: true }); + assert.isTrue(item.annotationIsExternal); + }); + + it("should prevent changing of annotationIsExternal on existing item", async function () { + var item = await createAnnotation('highlight', attachment); + assert.throws(() => { + item.annotationIsExternal = true; + }, "Cannot change annotationIsExternal"); + }); + }); }); it("should include inPublications=true for items in My Publications", function* () { @@ -2125,5 +2532,74 @@ describe("Zotero.Item", function () { }); assert.equal(item.getField("bookTitle"), "Publication Title"); }); + + it("should import attachment content type and path", async function () { + var contentType = 'application/pdf'; + var path = OS.Path.join(getTestDataDirectory().path, 'test.pdf'); + var json = { + itemType: 'attachment', + linkMode: 'linked_file', + contentType, + path + }; + var item = new Zotero.Item(); + item.libraryID = Zotero.Libraries.userLibraryID; + item.fromJSON(json, { strict: true }); + assert.propertyVal(item, 'attachmentContentType', contentType); + assert.propertyVal(item, 'attachmentPath', path); + }); + + it("should import other attachment fields", async function () { + var contentType = 'application/pdf'; + var json = { + itemType: 'attachment', + linkMode: 'linked_file', + contentType: 'text/plain', + charset: 'utf-8', + path: 'attachments:test.txt' + }; + var item = new Zotero.Item(); + item.libraryID = Zotero.Libraries.userLibraryID; + item.fromJSON(json, { strict: true }); + assert.propertyVal(item, 'attachmentCharset', 'utf-8'); + }); + + it("should import annotation fields", async function () { + var attachment = await importPDFAttachment(); + + var item = new Zotero.Item(); + item.libraryID = attachment.libraryID; + var json = { + itemType: "annotation", + parentItem: attachment.key, + annotationType: 'highlight', + annotationText: "This is highlighted text.", + annotationComment: "This is a comment with rich-text\nAnd a new line", + annotationSortIndex: '00015|002431|00000', + annotationPosition: JSON.stringify({ + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }), + tags: [ + { + tag: "tagA" + } + ] + }; + item.fromJSON(json, { strict: true }); + for (let i in json) { + if (i == 'tags') { + assert.deepEqual(item.getTags(), json[i]); + } + else if (i == 'parentItem') { + assert.equal(item.parentKey, json[i]); + } + else { + assert.equal(item[i], json[i]); + } + } + }); }); }); diff --git a/test/tests/itemsTest.js b/test/tests/itemsTest.js index aa4798acb4..562b0d4f1c 100644 --- a/test/tests/itemsTest.js +++ b/test/tests/itemsTest.js @@ -624,4 +624,16 @@ describe("Zotero.Items", function () { ); }); }); + + describe("#_loadChildItems()", function () { + it("should mark child items as loaded for an attachment", async function () { + var attachment = await importPDFAttachment(); + var itemID = attachment.id; + Zotero.Items.unload([itemID]); + attachment = await Zotero.Items.getAsync(itemID); + await attachment.loadDataType('childItems'); + assert.isTrue(attachment._loaded.childItems); + attachment.getAnnotations(); + }); + }); }); diff --git a/test/tests/noteeditorTest.js b/test/tests/noteeditorTest.js index 6145793361..7b0ffb11c4 100644 --- a/test/tests/noteeditorTest.js +++ b/test/tests/noteeditorTest.js @@ -20,7 +20,7 @@ describe("Note Editor", function () { noteEditor = win.document.getElementById('zotero-note-editor'); } return new Zotero.Promise((resolve, reject) => { - noteEditor.noteField.onInit(() => resolve(noteEditor)); + noteEditor.onInit(() => resolve(noteEditor)); }); }); diff --git a/test/tests/schemaTest.js b/test/tests/schemaTest.js index a6b3e6f1f6..8e528ced7e 100644 --- a/test/tests/schemaTest.js +++ b/test/tests/schemaTest.js @@ -296,5 +296,11 @@ describe("Zotero.Schema", function() { await assert.isTrue(await Zotero.Schema.integrityCheck(true)); await assert.isTrue(await Zotero.Schema.integrityCheck()); }); + + it("should allow embedded-image attachments under notes", async function () { + var item = await createDataObject('item', { itemType: 'note' }); + await createEmbeddedImage(item); + await assert.isTrue(await Zotero.Schema.integrityCheck()); + }); }) }) diff --git a/test/tests/searchTest.js b/test/tests/searchTest.js index 3d550364e3..ea15597623 100644 --- a/test/tests/searchTest.js +++ b/test/tests/searchTest.js @@ -360,6 +360,36 @@ describe("Zotero.Search", function() { }); }); + describe("annotationText", function () { + it("should return matches for annotation text", async function () { + var attachment = await importPDFAttachment(); + var annotation = await createAnnotation('highlight', attachment); + var str = annotation.annotationText.substr(0, 7); + + var s = new Zotero.Search(); + s.libraryID = userLibraryID; + s.addCondition('joinMode', 'any'); + s.addCondition('annotationText', 'contains', str); + var matches = await s.search(); + assert.sameMembers(matches, [annotation.id]); + }); + }); + + describe("annotationComment", function () { + it("should return matches for annotation comment", async function () { + var attachment = await importPDFAttachment(); + var annotation = await createAnnotation('note', attachment); + var str = annotation.annotationComment.substr(0, 7); + + var s = new Zotero.Search(); + s.libraryID = userLibraryID; + s.addCondition('joinMode', 'any'); + s.addCondition('annotationComment', 'contains', str); + var matches = await s.search(); + assert.sameMembers(matches, [annotation.id]); + }); + }); + describe("fulltextWord", function () { it("should return matches with full-text conditions", function* () { let s = new Zotero.Search(); diff --git a/test/tests/syncAPIClientTest.js b/test/tests/syncAPIClientTest.js index 35867158d5..481a5d9951 100644 --- a/test/tests/syncAPIClientTest.js +++ b/test/tests/syncAPIClientTest.js @@ -43,6 +43,31 @@ describe("Zotero.Sync.APIClient", function () { Zotero.HTTP.mock = null; }) + + describe("#makeRequest()", function () { + after(function () { + sinon.restore(); + }); + + it("should send Zotero-Schema-Version", async function () { + server.respond(function (req) { + if (req.method == "GET" && req.url == baseURL + "test-schema-version") { + assert.propertyVal( + req.requestHeaders, + 'Zotero-Schema-Version', + Zotero.Schema.globalSchemaVersion.toString() + ); + + req.respond(200, {}, ""); + } + }); + var spy = sinon.spy(Zotero.HTTP, "request"); + await client.makeRequest("GET", baseURL + "test-schema-version"); + assert.isTrue(spy.calledOnce); + }); + }); + + describe("#getGroups()", function () { it("should automatically fetch multiple pages of results", function* () { function groupJSON(groupID) { diff --git a/test/tests/syncEngineTest.js b/test/tests/syncEngineTest.js index 542ba19398..30ed31473f 100644 --- a/test/tests/syncEngineTest.js +++ b/test/tests/syncEngineTest.js @@ -244,7 +244,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: "users/1/collections?format=json&collectionKey=AAAAAAAA", + url: "users/1/collections?collectionKey=AAAAAAAA", status: 200, headers: headers, json: [ @@ -257,7 +257,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: "users/1/searches?format=json&searchKey=AAAAAAAA", + url: "users/1/searches?searchKey=AAAAAAAA", status: 200, headers: headers, json: [ @@ -270,7 +270,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: "users/1/items?format=json&itemKey=AAAAAAAA&includeTrashed=1", + url: "users/1/items?itemKey=AAAAAAAA&includeTrashed=1", status: 200, headers: headers, json: [ @@ -284,7 +284,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: "users/1/items?format=json&itemKey=BBBBBBBB&includeTrashed=1", + url: "users/1/items?itemKey=BBBBBBBB&includeTrashed=1", status: 200, headers: headers, json: [ @@ -339,7 +339,7 @@ describe("Zotero.Sync.Data.Engine", function () { yield assertInCache(obj); obj = yield Zotero.Items.getByLibraryAndKeyAsync(userLibraryID, "BBBBBBBB"); - assert.equal(obj.getNote(), 'This is a note.'); + assert.equal(obj.note, 'This is a note.'); assert.equal(obj.parentItemID, parentItemID); assert.equal(obj.version, 3); assert.isTrue(obj.synced); @@ -418,7 +418,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: `groups/${group.id}/collections?format=json&collectionKey=AAAAAAAA`, + url: `groups/${group.id}/collections?collectionKey=AAAAAAAA`, status: 200, headers: headers, json: [ @@ -431,7 +431,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: `groups/${group.id}/searches?format=json&searchKey=AAAAAAAA`, + url: `groups/${group.id}/searches?searchKey=AAAAAAAA`, status: 200, headers: headers, json: [ @@ -444,7 +444,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: `groups/${group.id}/items?format=json&itemKey=AAAAAAAA&includeTrashed=1`, + url: `groups/${group.id}/items?itemKey=AAAAAAAA&includeTrashed=1`, status: 200, headers: headers, json: [ @@ -458,7 +458,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: `groups/${group.id}/items?format=json&itemKey=BBBBBBBB&includeTrashed=1`, + url: `groups/${group.id}/items?itemKey=BBBBBBBB&includeTrashed=1`, status: 200, headers: headers, json: [ @@ -513,7 +513,7 @@ describe("Zotero.Sync.Data.Engine", function () { yield assertInCache(obj); obj = Zotero.Items.getByLibraryAndKey(libraryID, "BBBBBBBB"); - assert.equal(obj.getNote(), 'This is a note.'); + assert.equal(obj.note, 'This is a note.'); assert.equal(obj.parentItemID, parentItemID); assert.equal(obj.version, 3); assert.isTrue(obj.synced); @@ -746,35 +746,58 @@ describe("Zotero.Sync.Data.Engine", function () { }) - it("should upload child item after parent item", function* () { - ({ engine, client, caller } = yield setup()); + it("should upload child items after parent items", async function () { + ({ engine, client, caller } = await setup()); var library = Zotero.Libraries.userLibrary; var lastLibraryVersion = 5; library.libraryVersion = lastLibraryVersion; - yield library.saveTx(); + await library.saveTx(); - // Create top-level note, book, and child note - var item1 = new Zotero.Item('note'); - item1.setNote('A'); - yield item1.saveTx(); - var item2 = yield createDataObject('item'); - var item3 = new Zotero.Item('note'); - item3.parentItemID = item2.id; - item3.setNote('B'); - yield item3.saveTx(); + // Create top-level note, embedded-image attachment, book, and child note + var note1 = await createDataObject('item', { itemType: 'note', note: 'A' }); + var attachment = await Zotero.Attachments.importEmbeddedImage({ + blob: await File.createFromFileName( + OS.Path.join(getTestDataDirectory().path, 'test.png') + ), + parentItemID: note1.id + }); + var item = await createDataObject('item'); + var note2 = await createDataObject('item', { itemType: 'note', parentID: item.id, note: 'B' }); // Move note under parent - item1.parentItemID = item2.id; - yield item1.saveTx(); + note1.parentItemID = item.id; + await note1.saveTx(); var handled = false; server.respond(function (req) { if (req.method == "POST" && req.url == baseURL + "users/1/items") { let json = JSON.parse(req.requestBody); - assert.lengthOf(json, 3); - assert.equal(json[0].key, item2.key); - assert.equal(json[1].key, item1.key); - assert.equal(json[2].key, item3.key); + assert.lengthOf(json, 4); + assert.equal(json[0].key, item.key); + assert.oneOf( + [json[1].key, json[2].key, json[3].key].join(''), + [ + [note1.key, attachment.key, note2.key].join(''), + [note2.key, note1.key, attachment.key].join(''), + ] + ); + let successful; + if (json[1].key == note1.key) { + successful = { + "0": item.toResponseJSON({ version: lastLibraryVersion }), + "1": note1.toResponseJSON({ version: lastLibraryVersion }), + "2": attachment.toResponseJSON({ version: lastLibraryVersion }), + "3": note2.toResponseJSON({ version: lastLibraryVersion }), + }; + } + else { + successful = { + "0": item.toResponseJSON({ version: lastLibraryVersion }), + "1": note2.toResponseJSON({ version: lastLibraryVersion }), + "2": note1.toResponseJSON({ version: lastLibraryVersion }), + "3": attachment.toResponseJSON({ version: lastLibraryVersion }), + }; + } handled = true; req.respond( 200, @@ -783,11 +806,7 @@ describe("Zotero.Sync.Data.Engine", function () { "Last-Modified-Version": ++lastLibraryVersion }, JSON.stringify({ - successful: { - "0": item2.toResponseJSON({ version: lastLibraryVersion }), - "1": item1.toResponseJSON({ version: lastLibraryVersion }), - "2": item3.toResponseJSON({ version: lastLibraryVersion }) - }, + successful, unchanged: {}, failed: {} }) @@ -796,7 +815,7 @@ describe("Zotero.Sync.Data.Engine", function () { } }); - yield engine.start(); + await engine.start(); assert.isTrue(handled); }); @@ -1124,7 +1143,7 @@ describe("Zotero.Sync.Data.Engine", function () { setResponse({ method: "GET", - url: `${target}/items?format=json&itemKey=${item.key}&includeTrashed=1`, + url: `${target}/items?itemKey=${item.key}&includeTrashed=1`, status: 200, headers, json: [itemResponseJSON] @@ -1367,6 +1386,62 @@ describe("Zotero.Sync.Data.Engine", function () { assert.equal(json.data.md5, md5); }) + // See also: "shouldn't include external annotations" in syncLocalTest.js + it("shouldn't upload external annotations", async function () { + ({ engine, client, caller } = await setup()); + + var library = Zotero.Libraries.userLibrary; + var libraryID = library.id; + var lastLibraryVersion = 5; + library.libraryVersion = lastLibraryVersion; + await library.saveTx(); + var nextLibraryVersion = lastLibraryVersion + 1; + + var attachment = await importFileAttachment('test.pdf'); + var annotation1 = await createAnnotation('highlight', attachment); + var annotation2 = await createAnnotation('highlight', attachment, { isExternal: true }); + + var item1ResponseJSON = attachment.toResponseJSON(); + item1ResponseJSON.version = item1ResponseJSON.data.version = nextLibraryVersion; + var item2ResponseJSON = annotation1.toResponseJSON(); + item2ResponseJSON.version = item2ResponseJSON.data.version = nextLibraryVersion; + + server.respond(function (req) { + if (req.method == "POST") { + assert.equal( + req.requestHeaders["If-Unmodified-Since-Version"], lastLibraryVersion + ); + + if (req.url == baseURL + "users/1/items") { + let json = JSON.parse(req.requestBody); + assert.lengthOf(json, 2); + let keys = [json[0].key, json[1].key]; + assert.include(keys, attachment.key); + assert.include(keys, annotation1.key); + + req.respond( + 200, + { + "Content-Type": "application/json", + "Last-Modified-Version": nextLibraryVersion + }, + JSON.stringify({ + successful: { + "0": item1ResponseJSON, + "1": item2ResponseJSON + }, + unchanged: {}, + failed: {} + }) + ); + return; + } + } + }) + + await engine.start(); + }); + it("should update local objects with remotely saved version after uploading if necessary", function* () { ({ engine, client, caller } = yield setup()); @@ -1590,7 +1665,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: "users/1/collections?format=json&collectionKey=AAAAAAAA%2CBBBBBBBB%2CCCCCCCCC", + url: "users/1/collections?collectionKey=AAAAAAAA%2CBBBBBBBB%2CCCCCCCCC", status: 200, headers: headers, json: [ @@ -1617,7 +1692,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: "users/1/searches?format=json&searchKey=DDDDDDDD%2CEEEEEEEE%2CFFFFFFFF", + url: "users/1/searches?searchKey=DDDDDDDD%2CEEEEEEEE%2CFFFFFFFF", status: 200, headers: headers, json: [ @@ -1663,7 +1738,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: "users/1/items?format=json&itemKey=GGGGGGGG%2CHHHHHHHH&includeTrashed=1", + url: "users/1/items?itemKey=GGGGGGGG%2CHHHHHHHH&includeTrashed=1", status: 200, headers: headers, json: [ @@ -1686,7 +1761,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: "users/1/items?format=json&itemKey=JJJJJJJJ&includeTrashed=1", + url: "users/1/items?itemKey=JJJJJJJJ&includeTrashed=1", status: 200, headers: headers, json: [ @@ -2301,7 +2376,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${itemKey}&includeTrashed=1`, + url: `users/1/items?itemKey=${itemKey}&includeTrashed=1`, status: 200, headers, json: [ @@ -2356,7 +2431,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${itemKey}&includeTrashed=1`, + url: `users/1/items?itemKey=${itemKey}&includeTrashed=1`, status: 200, headers, json: [ @@ -2420,7 +2495,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${itemKey}&includeTrashed=1`, + url: `users/1/items?itemKey=${itemKey}&includeTrashed=1`, status: 200, headers, json: [itemResponseJSON] @@ -2480,7 +2555,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=AAAAAAAA%2C${itemKey}&includeTrashed=1`, + url: `users/1/items?itemKey=AAAAAAAA%2C${itemKey}&includeTrashed=1`, status: 200, headers: headers, json: [ @@ -2754,7 +2829,7 @@ describe("Zotero.Sync.Data.Engine", function () { }; setResponse({ method: "GET", - url: "users/1/collections?format=json&collectionKey=AAAAAAAA%2CBBBBBBBB%2CCCCCCCCC", + url: "users/1/collections?collectionKey=AAAAAAAA%2CBBBBBBBB%2CCCCCCCCC", status: 200, headers, json: [ @@ -2795,7 +2870,7 @@ describe("Zotero.Sync.Data.Engine", function () { }; setResponse({ method: "GET", - url: `users/1/collections?format=json&collectionKey=${collectionKey}`, + url: `users/1/collections?collectionKey=${collectionKey}`, status: 200, headers, json: [ @@ -2839,7 +2914,7 @@ describe("Zotero.Sync.Data.Engine", function () { }; setResponse({ method: "GET", - url: `users/1/collections?format=json&collectionKey=${collectionKey}`, + url: `users/1/collections?collectionKey=${collectionKey}`, status: 200, headers, json: [ @@ -3386,7 +3461,7 @@ describe("Zotero.Sync.Data.Engine", function () { setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${objects.map(o => o.key).join('%2C')}` + url: `users/1/items?itemKey=${objects.map(o => o.key).join('%2C')}` + `&includeTrashed=1`, status: 200, headers: { @@ -3487,13 +3562,13 @@ describe("Zotero.Sync.Data.Engine", function () { await obj.saveTx({ skipDateModifiedUpdate: true }); - values[i].left.note = obj.getNote(); + values[i].left.note = obj.note; values[i].left.version = obj.getField('version'); } setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${objects.map(o => o.key).join('%2C')}` + url: `users/1/items?itemKey=${objects.map(o => o.key).join('%2C')}` + `&includeTrashed=1`, status: 200, headers: { @@ -3529,8 +3604,8 @@ describe("Zotero.Sync.Data.Engine", function () { await engine._downloadObjects('item', objects.map(o => o.key)); await crPromise; - assert.equal(objects[0].getNote(), values[0].right.note); - assert.equal(objects[1].getNote(), values[1].left.note); + assert.equal(objects[0].note, values[0].right.note); + assert.equal(objects[1].note, values[1].left.note); assert.equal(objects[0].version, values[0].right.version); assert.equal(objects[1].version, values[1].right.version); assert.isTrue(objects[0].synced); @@ -3609,7 +3684,7 @@ describe("Zotero.Sync.Data.Engine", function () { setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${objects.map(o => o.key).join('%2C')}` + url: `users/1/items?itemKey=${objects.map(o => o.key).join('%2C')}` + `&includeTrashed=1`, status: 200, headers: { @@ -3731,7 +3806,7 @@ describe("Zotero.Sync.Data.Engine", function () { setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${objects.map(o => o.key).join('%2C')}` + url: `users/1/items?itemKey=${objects.map(o => o.key).join('%2C')}` + `&includeTrashed=1`, status: 200, headers: { @@ -3817,7 +3892,7 @@ describe("Zotero.Sync.Data.Engine", function () { setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${obj.key}&includeTrashed=1`, + url: `users/1/items?itemKey=${obj.key}&includeTrashed=1`, status: 200, headers: { "Last-Modified-Version": 15 @@ -3877,7 +3952,7 @@ describe("Zotero.Sync.Data.Engine", function () { setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${obj.key}&includeTrashed=1`, + url: `users/1/items?itemKey=${obj.key}&includeTrashed=1`, status: 200, headers: { "Last-Modified-Version": 15 @@ -3932,7 +4007,7 @@ describe("Zotero.Sync.Data.Engine", function () { setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${key}&includeTrashed=1`, + url: `users/1/items?itemKey=${key}&includeTrashed=1`, status: 200, headers: { "Last-Modified-Version": 15 @@ -3987,7 +4062,7 @@ describe("Zotero.Sync.Data.Engine", function () { setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${key}&includeTrashed=1`, + url: `users/1/items?itemKey=${key}&includeTrashed=1`, status: 200, headers: { "Last-Modified-Version": 15 @@ -4046,6 +4121,85 @@ describe("Zotero.Sync.Data.Engine", function () { }); + describe("#_updateGroupItemUsers()", function () { + it("should update createdByUserID and lastModifiedByUserID", async function () { + var { id: groupID, libraryID } = await createGroup(); + ({ engine, client, caller } = await setup({ libraryID })); + + var item1 = await createDataObject('item', { libraryID }); + var item1DateModified = item1.dateModified; + var item2 = await createDataObject('item', { libraryID }); + var responseJSON = [ + item1.toResponseJSON(), + item2.toResponseJSON() + ]; + responseJSON[0].meta.createdByUser = { + id: 152315, + username: "user152315", + name: "User 152315" + }; + responseJSON[0].meta.lastModifiedByUser = { + id: 352352, + username: "user352352", + name: "User 352352" + }; + responseJSON[1].meta.createdByUser = { + id: 346534, + username: "user346534", + name: "User 346534" + }; + + setResponse({ + method: "GET", + url: `groups/${groupID}/items?itemKey=${item1.key}%2C${item2.key}&includeTrashed=1`, + status: 200, + headers: { + "Last-Modified-Version": 5 + }, + json: responseJSON + }); + + await engine._updateGroupItemUsers(); + + assert.equal(item1.createdByUserID, 152315); + assert.equal(item1.lastModifiedByUserID, 352352); + assert.equal(item1.dateModified, item1DateModified); + assert.equal(item2.createdByUserID, 346534); + }); + + + it("should use username if no name", async function () { + var { id: groupID, libraryID } = await createGroup(); + ({ engine, client, caller } = await setup({ libraryID })); + + var item = await createDataObject('item', { libraryID }); + var responseJSON = [ + item.toResponseJSON() + ]; + responseJSON[0].meta.createdByUser = { + id: 235235, + username: "user235235", + name: "" + }; + + setResponse({ + method: "GET", + url: `groups/${groupID}/items?itemKey=${item.key}&includeTrashed=1`, + status: 200, + headers: { + "Last-Modified-Version": 6 + }, + json: responseJSON + }); + + await engine._updateGroupItemUsers(); + + assert.equal(item.createdByUserID, 235235); + assert.equal(Zotero.Users.getName(235235), 'user235235'); + }); + }); + + describe("#_upgradeCheck()", function () { it("should upgrade a library last synced with the classic sync architecture", function* () { var userLibraryID = Zotero.Libraries.userLibraryID; @@ -4241,8 +4395,7 @@ describe("Zotero.Sync.Data.Engine", function () { setResponse({ method: "GET", url: "users/1/" + plural - + "?format=json" - + "&" + type + "Key=" + objectJSON[type][0].key + "%2C" + objectJSON[type][1].key + + "?" + type + "Key=" + objectJSON[type][0].key + "%2C" + objectJSON[type][1].key + suffix, status: 200, headers: headers, @@ -4555,29 +4708,32 @@ describe("Zotero.Sync.Data.Engine", function () { ); let version = data.expectedVersion + 1; let json = JSON.parse(req.requestBody); + let o1 = json.find(o => o.key == objectJSON[type][1].key); assert.notProperty(o1, 'version'); let o2 = json.find(o => o.key == objectJSON[type][2].key); assert.notProperty(o2, 'version'); let o3 = json.find(o => o.key == objectJSON[type][3].key); assert.notProperty(o3, 'version'); - let response = { - successful: { - "0": Object.assign(objectJSON[type][1], { version }), - "1": Object.assign(objectJSON[type][2], { version }), - "2": Object.assign(objectJSON[type][3], { version }) - }, - unchanged: {}, - failed: {} - }; if (type == 'item') { let o = json.find(o => o.key == objectJSON.item[4].key); assert.notProperty(o, 'version'); // Attachment items should include storage properties assert.propertyVal(o, 'mtime', objects.item[4].attachmentSyncedModificationTime); assert.propertyVal(o, 'md5', objects.item[4].attachmentSyncedHash); - response.successful["3"] = Object.assign(objectJSON[type][4], { version }) } + let response = { + successful: {}, + unchanged: {}, + failed: {} + }; + // Return objects in the order provided + json.map(x => x.key).forEach((key, index) => { + response.successful[index] = Object.assign( + objectJSON[type].find(x => x.key == key), + { version } + ); + }); req.respond( 200, { diff --git a/test/tests/syncEventListenersTest.js b/test/tests/syncEventListenersTest.js index 867dfb5037..d2b4666617 100644 --- a/test/tests/syncEventListenersTest.js +++ b/test/tests/syncEventListenersTest.js @@ -1,6 +1,39 @@ "use strict"; describe("Zotero.Sync.EventListeners", function () { + describe("ChangeListener", function () { + it("should add items to sync delete log", async function () { + var item = await createDataObject('item'); + await item.eraseTx(); + assert.ok( + await Zotero.Sync.Data.Local.getDateDeleted('item', item.libraryID, item.key) + ); + }); + + it("shouldn't add items with `skipDeleteLog: true`", async function () { + var item = await createDataObject('item'); + await item.eraseTx({ + skipDeleteLog: true + }); + assert.isFalse( + await Zotero.Sync.Data.Local.getDateDeleted('item', item.libraryID, item.key) + ); + }); + + // Technically skipped in Zotero.DataObject._finalizeErase(), which sets skipDeleteLog + // based on the result of Sync.Data.Local.isSyncItem() + it("shouldn't add non-syncing items to sync delete log", async function () { + var attachment = await importFileAttachment('test.pdf'); + var annotation = await createAnnotation('image', attachment, { isExternal: true }); + await annotation.eraseTx(); + assert.isFalse( + await Zotero.Sync.Data.Local.getDateDeleted( + 'item', attachment.libraryID, annotation.key + ) + ); + }); + }); + describe("AutoSyncListener", function () { var originalTimeout; @@ -44,5 +77,19 @@ describe("Zotero.Sync.EventListeners", function () { yield Zotero.Promise.delay(10); mock.verify(); }); + + it("should auto-sync after settings change", async function () { + var attachment = await importFileAttachment('test.pdf'); + + var mock = sinon.mock(Zotero.Sync.Runner); + var expectation = mock.expects("setSyncTimeout").once(); + + // Create setting (e.g., lastPageIndex_u_ABCD2345) + await attachment.setAttachmentLastPageIndex(1); + + await Zotero.Promise.delay(10); + mock.verify(); + assert.sameMembers(expectation.getCall(0).args[2].libraries, [Zotero.Libraries.userLibraryID]); + }); }); }); diff --git a/test/tests/syncLocalTest.js b/test/tests/syncLocalTest.js index 0c8a84d4e4..9d9c4f8e56 100644 --- a/test/tests/syncLocalTest.js +++ b/test/tests/syncLocalTest.js @@ -468,6 +468,17 @@ describe("Zotero.Sync.Data.Local", function() { describe("#getUnsynced()", function () { + // See also: "shouldn't upload external annotations" in syncEngineTest.js + it("shouldn't include external annotations", async function () { + var attachment = await importFileAttachment('test.pdf'); + var annotation1 = await createAnnotation('highlight', attachment); + var annotation2 = await createAnnotation('highlight', attachment, { isExternal: true }); + + var ids = await Zotero.Sync.Data.Local.getUnsynced('item', Zotero.Libraries.userLibraryID); + assert.include(ids, attachment.id); + assert.include(ids, annotation1.id); + }); + it("should correct incorrectly nested collections", async function () { var c1 = await createDataObject('collection'); var c2 = await createDataObject('collection'); @@ -930,6 +941,69 @@ describe("Zotero.Sync.Data.Local", function() { "SELECT COUNT(*) FROM items WHERE libraryID=? AND key=?", [libraryID, key2] ), 0); }); + + it("should update createdByUser and lastModifiedBy when saving group item", async function () { + var { libraryID } = await getGroup(); + let item = await createDataObject('item', { libraryID }); + let data = item.toJSON(); + data.key = item.key; + data.version = 10; + let json = { + key: item.key, + version: 10, + meta: { + createdByUser: { + id: 12345, + username: 'foo', + name: 'Foo Foo' + }, + lastModifiedByUser: { + id: 23456, + username: 'bar', + name: 'Bar Bar' + } + }, + data + }; + await Zotero.Sync.Data.Local.processObjectsFromJSON( + 'item', libraryID, [json], { stopOnError: true } + ); + let localItem = Zotero.Items.getByLibraryAndKey(libraryID, item.key); + assert.isTrue(localItem.synced); + + assert.equal(localItem.createdByUserID, 12345); + assert.equal(localItem.lastModifiedByUserID, 23456); + assert.equal(Zotero.Users.getName(12345), 'Foo Foo'); + assert.equal(Zotero.Users.getName(23456), 'Bar Bar'); + }); + + it("should use username if empty name for createdByUser when saving group item", async function () { + var { libraryID } = await getGroup(); + let item = await createDataObject('item', { libraryID }); + let data = item.toJSON(); + data.key = item.key; + data.version = 10; + let json = { + key: item.key, + version: 10, + meta: { + createdByUser: { + id: 12345, + username: 'foo', + name: '' + }, + }, + data + }; + await Zotero.Sync.Data.Local.processObjectsFromJSON( + 'item', libraryID, [json], { stopOnError: true } + ); + let localItem = Zotero.Items.getByLibraryAndKey(libraryID, item.key); + assert.isTrue(localItem.synced); + + assert.equal(localItem.createdByUserID, 12345); + assert.equal(Zotero.Users.getName(12345), 'foo'); + }); }) describe("Sync Queue", function () { diff --git a/test/tests/translateTest.js b/test/tests/translateTest.js index 6ee924da05..936d30afa4 100644 --- a/test/tests/translateTest.js +++ b/test/tests/translateTest.js @@ -213,13 +213,13 @@ describe("Zotero.Translate", function() { let noteIDs = newItems["Test Item"].getNotes(); let note1 = yield Zotero.Items.getAsync(noteIDs[0]); assert.equal(Zotero.ItemTypes.getName(note1.itemTypeID), "note"); - assert.equal(note1.getNote(), "1 note as string"); + assert.equal(note1.note, "1 note as string"); let note2 = yield Zotero.Items.getAsync(noteIDs[1]); assert.equal(Zotero.ItemTypes.getName(note2.itemTypeID), "note"); - assert.equal(note2.getNote(), "2 note as object"); + assert.equal(note2.note, "2 note as object"); checkTestTags(note2); let note3 = newItems["standalone note"]; - assert.equal(note3.getNote(), "standalone note"); + assert.equal(note3.note, "standalone note"); checkTestTags(note3); }); @@ -323,20 +323,20 @@ describe("Zotero.Translate", function() { for (let savedAttachments of [[newItems["Empty PDF"], newItems["Link to zotero.org"]], [containedAttachments[0], containedAttachments[1]]]) { assert.equal(savedAttachments[0].getField("title"), "Empty PDF"); - assert.equal(savedAttachments[0].getNote(), "attachment note"); + assert.equal(savedAttachments[0].note, "attachment note"); assert.equal(savedAttachments[0].attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_FILE); checkTestTags(savedAttachments[0]); assert.equal(savedAttachments[1].getField("title"), "Link to zotero.org"); assert.equal(savedAttachments[1].getField("url"), "http://www.zotero.org/"); - assert.equal(savedAttachments[1].getNote(), "attachment 2 note"); + assert.equal(savedAttachments[1].note, "attachment 2 note"); assert.equal(savedAttachments[1].attachmentLinkMode, Zotero.Attachments.LINK_MODE_LINKED_URL); checkTestTags(savedAttachments[1]); } assert.equal(containedAttachments[2].getField("title"), "Snapshot"); assert.equal(containedAttachments[2].getField("url"), "http://www.example.com/"); - assert.equal(containedAttachments[2].getNote(), "attachment 3 note"); + assert.equal(containedAttachments[2].note, "attachment 3 note"); assert.equal(containedAttachments[2].attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL); checkTestTags(containedAttachments[2]); }); @@ -371,7 +371,7 @@ describe("Zotero.Translate", function() { assert.equal(containedAttachments[0].getField("title"), "Snapshot with missing file"); assert.equal(containedAttachments[0].getField("url"), "http://www.example.com/"); - assert.equal(containedAttachments[0].getNote(), "attachment note"); + assert.equal(containedAttachments[0].note, "attachment note"); assert.equal(containedAttachments[0].attachmentLinkMode, Zotero.Attachments.LINK_MODE_LINKED_URL); checkTestTags(containedAttachments[0]); }); @@ -548,7 +548,7 @@ describe("Zotero.Translate", function() { assert.equal(attachments[1].attachmentLinkMode, Zotero.Attachments.LINK_MODE_LINKED_FILE); assert.equal(attachments[1].attachmentContentType, 'text/html'); assert.equal(attachments[1].attachmentCharset, 'utf-8'); - assert.equal(attachments[1].getNote(), file2URL); + assert.equal(attachments[1].note, file2URL); }); it("import translators shouldn't save linked-file attachment with linkFiles: true if path is within current storage directory", async function () { @@ -648,20 +648,20 @@ describe("Zotero.Translate", function() { let link = containedAttachments["Link to zotero.org"]; assert.equal(link.getField("url"), "http://www.zotero.org/"); - assert.equal(link.getNote(), "attachment note"); + assert.equal(link.note, "attachment note"); assert.equal(link.attachmentLinkMode, Zotero.Attachments.LINK_MODE_LINKED_URL); checkTestTags(link, true); let snapshot = containedAttachments["Test Snapshot"]; assert.equal(snapshot.getField("url"), "http://127.0.0.1:23119/test/translate/test.html"); - assert.equal(snapshot.getNote(), "attachment 2 note"); + assert.equal(snapshot.note, "attachment 2 note"); assert.equal(snapshot.attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL); assert.equal(snapshot.attachmentContentType, "text/html"); checkTestTags(snapshot, true); let pdf = containedAttachments["Test PDF"]; assert.equal(pdf.getField("url"), "http://127.0.0.1:23119/test/translate/test.pdf"); - assert.equal(pdf.getNote(), "attachment 3 note"); + assert.equal(pdf.note, "attachment 3 note"); assert.equal(pdf.attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL); assert.equal(pdf.attachmentContentType, "application/pdf"); checkTestTags(pdf, true); @@ -700,7 +700,7 @@ describe("Zotero.Translate", function() { let snapshot = containedAttachments[0]; assert.equal(snapshot.getField("url"), "http://127.0.0.1:23119/test/translate/test.html"); - assert.equal(snapshot.getNote(), "attachment note"); + assert.equal(snapshot.note, "attachment note"); assert.equal(snapshot.attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL); assert.equal(snapshot.attachmentContentType, "text/html"); checkTestTags(snapshot, true); @@ -734,7 +734,7 @@ describe("Zotero.Translate", function() { let snapshot = containedAttachments[0]; assert.equal(snapshot.getField("url"), "http://127.0.0.1:23119/test/translate/test.html"); - assert.equal(snapshot.getNote(), "attachment note"); + assert.equal(snapshot.note, "attachment note"); assert.equal(snapshot.attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL); assert.equal(snapshot.attachmentContentType, "text/html"); checkTestTags(snapshot, true); @@ -798,7 +798,7 @@ describe("Zotero.Translate", function() { let pdf = containedAttachments[0]; assert.equal(pdf.getField("title"), "Test PDF"); assert.equal(pdf.getField("url"), "http://127.0.0.1:23119/test/translate/test.pdf"); - assert.equal(pdf.getNote(), "attachment note"); + assert.equal(pdf.note, "attachment note"); assert.equal(pdf.attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL); checkTestTags(pdf, true); }); @@ -901,7 +901,7 @@ describe("Zotero.Translate", function() { var snapshot = containedAttachments[0]; assert.equal(snapshot.getField("url"), url + '?t'); - assert.equal(snapshot.getNote(), "attachment note"); + assert.equal(snapshot.note, "attachment note"); assert.equal(snapshot.attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL); assert.equal(snapshot.attachmentContentType, "text/html"); checkTestTags(snapshot, true); @@ -951,7 +951,7 @@ describe("Zotero.Translate", function() { var snapshot = containedAttachments[0]; assert.equal(snapshot.getField("url"), url); - assert.equal(snapshot.getNote(), "attachment note"); + assert.equal(snapshot.note, "attachment note"); assert.equal(snapshot.attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL); assert.equal(snapshot.attachmentContentType, "text/html"); checkTestTags(snapshot, true); @@ -2059,7 +2059,7 @@ describe("Zotero.Translate.ItemGetter", function() { // saveFile function assert.isFunction(attachment.saveFile, prefix + 'has saveFile function' + suffix); - attachment.saveFile(attachment.defaultPath); + yield attachment.saveFile(attachment.defaultPath); assert.equal(attachment.path, OS.Path.join(exportDir, OS.Path.normalize(attachment.defaultPath)), prefix + 'path is set correctly after saveFile call' + suffix); let fileExists = yield OS.File.exists(attachment.path); @@ -2067,8 +2067,11 @@ describe("Zotero.Translate.ItemGetter", function() { fileExists = yield OS.File.exists(attachment.localPath); assert.isTrue(fileExists, prefix + 'file was not removed from original location' + suffix); - assert.throws(attachment.saveFile.bind(attachment, attachment.defaultPath), /^ERROR_FILE_EXISTS /, prefix + 'saveFile does not overwrite existing file by default' + suffix); - assert.throws(attachment.saveFile.bind(attachment, 'file/../../'), /./, prefix + 'saveFile does not allow exporting outside export directory' + suffix); + let e; + e = yield getPromiseError(attachment.saveFile(attachment.defaultPath)); + assert.match(e.message, /^ERROR_FILE_EXISTS /, prefix + 'saveFile does not overwrite existing file by default' + suffix); + e = yield getPromiseError(attachment.saveFile('file/../../')); + assert.match(e.message, /./, prefix + 'saveFile does not allow exporting outside export directory' + suffix); /** TODO: check if overwriting existing file works **/ } @@ -2118,6 +2121,25 @@ describe("Zotero.Translate.ItemGetter", function() { }); }); + describe("#setAll()", function () { + it("should exclude annotations", async function () { + var attachment = await importFileAttachment('test.pdf'); + var annotation = await createAnnotation('highlight', attachment); + + var getter = new Zotero.Translate.ItemGetter(); + await getter.setAll(attachment.libraryID, false); + + var item; + while (item = getter.nextItem()) { + if (item.itemType == 'annotation') { + assert.fail("Annotation item should not be returned from nextItem()"); + break; + } + } + assert.equal(getter.numItems, 1); + }); + }); + describe("#_attachmentToArray()", function () { it("should handle missing attachment files", function* () { var item = yield importFileAttachment('test.png'); diff --git a/test/tests/zfsTest.js b/test/tests/zfsTest.js index 6378a31249..0a3bdb16f9 100644 --- a/test/tests/zfsTest.js +++ b/test/tests/zfsTest.js @@ -884,7 +884,7 @@ describe("Zotero.Sync.Storage.Mode.ZFS", function () { }) setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${item.key}&includeTrashed=1`, + url: `users/1/items?itemKey=${item.key}&includeTrashed=1`, status: 200, text: JSON.stringify([itemJSON]) }); @@ -932,7 +932,7 @@ describe("Zotero.Sync.Storage.Mode.ZFS", function () { }) setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${item.key}&includeTrashed=1`, + url: `users/1/items?itemKey=${item.key}&includeTrashed=1`, status: 200, text: JSON.stringify([itemJSON]) });