pdf-tei-editor / app /src /modules /doi-utils.js
cmboulanger's picture
disaster-recovery deploy of pdf-tei-editor
6a49f21 verified
Raw
History Blame Contribute Delete
6.56 kB
/**
* DOI utility functions mirroring fastapi_app/lib/utils/doi_utils.py.
*
* Provides:
* - DOI validation and normalization
* - Filesystem-safe encoding/decoding for doc_ids
*
* Keep in sync with doi_utils.py.
*/
// DOI validation regex (from CrossRef specification)
// Matches: 10.{4-9 digits}/{suffix with allowed characters}
export const DOI_REGEX = /^10\.\d{4,9}\/[-._;()\/:A-Z0-9]+$/i;
const DOI_EXTRACT_PATTERN = /10\.\d{4,9}\/[-._;()\/:A-Z0-9]+/i;
const _LEGACY_ENCODING_RE = /\$([0-9A-Fa-f]{2})\$/g;
/**
* Convert legacy $XX$ percent-style encoding to the current _xXX_ format.
* Mirrors: normalize_legacy_encoding()
* @param {string} s
* @returns {string}
*/
export function normalizeLegacyEncoding(s) {
return s.replace(_LEGACY_ENCODING_RE, (_, hex) => `_x${hex.toUpperCase()}_`)
}
/**
* Check if a filename is already encoded.
* Mirrors: is_filename_encoded()
* @param {string} filename
* @returns {boolean}
*/
export function isFilenameEncoded(filename) {
if (!filename) return false
if (/_x[0-9A-F]{2}_/i.test(filename)) return true
if (/\$[0-9A-F]{2}\$/i.test(filename)) return true
if (filename.includes('__') && /^10\.\d+__/.test(filename)) return true
return false
}
/**
* Encode a document ID (e.g. DOI) to a filesystem-safe filename.
*
* Encoding rules:
* - Forward slashes (/) → double underscore (__)
* - Other filesystem-incompatible characters → _xXX_ encoding
* where XX is the uppercase hexadecimal representation of the character code
*
* Mirrors: encode_filename()
*
* @param {string} docId - Document identifier to encode (e.g. DOI)
* @returns {string} Filesystem-safe encoded string
* @throws {Error} If docId is empty
*
* @example
* encodeFilename("10.1111/1467-6478.00040") // "10.1111__1467-6478.00040"
* encodeFilename("10.1234/test:file") // "10.1234__test_x3A_file"
* encodeFilename("doc<name>") // "doc_x3C_name_x3E_"
*/
export function encodeFilename(docId) {
if (!docId) throw new Error('docId cannot be empty')
const unsafeChars = new Set(['<', '>', ':', '"', '|', '?', '*', '\\'])
let result = docId.replace(/\//g, '__')
return [...result].map(char => {
if (unsafeChars.has(char) || char.charCodeAt(0) < 32) {
return `_x${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}_`
}
return char
}).join('')
}
/**
* Decode a filesystem-safe filename back to the original document ID.
*
* Reverses encodeFilename():
* - __ → / (forward slash)
* - _xXX_ → original character (current encoding)
* - $XX$ → original character (legacy BC encoding)
*
* Mirrors: decode_filename()
*
* @param {string} filename - Encoded filename to decode
* @returns {string} Original document ID string
* @throws {Error} If filename is empty
*
* @example
* decodeFilename("10.1111__1467-6478.00040") // "10.1111/1467-6478.00040"
* decodeFilename("10.1234__test_x3A_file") // "10.1234/test:file"
* decodeFilename("10.1234__test$3A$file") // "10.1234/test:file" (legacy BC)
*/
export function decodeFilename(filename) {
if (!filename) throw new Error('filename cannot be empty')
let result = filename.replace(/__/g, '/')
result = result.replace(/_x([0-9A-F]{2})_/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
result = result.replace(/\$([0-9A-F]{2})\$/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
return result
}
/**
* Convert any file identifier to a valid XML NCName for use as xml:id.
*
* Mirrors: encode_for_xml_id()
*
* @param {string} fileId - Any file identifier string
* @returns {string} NCName-safe string suitable for use as xml:id
*
* @example
* encodeForXmlId("10.5771__2699-1284-2024-3-149") // "_10.5771__2699-1284-2024-3-149"
* encodeForXmlId("test$3A$value") // "test_x3A_value"
*/
export function encodeForXmlId(fileId) {
let result = normalizeLegacyEncoding(fileId)
result = result.replace(/%([0-9A-Fa-f]{2})/g, (_, hex) => `_x${hex.toUpperCase()}_`)
result = result.replace(/\//g, '__')
result = result.replace(/[^a-zA-Z0-9._\-]/g, char =>
`_x${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}_`
)
if (result && /^\d/.test(result)) result = '_' + result
return result
}
/**
* Decode an xml:id value back to encode_filename() format.
*
* Mirrors: decode_from_xml_id()
*
* @param {string} xmlId - xml:id value produced by encodeForXmlId
* @returns {string} File ID in encode_filename() format
*/
export function decodeFromXmlId(xmlId) {
if (xmlId && xmlId.length > 1 && xmlId[0] === '_' && /\d/.test(xmlId[1])) {
return xmlId.slice(1)
}
return xmlId
}
/**
* Check if a DOI string is valid according to CrossRef specifications.
*
* Mirrors: validate_doi()
*
* @param {string} doi - The DOI string to validate
* @returns {boolean}
*
* @example
* validateDoi("10.5771/2699-1284-2024-3-149") // true
* validateDoi("not-a-doi") // false
*/
export function validateDoi(doi) {
if (!doi) return false
return DOI_REGEX.test(doi)
}
/**
* Normalize a DOI string: strip whitespace and common URL prefixes.
*
* Mirrors: normalize_doi()
*
* @param {string} doi - DOI string to normalize
* @returns {string} Normalized DOI string
*
* @example
* normalizeDoi(" 10.5771/2699-1284-2024-3-149 ") // "10.5771/2699-1284-2024-3-149"
* normalizeDoi("doi:10.5771/2699-1284-2024-3-149") // "10.5771/2699-1284-2024-3-149"
* normalizeDoi("https://doi.org/10.5771/2699-1284-2024-3-149") // "10.5771/2699-1284-2024-3-149"
*/
export function normalizeDoi(doi) {
if (!doi) return doi
doi = doi.trim()
const prefixes = [
'doi:', 'DOI:',
'http://doi.org/', 'https://doi.org/',
'http://dx.doi.org/', 'https://dx.doi.org/',
]
for (const prefix of prefixes) {
if (doi.startsWith(prefix)) {
return doi.slice(prefix.length)
}
}
return doi
}
/**
* Extract a DOI from a string. Decodes filename encoding before matching,
* so encoded doc_ids like "10.6093__2284-0184__11598" are handled correctly.
*
* @param {string} str - The string to extract from
* @returns {string|null} Extracted DOI or null
*/
export function extractDoi(str) {
if (!str) return null
try {
const decoded = decodeFilename(str)
const match = decoded.match(DOI_EXTRACT_PATTERN)
if (match) return match[0]
} catch (_) {
// fall through to raw match
}
return str.match(DOI_EXTRACT_PATTERN)?.[0] ?? null
}
// BC alias — isDoi was the original name in utils.js
export { validateDoi as isDoi }