Skip to content

Commit ae07b2e

Browse files
committed
feat: very start of Syllabus XML map to MODS, ref #115
1 parent 5d5c4de commit ae07b2e

6 files changed

Lines changed: 210 additions & 3 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<mods xmlns="http://www.loc.gov/mods/v3" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
2+
version="3.8"
3+
xsi:schemaLocation="http://www.loc.gov/mods/v3 http://www.loc.gov/standards/mods/v3/mods-3-8.xsd">
4+
5+
<titleInfo>
6+
<title>Media History</title>
7+
<partNumber>FASHN-360</partNumber>
8+
</titleInfo>
9+
10+
<genre authority="aat">syllabi</genre>
11+
12+
<name type="personal">
13+
<namePart>Eric Phetteplace</namePart>
14+
<role>
15+
<roleTerm type="text" authority="marcrelator">instructor</roleTerm>
16+
</role>
17+
</name>
18+
19+
<name type="corporate">
20+
<namePart>Design Division</namePart>
21+
<namePart>Fasion Design (BFA)</namePart>
22+
<role>
23+
<roleTerm type="text" authority="marcrelator">sponsor</roleTerm>
24+
</role>
25+
</name>
26+
27+
<originInfo>
28+
<dateIssued encoding="edtf">2026-09</dateIssued><!-- map term to first month -->
29+
</originInfo>
30+
31+
<part>
32+
<text type="attachment-uuid">87e7c816-35cd-4ae5-b443-b55eaa7f9527</text>
33+
</part>
34+
35+
<subject>
36+
<temporal>Fall 2026</temporal>
37+
<topic>Fashion</topic>
38+
</subject>
39+
40+
<identifier type="course number">FASHN-360</identifier>
41+
<identifier type="section">FASHN-3600-1</identifier>
42+
43+
<note type="course information">Fall 2026 | Design Division | FASHN | FASHN-360 | Media History | FASHN-3600-1
44+
</note>
45+
</mods>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<xml>
2+
<local>
3+
<courseInfo>
4+
<semester>Fall 2026</semester>
5+
<department>FASHN</department>
6+
<course>Media History</course>
7+
<faculty>Eric Phetteplace</faculty>
8+
<section>FASHN-3600-1</section>
9+
<courseName>FASHN-360</courseName>
10+
<facultyID>ephetteplace</facultyID>
11+
<courseinfo>Fall 2026\FASHN\Media History\Melissa Leventon\FASHN-3600-1</courseinfo>
12+
</courseInfo>
13+
<department>Fashion Design (BFA)</department>
14+
<division>Design Division</division>
15+
</local>
16+
<mods>
17+
<part>
18+
<number>87e7c816-35cd-4ae5-b443-b55eaa7f9527</number>
19+
</part>
20+
<titleInfo>
21+
<title>Fall 2026 | FASHN-360 | Media History</title>
22+
</titleInfo>
23+
</mods>
24+
</xml>

collection-export/readme.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ Download all (or a subset) of items from a VAULT collection. Each item becomes i
66

