Skip to content

Commit 87baa1e

Browse files
committed
feat: archivesWrapper -> relatedItem@type=series
local/archivesWrapper/subseries -> relatedItem@type=series,@displayLabel=Subseries which contains a second series for the archivesWrapper/series text no identifiers yet. Ref #108
1 parent 6a65539 commit 87baa1e

2 files changed

Lines changed: 273 additions & 1 deletion

File tree

collection-export/strict-mods.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,103 @@ export function removeEmptyClassifications(doc) {
911911
return doc
912912
}
913913

914+
/**
915+
* Convert local/archivesWrapper (series/subseries) to nested relatedItem elements
916+
* Maps archives metadata to MODS relatedItem structure:
917+
* - subseries -> relatedItem type="series" displayLabel="subseries" containing
918+
* - series -> nested relatedItem type="series" displayLabel="series"
919+
* If only series exists, creates single relatedItem with displayLabel="series"
920+
*
921+
* @param {Document} doc - XML DOM document
922+
* @returns {Document} Modified document
923+
*/
924+
export function convertArchivesWrapper(doc) {
925+
if (!doc) {
926+
return doc
927+
}
928+
929+
const archivesWrappers = safeSelect('//local/archivesWrapper', doc)
930+
const select = xpath.useNamespaces({})
931+
932+
for (let wrapper of archivesWrappers) {
933+
const local = wrapper.parentNode // archivesWrapper's parent is local
934+
935+
// Find the mods element - local is a sibling of mods, not a child
936+
// Navigate to parent (xml) then find mods child
937+
const modsElements = safeSelect('//mods', doc)
938+
if (modsElements.length === 0) {
939+
continue
940+
}
941+
const mods = modsElements[0]
942+
943+
// Get series and subseries BEFORE removing wrapper
944+
const seriesEl = select('series', wrapper)[0]
945+
const subseriesEl = select('subseries', wrapper)[0]
946+
947+
const seriesText = seriesEl?.textContent?.trim() || ''
948+
const subseriesText = subseriesEl?.textContent?.trim() || ''
949+
950+
// Remove the archivesWrapper from local
951+
local.removeChild(wrapper)
952+
953+
// Skip if both are empty
954+
if (!seriesText && !subseriesText) {
955+
continue
956+
}
957+
958+
if (subseriesText && seriesText) {
959+
// Both exist: create nested structure
960+
// Outer relatedItem is subseries
961+
const outerRelatedItem = doc.createElement('relatedItem')
962+
outerRelatedItem.setAttribute('type', 'series')
963+
outerRelatedItem.setAttribute('displayLabel', 'subseries')
964+
965+
const outerTitleInfo = doc.createElement('titleInfo')
966+
const outerTitle = doc.createElement('title')
967+
outerTitle.textContent = subseriesText
968+
outerTitleInfo.appendChild(outerTitle)
969+
outerRelatedItem.appendChild(outerTitleInfo)
970+
971+
// Inner relatedItem is series
972+
const innerRelatedItem = doc.createElement('relatedItem')
973+
innerRelatedItem.setAttribute('type', 'series')
974+
innerRelatedItem.setAttribute('displayLabel', 'series')
975+
976+
const innerTitleInfo = doc.createElement('titleInfo')
977+
const innerTitle = doc.createElement('title')
978+
innerTitle.textContent = seriesText
979+
innerTitleInfo.appendChild(innerTitle)
980+
innerRelatedItem.appendChild(innerTitleInfo)
981+
982+
outerRelatedItem.appendChild(innerRelatedItem)
983+
mods.appendChild(outerRelatedItem)
984+
985+
} else if (seriesText) {
986+
// Only series: create single relatedItem
987+
const relatedItem = doc.createElement('relatedItem')
988+
relatedItem.setAttribute('type', 'series')
989+
relatedItem.setAttribute('displayLabel', 'series')
990+
991+
const titleInfo = doc.createElement('titleInfo')
992+
const title = doc.createElement('title')
993+
title.textContent = seriesText
994+
titleInfo.appendChild(title)
995+
relatedItem.appendChild(titleInfo)
996+
997+
mods.appendChild(relatedItem)
998+
}
999+
// Note: subseries without series shouldn't happen based on data analysis
1000+
}
1001+
1002+
// Remove empty local elements (if all archivesWrappers were removed)
1003+
const localElements = safeSelect('//local[not(*)]', doc)
1004+
for (let local of localElements) {
1005+
local.parentNode.removeChild(local)
1006+
}
1007+
1008+
return doc
1009+
}
1010+
9141011
/**
9151012
* Fix nonstandard mods/name/subNameWrapper elements, several operations:
9161013
* - affiliation -> name/affiliation ("CCAC")
@@ -1065,6 +1162,9 @@ export function toStrictMODS(xmlString) {
10651162
// Fix nonstandard subNameWrapper elements under mods/name
10661163
convertSubNameWrapper(doc)
10671164

1165+
// Convert local/archivesWrapper (series/subseries) to nested relatedItem structure
1166+
convertArchivesWrapper(doc)
1167+
10681168
// Remove usage="secondary" attribute from name elements
10691169
removeBadNameUsageAttrs(doc)
10701170

collection-export/strict-mods.test.js

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { describe, it } from 'mocha'
33
import xpath from 'xpath'
44
import { DOMParser as xmldom } from '@xmldom/xmldom'
55

6-
import { removeBadNameUsageAttrs, unwrapSimpleElement, fixTitleAttributes, unwrapDateCreated, unwrapDateOther, fixDateCreatedKeyDate, fixDateCreatedQualifer, renameElement, removeElement, removeEmptyElements, removeAttribute, convertAuthorityElement, moveClassificationToSubject, wrapElement, wrapTextWithChild, moveAndRenameElement, convertNamePartDate, convertSubNameWrapper, wrapCopyInformation, wrapLocationTextContent, removeEmptyClassifications, convertSpeakerReleaseDetail, toStrictMODS } from './strict-mods.js'
6+
import { removeBadNameUsageAttrs, unwrapSimpleElement, fixTitleAttributes, unwrapDateCreated, unwrapDateOther, fixDateCreatedKeyDate, fixDateCreatedQualifer, renameElement, removeElement, removeEmptyElements, removeAttribute, convertAuthorityElement, moveClassificationToSubject, wrapElement, wrapTextWithChild, moveAndRenameElement, convertNamePartDate, convertSubNameWrapper, wrapCopyInformation, wrapLocationTextContent, removeEmptyClassifications, convertSpeakerReleaseDetail, convertArchivesWrapper, toStrictMODS } from './strict-mods.js'
77
import { hasDirectTextContent } from './strict-mods-helpers.js'
88

99
// Test fixtures
@@ -779,6 +779,100 @@ const fixtures = {
779779
expected: `<xml><mods>
780780
<titleInfo><title>Test</title></titleInfo>
781781
</mods></xml>`
782+
},
783+
784+
archivesWrapperBoth: {
785+
input: `<xml><mods>
786+
<titleInfo><title>Provost's Office Emails</title></titleInfo>
787+
<local>
788+
<archivesWrapper>
789+
<series>I. Administrative Materials</series>
790+
<subseries>7. General Admin Files</subseries>
791+
<seriesStaging>I. Administrative Materials\\7. General Admin Files</seriesStaging>
792+
</archivesWrapper>
793+
</local>
794+
</mods></xml>`,
795+
expected: `<xml><mods>
796+
<titleInfo><title>Provost's Office Emails</title></titleInfo>
797+
<relatedItem type="series" displayLabel="subseries">
798+
<titleInfo>
799+
<title>7. General Admin Files</title>
800+
</titleInfo>
801+
<relatedItem type="series" displayLabel="series">
802+
<titleInfo>
803+
<title>I. Administrative Materials</title>
804+
</titleInfo>
805+
</relatedItem>
806+
</relatedItem>
807+
</mods></xml>`
808+
},
809+
810+
archivesWrapperSeriesOnly: {
811+
input: `<xml><mods>
812+
<titleInfo><title>College Newsletter</title></titleInfo>
813+
<local>
814+
<archivesWrapper>
815+
<series>II. Publications</series>
816+
<seriesStaging>II. Publications</seriesStaging>
817+
</archivesWrapper>
818+
</local>
819+
</mods></xml>`,
820+
expected: `<xml><mods>
821+
<titleInfo><title>College Newsletter</title></titleInfo>
822+
<relatedItem type="series" displayLabel="series">
823+
<titleInfo>
824+
<title>II. Publications</title>
825+
</titleInfo>
826+
</relatedItem>
827+
</mods></xml>`
828+
},
829+
830+
archivesWrapperEmpty: {
831+
input: `<xml><mods>
832+
<titleInfo><title>Test Item</title></titleInfo>
833+
<local>
834+
<archivesWrapper>
835+
<series></series>
836+
<seriesStaging></seriesStaging>
837+
</archivesWrapper>
838+
</local>
839+
</mods></xml>`,
840+
expected: `<xml><mods>
841+
<titleInfo><title>Test Item</title></titleInfo>
842+
</mods></xml>`
843+
},
844+
845+
archivesWrapperMultiple: {
846+
input: `<xml><mods>
847+
<titleInfo><title>Multiple Archives</title></titleInfo>
848+
<local>
849+
<archivesWrapper>
850+
<series>I. First Series</series>
851+
<subseries>A. First Subseries</subseries>
852+
</archivesWrapper>
853+
<archivesWrapper>
854+
<series>II. Second Series</series>
855+
</archivesWrapper>
856+
</local>
857+
</mods></xml>`,
858+
expected: `<xml><mods>
859+
<titleInfo><title>Multiple Archives</title></titleInfo>
860+
<relatedItem type="series" displayLabel="subseries">
861+
<titleInfo>
862+
<title>A. First Subseries</title>
863+
</titleInfo>
864+
<relatedItem type="series" displayLabel="series">
865+
<titleInfo>
866+
<title>I. First Series</title>
867+
</titleInfo>
868+
</relatedItem>
869+
</relatedItem>
870+
<relatedItem type="series" displayLabel="series">
871+
<titleInfo>
872+
<title>II. Second Series</title>
873+
</titleInfo>
874+
</relatedItem>
875+
</mods></xml>`
782876
}
783877
}
784878

@@ -3934,6 +4028,84 @@ describe('Strict MODS Conversion', () => {
39344028
})
39354029
})
39364030

4031+
describe('convertArchivesWrapper', () => {
4032+
it('should convert both series and subseries to nested relatedItem', () => {
4033+
const parser = new xmldom()
4034+
const doc = parser.parseFromString(fixtures.archivesWrapperBoth.input, 'text/xml')
4035+
4036+
convertArchivesWrapper(doc)
4037+
4038+
const result = normalizeXML(doc.toString())
4039+
const expected = normalizeXML(fixtures.archivesWrapperBoth.expected)
4040+
4041+
assert.strictEqual(result, expected)
4042+
})
4043+
4044+
it('should convert series only to single relatedItem', () => {
4045+
const parser = new xmldom()
4046+
const doc = parser.parseFromString(fixtures.archivesWrapperSeriesOnly.input, 'text/xml')
4047+
4048+
convertArchivesWrapper(doc)
4049+
4050+
const result = normalizeXML(doc.toString())
4051+
const expected = normalizeXML(fixtures.archivesWrapperSeriesOnly.expected)
4052+
4053+
assert.strictEqual(result, expected)
4054+
})
4055+
4056+
it('should remove empty archivesWrapper', () => {
4057+
const parser = new xmldom()
4058+
const doc = parser.parseFromString(fixtures.archivesWrapperEmpty.input, 'text/xml')
4059+
4060+
convertArchivesWrapper(doc)
4061+
4062+
const result = normalizeXML(doc.toString())
4063+
const expected = normalizeXML(fixtures.archivesWrapperEmpty.expected)
4064+
4065+
assert.strictEqual(result, expected)
4066+
})
4067+
4068+
it('should handle multiple archivesWrapper elements', () => {
4069+
const parser = new xmldom()
4070+
const doc = parser.parseFromString(fixtures.archivesWrapperMultiple.input, 'text/xml')
4071+
4072+
convertArchivesWrapper(doc)
4073+
4074+
const result = normalizeXML(doc.toString())
4075+
const expected = normalizeXML(fixtures.archivesWrapperMultiple.expected)
4076+
4077+
assert.strictEqual(result, expected)
4078+
})
4079+
4080+
it('should handle null document gracefully', () => {
4081+
const result = convertArchivesWrapper(null)
4082+
4083+
assert.strictEqual(result, null)
4084+
})
4085+
4086+
it('should verify nested structure is created correctly', () => {
4087+
const parser = new xmldom()
4088+
const doc = parser.parseFromString(fixtures.archivesWrapperBoth.input, 'text/xml')
4089+
4090+
convertArchivesWrapper(doc)
4091+
4092+
const select = xpath.useNamespaces({})
4093+
const outerRelated = select('//relatedItem[@displayLabel="subseries"]', doc)
4094+
const innerRelated = select('//relatedItem[@displayLabel="series"]', doc)
4095+
4096+
assert.strictEqual(outerRelated.length, 1, 'Should have one subseries relatedItem')
4097+
assert.strictEqual(innerRelated.length, 1, 'Should have one series relatedItem')
4098+
assert.strictEqual(outerRelated[0].getAttribute('type'), 'series')
4099+
assert.strictEqual(innerRelated[0].getAttribute('type'), 'series')
4100+
4101+
// Verify titles
4102+
const subseriesTitle = select('titleInfo/title', outerRelated[0])[0]
4103+
const seriesTitle = select('titleInfo/title', innerRelated[0])[0]
4104+
assert.strictEqual(subseriesTitle.textContent, '7. General Admin Files')
4105+
assert.strictEqual(seriesTitle.textContent, 'I. Administrative Materials')
4106+
})
4107+
})
4108+
39374109
describe('Helper functions', () => {
39384110
describe('hasDirectTextContent', () => {
39394111
it('should return true for element with direct text content', () => {

0 commit comments

Comments
 (0)