Skip to content

Commit

Permalink
Merge pull request #10032 from schu96/3430/feature/add-work-ids-ui
Browse files Browse the repository at this point in the history
Use IdentifiersInput Vue component for work identifier UI
  • Loading branch information
cdrini authored Jan 9, 2025
2 parents 70fc10d + 703e2a1 commit 37d059b
Show file tree
Hide file tree
Showing 12 changed files with 213 additions and 79 deletions.
40 changes: 25 additions & 15 deletions openlibrary/components/IdentifiersInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@
</th>
</tr>
<template v-for="(value, name) in assignedIdentifiers">
<tr :key="name" v-if="value && !isEdition">
<tr :key="name" v-if="value && !saveIdentifiersAsList">
<td>{{ identifierConfigsByKey[name].label }}</td>
<td>{{ value }}</td>
<td>
<button class="form-control" @click="removeIdentifier(name)">Remove</button>
</td>
</tr>
<template v-else-if="value && isEdition">
<template v-else-if="value && saveIdentifiersAsList">
<tr v-for="(item, idx) in value" :key="name + idx">
<td>{{ identifierConfigsByKey[name].label }}</td>
<td>{{ item }}</td>
Expand All @@ -52,7 +52,7 @@
</template>

<script>
import { errorDisplay, validateEditionIdentifiers } from './IdentifiersInput/utils/utils.js';
import { errorDisplay, validateIdentifiers } from './IdentifiersInput/utils/utils.js';
const identifierPatterns = {
wikidata: /^Q[1-9]\d*$/i,
isni: /^[0]{4} ?[0-9]{4} ?[0-9]{4} ?[0-9]{3}[0-9X]$/i,
Expand All @@ -76,7 +76,9 @@ export default {
id_config_string: {
type: String
},
/** see createHiddenInputs function for usage */
/** see createHiddenInputs function for usage
* #hiddenEditionIdentifiers, #hiddenWorkIdentifiers
*/
output_selector: {
type: String
},
Expand Down Expand Up @@ -108,20 +110,20 @@ export default {
return {
selectedIdentifier: '', // Which identifier is selected in dropdown
inputValue: '', // What user put into input
assignedIdentifiers: {}, // IDs assigned to the entity Ex: {'viaf': '12632978'}
assignedIdentifiers: {}, // IDs assigned to the entity Ex: {'viaf': '12632978'} or {'abaa': ['123456','789012']}
}
},
computed: {
popularEditionConfigs: function() {
if (this.isEdition) {
if (this.edition_popular) {
const popularConfigs = JSON.parse(decodeURIComponent(this.edition_popular));
return Object.fromEntries(popularConfigs.map(e => [e.name, e]));
}
return {};
},
secondaryEditionConfigs: function() {
if (this.isEdition) {
if (this.secondary_identifiers) {
const secondConfigs = JSON.parse(decodeURIComponent(this.secondary_identifiers));
return Object.fromEntries(secondConfigs.map(e => [e.name, e]));
}
Expand All @@ -139,6 +141,9 @@ export default {
return this.admin.toLowerCase() === 'true';
},
isEdition() {
return this.multiple.toLowerCase() === 'true' && this.edition_popular;
},
saveIdentifiersAsList() {
return this.multiple.toLowerCase() === 'true';
},
setButtonEnabled: function(){
Expand All @@ -154,10 +159,10 @@ export default {
if (this.selectedIdentifier === 'isni') {
this.inputValue = this.inputValue.replace(/\s/g, '')
}
if (this.isEdition) {
if (this.saveIdentifiersAsList) {
// collect id values of matching type, or empty array if none present
const existingIds = this.assignedIdentifiers[this.selectedIdentifier] ?? [];
const validEditionId = validateEditionIdentifiers(this.selectedIdentifier, this.inputValue, existingIds);
const validEditionId = validateIdentifiers(this.selectedIdentifier, this.inputValue, existingIds, this.output_selector);
if (validEditionId) {
if (!this.assignedIdentifiers[this.selectedIdentifier]) {
this.inputValue = [this.inputValue];
Expand All @@ -170,18 +175,18 @@ export default {
return;
}
} else if (this.assignedIdentifiers[this.selectedIdentifier]) {
errorDisplay(`An author identifier for ${this.identifierConfigsByKey[this.selectedIdentifier].label} already exists.`)
errorDisplay(`An identifier for ${this.identifierConfigsByKey[this.selectedIdentifier].label} already exists.`, this.output_selector)
return;
} else { errorDisplay() }
} else { errorDisplay('', this.output_selector) }
// We use $set otherwise we wouldn't get the reactivity desired
// See https://vuejs.org/v2/guide/reactivity.html#Change-Detection-Caveats
this.$set(this.assignedIdentifiers, this.selectedIdentifier, this.inputValue);
this.inputValue = '';
this.selectedIdentifier = '';
},
/** Removes an identifier with value from memory and it will be deleted from database on save */
removeIdentifier: function(identifierName, idx = 0){
if (this.isEdition) {
removeIdentifier: function(identifierName, idx = 0) {
if (this.saveIdentifiersAsList) {
this.assignedIdentifiers[identifierName].splice(idx, 1);
} else {
this.$set(this.assignedIdentifiers, identifierName, '');
Expand All @@ -194,7 +199,8 @@ export default {
* So for now this just drops the hidden inputs into the the parent form anytime there is a change
*/
let html = '';
if (this.isEdition) {
// should save a list of ids for work + edition identifiers
if (this.saveIdentifiersAsList) {
let num = 0;
for (const [key, value] of Object.entries(this.assignedIdentifiers)) {
for (const idx in value) {
Expand Down Expand Up @@ -227,7 +233,11 @@ export default {
},
created: function(){
this.assignedIdentifiers = JSON.parse(decodeURIComponent(this.assigned_ids_string));
if (this.isEdition) {
if (this.assignedIdentifiers.length === 0) {
this.assignedIdentifiers = {}
return;
}
if (this.saveIdentifiersAsList) {
const edition_identifiers = {};
this.assignedIdentifiers.forEach(entry => {
if (!edition_identifiers[entry.name]) {
Expand Down
37 changes: 24 additions & 13 deletions openlibrary/components/IdentifiersInput/utils/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@ import {
isValidLccn,
} from '../../../plugins/openlibrary/js/idValidation.js';

export function errorDisplay(message) {
const errorSelector = document.querySelector('#id-errors');
export function errorDisplay(message, error_output) {
let errorSelector;
if (error_output === '#hiddenAuthorIdentifiers') {
errorSelector = document.querySelector('#id-errors-author')
} else if (error_output === '#hiddenWorkIdentifiers') {
errorSelector = document.querySelector('#id-errors-work')
} else if (error_output === '#hiddenEditionIdentifiers') {
errorSelector = document.querySelector('#id-errors-edition')
}
if (message) {
errorSelector.style.display = '';
errorSelector.innerHTML = `<div>${message}</div>`;
Expand All @@ -23,10 +30,12 @@ export function errorDisplay(message) {
function validateIsbn10(value) {
const isbn10_value = parseIsbn(value);
if (!isFormatValidIsbn10(isbn10_value)) {
errorDisplay('ID must be exactly 10 characters [0-9] or X.');
errorDisplay('ID must be exactly 10 characters [0-9] or X.', '#hiddenEditionIdentifiers');
return false;
} else if (isFormatValidIsbn10(isbn10_value) === true && isChecksumValidIsbn10(isbn10_value) === false) {
errorDisplay(`ISBN ${isbn10_value} may be invalid. Please confirm if you'd like to add it before saving all changes`);
} else if (
isFormatValidIsbn10(isbn10_value) && !isChecksumValidIsbn10(isbn10_value)
) {
errorDisplay(`ISBN ${isbn10_value} may be invalid. Please confirm if you'd like to add it before saving all changes`, '#hiddenEditionIdentifiers');
}
return true;
}
Expand All @@ -35,10 +44,12 @@ function validateIsbn13(value) {
const isbn13_value = parseIsbn(value);

if (!isFormatValidIsbn13(isbn13_value)) {
errorDisplay('ID must be exactly 13 digits [0-9]. For example: 978-1-56619-909-4');
errorDisplay('ID must be exactly 13 digits [0-9]. For example: 978-1-56619-909-4', '#hiddenEditionIdentifiers');
return false;
} else if (isFormatValidIsbn13(isbn13_value) === true && isChecksumValidIsbn13(isbn13_value) === false) {
errorDisplay(`ISBN ${isbn13_value} may be invalid. Please confirm if you'd like to add it before saving all changes`);
} else if (
isFormatValidIsbn13(isbn13_value) && !isChecksumValidIsbn13(isbn13_value)
) {
errorDisplay(`ISBN ${isbn13_value} may be invalid. Please confirm if you'd like to add it before saving all changes`, '#hiddenEditionIdentifiers');
}
return true;
}
Expand All @@ -47,18 +58,18 @@ function validateLccn(value) {
const lccn_value = parseLccn(value);

if (!isValidLccn(lccn_value)) {
errorDisplay('Invalid ID format');
errorDisplay('Invalid ID format', '#hiddenEditionIdentifiers');
return false;
}
return true;
}

export function validateEditionIdentifiers(name, value, entries) {
export function validateIdentifiers(name, value, entries, error_output) {
let validId = true;
errorDisplay('');
errorDisplay('', error_output);
if (name === '' || name === '---') {
// if somehow an invalid identifier is passed through
errorDisplay('Invalid identifier');
errorDisplay('Invalid identifier', error_output);
return false;
}
if (name === 'isbn_10') {
Expand All @@ -70,7 +81,7 @@ export function validateEditionIdentifiers(name, value, entries) {
}
if (Array.from(entries).some(entry => entry === value) === true) {
validId = false;
errorDisplay('That ID already exists for this edition');
errorDisplay('That ID already exists for an identifier.', error_output);
}
return validId;
}
15 changes: 15 additions & 0 deletions openlibrary/i18n/messages.pot
Original file line number Diff line number Diff line change
Expand Up @@ -3604,6 +3604,21 @@ msgstr ""
msgid "Links"
msgstr ""

#: books/edit.html
msgid "Work Identifiers"
msgstr ""

#: books/edit.html
msgid "Do you know any identifiers for this work?"
msgstr ""

#: books/edit.html
msgid ""
"These identifiers apply to all editions of this work, for example "
"Wikidata work identifiers. For edition-specific identifiers, like ISBN or"
" LCCN, go to the edition tab."
msgstr ""

#: FulltextSearchSuggestionItem.html IABook.html SearchResultsWork.html
#: books/edition-sort.html jsdef/LazyWorkPreview.html lists/list_overview.html
#: lists/preview.html lists/snippet.html lists/widget.html
Expand Down
28 changes: 28 additions & 0 deletions openlibrary/plugins/openlibrary/config/work/identifiers.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
identifiers:
- label: BookBrainz
name: bookbrainz
notes: ''
url: https://bookbrainz.org/work/@@@
website: https://bookbrainz.org
- label: Книга Файнфиков
name: ficbook
notes: ''
url: https://ficbook.net/readfic/@@@
- label: MusicBrainz
name: musicbrainz
url: https://musicbrainz.org/artist/@@@
website: https://musicbrainz.org
- label: MyAnimeList
name: myanimelist
notes: ''
url: https://myanimelist.net/manga/@@@
- label: Wikidata
name: wikidata
notes: ''
url: https://www.wikidata.org/wiki/@@@
website: https://wikidata.org
- label: SBN/ICCU (National Library Service of Italy)
name: opac_sbn
notes: format is /^\D{2}[A-Z0-3]V\d{6}$/
url: https://opac.sbn.it/risultati-autori/-/opac-autori/detail/@@@
website: https://opac.sbn.it/
7 changes: 4 additions & 3 deletions openlibrary/plugins/upstream/addbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,8 +595,9 @@ def save(self, formdata: web.Storage) -> None:
elif self.work is not None and new_work_key is None:
# we're trying to create an orphan; let's not do that
edition_data.works = [{'key': self.work.key}]

if self.work is not None:
work_identifiers = work_data.pop('identifiers', {})
self.work.set_identifiers(work_identifiers)
self.work.update(work_data)
saveutil.save(self.work)

Expand Down Expand Up @@ -782,8 +783,8 @@ def read_subject(subjects):
else:
work.subtitle = None

for k in ('excerpts', 'links'):
work[k] = work.get(k) or []
for k in ['excerpts', 'links', 'identifiers']:
work[k] = work.get(k, {})

# ignore empty authors
work.authors = [
Expand Down
74 changes: 71 additions & 3 deletions openlibrary/plugins/upstream/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
borrow,
)
from openlibrary.plugins.upstream.table_of_contents import TableOfContents
from openlibrary.plugins.upstream.utils import MultiDict, get_edition_config
from openlibrary.plugins.upstream.utils import MultiDict, get_identifier_config
from openlibrary.plugins.worksearch.code import works_by_author
from openlibrary.plugins.worksearch.search import get_solr
from openlibrary.utils import dateutil # noqa: F401 side effects may be needed
Expand Down Expand Up @@ -124,7 +124,7 @@ def get_identifiers(self):
"""Returns (name, value) pairs of all available identifiers."""
names = ['ocaid', 'isbn_10', 'isbn_13', 'lccn', 'oclc_numbers']
return self._process_identifiers(
get_edition_config().identifiers, names, self.identifiers
get_identifier_config('edition').identifiers, names, self.identifiers
)

def get_ia_meta_fields(self):
Expand Down Expand Up @@ -358,10 +358,15 @@ def set_identifiers(self, identifiers):
else:
self.identifiers[name] = value

if not d.items():
self.identifiers = None

def get_classifications(self):
names = ["dewey_decimal_class", "lc_classifications"]
return self._process_identifiers(
get_edition_config().classifications, names, self.classifications
get_identifier_config('edition').classifications,
names,
self.classifications,
)

def set_classifications(self, classifications):
Expand All @@ -386,6 +391,9 @@ def set_classifications(self, classifications):
else:
self.classifications[name] = value

if not self.classifications.items():
self.classifications = None

def get_weight(self):
"""returns weight as a storage object with value and units fields."""
w = self.weight
Expand Down Expand Up @@ -768,6 +776,66 @@ def as_fake_solr_record(self):
record['subtitle'] = self.subtitle
return record

def get_identifiers(self):
"""Returns (name, value) pairs of all available identifiers."""
names = []
return self._process_identifiers(
get_identifier_config('work').identifiers, names, self.identifiers
)

def set_identifiers(self, identifiers):
"""Updates the work from identifiers specified as (name, value) pairs."""

d = {}
if identifiers:
for id in identifiers:
if 'name' not in id or 'value' not in id:
continue
name, value = id['name'], id['value']
if value is not None:
d.setdefault(name, []).append(value)

self.identifiers = {}

for name, value in d.items():
self.identifiers[name] = value

if not d.items():
self.identifiers = None

def _process_identifiers(self, config_, names, values):
id_map = {}
for id in config_:
id_map[id.name] = id
id.setdefault("label", id.name)
id.setdefault("url_format", None)

d = MultiDict()

def process(name, value):
if value:
if not isinstance(value, list):
value = [value]

id = id_map.get(name) or web.storage(
name=name, label=name, url_format=None
)
for v in value:
d[id.name] = web.storage(
name=id.name,
label=id.label,
value=v,
url=id.get('url') and id.url.replace('@@@', v.replace(' ', '')),
)

for name in names:
process(name, self[name])

for name in values:
process(name, values[name])

return d


class Subject(client.Thing):
pass
Expand Down
Loading

0 comments on commit 37d059b

Please sign in to comment.