77
1. `pnpm install` or `npm install` dependencies
88
2. create an .apprc file with an OAuth token and the root URL of the openEQUELLA instance
9-
3. optionally edit collection and filtering options into the .apprc
9+
3. (optional) edit collection and filtering options into the .apprc
10+
4. (optional) validate MODS XML, download [mods.xsd](https://www.loc.gov/standards/mods/v3/mods.xsd) and install `xmllint` (e.g. `brew install xmllint`)
11+
12+
`xmlstarlet` is also useful, specifically its `xmlstarlet fo` format subcommand to pretty-print XML files.
1013

1114
## Usage
1215

@@ -97,14 +100,21 @@ node test-collection-samples.js data/mudflats.json 10
97100
98101
The `test-collection-samples.js` script tests random samples of XML metadata from exported EQUELLA JSON files against the strict-mods library to verify conversions work correctly. We can also download item XML to run the strict MODS conversion on our own.
99102
103+
## Syllabus Conversion
104+
105+
The syllabus.js file converts XML metadata from CCA's "courseInfo" schema to standards-compliant MODS. It can be used as a library or passed a single XML file on the command line; it will print the converted MODS to stdout.
106+
107+
TODO: add `--syllabus` flag to collect.js to convert syllabus XML to MODS.
108+
100109
### Converting & Validating MODS Files
101110
102111
The strict-mods module can be run as a command-line tool to convert and validate MODS metadata. It automatically extracts the `<mods>` element and adds the required MODS namespace.
103112
104113
```sh
105-
# Convert an item's metadata to strict MODS (outputs <mods> element with namespace)
114+
# Convert an item's metadata to strict MODS
106115
node strict-mods.js data/item-uuid/metadata/metadata.xml
107-
116+
# Syllabus conversion
117+
node syllabus.js fixtures/syllabus-one-faculty.xml
108118
# Validate against the MODS 3.8 schema (requires xmllint)
109119
# First download the MODS schema: https://www.loc.gov/standards/mods/mods-schemas.html
110120
wget https://www.loc.gov/standards/mods/v3/mods-3-8.xsd -O data/mods.xsd

collection-export/syllabus.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/* Much of this is modeled on strict-mods.js */
2+
import xpath from 'xpath'
3+
import { DOMParser as xmldom } from '@xmldom/xmldom'
4+
import {
5+
safeSelect,
6+
copyAttributes,
7+
moveChildren,
8+
moveAndTransformElement,
9+
createElement,
10+
isElementEmpty,
11+
hasDirectTextContent
12+
} from './xml-helpers.js'
13+
import {convertPartNumbers, removeEmptyElements} from './strict-mods.js'
14+
15+
/**
16+
* Main conversion function to convert Syllabus 'courseInfo' XML to MODS
17+
*
18+
* @param {string} xmlString - XML string to convert
19+
* @returns {string} Converted MODS XML string with namespace, ready for validation
20+
* @throws {Error} If XML is malformed, cannot be parsed, or input is invalid
21+
*/
22+
export function convertSyllabusXMLtoMODS(xmlString) {
23+
// Handle invalid input types
24+
if (typeof xmlString !== 'string') {
25+
throw new Error('XML input must be a string')
26+
}
27+
28+
// Handle empty input
29+
if (!xmlString.trim()) {
30+
throw new Error('XML input cannot be empty')
31+
}
32+
33+
const parser = new xmldom()
34+
let doc
35+
36+
try {
37+
doc = parser.parseFromString(xmlString, 'text/xml')
38+
} catch (error) {
39+
throw new Error(`Failed to parse XML: ${error.message}`, { cause: error })
40+
}
41+
42+
// Check for parse errors in the document
43+
const parseError = doc.getElementsByTagName('parsererror')[0]
44+
if (parseError) {
45+
throw new Error(`XML parsing error: ${parseError.textContent}`)
46+
}
47+
48+
// Ensure we have a root mods element
49+
const modsElements = safeSelect("//mods", doc)
50+
let mods
51+
if (modsElements.length > 0) {
52+
mods = modsElements[0] // default to first mods element if multiple exist
53+
} else {
54+
mods = createElement(doc, 'mods')
55+
}
56+
mods.setAttribute("xmlns", 'http://www.loc.gov/mods/v3')
57+
mods.setAttribute("version", '3.8')
58+
59+
// We do this last in strict mods but it might simplify the tree to do it first here
60+
removeEmptyElements(doc)
61+
62+
return mods.toString()
63+
}
64+
65+
// CLI functionality - run when executed directly
66+
if (import.meta.url === `file://${process.argv[1]}`) {
67+
const fs = await import('fs')
68+
const path = await import('path')
69+
70+
const args = process.argv.slice(2)
71+
72+
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
73+
const helpText = `Usage: node syllabus.js <input-file>
74+
75+
Convert EQUELLA custom Syllabus "courseInfo" XML to schema-compliant MODS.
76+
`
77+
if (args.includes('--help') || args.includes('-h')) {
78+
console.log(helpText)
79+
process.exit(0)
80+
} else {
81+
console.error(helpText)
82+
process.exit(1)
83+
}
84+
}
85+
86+
const inputFile = args.find(arg => !arg.startsWith('--'))
87+
88+
if (!inputFile) {
89+
console.error('Error: No input file specified')
90+
process.exit(1)
91+
}
92+
93+
try {
94+
const xmlString = fs.readFileSync(inputFile, 'utf-8')
95+
const result = convertSyllabusXMLtoMODS(xmlString)
96+
console.log(result.toString())
97+
} catch (error) {
98+
console.error(`Error: ${error.message}`)
99+
process.exit(1)
100+
}
101+
}

collection-export/syllabus.test.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import assert from 'node:assert'
2+
import {describe, it} from 'mocha'
3+
import xpath from 'xpath'
4+
import { DOMParser as xmldom } from '@xmldom/xmldom'
5+
import { convertSyllabusXMLtoMODS } from './syllabus.js'
6+
7+
describe('convertSyllabusXMLtoMODS', () => {
8+
it('should include the MODS namespaces & attributes', async () => {
9+
const result = convertSyllabusXMLtoMODS('<xml></xml>')
10+
const doc = new xmldom().parseFromString(result.toString(), 'text/xml')
11+
const mods = doc.documentElement
12+
assert.strictEqual(mods.getAttribute('xmlns'), 'http://www.loc.gov/mods/v3')
13+
assert.strictEqual(mods.getAttribute('version'), '3.8')
14+
})
15+
16+
it('should drop empty elements', async () => {
17+
// local courseInfo
18+
const inputXML = `<local><courseInfo><faculty></faculty></courseInfo></local>`
19+
const result = convertSyllabusXMLtoMODS(inputXML)
20+
assert.ok(!result.includes("faculty"))
21+
// MODS
22+
const inputXML2 = `<mods><name></name></mods>`
23+
const result2 = convertSyllabusXMLtoMODS(inputXML2)
24+
assert.ok(!result2.includes("name"))
25+
})
26+
})

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"modstest": "mocha collection-export/strict-mods.test.js",
1212
"modstest:integration": "mocha collection-export/strict-mods-integration.test.js",
1313
"retentiontest": "mocha retention/test --config retention/test/.testretentionrc",
14+
"sylxmltest": "mocha collection-export/syllabus.test.js",
1415
"test": "mocha",
1516
"lint": "eslint . --cache"
1617
},

0 commit comments

Comments
 (0)