diff --git a/portality/crosswalks/article_crossref_xml.py b/portality/crosswalks/article_crossref_xml.py index e59340be48..6ed6f53f33 100644 --- a/portality/crosswalks/article_crossref_xml.py +++ b/portality/crosswalks/article_crossref_xml.py @@ -171,6 +171,7 @@ def crosswalk_article(self, record, journal): self.extract_issue(journal, bibjson) self.extract_pages(record, journal, bibjson) self.extract_doi(record, journal, bibjson) + self.extract_identifiers(record, journal, bibjson) self.extract_fulltext(record, journal, bibjson) self.extract_article_title(record, journal, bibjson) self.extract_authors(record, journal, bibjson) @@ -284,6 +285,39 @@ def extract_doi(self, record, journal, bibjson): if doi is not None: bibjson.add_identifier(bibjson.DOI, doi) + def extract_identifiers(self, record, journal, bibjson): + # This is used to extract item_number such as article number, eLocator, or e-location + # and capture other identifiers such as handle, ark, doi, urn, dor and purl + # Both item_number and identifier have a type attribute which is free text. + # ToDO - Do we need to differentiate between item_number and other identifiers? + publisher_item = record.find("x:publisher_item", self.NS) + if publisher_item is not None: + ans = [] + ans1 = publisher_item.findall("x:item_number", self.NS) + ans2 = publisher_item.findall("x:identifier", self.NS) + if ans1 is not None: + ans = ans + ans1 + if ans2 is not None: + ans = ans + ans2 + for an in ans: + typ = None + typ1 = an.attrib["item_number_type"] + typ2 = an.attrib["id_type"] + if typ1: + typ = typ1 + elif typ2: + typ = typ2 + if typ and typ.lower() in ["eLocator", "e-location"]: + bibjson.add_identifier(bibjson.ELOCATION, an[0].text.upper()) + elif typ and typ.lower() in bibjson.IDENTIFIER_TYPES: + bibjson.add_identifier(typ.lower(), an[0].text.upper()) + elif typ: + # TODO - Do we capture identifiers of other types? + bibjson.add_identifier(typ.lower(), an[0].text.upper()) + else: + # TODO - Do we capture identifiers of unknown type? + bibjson.add_identifier('other', an[0].text.upper()) + def extract_fulltext(self, record, journal, bibjson): d = record.find("x:doi_data", self.NS) ftel = _element(d, "x:resource", self.NS) diff --git a/portality/crosswalks/article_form.py b/portality/crosswalks/article_form.py index 9ab82cdef9..5458ebd41d 100644 --- a/portality/crosswalks/article_form.py +++ b/portality/crosswalks/article_form.py @@ -91,6 +91,15 @@ def form2obj(cls, form, add_journal_info=True): end = form.end.data if end is not None and end != "": bibjson.end_page = end + elocation = form.elocation.data + if elocation is not None and elocation != "": + bibjson.add_identifier(bibjson.ELOCATION, elocation) + for subfield in form.other_identifiers: + if (subfield.form.type.data is not None and subfield.form.type.data != "" and + subfield.form.id.data is not None and subfield.form.id.data != ""): + id_typ = subfield.form.type.data + id_val = subfield.form.id.data + bibjson.add_identifier(id_typ, id_val) # add the journal info if requested if add_journal_info: @@ -153,4 +162,25 @@ def obj2form(cls, form, article): form.end.data = bibjson.end_page if bibjson.abstract: form.abstract.data = bibjson.abstract + elocation = bibjson.get_one_identifier(bibjson.ELOCATION) + if elocation: + form.elocation.data = elocation + other_identifiers = [] + for each_id in bibjson.get_identifiers(): + if not each_id['type'] in bibjson.NAMED_IDENTIFIERS: + other_identifiers.append(each_id) + if other_identifiers: + for i in range(len(form.other_identifiers)): + form.other_identifiers.pop_entry() + for i in other_identifiers: + id_form = OtherIdentifierForm() + if "type" in i: + id_form.type = i["type"] + else: + id_form.type = "" + if "id" in i: + id_form.id = i["id"] + else: + id_form.id = "" + form.other_identifiers.append_entry(id_form) diff --git a/portality/forms/article_forms.py b/portality/forms/article_forms.py index 28a84a6c62..2d9829d69c 100644 --- a/portality/forms/article_forms.py +++ b/portality/forms/article_forms.py @@ -14,6 +14,7 @@ from portality.forms.fields import DOAJSelectField, TagListField from portality.forms.validate import OptionalIf, ThisOrThat, NoScriptTag, DifferentTo from portality.lib import dates +from portality.models.v1 import bibjson from portality.ui.messages import Messages from portality.ui import templates @@ -561,6 +562,20 @@ class AuthorForm(Form): orcid_id = StringField("ORCID iD", [validators.Optional(), validators.Regexp(regex=regex.ORCID_COMPILED, message=ORCID_ERROR)]) +class OtherIdentifierForm(Form): + """ + ~~->$ OtherIdentifiers:Form~~ + """ + # WTForms choices are (value, label) - value is what actually gets submitted, so it + # must stay lower-case to match bibjson.GenericBibJSON.IDENTIFIER_TYPES / add_identifier + choices = [('', 'Select type')] + choices += [(i, i.upper()) for i in bibjson.GenericBibJSON.IDENTIFIER_TYPES] + choices += [('other', 'Other')] + id_types = DOAJSelectField("Type of identifier", [validators.Optional()], choices=choices, default="" ) + type = StringField("Type", [validators.Optional(), NoScriptTag()]) + id = StringField("ID", [validators.Optional(), NoScriptTag()]) + + class ArticleForm(Form): title = StringField("Article title (required)", [validators.DataRequired(), NoScriptTag()]) doi = StringField("DOI", [OptionalIf("fulltext", "You must provide the DOI or the Full-Text URL"), validators.Regexp(regex=regex.DOI_COMPILED, message=DOI_ERROR)], description="(You must provide a DOI and/or a Full-Text URL)") @@ -583,6 +598,8 @@ class ArticleForm(Form): number = StringField("Issue", [validators.Optional(), NoScriptTag()]) start = StringField("Start", [validators.Optional(), NoScriptTag()]) end = StringField("End", [validators.Optional(), NoScriptTag()]) + elocation = StringField("eLocation ID", [validators.Optional(), NoScriptTag()]) + other_identifiers = FieldList(FormField(OtherIdentifierForm), min_entries=1) def __init__(self, *args, **kwargs): super(ArticleForm, self).__init__(*args, **kwargs) @@ -599,7 +616,6 @@ def set_choices(self, user=None, article_id=None): app.logger.exception(str(e)) - ######################################### # Formcontexts and factory @@ -626,6 +642,7 @@ class MetadataForm(FormContext): def __init__(self, source, form_data, user): self.user = user self.author_error = False + self.other_identifier_error = False super(MetadataForm, self).__init__(source=source, form_data=form_data) def _set_choices(self): @@ -671,6 +688,48 @@ def _validate_authors(self): counted += 1 return counted >= 1 + def modify_other_identifiers_if_required(self, request_data): + + more_ids = request_data.get("more_other_identifiers") + remove_id = None + for v in list(request.values.keys()): + if v.startswith("remove_other_identifiers"): + remove_id = v.split("-")[1] + + # if the user wants more ids, add an extra entry + if more_ids: + return self.render_template(more_other_identifiers=True) + + # if the user wants to remove an author, do the various back-flips required + if remove_id is not None: + return self.render_template(remove_other_identifiers=remove_id) + + def _check_for_other_identifier_errors(self, **kwargs): + + if "more_other_identifiers" in kwargs and kwargs["more_other_identifiers"] == True: + self.form.other_identifiers.append_entry() + if "remove_other_identifiers" in kwargs: + keep = [] + while len(self.form.other_identifiers.entries) > 0: + entry = self.form.other_identifiers.pop_entry() + if entry.short_name == "other_identifiers-" + kwargs["remove_other_identifier"]: + break + else: + keep.append(entry) + while len(keep) > 0: + self.form.other_identifiers.append_entry(keep.pop().data) + + def _validate_other_identifiers(self): + errors = 0 + for entry in self.form.other_identifiers.entries: + typ = entry.data.get("type") + id = entry.data.get("id") + if typ is not None and typ != "" and (id is None or id == "") : + errors += 1 + elif id is not None and id != "" and (typ is None or typ == ""): + errors += 1 + return errors == 0 + def blank_form(self): self.form = ArticleForm() self._set_choices() @@ -690,6 +749,8 @@ def form2target(self): def validate(self): if not self._validate_authors(): self.author_error = True + if not self._validate_other_identifiers(): + self.other_identifier_error = True if not self.form.validate(): return False return True @@ -720,9 +781,11 @@ def set_template(self): def render_template(self, **kwargs): self._check_for_author_errors(**kwargs) + self._check_for_other_identifier_errors(**kwargs) if "validated" in kwargs and kwargs["validated"] == True: self.blank_form() - return render_template(self.template, form=self.form, form_context=self, author_error=self.author_error) + return render_template(self.template, form=self.form, form_context=self, author_error=self.author_error, + other_identifier_error=self.other_identifier_error) class AdminMetadataArticleForm(MetadataForm): @@ -737,4 +800,6 @@ def set_template(self): def render_template(self, **kwargs): self._check_for_author_errors(**kwargs) - return render_template(self.template, form=self.form, form_context=self, author_error=self.author_error) + self._check_for_other_identifier_errors(**kwargs) + return render_template(self.template, form=self.form, form_context=self, author_error=self.author_error, + other_identifier_error=self.other_identifier_error) diff --git a/portality/models/article.py b/portality/models/article.py index 7e46bc4f9f..d9b2db93cb 100644 --- a/portality/models/article.py +++ b/portality/models/article.py @@ -749,6 +749,22 @@ def end_page(self): def end_page(self, val): self._set_with_struct("end_page", val) + @property + def elocation(self): + self.elocation += self.get_identifiers(self.ELOCATION) + + @elocation.setter + def elocation(self, val): + self.add_identifier(self.ELOCATION, val) + + @property + def other_identifiers(self): + identifiers = [] + for each_id in self.get_identifiers(): + if not each_id['type'] in self.NAMED_IDENTIFIERS: + identifiers.append(each_id) + return identifiers + @property def abstract(self): return self._get_single("abstract") diff --git a/portality/models/v1/bibjson.py b/portality/models/v1/bibjson.py index ba97fc67d9..fa48fc4472 100644 --- a/portality/models/v1/bibjson.py +++ b/portality/models/v1/bibjson.py @@ -6,6 +6,9 @@ class GenericBibJSON(dataobj.DataObj): P_ISSN = "pissn" E_ISSN = "eissn" DOI = "doi" + IDENTIFIER_TYPES = ['handle', 'ark', 'doi', 'urn', 'dor', 'purl'] + ELOCATION = 'e-location' + NAMED_IDENTIFIERS = [P_ISSN, E_ISSN, DOI, ELOCATION] # allowable values for the url types HOMEPAGE = "homepage" diff --git a/portality/static/js/article_metadata_form.js b/portality/static/js/article_metadata_form.js index 9e187e3f3f..8be9dc0872 100644 --- a/portality/static/js/article_metadata_form.js +++ b/portality/static/js/article_metadata_form.js @@ -2,112 +2,189 @@ $(document).ready(function() { - function prepAuthorContainer(params) { - var ne = params.element; - var reset = params.reset_value; - var number = params.number; + function initRepeatableFieldList(options) { + var prefix = options.prefix; + var removeButtonSelector = options.removeButtonSelector; + var addButtonSelector = options.addButtonSelector; + var onAdd = options.onAdd; + + function prepContainer(params) { + var ne = params.element; + var reset = params.reset_value; + var number = params.number; + + ne.id = prefix + '-' + number + '-container'; + + ne = $(ne); + ne.find('[id^=' + prefix + '-]').each(function () { + var ce = $(this); + + // reset the value + if (reset) { + if (ce.is('select')) { + // val('') can leave nothing visibly selected if there's no blank + // option; selectedIndex always lands on a real, visible option + ce.prop('selectedIndex', 0); + } else { + ce.val(''); + } + } + + // set the id as requested + var items = ce.attr('id').split('-'); + var id = prefix + '-' + number + '-' + items[2]; + + // set both the id and the name to the new id, as per wtforms requirements + ce.attr('id', id); + ce.attr('name', id); + }); - ne.id = 'authors-' + number + '-container'; + // we also need to update the remove button + ne.find(removeButtonSelector).each(function () { + var ce = $(this); + var id = 'remove_' + prefix + '-' + number; - ne = $(ne); - ne.find('[id^=authors-]').each(function () { - var ce = $(this); + // set both the id and the name to the new id + ce.attr('id', id); + ce.attr('name', id); + }); + } - // reset the value - if (reset) { - ce.val('') + function showHideFirstRemoveButton() { + // Hide delete button when there's just one entry + if ($('[id^=' + prefix + '-][id$="container"]').length === 1) { + $('#remove_' + prefix + '-0').css('display', 'none'); + } else { + $('#remove_' + prefix + '-0').css('display', 'inherit'); } + } - // set the id as requestsed - items = ce.attr('id').split('-'); - var id = 'authors-' + number + '-' + items[2]; - - // set both the id and the name to the new id, as per wtforms requirements - ce.attr('id', id); - ce.attr('name', id); - }); + function removeItem(event) { + event.preventDefault(); - // we also need to update the remove button - ne.find("[id^=remove_authors-]").each(function () { - var ce = $(this); + var id = $(this).attr("id"); + // strip the leading "remove_" to get the container's short_name (e.g. other_identifiers-0) + var short_name = id.replace(/^remove_/, ''); + var container = short_name + "-container"; - // update the id as above - saving us a closure again - items = ce.attr('id').split('-'); - var id = 'remove_authors-' + number; + $("#" + container).remove(); - // set both the id and the name to the new id - ce.attr('id', id); - ce.attr('name', id); - }) - } + var count = 0; + $('[id^=' + prefix + '-][id$="container"]').each(function () { + prepContainer({ + element: this, + number: count, + reset_value: false + }); + count++; + }); - function showHideFirstRemoveButton() { - // Hide delete button when there's just one author - if ($('[id^=authors-][id$="container"]').length === 1) { - $('#remove_authors-0').css('display', 'none') - } else { - $('#remove_authors-0').css('display', 'inherit') + showHideFirstRemoveButton(); } - } - function removeAuthor() { - event.preventDefault(); + $(addButtonSelector).click(function (event) { + event.preventDefault(); - var id = $(this).attr("id"); - var short_name = id.split("_")[1]; - var container = short_name + "-container"; + // get the last entry in the list + var all_e = $('[id^=' + prefix + '-][id$="container"]'); + var e = all_e.last(); - $("#" + container).remove(); + // make a clone of the last entry + var ne = e.clone()[0]; - var count = 0; - $('[id^=authors-][id$="container"]').each(function () { - prepAuthorContainer({ - element: this, - number: count, - reset_value: false + // extract the last entry's number from the container id and increment it + var items = ne.id.split('-'); + var number = parseInt(items[1]); + number = number + 1; + + // increment all the numbers + prepContainer({ + element: ne, + number: number, + reset_value: true }); - count++; + + e.after(ne); + + var rem_b = $(removeButtonSelector); + rem_b.unbind("click"); + rem_b.click(removeItem); + if (all_e.length === 1) { + $('#remove_' + prefix + '-1').css('display', 'inherit'); + } + + showHideFirstRemoveButton(); + + if (onAdd) { + onAdd(ne); + } }); - showHideFirstRemoveButton() + showHideFirstRemoveButton(); + $(removeButtonSelector).click(removeItem); } - $('button[name=more_authors]').click(function (event) { - event.preventDefault(); - - // get the last author div in the list - var all_e = $('[id^=authors-][id$="container"]'); - var e = all_e.last(); + initRepeatableFieldList({ + prefix: 'authors', + removeButtonSelector: '.remove_author__button', + addButtonSelector: 'button[name=more_authors]' + }); - // make a clone of the last author div - var ne = e.clone()[0]; + initRepeatableFieldList({ + prefix: 'other_identifiers', + removeButtonSelector: '.remove_identifier__button', + addButtonSelector: 'button[name=more_other_identifiers]', + onAdd: function (container) { + syncIdentifierTypeVisibility($(container).find('.identifier-type-select')); + } + }); - // extract the last author's number from the div id and increment it - var items = ne.id.split('-'); - var number = parseInt(items[1]); - number = number + 1; + // The "Type of identifier" dropdown offers the known identifier types plus an + // "other" option. The free-text type field is only needed (and shown) when + // "other" is selected - the rest of the time the dropdown value *is* the type. + function syncIdentifierTypeVisibility(select) { + var typeInput = select.closest('.identifier-item').find('.identifier-type-other'); + if (select.val() === 'other') { + typeInput.show(); + } else { + typeInput.hide(); + } + } - // increment all the numbers - prepAuthorContainer({ - element: ne, - number: number, - reset_value: true - }); + $('#identifier-list').on('change', '.identifier-type-select', function () { + syncIdentifierTypeVisibility($(this)); + }); - e.after(ne); + $('.identifier-type-select').each(function () { + var select = $(this); + var typeInput = select.closest('.identifier-item').find('.identifier-type-other'); + var existingType = typeInput.val(); - var rem_b = $(".remove_field__button"); - rem_b.unbind("click"); - rem_b.click(removeAuthor); - if (all_e.length === 1) { - $('#remove_authors-1').css('display', 'inherit') + // an existing type that isn't one of the dropdown's known options must have + // been freely typed in previously, so select "other" and reveal the field + if (existingType && select.find('option[value="' + existingType + '"]').length === 0) { + select.val('other'); } - showHideFirstRemoveButton(); + syncIdentifierTypeVisibility(select); }); - showHideFirstRemoveButton(); - $(".remove_field__button").click(removeAuthor); + $("#article_metadata_form").on("submit", function () { + // whenever a known type is selected, that dropdown value is the type - + // copy it into the (possibly hidden) type field so it gets submitted. + // Rows the user hasn't engaged with (no identifier value entered) are left + // alone so they don't get flagged as incomplete. + $('#identifier-list .identifier-item').each(function () { + var item = $(this); + var select = item.find('.identifier-type-select'); + var idInput = item.find('input[id$="-id"]'); + var typeInput = item.find('.identifier-type-other'); + + if (select.val() !== 'other' && idInput.val() !== '') { + typeInput.val(select.val()); + } + }); + }); $("#pissn").select2({ allowClear: false, @@ -132,4 +209,4 @@ $(document).ready(function() { $("#article_metadata_form").on("submit", function(event) { $("button[type=submit]").prop("disabled", true); }) - }) \ No newline at end of file + }) diff --git a/portality/templates-v2/_articles/includes/_article_metadata_form.html b/portality/templates-v2/_articles/includes/_article_metadata_form.html index 09ec2c9f13..25976e66b8 100644 --- a/portality/templates-v2/_articles/includes/_article_metadata_form.html +++ b/portality/templates-v2/_articles/includes/_article_metadata_form.html @@ -4,6 +4,41 @@

