Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Import files and clipboard #580

Closed
wants to merge 11 commits into from
2 changes: 1 addition & 1 deletion scripts/server.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const handler = (req, resp) => {
resp.end(buf);
});
};
if(req.url.startsWith('/web') || req.url.startsWith('/search') || req.url.startsWith('/export')) {
if (req.url.startsWith('/web') || req.url.startsWith('/search') || req.url.startsWith('/export') || req.url.startsWith('/import')) {
proxy.web(req, resp, {
changeOrigin: true,
target: `${translateURL}`,
Expand Down
72 changes: 44 additions & 28 deletions src/js/actions/identifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,30 +22,42 @@ const getNextLinkFromResponse = response => {
return next;
}

const importFromFile = fileData => {
return searchIdentifier(fileData.file, { shouldImport: true });
}

const searchIdentifier = identifier => {
const searchIdentifier = (identifier, { shouldImport = false } = {}) => {
return async (dispatch, getState) => {
identifier = identifier.trim();
const { config } = getState();
const { translateUrl } = config;
const matchDOI = decodeURIComponent(identifier)
.match(/^https?:\/\/doi.org\/(10(?:\.[0-9]{4,})?\/[^\s]*[^\s.,])$/);
if(matchDOI) {
identifier = matchDOI[1];
}
const identifierIsUrl = isLikeURL(identifier);
if(!identifierIsUrl) {
const identifierObjects = extractIdentifiers(identifier);
if(identifierObjects.length === 0) {
// invalid identifier, if we don't return, it will run search for a generic term like zbib
dispatch(reportIdentifierNoResults());
return;
} else {
return dispatch(currentAddMultipleTranslatedItems(identifierObjects.map(io => Object.values(io)[0])));

let identifierIsUrl = false;
let url;

if(!shouldImport) {
identifier = identifier.trim();
const matchDOI = decodeURIComponent(identifier)
.match(/^https?:\/\/doi.org\/(10(?:\.[0-9]{4,})?\/[^\s]*[^\s.,])$/);
if(matchDOI) {
identifier = matchDOI[1];
}
const identifierIsUrl = isLikeURL(identifier);
if(!identifierIsUrl) {
const identifierObjects = extractIdentifiers(identifier);
if(identifierObjects.length === 0) {
// invalid identifier, if we don't return, it will run search for a generic term like zbib
dispatch(reportIdentifierNoResults());
return;
} else {
return dispatch(currentAddMultipleTranslatedItems(identifierObjects.map(io => Object.values(io)[0])));
}
}
url = `${translateUrl}/${((identifierIsUrl ? 'web' : 'search'))}`;
} else {
url = `${translateUrl}/import`;
}
const url = `${translateUrl}/${((identifierIsUrl ? 'web' : 'search'))}`;
dispatch({ type: REQUEST_ADD_BY_IDENTIFIER, identifier, identifierIsUrl });

dispatch({ type: REQUEST_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, import: shouldImport });

try {
const response = await fetch(url, {
Expand All @@ -58,6 +70,9 @@ const searchIdentifier = identifier => {
if (response.status === 501 || response.status === 400) {
const message = 'Zotero could not find any identifiers in your input. Please verify your input and try again.';
dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message });
} else if (response.status === 413) {
const message = 'Selected file is too large.';
dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message });
} else if (response.status === 300) {
const data = await response.json();
const items = 'items' in data && 'session' in data ? data.items : data;
Expand All @@ -71,20 +86,21 @@ const searchIdentifier = identifier => {
identifierIsUrl,
identifier,
items,
import: shouldImport,
response
});
return items;
} else if(response.status !== 200) {
const message = 'Unexpected response from the server.';
dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message });
dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message, import: shouldImport });
} else if (!response.headers.get('content-type').startsWith('application/json')) {
const message = 'Unexpected response from the server.';
dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message });
dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message, import: shouldImport });
} else {
const json = await response.json();
if (!json.length) {
const message = 'Zotero could not find any identifiers in your input. Please verify your input and try again.';
dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message });
dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message, import: shouldImport });
} else {
const rootItems = json.filter(item => !item.parentItem);

Expand All @@ -95,6 +111,7 @@ const searchIdentifier = identifier => {
items: rootItems.map(ri => omit(ri, ['key', 'version'])),
identifierIsUrl,
identifier,
import: shouldImport,
response
});
return rootItems;
Expand All @@ -109,14 +126,15 @@ const searchIdentifier = identifier => {
item,
identifier,
identifierIsUrl,
import: shouldImport,
response
});
return item;
}
}
}
} catch(error) {
dispatch({ type: ERROR_ADD_BY_IDENTIFIER, error, identifier });
dispatch({ type: ERROR_ADD_BY_IDENTIFIER, error, identifier, import: shouldImport });
}
}
}
Expand Down Expand Up @@ -214,9 +232,7 @@ const currentAddTranslatedItem = translatedItem => {
const searchIdentifierMore = () => {
return async (dispatch, getState) => {
const state = getState();
const { config } = state
const { translateUrl } = config;
const { identifier, identifierIsUrl, next, result } = state.identifier;
const { identifier, next, result } = state.identifier;
if(!identifier || result !== CHOICE) {
return;
}
Expand Down Expand Up @@ -256,10 +272,10 @@ const searchIdentifierMore = () => {
}
}

const reportIdentifierNoResults = () => ({
const reportIdentifierNoResults = (message = 'Zotero could not find any identifiers in your input. Please verify your input and try again.') => ({
type: ERROR_IDENTIFIER_NO_RESULT,
error: 'Zotero could not find any identifiers in your input. Please verify your input and try again.',
error: message,
errorType: 'info',
});

export { currentAddTranslatedItem, currentAddMultipleTranslatedItems, resetIdentifier, searchIdentifier, searchIdentifierMore, reportIdentifierNoResults };
export { currentAddTranslatedItem, currentAddMultipleTranslatedItems, importFromFile, resetIdentifier, searchIdentifier, searchIdentifierMore, reportIdentifierNoResults };
9 changes: 9 additions & 0 deletions src/js/component/item/actions.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Button, Dropdown, DropdownToggle, DropdownMenu, DropdownItem, Icon } fr

import { ToolGroup } from '../ui/toolbars';
import NewItemSelector from 'component/item/actions/new-item';
import ImportAction from 'component/item/actions/import';
import ExportActions from 'component/item/actions/export';
import columnProperties from '../../constants/column-properties';
import AddByIdentifier from 'component/item/actions/add-by-identifier';
Expand Down Expand Up @@ -101,6 +102,7 @@ const ItemActionsTouch = memo(() => {
<DropdownItem onClick={ handleAddByIdentifierModalOpen } >
Add By Identifier
</DropdownItem>
<ImportAction />
</Fragment>
)}
{ !isEmbedded && (
Expand Down Expand Up @@ -180,6 +182,13 @@ const ItemActionsDesktop = memo(props => {
<Icon type="16/note" width="16" height="16" />
</Button>
)}
{(itemsSource === 'collection' || itemsSource === 'top') && (
<ImportAction
tabIndex={-2}
onFocusNext={onFocusNext}
onFocusPrev={onFocusPrev}
/>
)}
</ToolGroup>
<ToolGroup>
{ !isTrash && (
Expand Down
22 changes: 19 additions & 3 deletions src/js/component/item/actions/add-by-identifier.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const AddByIdentifier = props => {
const result = useSelector(state => state.identifier.result);
const item = useSelector(state => state.identifier.item);
const items = useSelector(state => state.identifier.items);
const message = useSelector(state => state.identifier.message);
const prevItem = usePrevious(item);
const prevItems = usePrevious(items);
const wasSearching = usePrevious(isSearching);
Expand Down Expand Up @@ -96,14 +97,28 @@ const AddByIdentifier = props => {
}
}, [toggleOpen]);

const handlePaste = useCallback(ev => {
const clipboardData = ev.clipboardData || window.clipboardData;
const pastedData = clipboardData.getData('Text');
const isMultiLineData = pastedData.split('\n').filter(line => line.trim().length > 0).length > 1;

if (!isMultiLineData) {
return;
}

ev.preventDefault();
setIdentifier(pastedData);
dispatch(searchIdentifier(pastedData, { shouldImport: true }));
}, [dispatch]);

useEffect(() => {
if(isOpen && item && prevItem === null) {
addItem({ ...item });
}
}, [addItem, isOpen, item, prevItem]);

useEffect(() => {
if(isOpen && items && prevItems === null && [CHOICE, CHOICE_EXHAUSTED, MULTIPLE].includes(result)) {
if (isOpen && items && prevItems === null && [CHOICE, CHOICE_EXHAUSTED, MULTIPLE].includes(result)) {
setIsOpen(!isOpen);
dispatch(toggleModal(IDENTIFIER_PICKER, true));
}
Expand All @@ -113,13 +128,13 @@ const AddByIdentifier = props => {
if(!isSearching && wasSearching) {
setIdentifier('');
if(result === EMPTY) {
dispatch(reportIdentifierNoResults());
dispatch(reportIdentifierNoResults(message));
} else {
ref.current.focus();
setIsOpen(false);
}
}
}, [dispatch, isSearching, wasSearching, result]);
}, [dispatch, isSearching, wasSearching, result, message]);

useLayoutEffect(() => {
if (isOpen && !wasOpen) {
Expand Down Expand Up @@ -193,6 +208,7 @@ const AddByIdentifier = props => {
onChange={ handleInputChange }
onCommit={ handleInputCommit }
onKeyDown={ handleInputKeyDown }
onPaste={ handlePaste }
ref={ inputEl }
tabIndex={ 0 }
value={ identifier }
Expand Down
100 changes: 100 additions & 0 deletions src/js/component/item/actions/import.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import PropTypes from 'prop-types';
import { memo, useCallback, useId, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Button, DropdownItem, Icon } from 'web-common/components';

import { importFromFile, toggleModal } from '../../../actions';
import { getFileData } from '../../../common/event';
import { IDENTIFIER_PICKER } from '../../../constants/modals';


const ImportAction = ({ disabled, onFocusNext, onFocusPrev, tabIndex }) => {
const dispatch = useDispatch();
const isTouchOrSmall = useSelector(state => state.device.isTouchOrSmall);
const uploadFileId = useId();
const fileInputRef = useRef(null);

const handleKeyDown = useCallback(ev => {
if (ev.target !== ev.currentTarget) {
return;
}

if (ev.key === 'ArrowRight') {
onFocusNext(ev);
} else if (ev.key === 'ArrowLeft') {
onFocusPrev(ev);
}
}, [onFocusNext, onFocusPrev]);

const handleImportClick = useCallback(ev => {
if (ev.currentTarget === ev.target) {
fileInputRef.current.click();
}
ev.stopPropagation();
}, []);

const handleFileInputChange = useCallback(async ev => {
const target = ev.currentTarget; // persist, or it will be nullified after await
const fileData = await getFileData(target.files[0]);
target.value = ''; // clear the invisible input so that onChange is triggered even if the same file is selected again
if (fileData) {
dispatch(importFromFile(fileData));
dispatch(toggleModal(IDENTIFIER_PICKER, true));
}
}, [dispatch]);

return isTouchOrSmall ? (
<DropdownItem
onClick={handleImportClick}
className="btn-file"
aria-labelledby={uploadFileId}
>
<span
id={uploadFileId}
className="flex-row align-items-center"
>
Import From a File
</span>
<input
aria-labelledby={uploadFileId}
multiple={false}
onChange={handleFileInputChange}
ref={fileInputRef}
tabIndex={-1}
type="file"
/>
</DropdownItem>
) : (
<div className="btn-file">
<input
aria-labelledby={uploadFileId}
multiple={false}
onChange={handleFileInputChange}
ref={fileInputRef}
tabIndex={-1}
type="file"
title="Import From a File (BiBTeX, RIS, etc.)"
/>
<Button
disabled={disabled}
icon
onClick={handleImportClick}
onKeyDown={handleKeyDown}
tabIndex={tabIndex}
title="Import From a File (BiBTeX, RIS, etc.)"
>
<Icon type="16/import" width="16" height="16" />
</Button>

</div>
);
}

ImportAction.propTypes = {
disabled: PropTypes.bool,
onFocusNext: PropTypes.func,
onFocusPrev: PropTypes.func,
tabIndex: PropTypes.number
};

export default memo(ImportAction);
2 changes: 1 addition & 1 deletion src/js/component/item/actions/new-item.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ const NewItemSelector = props => {
</DropdownItem>
</Fragment>
)}
<DropdownItem divider />
{ isSecondaryVisible ?
secondaryItemTypesDesc.map(itemTypeSpec => (
<DropdownItemType
Expand All @@ -163,7 +164,6 @@ const NewItemSelector = props => {
/>
)) : (
<Fragment>
<DropdownItem divider />
<DropdownItem onClick={ handleToggleMore }>
More
</DropdownItem>
Expand Down
Loading
Loading