@@ -52,6 +52,16 @@ const _summaryProposalsCache = { last: null }
5252const _tocProposalCache = { last : null }
5353const _ocrProposalCache = { last : null }
5454const _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 - 9 X ] / 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