Skip to content

Commit 7032d83

Browse files
add isbn from back cover
1 parent 19e7e7c commit 7032d83

2 files changed

Lines changed: 149 additions & 1 deletion

File tree

src/components/panels/edit/modals/MarvaScanModal.vue

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@
203203
<!-- Insertable components, surfaced from merged (live + retrieved) data. -->
204204
<div class="ms-card ms-inserts-card">
205205
<h2>Insert from scanned data</h2>
206-
<p class="ms-muted" v-if="!titleProposal && !sorProposal && !editionProposal && !provisionProposal && !primaryAuthorProposal && otherContributorsProposal.length === 0 && summaryProposals.length === 0 && !tocProposal && !ocrProposal">
206+
<p class="ms-muted" v-if="!titleProposal && !sorProposal && !editionProposal && !provisionProposal && !primaryAuthorProposal && otherContributorsProposal.length === 0 && summaryProposals.length === 0 && !tocProposal && !ocrProposal && !isbnProposal">
207207
Capture a title page, copyright page, summary, back cover, table of contents, or OCR to enable insertions.
208208
</p>
209209

@@ -259,6 +259,22 @@
259259
</div>
260260
</div>
261261

262+
<div v-if="isbnProposal" class="ms-insert-block">
263+
<div class="ms-insert-head">
264+
<h3>ISBN <span class="ms-source-badge">{{ isbnSourceLabel }}</span></h3>
265+
<button class="ms-insert-btn" @click="doInsertIsbn()">Insert into Instance</button>
266+
</div>
267+
<div class="ms-insert-fields">
268+
<label>
269+
<span>Number</span>
270+
<input type="text" :value="isbnValue" @input="isbnOverride = $event.target.value" />
271+
</label>
272+
</div>
273+
<div v-if="isbnInsertedAt" class="ms-insert-confirm">
274+
Inserted {{ formatTime(isbnInsertedAt) }}
275+
</div>
276+
</div>
277+
262278
<div v-if="provisionProposal" class="ms-insert-block">
263279
<div class="ms-insert-head">
264280
<h3>Publication info <span class="ms-source-badge">{{ provisionSourceLabel }}</span></h3>
@@ -534,6 +550,9 @@ export default {
534550
summaryInsertedAt: {},
535551
tocOverride: null,
536552
tocInsertedAt: null,
553+
// ISBN: editable override + inserted timestamp, same pattern as the others.
554+
isbnOverride: null,
555+
isbnInsertedAt: null,
537556
// Provision activity per-field overrides; null means "follow the proposal".
538557
provisionDateOverride: null,
539558
provisionPublisherOverride: null,
@@ -615,6 +634,7 @@ export default {
615634
'tocProposal',
616635
'ocrProposal',
617636
'provisionProposal',
637+
'isbnProposal',
618638
]),
619639
...mapWritableState(useMarvaScanStore, ['showModal']),
620640
@@ -685,6 +705,16 @@ export default {
685705
return (this.tocProposal && this.tocProposal.text) || ''
686706
},
687707
708+
isbnValue() {
709+
if (this.isbnOverride !== null) return this.isbnOverride
710+
return (this.isbnProposal && this.isbnProposal.value) || ''
711+
},
712+
713+
isbnSourceLabel() {
714+
if (this.resultsByCategory && this.resultsByCategory.back_cover) return 'live scan'
715+
return 'retrieved'
716+
},
717+
688718
tocSourceLabel() {
689719
if (this.resultsByCategory && this.resultsByCategory.toc) return 'live scan'
690720
return 'retrieved'
@@ -833,6 +863,8 @@ export default {
833863
this.summaryInsertedAt = {}
834864
this.tocOverride = null
835865
this.tocInsertedAt = null
866+
this.isbnOverride = null
867+
this.isbnInsertedAt = null
836868
this.provisionDateOverride = null
837869
this.provisionPublisherOverride = null
838870
this.provisionPlaceCodeOverride = null
@@ -955,6 +987,15 @@ export default {
955987
if (ok) this.tocInsertedAt = Date.now()
956988
},
957989
990+
async doInsertIsbn() {
991+
// Strip whitespace and dashes the same way as the store proposal getter,
992+
// so a user-edited value still gets normalized before insert.
993+
const cleaned = (this.isbnValue || '').replace(/[\s-]/g, '').toUpperCase().replace(/[^0-9X]/g, '')
994+
if (!cleaned) return
995+
const ok = await this.marvaScanStore.insertIsbn({ value: cleaned })
996+
if (ok) this.isbnInsertedAt = Date.now()
997+
},
998+
958999
async doInsertProvision() {
9591000
const ok = await this.marvaScanStore.insertProvisionActivity({
9601001
date: (this.provisionDateValue || '').trim(),

src/stores/marvaScan.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@ const _summaryProposalsCache = { last: null }
5252
const _tocProposalCache = { last: null }
5353
const _ocrProposalCache = { last: null }
5454
const _provisionProposalCache = { last: null }
55+
const _isbnProposalCache = { last: null }
56+
57+
/**
58+
* Normalize an ISBN string: strip whitespace and hyphens, leave digits and
59+
* a trailing X. Returns '' for anything not meaningful.
60+
*/
61+
function normalizeIsbn(raw) {
62+
if (!isMeaningfulText(raw)) return ''
63+
return String(raw).replace(/[\s-]/g, '').toUpperCase().replace(/[^0-9X]/g, '')
64+
}
5565

5666
/**
5767
* Minimal MARC place-code → human label map for the codes we're likely to see
@@ -370,6 +380,25 @@ export const useMarvaScanStore = defineStore('marvaScan', {
370380
return next
371381
},
372382

383+
/**
384+
* ISBN proposal from merged back_cover payload, normalized (digits/X only,
385+
* no whitespace or hyphens). Returns null when there's nothing usable.
386+
*/
387+
isbnProposal() {
388+
const bc = this.mergedDataByCategory.back_cover
389+
const raw = bc && bc.isbn
390+
const normalized = normalizeIsbn(raw)
391+
if (!normalized) {
392+
if (_isbnProposalCache.last) _isbnProposalCache.last = null
393+
return null
394+
}
395+
const cached = _isbnProposalCache.last
396+
if (cached && cached.value === normalized) return cached
397+
const next = { value: normalized, raw: String(raw) }
398+
_isbnProposalCache.last = next
399+
return next
400+
},
401+
373402
/** Table-of-contents proposal from merged toc payload. */
374403
tocProposal() {
375404
const tocCat = this.mergedDataByCategory.toc
@@ -1162,6 +1191,84 @@ export const useMarvaScanStore = defineStore('marvaScan', {
11621191
return true
11631192
},
11641193

1194+
/**
1195+
* Insert an ISBN identifier onto the Instance RT under bf:identifiedBy
1196+
* as a bf:Isbn typed bnode with an rdf:value child.
1197+
*
1198+
* The identifiedBy slot is shared by many identifier templates (LCCN,
1199+
* ISBN, OCLC, etc.). We pick a PT that's either:
1200+
* - empty (no userValue), OR
1201+
* - already typed as bf:Isbn (so we can duplicate it to get an empty
1202+
* ISBN-flavored slot rather than clobbering an LCCN).
1203+
* If no Isbn-typed PT exists yet we fall back to any empty identifiedBy
1204+
* PT, since those are template-only and not yet committed to a subtype.
1205+
*
1206+
* @param {{value: string}} payload — caller is expected to pass the
1207+
* already-normalized digits (see normalizeIsbn).
1208+
*/
1209+
async insertIsbn(payload) {
1210+
const value = payload && typeof payload.value === 'string' ? payload.value.trim() : ''
1211+
if (!value) return false
1212+
const profileStore = useProfileStore()
1213+
const profile = profileStore.activeProfile
1214+
if (!profile || !profile.rt) return false
1215+
1216+
const instanceRt = this._findRt(profile, ':Instance')
1217+
if (!instanceRt) return false
1218+
1219+
const PROP = 'http://id.loc.gov/ontologies/bibframe/identifiedBy'
1220+
const ISBN_TYPE = 'http://id.loc.gov/ontologies/bibframe/Isbn'
1221+
const RDF_VALUE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#value'
1222+
1223+
// Prefer an existing Isbn-typed PT (empty or full). If only non-Isbn
1224+
// identifiedBy PTs exist, fall through to the unfiltered search so we
1225+
// can land in any empty identifiedBy slot.
1226+
const isIsbnPt = (pt) => {
1227+
const arr = pt && pt.userValue && pt.userValue[PROP]
1228+
if (!Array.isArray(arr) || arr.length === 0) return false
1229+
return arr.some((n) => n && n['@type'] === ISBN_TYPE)
1230+
}
1231+
1232+
let targetPt = await this._ensureEmptyPt(
1233+
profileStore,
1234+
profile,
1235+
instanceRt,
1236+
PROP,
1237+
(pt) => {
1238+
const arr = pt && pt.userValue && pt.userValue[PROP]
1239+
const empty = !arr || arr.length === 0
1240+
return empty || isIsbnPt(pt)
1241+
},
1242+
)
1243+
if (!targetPt) {
1244+
targetPt = await this._ensureEmptyPt(profileStore, profile, instanceRt, PROP)
1245+
}
1246+
if (!targetPt) return false
1247+
1248+
const isbnNode = {
1249+
'@guid': translator.new(),
1250+
'@type': ISBN_TYPE,
1251+
[RDF_VALUE]: [
1252+
{
1253+
'@guid': translator.new(),
1254+
[RDF_VALUE]: value,
1255+
},
1256+
],
1257+
}
1258+
1259+
targetPt.userValue = {
1260+
'@guid': (targetPt.userValue && targetPt.userValue['@guid']) || translator.new(),
1261+
'@root': PROP,
1262+
[PROP]: [isbnNode],
1263+
}
1264+
targetPt.hasData = true
1265+
targetPt.userModified = true
1266+
targetPt.dataLoaded = false
1267+
1268+
try { profileStore.dataChanged() } catch { /* best-effort */ }
1269+
return true
1270+
},
1271+
11651272
async insertEditionStatement(payload) {
11661273
const text = payload && typeof payload.text === 'string' ? payload.text.trim() : ''
11671274
if (!text) return false

0 commit comments

Comments
 (0)