Skip to content

Commit a2615b1

Browse files
committed
fix: relatedItem/location has text children
map URLs into location/url and physical places into location/physicalLocation
1 parent 8b98874 commit a2615b1

2 files changed

Lines changed: 208 additions & 1 deletion

File tree

collection-export/strict-mods.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,15 +76,18 @@ const ELEMENT_NAMES = {
7676
LANGUAGE_OF_CATALOGING: 'languageOfCataloging',
7777
LANGUAGE_TERM: 'languageTerm',
7878
LIST: 'list',
79+
LOCATION: 'location',
7980
NAME_PART: 'namePart',
8081
NOTE: 'note',
8182
NUMBER: 'number',
83+
PHYSICAL_LOCATION: 'physicalLocation',
8284
PLACE_TERM: 'placeTerm',
8385
SUBJECT: 'subject',
8486
TEXT: 'text',
8587
TITLE_INFO: 'titleInfo',
8688
TITLE: 'title',
8789
TOPIC: 'topic',
90+
URL: 'url',
8891
}
8992

9093
// Attributes
@@ -743,6 +746,79 @@ export function reorderCopyInformationChildren(doc) {
743746
return doc
744747
}
745748

749+
/**
750+
* Wrap location text content with appropriate child elements
751+
* - URLs go into <url> elements
752+
* - Physical locations go into <physicalLocation> elements
753+
* Uses Node's URL constructor to validate URLs
754+
*
755+
* @param {Document} doc - XML DOM document
756+
* @returns {Document} Modified document
757+
*/
758+
export function wrapLocationTextContent(doc) {
759+
if (!doc) {
760+
return doc
761+
}
762+
763+
// Find all location elements that might have direct text content
764+
const locationElements = safeSelect('//location', doc)
765+
766+
for (let location of locationElements) {
767+
// Only process if element has direct text content (not already wrapped)
768+
let textContent = ''
769+
let hasDirectText = false
770+
771+
for (let node of location.childNodes) {
772+
if (node.nodeType === 3) { // TEXT_NODE
773+
const text = node.nodeValue.trim()
774+
if (text) {
775+
hasDirectText = true
776+
textContent = text
777+
break
778+
}
779+
}
780+
}
781+
782+
if (!hasDirectText) {
783+
continue
784+
}
785+
786+
// Determine if text is a URL or physical location
787+
let isURL = false
788+
try {
789+
// Use Node's URL constructor to validate URL
790+
// This handles various URL formats including http, https, ftp, etc.
791+
new URL(textContent)
792+
isURL = true
793+
} catch {
794+
// Not a valid URL, treat as physical location
795+
isURL = false
796+
}
797+
798+
// Create appropriate child element
799+
const childElementName = isURL ? ELEMENT_NAMES.URL : ELEMENT_NAMES.PHYSICAL_LOCATION
800+
const child = doc.createElement(childElementName)
801+
802+
// Move text content to child element
803+
while (location.firstChild) {
804+
if (location.firstChild.nodeType === 3) { // TEXT_NODE
805+
child.appendChild(location.firstChild)
806+
} else {
807+
// If there's already an element child, don't process this location
808+
// (it means the text is mixed with elements, which shouldn't happen)
809+
break
810+
}
811+
}
812+
813+
// Only add the child if it has content
814+
if (child.textContent.trim()) {
815+
location.appendChild(child)
816+
}
817+
}
818+
819+
return doc
820+
}
821+
746822
/**
747823
* Fix nonstandard mods/name/subNameWrapper elements, several operations:
748824
* - affiliation -> name/affiliation ("CCAC")
@@ -908,6 +984,9 @@ export function toStrictMODS(xmlString) {
908984
// Wrap title elements that are direct children of relatedItem with titleInfo
909985
wrapElement(doc, XPATH_CONTEXTS.RELATEDITEM, ELEMENT_NAMES.TITLE, ELEMENT_NAMES.TITLE_INFO)
910986

987+
// Wrap location text content with url or physicalLocation
988+
wrapLocationTextContent(doc)
989+
911990
// Wrap language text content with languageTerm and move authority attribute
912991
wrapTextWithChild(doc, XPATH_CONTEXTS.LANGUAGE, ELEMENT_NAMES.LANGUAGE_TERM, [ATTRIBUTES.AUTHORITY])
913992

collection-export/strict-mods.test.js

Lines changed: 129 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, renameElement, removeElement, removeEmptyElements, removeAttribute, convertAuthorityElement, moveClassificationToSubject, wrapElement, wrapTextWithChild, moveAndRenameElement, convertNamePartDate, convertSubNameWrapper, wrapCopyInformation, convertSpeakerReleaseDetail, toStrictMODS } from './strict-mods.js'
6+
import { removeBadNameUsageAttrs, unwrapSimpleElement, fixTitleAttributes, unwrapDateCreated, unwrapDateOther, renameElement, removeElement, removeEmptyElements, removeAttribute, convertAuthorityElement, moveClassificationToSubject, wrapElement, wrapTextWithChild, moveAndRenameElement, convertNamePartDate, convertSubNameWrapper, wrapCopyInformation, wrapLocationTextContent, convertSpeakerReleaseDetail, toStrictMODS } from './strict-mods.js'
77

88
// Test fixtures
99
const fixtures = {
@@ -2090,6 +2090,134 @@ describe('Strict MODS Conversion', () => {
20902090
})
20912091
})
20922092

2093+
describe('wrapLocationTextContent', () => {
2094+
const locationTextFixtures = {
2095+
urlInRelatedItem: {
2096+
input: `<xml><mods>
2097+
<relatedItem type="isReferencedBy">
2098+
<titleInfo><title>Related Resource</title></titleInfo>
2099+
<location>https://vault.cca.edu/items/9d019022-72ce-4774-9e2a-0c315c14f1d1/1/</location>
2100+
</relatedItem>
2101+
</mods></xml>`,
2102+
expected: `<xml><mods>
2103+
<relatedItem type="isReferencedBy">
2104+
<titleInfo><title>Related Resource</title></titleInfo>
2105+
<location><url>https://vault.cca.edu/items/9d019022-72ce-4774-9e2a-0c315c14f1d1/1/</url></location>
2106+
</relatedItem>
2107+
</mods></xml>`
2108+
},
2109+
physicalLocationInRelatedItem: {
2110+
input: `<xml><mods>
2111+
<relatedItem type="otherVersion">
2112+
<titleInfo><title>Publication</title></titleInfo>
2113+
<location>CCA/C Archives / Archives Publications / Catalogs:Reference Copies / 1971-1974</location>
2114+
</relatedItem>
2115+
</mods></xml>`,
2116+
expected: `<xml><mods>
2117+
<relatedItem type="otherVersion">
2118+
<titleInfo><title>Publication</title></titleInfo>
2119+
<location><physicalLocation>CCA/C Archives / Archives Publications / Catalogs:Reference Copies / 1971-1974</physicalLocation></location>
2120+
</relatedItem>
2121+
</mods></xml>`
2122+
},
2123+
httpUrlInLocation: {
2124+
input: `<xml><mods>
2125+
<location>http://www.example.com/resource</location>
2126+
</mods></xml>`,
2127+
expected: `<xml><mods>
2128+
<location><url>http://www.example.com/resource</url></location>
2129+
</mods></xml>`
2130+
},
2131+
wikipediaUrl: {
2132+
input: `<xml><mods>
2133+
<location>https://en.wikipedia.org/wiki/Wikipedia:Meetup/Oakland/ArtandFeminism_2015</location>
2134+
</mods></xml>`,
2135+
expected: `<xml><mods>
2136+
<location><url>https://en.wikipedia.org/wiki/Wikipedia:Meetup/Oakland/ArtandFeminism_2015</url></location>
2137+
</mods></xml>`
2138+
},
2139+
alreadyWrapped: {
2140+
input: `<xml><mods>
2141+
<location>
2142+
<physicalLocation>Oakland Campus</physicalLocation>
2143+
</location>
2144+
</mods></xml>`,
2145+
expected: `<xml><mods>
2146+
<location>
2147+
<physicalLocation>Oakland Campus</physicalLocation>
2148+
</location>
2149+
</mods></xml>`
2150+
},
2151+
emptyLocation: {
2152+
input: `<xml><mods>
2153+
<location></location>
2154+
</mods></xml>`,
2155+
expected: `<xml><mods>
2156+
<location></location>
2157+
</mods></xml>`
2158+
},
2159+
whitespaceOnly: {
2160+
input: `<xml><mods>
2161+
<location> </location>
2162+
</mods></xml>`,
2163+
expected: `<xml><mods>
2164+
<location> </location>
2165+
</mods></xml>`
2166+
}
2167+
}
2168+
2169+
it('should wrap URL text content in <url> element', () => {
2170+
const result = normalizeXML(toStrictMODS(locationTextFixtures.urlInRelatedItem.input))
2171+
const expected = normalizeXML(`<mods xmlns="http://www.loc.gov/mods/v3"><relatedItem type="isReferencedBy"><titleInfo><title>Related Resource</title></titleInfo><location><url>https://vault.cca.edu/items/9d019022-72ce-4774-9e2a-0c315c14f1d1/1/</url></location></relatedItem></mods>`)
2172+
2173+
assert.strictEqual(result, expected)
2174+
})
2175+
2176+
it('should wrap physical location text content in <physicalLocation> element', () => {
2177+
const result = normalizeXML(toStrictMODS(locationTextFixtures.physicalLocationInRelatedItem.input))
2178+
const expected = normalizeXML(`<mods xmlns="http://www.loc.gov/mods/v3"><relatedItem type="otherVersion"><titleInfo><title>Publication</title></titleInfo><location><physicalLocation>CCA/C Archives / Archives Publications / Catalogs:Reference Copies / 1971-1974</physicalLocation></location></relatedItem></mods>`)
2179+
2180+
assert.strictEqual(result, expected)
2181+
})
2182+
2183+
it('should wrap HTTP URLs in <url> element', () => {
2184+
const result = normalizeXML(toStrictMODS(locationTextFixtures.httpUrlInLocation.input))
2185+
const expected = normalizeXML(`<mods xmlns="http://www.loc.gov/mods/v3"><location><url>http://www.example.com/resource</url></location></mods>`)
2186+
2187+
assert.strictEqual(result, expected)
2188+
})
2189+
2190+
it('should wrap Wikipedia URLs in <url> element', () => {
2191+
const result = normalizeXML(toStrictMODS(locationTextFixtures.wikipediaUrl.input))
2192+
const expected = normalizeXML(`<mods xmlns="http://www.loc.gov/mods/v3"><location><url>https://en.wikipedia.org/wiki/Wikipedia:Meetup/Oakland/ArtandFeminism_2015</url></location></mods>`)
2193+
2194+
assert.strictEqual(result, expected)
2195+
})
2196+
2197+
it('should not modify already wrapped location', () => {
2198+
const result = normalizeXML(toStrictMODS(locationTextFixtures.alreadyWrapped.input))
2199+
const expected = normalizeXML(`<mods xmlns="http://www.loc.gov/mods/v3"><location><physicalLocation>Oakland Campus</physicalLocation></location></mods>`)
2200+
2201+
assert.strictEqual(result, expected)
2202+
})
2203+
2204+
it('should not modify empty location', () => {
2205+
const result = normalizeXML(toStrictMODS(locationTextFixtures.emptyLocation.input))
2206+
// Empty elements are removed by removeEmptyElements()
2207+
const expected = normalizeXML(`<xml/>`)
2208+
2209+
assert.strictEqual(result, expected)
2210+
})
2211+
2212+
it('should not modify location with only whitespace', () => {
2213+
const result = normalizeXML(toStrictMODS(locationTextFixtures.whitespaceOnly.input))
2214+
// Empty elements are removed by removeEmptyElements()
2215+
const expected = normalizeXML(`<xml/>`)
2216+
2217+
assert.strictEqual(result, expected)
2218+
})
2219+
})
2220+
20932221
describe('toStrictMODS', () => {
20942222
it('should extract mods element and add namespace by default', () => {
20952223
const input = `<xml><mods>

0 commit comments

Comments
 (0)