Article

{{ render_field_horizontal(form.fulltext, placeholder="https://www.example.com") }} {{ render_field_horizontal(form.doi, placeholder="10.1234/article-32") }} +
+ Identifiers +
+ + {% for subfield in form.other_identifiers %} +
+ {{ subfield.form.id_types(class_="identifier-type-select") }} + {{ subfield.form.type(class_="identifier-type-other", placeholder="Type", style="display:none;") }} + {{ subfield.form.id }} + {% for error in subfield.errors %} +
+
    +
  • {{ subfield.errors.get(error)[0] }}
  • +
+
+ {% endfor %} + +
+ {% endfor %} +

+ +

+ {% if other_identifier_error %} +
+
    +
  • Please provide both the type and the identifier value
  • +
+
+ {% endif %} +
+
@@ -27,7 +62,7 @@

Author (required)

{% endfor %} - @@ -90,7 +125,7 @@

Journal

-

Page(s)

+

Location(s)

@@ -99,6 +134,9 @@

Page(s)

{{ render_field_horizontal(form.end) }}
+
+ {{ render_field_horizontal(form.elocation) }} +
diff --git a/portality/view/admin.py b/portality/view/admin.py index eb6da12ec1..a5ec3ed14f 100644 --- a/portality/view/admin.py +++ b/portality/view/admin.py @@ -185,6 +185,7 @@ def article_page(article_id): fc = ArticleFormFactory.get_from_context(role="admin", source=ap, user=user, form_data=request.form) fc.modify_authors_if_required(request.values) + fc.modify_other_identifiers_if_required(request.values) if fc.validate(): try: diff --git a/portality/view/publisher.py b/portality/view/publisher.py index f5d23d062d..a99a55fa34 100644 --- a/portality/view/publisher.py +++ b/portality/view/publisher.py @@ -361,6 +361,7 @@ def metadata(): # the user might request by pressing the add/remove authors buttons fc.modify_authors_if_required(request.values) + fc.modify_other_identifiers_if_required(request.values) validated = False if fc.validate():