├── public ├── robots.txt ├── api.php ├── favicon-32x32.png ├── .htaccess └── dini-logo.svg ├── api ├── test │ ├── xml │ │ ├── ListRecords │ │ │ ├── invalid.xml │ │ │ ├── no-records.xml │ │ │ ├── no-completelistsize.xml │ │ │ ├── unstable-resumptiontoken-2.xml │ │ │ ├── no-responsedate.xml │ │ │ ├── bad-record.xml │ │ │ ├── no-expirationdate.xml │ │ │ ├── invalid-responsedate.xml │ │ │ ├── invalid-expirationdate.xml │ │ │ ├── expiring-too-soon.xml │ │ │ ├── missing-record.xml │ │ │ ├── invalid-identifier.xml │ │ │ ├── no-metadata.xml │ │ │ ├── no-oaidc.xml │ │ │ ├── invalid-datestamps.xml │ │ │ ├── no-dc-rights.xml │ │ │ ├── small-repo.xml │ │ │ ├── invalid-dc-identifier.xml │ │ │ ├── no-dc-type.xml │ │ │ ├── about-without-provenance.xml │ │ │ ├── no-dc-date.xml │ │ │ ├── no-dc-type-coar.xml │ │ │ ├── no-dc-creator.xml │ │ │ ├── invalid-dc-date.xml │ │ │ ├── duplicate-dc-date.xml │ │ │ ├── no-dc-creator-orcid.xml │ │ │ ├── no-dc-identifier.xml │ │ │ ├── no-dc-title.xml │ │ │ ├── no-dc-language.xml │ │ │ ├── no-datacite.xml │ │ │ ├── datacite-bad-doi.xml │ │ │ ├── datacite-bad-metadata.xml │ │ │ ├── invalid-dc-language.xml │ │ │ ├── no-dc-subject-ddc.xml │ │ │ ├── datacite-no-match.xml │ │ │ ├── good-2.xml │ │ │ └── batchsize-too-small.xml │ │ ├── ListIdentifiers │ │ │ ├── empty.xml │ │ │ └── good.xml │ │ ├── Identify │ │ │ ├── no-admin-email.xml │ │ │ ├── no-description.xml │ │ │ ├── invalid-admin-email.xml │ │ │ ├── no-english-description.xml │ │ │ ├── bad-protocol-version.xml │ │ │ ├── invalid-description.xml │ │ │ ├── no-deletedrecord.xml │ │ │ ├── good.xml │ │ │ └── invalid-deletedrecord.xml │ │ ├── ListSets │ │ │ ├── no-sets.xml │ │ │ ├── bad.xml │ │ │ └── good.xml │ │ ├── ListMetadataFormats │ │ │ ├── empty.xml │ │ │ ├── no-datacite.xml │ │ │ └── good.xml │ │ ├── GetRecord │ │ │ ├── unknown-record-id.xml │ │ │ ├── invalid.xml │ │ │ ├── record-2.xml │ │ │ └── record-1.xml │ │ └── GetRecord-datacite │ │ │ └── bad-metadata.xml │ ├── bootstrap.php │ ├── helpers.php │ └── mock-oai-api │ │ └── index.php ├── data │ ├── status-types.json │ ├── mail-template.txt │ ├── doc-types.json │ ├── coar-resource-types-3.1.json │ └── schemas │ │ └── oai-identifier.xsd ├── rules │ ├── M_9_3_2Test.php │ ├── M_9_1.php │ ├── M_10_1Test.php │ ├── M_10_3Test.php │ ├── E_10_1Test.php │ ├── M_10_2Test.php │ ├── E_9_3Test.php │ ├── M_9_1Test.php │ ├── E_9_3.php │ ├── E_10_2Test.php │ ├── M_9_3_1Test.php │ ├── E_10_4Test.php │ ├── M_10_2.php │ ├── M_10_3.php │ ├── M_10_1.php │ ├── M_10_4Test.php │ ├── E_11_11Test.php │ ├── E_10_4.php │ ├── E_10_2.php │ ├── M_10_4.php │ ├── E_10_1.php │ ├── M_9_4.php │ ├── M_9_3_3Test.php │ ├── M_11_1_3Test.php │ ├── M_11_1_4Test.php │ ├── E_11_10Test.php │ ├── M_11_1_2Test.php │ ├── M_11_1_1Test.php │ ├── M_11_5Test.php │ ├── M_11_1_5Test.php │ ├── M_11_6Test.php │ ├── E_11_8Test.php │ ├── M_11_3Test.php │ ├── M_9_3_1.php │ ├── M_9_3_4Test.php │ ├── M_9_3_2.php │ ├── E_11_11.php │ ├── M_11_8Test.php │ ├── E_11_5Test.php │ ├── M_11_7Test.php │ ├── E_11_9Test.php │ ├── M_11_1_3.php │ ├── M_11_1_4.php │ ├── M_11_1_2.php │ ├── M_11_1_1.php │ ├── M_11_1_5.php │ ├── E_11_10.php │ ├── M_11_3.php │ ├── M_9_4Test.php │ ├── M_11_6.php │ ├── M_9_6Test.php │ ├── E_10_3Test.php │ ├── M_11_5.php │ ├── M_9_6.php │ ├── E_11_5.php │ ├── M_9_3_4.php │ ├── M_11_8.php │ ├── M_10_5Test.php │ ├── E_11_8.php │ ├── M_9_3_3.php │ ├── E_11_9.php │ ├── M_11_7.php │ ├── M_10_5.php │ └── M_11_9Test.php ├── index.php ├── routes │ ├── RouteIssues.php │ └── RouteXml.php ├── bootstrap.php └── classes │ └── Route.php ├── favicon.ico ├── jsconfig.json ├── frontend ├── styles │ ├── mixins │ │ ├── monospace.scss │ │ ├── caps.scss │ │ └── dotted-background.scss │ ├── mixins.scss │ └── settings.scss ├── components │ ├── VLink.vue │ ├── VTranslation.vue │ ├── VWarning.vue │ ├── VDiniScore.vue │ ├── VLoading.vue │ ├── TheFooter.vue │ ├── VError.vue │ ├── VModal.vue │ └── VDecoration.vue ├── modules │ ├── meta.js │ ├── error.js │ ├── fx.js │ ├── cache.js │ ├── format.js │ ├── ui.js │ └── http.js ├── views │ ├── ValidationView.vue │ ├── LegalView.vue │ ├── NotFoundView.vue │ ├── ContactView.vue │ └── ResultView.vue ├── main.js ├── config.js ├── App.vue └── router.js ├── phpstan.neon ├── docker-compose.yml ├── .editorconfig ├── composer.json ├── .gitignore ├── index.html ├── Dockerfile ├── .stylelintrc.js ├── README.md ├── phpunit.xml ├── .eslintrc.cjs ├── doc └── api.md ├── vite.config.js ├── package.json └── pint.json /public/robots.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/invalid.xml: -------------------------------------------------------------------------------- 1 | This is not XML. 2 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UB-Mannheim/dini-validator/main/favicon.ico -------------------------------------------------------------------------------- /public/api.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 6 3 | paths: 4 | - api 5 | excludePaths: 6 | - api/rules 7 | - cache 8 | ignoreErrors: 9 | - '#Cannot access property #' 10 | -------------------------------------------------------------------------------- /api/data/status-types.json: -------------------------------------------------------------------------------- 1 | [ 2 | "status-type:draft", 3 | "status-type:submittedVersion", 4 | "status-type:acceptedVersion", 5 | "status-type:publishedVersion", 6 | "status-type:updatedVersion" 7 | ] 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | php-apache: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | volumes: 8 | - .:/var/www/html:z 9 | ports: 10 | - 8001:80 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{md,php}] 12 | indent_size = 4 13 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require-dev": { 3 | "phpunit/phpunit": "^10", 4 | "laravel/pint": "^1.10", 5 | "phpstan/phpstan": "^1.10" 6 | }, 7 | "require": { 8 | "patrickschur/language-detection": "^5.2" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /frontend/components/VLink.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /frontend/styles/mixins/dotted-background.scss: -------------------------------------------------------------------------------- 1 | @mixin dotted-background { 2 | background: $bg-color; 3 | background-image: 4 | radial-gradient($shade-2 1px, transparent 1px), 5 | radial-gradient($shade-2 1px, transparent 1px); 6 | background-position: 0 1px, 2px 3px; 7 | background-size: 4px 4px, 4px 4px; 8 | } 9 | -------------------------------------------------------------------------------- /frontend/modules/meta.js: -------------------------------------------------------------------------------- 1 | export function setTitle (separator = ' · ', sourceElement = 'h1') { 2 | const pageTitle = document.querySelector(sourceElement)?.textContent 3 | const appTitle = document.title.split(separator).at(-1) 4 | document.title = (!pageTitle || pageTitle === appTitle ? '' : pageTitle + separator) + 5 | appTitle 6 | } 7 | -------------------------------------------------------------------------------- /api/test/xml/Identify/no-admin-email.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/no-records.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /frontend/modules/error.js: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | 3 | export const error = ref(undefined) 4 | 5 | export const errorHandler = { 6 | set (message) { 7 | console.error(message) 8 | 9 | error.value = { 10 | message, 11 | timestamp: Date.now(), 12 | } 13 | }, 14 | clear () { 15 | error.value = undefined 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /api/test/xml/Identify/no-description.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | admin@example.org 6 | 7 | 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .phpdoc 3 | *.cache 4 | *.local 5 | /*.sh 6 | /cache 7 | /dist 8 | /vendor 9 | node_modules 10 | 11 | # Log files 12 | *.log 13 | logs 14 | npm-debug.log* 15 | pnpm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | 19 | # Editor directories and files 20 | .idea 21 | .vscode/* 22 | !.vscode/extensions.json 23 | *.njsproj 24 | *.ntvs* 25 | *.sln 26 | *.suo 27 | *.sw? 28 | -------------------------------------------------------------------------------- /frontend/components/VTranslation.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 16 | -------------------------------------------------------------------------------- /frontend/views/ValidationView.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 14 | -------------------------------------------------------------------------------- /api/test/xml/Identify/invalid-admin-email.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | This is not a valid email address 6 | 7 | 8 | -------------------------------------------------------------------------------- /api/rules/M_9_3_2Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'Schema validation errors in $1:
$2', 14 | $result->issues[0]->text, 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /api/index.php: -------------------------------------------------------------------------------- 1 | setHttpHeader(); 12 | $route->serve(); 13 | } else { 14 | http_response_code(404); 15 | echo json_encode(['error' => 'Unknown route']); 16 | } 17 | -------------------------------------------------------------------------------- /api/test/xml/ListSets/no-sets.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/oai 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /api/data/mail-template.txt: -------------------------------------------------------------------------------- 1 | Greetings! 2 | 3 | The validation of the OAI-PMH API at $oaiApiUrl is complete. You can find the results here: 4 | 5 | $resultUrl 6 | 7 | All the best, 8 | Your DINI team 9 | 10 | https://dini.de/ 11 | 12 | --- 13 | 14 | Moin! 15 | 16 | Die Validierung der OAI-PMH-Schnittstelle unter $oaiApiUrl ist abgeschlossen. Die Ergebnisse finden Sie hier: 17 | 18 | $resultUrl/de 19 | 20 | Viele Grüße 21 | Ihr DINI-Team 22 | 23 | https://dini.de/ 24 | -------------------------------------------------------------------------------- /api/test/xml/Identify/no-english-description.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | admin@example.org 6 | Das ist keine englische Beschreibung 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | # NOTE: This is only a starting point, extend as required 2 | 3 | 4 | RewriteEngine On 5 | RewriteBase / 6 | 7 | # Rewrite API calls 8 | RewriteCond %{REQUEST_FILENAME} !-f 9 | RewriteRule api/(.*) api/index.php/$1 [L] 10 | 11 | # Rewrite everything else 12 | RewriteRule ^index\.html$ - [L] 13 | RewriteCond %{REQUEST_FILENAME} !-d 14 | RewriteCond %{REQUEST_FILENAME} !-f 15 | RewriteRule . /index.html [L] 16 | 17 | -------------------------------------------------------------------------------- /frontend/modules/fx.js: -------------------------------------------------------------------------------- 1 | import JSConfetti from 'js-confetti' 2 | 3 | export async function confetti () { 4 | // No confetti for users who dislike motion 5 | if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { 6 | return 7 | } 8 | 9 | const jsConfetti = new JSConfetti() 10 | await jsConfetti.addConfetti({ 11 | confettiColors: ['#344999', '#68ba80', '#f0821d', '#d72f89'], // DINI colors 12 | }) 13 | 14 | document.querySelector('canvas').remove() 15 | } 16 | -------------------------------------------------------------------------------- /api/test/xml/ListMetadataFormats/empty.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:20Z 5 | https://example.org/oai 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/no-completelistsize.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | https://example.org/opi 5 | 6 | token-1 7 | 8 | 9 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/unstable-resumptiontoken-2.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /api/rules/M_9_1.php: -------------------------------------------------------------------------------- 1 | Identify?->protocolVersion !== '2.0') { 14 | $this->addFatalIssue( 15 | 'Identify', 16 | 'Identify is invalid', 17 | ); 18 | 19 | return; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/no-responsedate.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | https://example.org/opi 5 | 6 | token-1 7 | 8 | 9 | -------------------------------------------------------------------------------- /frontend/modules/cache.js: -------------------------------------------------------------------------------- 1 | export const cache = { 2 | get (key) { 3 | if (process.env.NODE_ENV === 'development') { 4 | const query = new URLSearchParams(location.search) 5 | if (query.get('nocache') !== null) { 6 | return 7 | } 8 | } 9 | 10 | return JSON.parse(sessionStorage.getItem(key)) 11 | }, 12 | set (key, value) { 13 | try { 14 | sessionStorage.setItem(key, JSON.stringify(value)) 15 | } catch (error) { 16 | console.error(error) 17 | } 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /frontend/views/LegalView.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /api/test/xml/GetRecord/unknown-record-id.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:24Z 5 | https://example.org/oai 6 | The value of the identifier argument is unknown or illegal in this repository. 7 | 8 | -------------------------------------------------------------------------------- /frontend/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | 4 | import router from './router' 5 | 6 | import i18n from './plugins/i18n' 7 | 8 | import '@fontsource/barlow' 9 | // NOTE: Italic font variants have to be imported one-by-one 10 | import '@fontsource/barlow/300-italic.css' 11 | import '@fontsource/noto-sans-mono' 12 | import 'unfonts.css' 13 | 14 | const app = createApp(App) 15 | 16 | app.use(router) 17 | app.use(i18n) 18 | 19 | app.provide('i18n', app.config.globalProperties.$i18n) 20 | 21 | app.mount('#app') 22 | -------------------------------------------------------------------------------- /api/rules/M_10_1Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'open_access is missing in ListSets', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListSets/good'); 18 | $this->assertEquals(0, $result->issuesCount); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /api/rules/M_10_3Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'Sets for document types are missing in ListSets.', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListSets/good'); 18 | $this->assertEquals(0, $result->issuesCount); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /api/test/bootstrap.php: -------------------------------------------------------------------------------- 1 | /dev/null 2>&1 & echo $!', $output); 11 | $pid = (int) $output[0]; 12 | 13 | // Kill the local web server when the process ends 14 | register_shutdown_function(function () use ($pid) { 15 | exec("kill $pid"); 16 | }); 17 | -------------------------------------------------------------------------------- /api/rules/E_10_1Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'Sets for publication status are missing in ListSets.', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListSets/good'); 18 | $this->assertEquals(0, $result->issuesCount); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /api/rules/M_10_2Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'Sets for DDC subject groups are missing in ListSets.', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListSets/good'); 18 | $this->assertEquals(0, $result->issuesCount); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/bad-record.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/no-expirationdate.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | token-1 8 | 9 | 10 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/invalid-responsedate.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | Im Jahre 753 v. Chr. 5 | https://example.org/opi 6 | 7 | token-1 8 | 9 | 10 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | DINI Validator 8 | 9 | 10 |
11 | 12 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/invalid-expirationdate.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | token-1 8 | 9 | 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.2-apache 2 | 3 | # Install required PHP extensions 4 | RUN docker-php-ext-install sysvmsg 5 | RUN docker-php-ext-install sysvsem 6 | RUN docker-php-ext-install sysvshm 7 | RUN docker-php-ext-configure sysvmsg --enable-sysvmsg 8 | RUN docker-php-ext-configure sysvsem --enable-sysvsem 9 | RUN docker-php-ext-configure sysvshm --enable-sysvshm 10 | 11 | # Configure Apache 12 | RUN echo "ServerName localhost" >> /etc/apache2/apache2.conf 13 | RUN sed -i '//,/<\/Directory>/ s/AllowOverride None/AllowOverride All/' \ 14 | /etc/apache2/apache2.conf 15 | RUN a2enmod headers rewrite 16 | -------------------------------------------------------------------------------- /api/rules/E_9_3Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'about is set, but provenance is missing in about-without-provenance', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListRecords/good'); 18 | $this->assertEquals(0, $result->issuesCount); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/expiring-too-soon.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | token-1 8 | 9 | 10 | -------------------------------------------------------------------------------- /api/rules/M_9_1Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | (object) [ 14 | 'verb' => 'Identify', 15 | 'text' => 'Identify is invalid', 16 | ], 17 | $result->issues[0], 18 | ); 19 | 20 | $result = runRule('Identify/good'); 21 | $this->assertEquals(0, $result->issuesCount); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/missing-record.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | missing 10 |
11 |
12 |
13 |
14 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/invalid-identifier.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | invalid 10 |
11 |
12 |
13 |
14 | -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": [ 3 | "stylelint-stylistic/config", 4 | "stylelint-config-standard-scss", 5 | "stylelint-config-standard-vue/scss", 6 | ], 7 | "plugins": [ 8 | "stylelint-order", 9 | ], 10 | "rules": { 11 | "declaration-empty-line-before": "never", 12 | "order/properties-alphabetical-order": true, 13 | "rule-empty-line-before": [ 14 | "always", 15 | { 16 | "ignore": ["after-comment", "first-nested"], 17 | "severity": "warning", 18 | } 19 | ], 20 | "stylistic/number-leading-zero": "never", 21 | "stylistic/string-quotes": "single", 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /api/test/xml/ListSets/bad.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/oai 6 | 7 | 8 | set1 9 | Set 1 documents 10 | 11 | 12 | set2 13 | Set 2 documents 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /api/rules/E_9_3.php: -------------------------------------------------------------------------------- 1 | about && ! $record->about->provenance) { 14 | $this->addIssue( 15 | "GetRecord&identifier={$record->header->identifier}", 16 | '$1 is set, but $2 is missing in $3', 17 | 'about', 18 | 'provenance', 19 | (string) $record->header->identifier, 20 | ); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /api/rules/E_10_2Test.php: -------------------------------------------------------------------------------- 1 | assertEquals(0, $result->issuesCount); 13 | 14 | $result = runRule('ListRecords/batchsize-too-small'); 15 | $this->assertEquals( 16 | 'The batch size of ListRecords is 2.', 17 | getIssueText($result->issues[0]), 18 | ); 19 | 20 | $result = runRule('ListRecords/fake-100'); 21 | $this->assertEquals(0, $result->issuesCount); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /api/rules/M_9_3_1Test.php: -------------------------------------------------------------------------------- 1 | assertEquals('Identify', $result->issues[0]->verb); 13 | $this->assertEquals('Schema validation errors in $1: $2', $result->issues[0]->text); 14 | $this->assertEquals('Identify', $result->issues[0]->values[0]); 15 | $this->assertCount(2, $result->issues[0]->values); 16 | 17 | $result = runRule('Identify/good'); 18 | $this->assertEquals(0, $result->issuesCount); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/no-metadata.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | record-1 10 | 2022-12-18 11 | all 12 |
13 |
14 |
15 |
16 | -------------------------------------------------------------------------------- /api/rules/E_10_4Test.php: -------------------------------------------------------------------------------- 1 | assertEquals(0, $result->issuesCount); 13 | 14 | $result = runRule('ListRecords/no-completelistsize'); 15 | $this->assertEquals( 16 | 'resumptionToken without completeListSize in ListRecords', 17 | getIssueText($result->issues[0]), 18 | ); 19 | 20 | $result = runRule('ListRecords/fake-100'); 21 | $this->assertEquals(0, $result->issuesCount); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /api/test/xml/GetRecord/invalid.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-04T04:36:09Z 5 | https://example.org/oai 6 | 7 | 8 |
9 | invalid 10 | 2022-12-18 11 | all 12 |
13 | 14 |
15 |
16 |
17 | -------------------------------------------------------------------------------- /frontend/modules/format.js: -------------------------------------------------------------------------------- 1 | export function formatDuration (seconds) { 2 | const d = Math.floor(seconds / 86_400) 3 | const h = Math.floor((seconds % 86_400) / 3600) 4 | const min = Math.floor(((seconds % 86_400) % 3600) / 60) 5 | const s = Math.round(((seconds % 86_400) % 3600) % 60) 6 | 7 | return [ 8 | d ? `${d} d` : '', 9 | d || h ? `${h} h` : '', 10 | d || h || min ? `${min} min` : '', 11 | !d && !h && min < 10 ? `${s} s` : '', 12 | ].join(' ') 13 | } 14 | 15 | export function formatTimestamp (timestamp) { 16 | const timezoneOffset = new Date().getTimezoneOffset() * 60_000 17 | return new Date(timestamp * 1000 - timezoneOffset).toISOString().slice(0, 19).replace('T', ' ') 18 | } 19 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/no-oaidc.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | missing-oaidc 10 | 2022-12-18 11 | all 12 |
13 | 14 |
15 |
16 |
17 | -------------------------------------------------------------------------------- /api/rules/M_10_2.php: -------------------------------------------------------------------------------- 1 | ListSets->set as $set) { 14 | if (str_starts_with((string) $set->setSpec, 'ddc:')) { 15 | $this->finish(); 16 | 17 | return; 18 | } 19 | } 20 | 21 | if ($isLastBatch) { 22 | $this->addFatalIssue( 23 | 'ListSets', 24 | 'Sets for DDC subject groups are missing in ListSets.', 25 | ); 26 | 27 | return; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /api/rules/M_10_3.php: -------------------------------------------------------------------------------- 1 | ListSets->set as $set) { 14 | if (str_starts_with((string) $set->setSpec, 'doc-type:')) { 15 | $this->finish(); 16 | 17 | return; 18 | } 19 | } 20 | 21 | if ($isLastBatch) { 22 | $this->addFatalIssue( 23 | 'ListSets', 24 | 'Sets for document types are missing in ListSets.', 25 | ); 26 | 27 | return; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /api/rules/M_10_1.php: -------------------------------------------------------------------------------- 1 | ListSets->set as $set) { 14 | if ((string) $set->setSpec === 'open_access') { 15 | $this->finish(); 16 | 17 | return; 18 | } 19 | } 20 | 21 | if ($isLastBatch) { 22 | $this->addFatalIssue( 23 | 'ListSets', 24 | '$1 is missing in $2', 25 | 'open_access', 26 | 'ListSets', 27 | ); 28 | 29 | return; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/test/xml/GetRecord-datacite/bad-metadata.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-20T23:38:47Z 5 | https://example.org/oai 6 | 7 | 8 |
9 | 10.22000/152 10 | 2022-04-04T09:06:49Z 11 |
12 | 13 | 14 |
15 |
16 |
17 | -------------------------------------------------------------------------------- /api/rules/M_10_4Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'deletedRecord is missing in Identify', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('Identify/invalid-deletedrecord'); 18 | $this->assertEquals( 19 | 'deletedRecord is invalid in Identify', 20 | getIssueText($result->issues[0]), 21 | ); 22 | 23 | $result = runRule('Identify/good'); 24 | $this->assertEquals(0, $result->issuesCount); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /api/rules/E_11_11Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'Invalid response to ListMetadataFormats', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListMetadataFormats/no-datacite'); 18 | $this->assertEquals( 19 | 'oai_datacite is missing in ListMetadataFormats', 20 | getIssueText($result->issues[0]), 21 | ); 22 | 23 | $result = runRule('ListMetadataFormats/good'); 24 | $this->assertEquals(0, $result->issuesCount); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /frontend/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | // URL of the validator API 3 | // Protocol and domain may be omitted if same as frontend. 4 | apiUrl: 5 | process.env.NODE_ENV === 'development' 6 | ? '//localhost:8001/public/api' 7 | : '/api', 8 | 9 | // Default language 10 | // The value must match a key in "languages". 11 | defaultLang: 'en', 12 | 13 | // Available languages 14 | // Keys should have 2 characters and are used in URLs, and there must be 15 | // a translation file for every key other than the default language. 16 | languages: { 17 | en: 'English', 18 | de: 'Deutsch', 19 | }, 20 | 21 | // Available options for how many records are checked, 0 means "all" 22 | // NOTE: This must also be set in the API config (api/config.php). 23 | recordsLimitOptions: [100, 500, 1000, 5000, 0], 24 | } 25 | -------------------------------------------------------------------------------- /frontend/components/VWarning.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 10 | 11 | 46 | -------------------------------------------------------------------------------- /frontend/views/NotFoundView.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 24 | -------------------------------------------------------------------------------- /frontend/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 21 | 22 | 32 | -------------------------------------------------------------------------------- /api/rules/E_10_4.php: -------------------------------------------------------------------------------- 1 | ListRecords->resumptionToken)) { 14 | $this->finish('Not relevant due to small repository size'); 15 | 16 | return; 17 | } 18 | 19 | if (empty($xml->ListRecords->resumptionToken['completeListSize'])) { 20 | $this->addFatalIssue( 21 | 'ListRecords', 22 | '$1 without $2 in $3', 23 | 'resumptionToken', 24 | 'completeListSize', 25 | 'ListRecords', 26 | ); 27 | 28 | return; 29 | } 30 | 31 | $this->finish(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /api/test/xml/Identify/bad-protocol-version.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | http://example.org/oai 6 | 7 | OAI Test 8 | http://example.org/oai 9 | 1.0 10 | admin@example.org 11 | 2013-04-18 12 | persistent 13 | YYYY-MM-DD 14 | gzip 15 | deflate 16 | 17 | 18 | -------------------------------------------------------------------------------- /frontend/components/VDiniScore.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | 14 | 42 | -------------------------------------------------------------------------------- /api/rules/E_10_2.php: -------------------------------------------------------------------------------- 1 | ListRecords->resumptionToken)) { 14 | $this->finish('Not relevant due to small repository size'); 15 | 16 | return; 17 | } 18 | 19 | $recordsBatchSize = count($xml->ListRecords?->record ?? []); 20 | 21 | if ($recordsBatchSize < 100 || $recordsBatchSize > 500) { 22 | $this->addFatalIssue( 23 | 'ListRecords', 24 | 'The batch size of $1 is $2.', 25 | 'ListRecords', 26 | $recordsBatchSize, 27 | ); 28 | 29 | return; 30 | } 31 | 32 | $this->finish(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /api/rules/M_10_4.php: -------------------------------------------------------------------------------- 1 | Identify->deletedRecord)) { 14 | $this->addFatalIssue( 15 | 'Identify', 16 | '$1 is missing in $2', 17 | 'deletedRecord', 18 | 'Identify', 19 | ); 20 | 21 | return; 22 | } 23 | if (! in_array($xml->Identify->deletedRecord, ['no', 'persistent', 'transient'])) { 24 | $this->addFatalIssue( 25 | 'Identify', 26 | '$1 is invalid in $2', 27 | 'deletedRecord', 28 | 'Identify', 29 | ); 30 | 31 | return; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DINI Validator 2 | 3 | The DINI Validator is a web application for checking OAI-PMH APIs as part of the DINI certification process. 4 | 5 | Users provide the URL of an OAI-PMH API and their email address, select the number of records to check and start the validation process. The validator then thoroughly checks this API by downloading and validating multiple XML responses, during which the progress is shown. Once completed, the results are displayed and an email is sent, containing a permalink to these results. 6 | 7 | The validator consists of a PHP-based backend, doubling as a JSON REST API, and a Vue-based frontend. 8 | 9 | ## Documentation 10 | 11 | 1. [Setup](doc/setup.md) – How to set up the application locally and on a server 12 | 1. [API](doc/api.md) – How the API (backend) works 13 | 1. [Rulesets](doc/rulesets.md) – How to modify the ruleset and add new validation rules 14 | 1. [Frontend](doc/frontend.md) – How the frontend works, including translations 15 | -------------------------------------------------------------------------------- /api/rules/E_10_1.php: -------------------------------------------------------------------------------- 1 | statusTypes = json_decode($json); 17 | } 18 | 19 | public function check($xml, $isLastBatch): void 20 | { 21 | foreach ($xml->ListSets->set as $set) { 22 | if (in_array((string) $set->setSpec, $this->statusTypes)) { 23 | $this->finish(); 24 | 25 | return; 26 | } 27 | } 28 | 29 | if ($isLastBatch) { 30 | $this->addFatalIssue( 31 | 'ListSets', 32 | 'Sets for publication status are missing in ListSets.', 33 | ); 34 | 35 | return; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /api/rules/M_9_4.php: -------------------------------------------------------------------------------- 1 | header->datestamp) { 14 | $this->addIssue( 15 | "GetRecord&identifier={$record->header->identifier}", 16 | '$1 is missing in $2', 17 | 'datestamp', 18 | (string) $record->header->identifier, 19 | ); 20 | 21 | return; 22 | } 23 | 24 | if (! date_create((string) $record->header->datestamp)) { 25 | $this->addIssue( 26 | "GetRecord&identifier={$record->header->identifier}", 27 | '$1 is invalid in $2', 28 | 'datestamp', 29 | (string) $record->header->identifier, 30 | ); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /api/test/xml/ListMetadataFormats/no-datacite.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:20Z 5 | https://example.org/oai 6 | 7 | 8 | MarcXchange 9 | info:lc/xmlns/marcxchange-v1 10 | info:lc/xmlns/marcxchange-v1.xsd 11 | 12 | 13 | oai_dc 14 | http://www.openarchives.org/OAI/2.0/ 15 | http://www.openarchives.org/OAI/2.0/oai_dc.xsd 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /api/rules/M_9_3_3Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'Invalid response to ListRecords', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListRecords/missing-record'); 18 | $this->assertEquals( 19 | 'Invalid response to missing', 20 | getIssueText($result->issues[0]), 21 | ); 22 | 23 | $result = runRule('ListRecords/invalid-identifier'); 24 | $this->assertStringStartsWith( 25 | 'Schema validation errors in invalid:', 26 | getIssueText($result->issues[0]), 27 | ); 28 | 29 | $result = runRule('ListRecords/good'); 30 | $this->assertEquals(0, $result->issuesCount); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/test/xml/Identify/invalid-description.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | http://example.org/oai 6 | 7 | OAI Test 8 | http://example.org/oai 9 | 2.0 10 | admin@example.org 11 | 2013-04-18 12 | persistent 13 | YYYY-MM-DD 14 | gzip 15 | deflate 16 | This is a description in English, but invalid because not wrapped in another element 17 | 18 | 19 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/invalid-datestamps.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 |
7 | oai:1 8 | 9 |
10 |
11 | 12 |
13 | oai:2 14 | 2023-12-32 15 |
16 |
17 | 18 |
19 | oai:3 20 | This is not a datestamp 21 |
22 |
23 | 24 |
25 | oai:4 26 | 2023-01-01 27 |
28 |
29 |
30 |
31 | -------------------------------------------------------------------------------- /api/rules/M_11_1_3Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'metadata is missing in record-1', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListRecords/no-oaidc'); 18 | $this->assertEquals( 19 | 'oai_dc is missing in missing-oaidc', 20 | getIssueText($result->issues[0]), 21 | ); 22 | 23 | $result = runRule('ListRecords/no-dc-date'); 24 | $this->assertEquals( 25 | 'dc:date is missing in record-1', 26 | getIssueText($result->issues[0]), 27 | ); 28 | 29 | $result = runRule('ListRecords/good'); 30 | $this->assertEquals(0, $result->issuesCount); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/rules/M_11_1_4Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'metadata is missing in record-1', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListRecords/no-oaidc'); 18 | $this->assertEquals( 19 | 'oai_dc is missing in missing-oaidc', 20 | getIssueText($result->issues[0]), 21 | ); 22 | 23 | $result = runRule('ListRecords/no-dc-type'); 24 | $this->assertEquals( 25 | 'dc:type is missing in record-1', 26 | getIssueText($result->issues[0]), 27 | ); 28 | 29 | $result = runRule('ListRecords/good'); 30 | $this->assertEquals(0, $result->issuesCount); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/rules/E_11_10Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'metadata is missing in record-1', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListRecords/no-oaidc'); 18 | $this->assertEquals( 19 | 'oai_dc is missing in missing-oaidc', 20 | getIssueText($result->issues[0]), 21 | ); 22 | 23 | $result = runRule('ListRecords/no-dc-rights'); 24 | $this->assertEquals( 25 | 'dc:rights is missing in record-1', 26 | getIssueText($result->issues[0]), 27 | ); 28 | 29 | $result = runRule('ListRecords/good'); 30 | $this->assertEquals(0, $result->issuesCount); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/rules/M_11_1_2Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'metadata is missing in record-1', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListRecords/no-oaidc'); 18 | $this->assertEquals( 19 | 'oai_dc is missing in missing-oaidc', 20 | getIssueText($result->issues[0]), 21 | ); 22 | 23 | $result = runRule('ListRecords/no-dc-title'); 24 | $this->assertEquals( 25 | 'dc:title is missing in record-1', 26 | getIssueText($result->issues[0]), 27 | ); 28 | 29 | $result = runRule('ListRecords/good'); 30 | $this->assertEquals(0, $result->issuesCount); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/routes/RouteIssues.php: -------------------------------------------------------------------------------- 1 | sanitizeTaskId($_GET['taskId'] ?? ''); 12 | $ruleId = $this->sanitizeRuleId($_GET['ruleId'] ?? ''); 13 | $n = (int) ($_GET['n'] ?? 1); 14 | 15 | $cacheDir = Config::$cacheDir . "/$taskId"; 16 | 17 | if (! $taskId || ! is_dir($cacheDir)) { 18 | $this->fail('Unknown task ID', 404); 19 | } 20 | 21 | $issuesFile = "$cacheDir/issues/$ruleId/$n.json"; 22 | 23 | if (! file_exists($issuesFile)) { 24 | $this->fail('Unknown issues file', 400); 25 | } 26 | 27 | $issuesFile = "$cacheDir/issues/$ruleId/$n.json"; 28 | $content = file_get_contents($issuesFile); 29 | 30 | if ($content === false) { 31 | $this->fail('Error reading issues', 500); 32 | } 33 | 34 | echo $content; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /api/rules/M_11_1_1Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'metadata is missing in record-1', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListRecords/no-oaidc'); 18 | $this->assertEquals( 19 | 'oai_dc is missing in missing-oaidc', 20 | getIssueText($result->issues[0]), 21 | ); 22 | 23 | $result = runRule('ListRecords/no-dc-creator'); 24 | $this->assertEquals( 25 | 'dc:creator is missing in record-1', 26 | getIssueText($result->issues[0]), 27 | ); 28 | 29 | $result = runRule('ListRecords/good'); 30 | $this->assertEquals(0, $result->issuesCount); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/rules/M_11_5Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'metadata is missing in record-1', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListRecords/no-oaidc'); 18 | $this->assertEquals( 19 | 'oai_dc is missing in missing-oaidc', 20 | getIssueText($result->issues[0]), 21 | ); 22 | 23 | $result = runRule('ListRecords/no-dc-type'); 24 | $this->assertEquals( 25 | 'No dc:type with valid doc-type in record-1', 26 | getIssueText($result->issues[0]), 27 | ); 28 | 29 | $result = runRule('ListRecords/good'); 30 | $this->assertEquals(0, $result->issuesCount); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/rules/M_11_1_5Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'metadata is missing in record-1', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListRecords/no-oaidc'); 18 | $this->assertEquals( 19 | 'oai_dc is missing in missing-oaidc', 20 | getIssueText($result->issues[0]), 21 | ); 22 | 23 | $result = runRule('ListRecords/no-dc-identifier'); 24 | $this->assertEquals( 25 | 'dc:identifier is missing in record-1', 26 | getIssueText($result->issues[0]), 27 | ); 28 | 29 | $result = runRule('ListRecords/good'); 30 | $this->assertEquals(0, $result->issuesCount); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/rules/M_11_6Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'metadata is missing in record-1', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListRecords/no-oaidc'); 18 | $this->assertEquals( 19 | 'oai_dc is missing in missing-oaidc', 20 | getIssueText($result->issues[0]), 21 | ); 22 | 23 | $result = runRule('ListRecords/no-dc-subject-ddc'); 24 | $this->assertEquals( 25 | 'No dc:subject with valid DDC in record-1', 26 | getIssueText($result->issues[0]), 27 | ); 28 | 29 | $result = runRule('ListRecords/good'); 30 | $this->assertEquals(0, $result->issuesCount); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/rules/E_11_8Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'metadata is missing in record-1', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListRecords/no-oaidc'); 18 | $this->assertEquals( 19 | 'oai_dc is missing in missing-oaidc', 20 | getIssueText($result->issues[0]), 21 | ); 22 | 23 | $result = runRule('ListRecords/no-dc-type-coar'); 24 | $this->assertEquals( 25 | 'No dc:type with valid COAR Resource Type in record-1', 26 | getIssueText($result->issues[0]), 27 | ); 28 | 29 | $result = runRule('ListRecords/good'); 30 | $this->assertEquals(0, $result->issuesCount); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/rules/M_11_3Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'metadata is missing in record-1', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListRecords/no-oaidc'); 18 | $this->assertEquals( 19 | 'oai_dc is missing in missing-oaidc', 20 | getIssueText($result->issues[0]), 21 | ); 22 | 23 | $result = runRule('ListRecords/invalid-dc-identifier'); 24 | $this->assertEquals( 25 | 'dc:identifier with operable URL missing in invalid-dc-identifier.', 26 | getIssueText($result->issues[0]), 27 | ); 28 | 29 | $result = runRule('ListRecords/good'); 30 | $this->assertEquals(0, $result->issuesCount); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/rules/M_9_3_1.php: -------------------------------------------------------------------------------- 1 | ownerDocument; 15 | 16 | // @codeCoverageIgnoreStart 17 | if (! $dom) { 18 | $this->addFatalIssue('Invalid XML for $1', 'Identify'); 19 | 20 | return; 21 | } 22 | // @codeCoverageIgnoreEnd 23 | 24 | $dom->schemaValidate(Config::$dataDir . '/schemas/OAI-PMH.xsd'); 25 | $xmlErrors = libxml_get_errors(); 26 | libxml_clear_errors(); 27 | $errorHtml = $this->xmlErrorsToHtml($xmlErrors); 28 | 29 | if ($errorHtml) { 30 | $this->addIssue( 31 | 'Identify', 32 | 'Schema validation errors in $1: $2', 33 | 'Identify', 34 | $errorHtml, 35 | ); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | api/rules 24 | 25 | 26 | 27 | 28 | 29 | api/rules 30 | 31 | 32 | api/rules 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /api/rules/M_9_3_4Test.php: -------------------------------------------------------------------------------- 1 | '']); 12 | $this->assertEquals( 13 | (object) [ 14 | 'verb' => 'GetRecord&identifier=', 15 | 'text' => 'Invalid response to $1', 16 | 'values' => ['GetRecord'], 17 | ], 18 | $result->issues[0], 19 | ); 20 | 21 | $result = runRule('ListRecords/good', ['unknownRecordId' => 'invalid']); 22 | $this->assertEquals('GetRecord&identifier=invalid', $result->issues[0]->verb); 23 | $this->assertEquals('Schema validation errors in $1:
$2', $result->issues[0]->text); 24 | $this->assertEquals('GetRecord', $result->issues[0]->values[0]); 25 | $this->assertIsString($result->issues[0]->values[1]); 26 | 27 | $result = runRule('ListRecords/good'); 28 | $this->assertEquals(0, $result->issuesCount); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /api/rules/M_9_3_2.php: -------------------------------------------------------------------------------- 1 | ownerDocument; 15 | 16 | // @codeCoverageIgnoreStart 17 | if (! $dom) { 18 | $this->addFatalIssue('Invalid XML for $1', 'ListIdentifiers'); 19 | 20 | return; 21 | } 22 | // @codeCoverageIgnoreEnd 23 | 24 | $dom->schemaValidate(Config::$dataDir . '/schemas/OAI-PMH.xsd'); 25 | $xmlErrors = libxml_get_errors(); 26 | libxml_clear_errors(); 27 | $errorHtml = $this->xmlErrorsToHtml($xmlErrors); 28 | 29 | if ($errorHtml) { 30 | $this->addIssue( 31 | 'ListIdentifiers', 32 | 'Schema validation errors in $1:
$2', 33 | 'ListIdentifiers', 34 | $errorHtml, 35 | ); 36 | } 37 | 38 | $this->finish(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /frontend/modules/ui.js: -------------------------------------------------------------------------------- 1 | let scrollTimeout 2 | let scrollY = 0 3 | 4 | export function clearScrollTimeout () { 5 | clearTimeout(scrollTimeout) 6 | } 7 | 8 | export function scrollToHash (event) { 9 | const hash = event.newURL.split('#')[1] 10 | if (hash) { 11 | // eslint-disable-next-line unicorn/prefer-query-selector 12 | document.getElementById(hash)?.scrollIntoView() 13 | } else { 14 | window.scroll({ top: 0 }) 15 | } 16 | } 17 | 18 | export function updateUrlHash (elements) { 19 | if (!elements) { 20 | return 21 | } 22 | 23 | scrollY = window.scrollY 24 | 25 | clearTimeout(scrollTimeout) 26 | scrollTimeout = setTimeout(() => { 27 | if (window.scrollY !== scrollY) { 28 | return 29 | } 30 | 31 | if (!scrollY) { 32 | // Remove hash 33 | history.replaceState(history.state, '', ' ') 34 | return 35 | } 36 | 37 | for (const element of elements) { 38 | if (element.offsetTop - scrollY > 0) { 39 | if (location.hash.slice(1) !== element.id) { 40 | history.replaceState(history.state, '', `#${element.id}`) 41 | } 42 | 43 | break 44 | } 45 | } 46 | }, 200) 47 | } 48 | -------------------------------------------------------------------------------- /api/rules/E_11_11.php: -------------------------------------------------------------------------------- 1 | ListMetadataFormats->metadataFormat)) { 14 | $this->addFatalIssue( 15 | 'ListMetadataFormats', 16 | 'Invalid response to $1', 17 | 'ListMetadataFormats', 18 | ); 19 | 20 | return; 21 | } 22 | 23 | foreach ($xml->ListMetadataFormats->metadataFormat as $metadataFormat) { 24 | if ((string) $metadataFormat->metadataPrefix === 'oai_datacite') { 25 | $this->finish(); 26 | 27 | return; 28 | } 29 | } 30 | 31 | if ($isLastBatch) { 32 | $this->addFatalIssue( 33 | 'ListMetadataFormats', 34 | '$1 is missing in $2', 35 | 'oai_datacite', 36 | 'ListMetadataFormats', 37 | ); 38 | 39 | return; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /api/bootstrap.php: -------------------------------------------------------------------------------- 1 | $issue->values[(int) $matches[1] - 1], 13 | $issue->text, 14 | ); 15 | } 16 | 17 | /** 18 | * Run a rule isolated for unit tests. 19 | * 20 | * @param mixed[] $overriddenProperties 21 | */ 22 | function runRule(string $filename, array $overriddenProperties = []): object 23 | { 24 | $backtrace = debug_backtrace(); 25 | $testClassName = $backtrace[1]['class']; 26 | $testedClassName = str_replace('Test', '', $testClassName); 27 | 28 | $validator = new \Dini\Validator\Validator('http://localhost:8002'); 29 | $rule = new $testedClassName('', 0, $validator); 30 | $rule->setup(); 31 | 32 | foreach ($overriddenProperties as $key => $value) { 33 | $rule->{$key} = $value; 34 | } 35 | 36 | $content = file_get_contents(__DIR__ . "/xml/$filename.xml"); 37 | $xml = simplexml_load_string($content); 38 | 39 | $rule->run($xml); 40 | 41 | return $rule->getResult(true); 42 | } 43 | -------------------------------------------------------------------------------- /api/rules/M_11_8Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'metadata is missing in record-1', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListRecords/no-oaidc'); 18 | $this->assertEquals( 19 | 'oai_dc is missing in missing-oaidc', 20 | getIssueText($result->issues[0]), 21 | ); 22 | 23 | $result = runRule('ListRecords/no-dc-date'); 24 | $this->assertEquals( 25 | 'dc:date is missing in record-1', 26 | getIssueText($result->issues[0]), 27 | ); 28 | 29 | $result = runRule('ListRecords/invalid-dc-date'); 30 | $this->assertEquals( 31 | 'dc:date is invalid in record-1', 32 | getIssueText($result->issues[0]), 33 | ); 34 | 35 | $result = runRule('ListRecords/good'); 36 | $this->assertEquals(0, $result->issuesCount); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /api/test/xml/ListMetadataFormats/good.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:20Z 5 | https://example.org/oai 6 | 7 | 8 | MarcXchange 9 | info:lc/xmlns/marcxchange-v1 10 | info:lc/xmlns/marcxchange-v1.xsd 11 | 12 | 13 | oai_dc 14 | http://www.openarchives.org/OAI/2.0/ 15 | http://www.openarchives.org/OAI/2.0/oai_dc.xsd 16 | 17 | 18 | oai_datacite 19 | http://schema.datacite.org/oai/oai-1.1/ 20 | http://schema.datacite.org/oai/oai-1.1/oai.xsd 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /api/rules/E_11_5Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'metadata is missing in record-1', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListRecords/no-oaidc'); 18 | $this->assertEquals( 19 | 'oai_dc is missing in missing-oaidc', 20 | getIssueText($result->issues[0]), 21 | ); 22 | 23 | $result = runRule('ListRecords/no-dc-date'); 24 | $this->assertEquals( 25 | 'dc:date is missing in record-1', 26 | getIssueText($result->issues[0]), 27 | ); 28 | 29 | $result = runRule('ListRecords/duplicate-dc-date'); 30 | $this->assertEquals( 31 | 'dc:date is used multiple times in record-1', 32 | getIssueText($result->issues[0]), 33 | ); 34 | 35 | $result = runRule('ListRecords/good'); 36 | $this->assertEquals(0, $result->issuesCount); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /api/rules/M_11_7Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'metadata is missing in record-1', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListRecords/no-oaidc'); 18 | $this->assertEquals( 19 | 'oai_dc is missing in missing-oaidc', 20 | getIssueText($result->issues[0]), 21 | ); 22 | 23 | $result = runRule('ListRecords/no-dc-language'); 24 | $this->assertEquals( 25 | 'dc:language is missing in record-1', 26 | getIssueText($result->issues[0]), 27 | ); 28 | 29 | $result = runRule('ListRecords/invalid-dc-language'); 30 | $this->assertEquals( 31 | 'dc:language is invalid in record-1', 32 | getIssueText($result->issues[0]), 33 | ); 34 | 35 | $result = runRule('ListRecords/good'); 36 | $this->assertEquals(0, $result->issuesCount); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /api/rules/E_11_9Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'metadata is missing in record-1', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListRecords/no-oaidc'); 18 | $this->assertEquals( 19 | 'oai_dc is missing in missing-oaidc', 20 | getIssueText($result->issues[0]), 21 | ); 22 | 23 | $result = runRule('ListRecords/no-dc-creator'); 24 | $this->assertEquals( 25 | 'dc:creator is missing in record-1', 26 | getIssueText($result->issues[0]), 27 | ); 28 | 29 | $result = runRule('ListRecords/no-dc-creator-orcid'); 30 | $this->assertEquals( 31 | 'No dc:creator with valid ORCID iD in record-1', 32 | getIssueText($result->issues[0]), 33 | ); 34 | 35 | $result = runRule('ListRecords/good'); 36 | $this->assertEquals(0, $result->issuesCount); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /api/test/xml/ListIdentifiers/good.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:20Z 5 | https://example.org/oai 6 | 7 |
8 | oai:1 9 | 2022-12-18 10 | all 11 |
12 |
13 | oai:2 14 | 2022-12-18 15 | all 16 |
17 |
18 | oai:3 19 | 2022-12-18 20 | all 21 |
22 |
23 | oai:4 24 | 2022-12-18 25 | all 26 |
27 |
28 | oai:5 29 | 2022-12-18 30 | all 31 |
32 |
33 |
34 | -------------------------------------------------------------------------------- /frontend/components/VLoading.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 16 | 17 | 53 | -------------------------------------------------------------------------------- /api/test/xml/Identify/no-deletedrecord.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | http://example.org/oai 6 | 7 | OAI Test 8 | http://example.org/oai 9 | 2.0 10 | admin@example.org 11 | 2013-04-18 12 | YYYY-MM-DD 13 | gzip 14 | deflate 15 | 16 | 18 | 19 | https://example.org/ 20 | This is a valid description in English. 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /frontend/components/TheFooter.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 29 | 30 | 64 | -------------------------------------------------------------------------------- /api/test/xml/Identify/good.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | http://example.org/oai 6 | 7 | OAI Test 8 | http://example.org/oai 9 | 2.0 10 | admin@example.org 11 | 2013-04-18 12 | persistent 13 | YYYY-MM-DD 14 | gzip 15 | deflate 16 | 17 | 19 | 20 | https://example.org/ 21 | This is a valid description in English. 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /frontend/views/ContactView.vue: -------------------------------------------------------------------------------- 1 | 40 | -------------------------------------------------------------------------------- /api/rules/M_11_1_3.php: -------------------------------------------------------------------------------- 1 | metadata) { 14 | $this->addIssue( 15 | "GetRecord&identifier={$record->header->identifier}", 16 | '$1 is missing in $2', 17 | 'metadata', 18 | (string) $record->header->identifier, 19 | ); 20 | 21 | return; 22 | } 23 | 24 | $oaiDc = $record->metadata->children('oai_dc', true); 25 | 26 | if (! $oaiDc) { 27 | $this->addIssue( 28 | "GetRecord&identifier={$record->header->identifier}", 29 | '$1 is missing in $2', 30 | 'oai_dc', 31 | (string) $record->header->identifier, 32 | ); 33 | } elseif (! trim((string) $oaiDc->children('dc', true)->date)) { 34 | $this->addIssue( 35 | "GetRecord&identifier={$record->header->identifier}", 36 | '$1 is missing in $2', 37 | 'dc:date', 38 | (string) $record->header->identifier, 39 | ); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /api/rules/M_11_1_4.php: -------------------------------------------------------------------------------- 1 | metadata) { 14 | $this->addIssue( 15 | "GetRecord&identifier={$record->header->identifier}", 16 | '$1 is missing in $2', 17 | 'metadata', 18 | (string) $record->header->identifier, 19 | ); 20 | 21 | return; 22 | } 23 | 24 | $oaiDc = $record->metadata->children('oai_dc', true); 25 | 26 | if (! $oaiDc) { 27 | $this->addIssue( 28 | "GetRecord&identifier={$record->header->identifier}", 29 | '$1 is missing in $2', 30 | 'oai_dc', 31 | (string) $record->header->identifier, 32 | ); 33 | } elseif (! trim((string) $oaiDc->children('dc', true)->type)) { 34 | $this->addIssue( 35 | "GetRecord&identifier={$record->header->identifier}", 36 | '$1 is missing in $2', 37 | 'dc:type', 38 | (string) $record->header->identifier, 39 | ); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /api/test/xml/Identify/invalid-deletedrecord.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | http://example.org/oai 6 | 7 | OAI Test 8 | http://example.org/oai 9 | 2.0 10 | admin@example.org 11 | 2013-04-18 12 | nomnomnom 13 | YYYY-MM-DD 14 | gzip 15 | deflate 16 | 17 | 19 | 20 | https://example.org/ 21 | This is a valid description in English. 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /api/rules/M_11_1_2.php: -------------------------------------------------------------------------------- 1 | metadata) { 14 | $this->addIssue( 15 | "GetRecord&identifier={$record->header->identifier}", 16 | '$1 is missing in $2', 17 | 'metadata', 18 | (string) $record->header->identifier, 19 | ); 20 | 21 | return; 22 | } 23 | 24 | $oaiDc = $record->metadata->children('oai_dc', true); 25 | 26 | if (! $oaiDc) { 27 | $this->addIssue( 28 | "GetRecord&identifier={$record->header->identifier}", 29 | '$1 is missing in $2', 30 | 'oai_dc', 31 | (string) $record->header->identifier, 32 | ); 33 | } elseif (! trim((string) $oaiDc->children('dc', true)->title)) { 34 | $this->addIssue( 35 | "GetRecord&identifier={$record->header->identifier}", 36 | '$1 is missing in $2', 37 | 'dc:title', 38 | (string) $record->header->identifier, 39 | ); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /api/rules/M_11_1_1.php: -------------------------------------------------------------------------------- 1 | metadata) { 14 | $this->addIssue( 15 | "GetRecord&identifier={$record->header->identifier}", 16 | '$1 is missing in $2', 17 | 'metadata', 18 | (string) $record->header->identifier, 19 | ); 20 | 21 | return; 22 | } 23 | 24 | $oaiDc = $record->metadata->children('oai_dc', true); 25 | 26 | if (! $oaiDc) { 27 | $this->addIssue( 28 | "GetRecord&identifier={$record->header->identifier}", 29 | '$1 is missing in $2', 30 | 'oai_dc', 31 | (string) $record->header->identifier, 32 | ); 33 | } elseif (! trim((string) $oaiDc->children('dc', true)->creator)) { 34 | $this->addIssue( 35 | "GetRecord&identifier={$record->header->identifier}", 36 | '$1 is missing in $2', 37 | 'dc:creator', 38 | (string) $record->header->identifier, 39 | ); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /api/rules/M_11_1_5.php: -------------------------------------------------------------------------------- 1 | metadata) { 14 | $this->addIssue( 15 | "GetRecord&identifier={$record->header->identifier}", 16 | '$1 is missing in $2', 17 | 'metadata', 18 | (string) $record->header->identifier, 19 | ); 20 | 21 | return; 22 | } 23 | 24 | $oaiDc = $record->metadata->children('oai_dc', true); 25 | 26 | if (! $oaiDc) { 27 | $this->addIssue( 28 | "GetRecord&identifier={$record->header->identifier}", 29 | '$1 is missing in $2', 30 | 'oai_dc', 31 | (string) $record->header->identifier, 32 | ); 33 | } elseif (! trim((string) $oaiDc->children('dc', true)->identifier)) { 34 | $this->addIssue( 35 | "GetRecord&identifier={$record->header->identifier}", 36 | '$1 is missing in $2', 37 | 'dc:identifier', 38 | (string) $record->header->identifier, 39 | ); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /api/test/mock-oai-api/index.php: -------------------------------------------------------------------------------- 1 | Invalid verb'; 27 | exit(400); 28 | } 29 | 30 | $xmlDir = __DIR__ . "/../xml/$verb" . ($metadataPrefix === 'oai_datacite' ? '-datacite' : ''); 31 | 32 | if ($identifier) { 33 | $file = "$xmlDir/$identifier.xml"; 34 | 35 | if (file_exists($file)) { 36 | echo file_get_contents($file); 37 | } else { 38 | http_response_code(404); 39 | } 40 | } elseif (! $resumptionToken) { 41 | echo file_get_contents("$xmlDir/good.xml"); 42 | } else { 43 | echo file_get_contents("$xmlDir/good-$resumptionToken.xml"); 44 | } 45 | -------------------------------------------------------------------------------- /api/rules/E_11_10.php: -------------------------------------------------------------------------------- 1 | metadata) { 14 | $this->addIssue( 15 | "GetRecord&identifier={$record->header->identifier}", 16 | '$1 is missing in $2', 17 | 'metadata', 18 | (string) $record->header->identifier, 19 | ); 20 | 21 | return; 22 | } 23 | 24 | $oaiDc = $record->metadata->children('oai_dc', true); 25 | 26 | if (! $oaiDc) { 27 | $this->addIssue( 28 | "GetRecord&identifier={$record->header->identifier}", 29 | '$1 is missing in $2', 30 | 'oai_dc', 31 | (string) $record->header->identifier, 32 | ); 33 | 34 | return; 35 | } 36 | 37 | if (! $oaiDc->children('dc', true)->rights) { 38 | $this->addIssue( 39 | "GetRecord&identifier={$record->header->identifier}", 40 | '$1 is missing in $2', 41 | 'dc:rights', 42 | (string) $record->header->identifier, 43 | ); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/no-dc-rights.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | record-1 10 | 2022-12-18 11 | all 12 |
13 | 14 | 16 | This is the title 17 | This is the contributor 18 | This is the description 19 | This is the publisher 20 | 2000 21 | deu 22 | This is the type 23 | https://example.org/repo/record-1 24 | 25 | 26 |
27 |
28 |
29 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/small-repo.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | record-1 10 | 2022-12-18 11 | all 12 |
13 | 14 | 16 | This is the title 17 | This is the contributor 18 | This is the description 19 | This is the publisher 20 | 2000 21 | deu 22 | This is the type 23 | https://example.org/repo/record-1 24 | 25 | 26 |
27 |
28 |
29 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/invalid-dc-identifier.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | invalid-dc-identifier 10 | 2022-12-18 11 | all 12 |
13 | 14 | 16 | This is the title 17 | This is the contributor 18 | This is the description 19 | This is the publisher 20 | 2000 21 | deu 22 | This is the type 23 | This should be a URL 24 | 25 | 26 |
27 |
28 |
29 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/no-dc-type.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | record-1 10 | 2022-12-18 11 | all 12 |
13 | 14 | 16 | This is the contributor 17 | 2000-01-01 18 | This is the description 19 | https://example.org/repo/record-1 20 | deu 21 | This is the publisher 22 | This are the rights 23 | This is the title 24 | 25 | 26 |
27 |
28 |
29 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/about-without-provenance.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | about-without-provenance 10 | 2022-12-18 11 | all 12 |
13 | 14 | 15 | 17 | This is the title 18 | This is the contributor 19 | This is the description 20 | This is the publisher 21 | 2000 22 | deu 23 | This is the type 24 | oai: 25 | 26 | 27 | 28 | 29 |
30 |
31 |
32 | -------------------------------------------------------------------------------- /api/rules/M_11_3.php: -------------------------------------------------------------------------------- 1 | metadata) { 14 | $this->addIssue( 15 | "GetRecord&identifier={$record->header->identifier}", 16 | '$1 is missing in $2', 17 | 'metadata', 18 | (string) $record->header->identifier, 19 | ); 20 | 21 | return; 22 | } 23 | 24 | $oaiDc = $record->metadata->children('oai_dc', true); 25 | 26 | if (! $oaiDc) { 27 | $this->addIssue( 28 | "GetRecord&identifier={$record->header->identifier}", 29 | '$1 is missing in $2', 30 | 'oai_dc', 31 | (string) $record->header->identifier, 32 | ); 33 | 34 | return; 35 | } 36 | 37 | foreach ($oaiDc->children('dc', true)->identifier as $dcIdentifier) { 38 | if (filter_var($dcIdentifier, FILTER_VALIDATE_URL)) { 39 | return; 40 | } 41 | } 42 | 43 | $this->addIssue( 44 | "GetRecord&identifier={$record->header->identifier}", 45 | 'dc:identifier with operable URL missing in $1.', 46 | (string) $record->header->identifier, 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/no-dc-date.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | record-1 10 | 2022-12-18 11 | all 12 |
13 | 14 | 16 | This is the contributor 17 | This is the description 18 | https://example.org/repo/record-1 19 | deu 20 | This is the publisher 21 | This are the rights 22 | This is the title 23 | http://purl.org/coar/resource_type/NHD0-W6SY 24 | 25 | 26 |
27 |
28 |
29 | -------------------------------------------------------------------------------- /api/test/xml/ListSets/good.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/oai 6 | 7 | 8 | status-type:draft 9 | Drafts 10 | 11 | 12 | status-type:submittedVersion 13 | Submitted 14 | 15 | 16 | status-type:accepctedVersion 17 | Accepted 18 | 19 | 20 | status-type:publishedVersion 21 | Published 22 | 23 | 24 | status-type:updatedVersion 25 | Updated 26 | 27 | 28 | open_access 29 | Open Access documents 30 | 31 | 32 | ddc:100 33 | Philosophy 34 | 35 | 36 | doc-type:Article 37 | Articles 38 | 39 | 40 | doc-type:DoctoralThesis 41 | Doctoral Theses 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /api/rules/M_9_4Test.php: -------------------------------------------------------------------------------- 1 | assertEquals(3, $result->issuesCount); 13 | 14 | $this->assertEquals( 15 | '$1 is missing in $2', 16 | $result->issues[0]->text, 17 | ); 18 | $this->assertEquals( 19 | 'datestamp', 20 | $result->issues[0]->values[0], 21 | ); 22 | $this->assertEquals( 23 | 'oai:1', 24 | $result->issues[0]->values[1], 25 | ); 26 | 27 | $this->assertEquals( 28 | '$1 is invalid in $2', 29 | $result->issues[1]->text, 30 | ); 31 | $this->assertEquals( 32 | 'datestamp', 33 | $result->issues[1]->values[0], 34 | ); 35 | $this->assertEquals( 36 | 'oai:2', 37 | $result->issues[1]->values[1], 38 | ); 39 | 40 | $this->assertEquals( 41 | '$1 is invalid in $2', 42 | $result->issues[2]->text, 43 | ); 44 | $this->assertEquals( 45 | 'datestamp', 46 | $result->issues[2]->values[0], 47 | ); 48 | $this->assertEquals( 49 | 'oai:3', 50 | $result->issues[2]->values[1], 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /api/test/xml/GetRecord/record-2.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:24Z 5 | https://example.org/oai 6 | 7 | 8 |
9 | oai:23 10 | 2022-10-19 11 | all 12 |
13 | 14 | 16 | Programm der K. Studienanstalt zu Schweinfurt : für d. Schuljahr ... / 1870/71 (1871) 17 | Königliche Studienanstalt (Schweinfurt) 18 | Würzburg 19 | 1871 20 | ger 21 | Text 22 | https://example.org/resolver/oai-23 23 | Das ist eine Beschreibung 24 | 25 | 26 |
27 |
28 |
29 | -------------------------------------------------------------------------------- /api/data/doc-types.json: -------------------------------------------------------------------------------- 1 | [ 2 | "doc-type:Annotation", 3 | "doc-type:Article", 4 | "doc-type:BachelorThesis", 5 | "doc-type:Book", 6 | "doc-type:BookPart", 7 | "doc-type:CartographicMaterial", 8 | "doc-type:ConferenceObject", 9 | "doc-type:ConferencePaper", 10 | "doc-type:ConferencePoster", 11 | "doc-type:ConferenceProceedings", 12 | "doc-type:ConferenceSlides", 13 | "doc-type:ContributionToPeriodical", 14 | "doc-type:Corrigendum", 15 | "doc-type:CourseMaterial", 16 | "doc-type:DataPaper", 17 | "doc-type:DoctoralThesis", 18 | "doc-type:DynamicWebResource", 19 | "doc-type:EditedCollection", 20 | "doc-type:Editorial", 21 | "doc-type:Habilitation", 22 | "doc-type:Image", 23 | "doc-type:Lecture", 24 | "doc-type:LetterTo", 25 | "doc-type:Manuscript", 26 | "doc-type:MasterThesis", 27 | "doc-type:MeetingAbstract", 28 | "doc-type:Monograph", 29 | "doc-type:MovingImage", 30 | "doc-type:MusicalNotation", 31 | "doc-type:Other", 32 | "doc-type:PartOfADynamicWebResource", 33 | "doc-type:Patent", 34 | "doc-type:Periodical", 35 | "doc-type:PeriodicalPart", 36 | "doc-type:PhDThesis", 37 | "doc-type:Preprint", 38 | "doc-type:Recension", 39 | "doc-type:Report", 40 | "doc-type:ResearchArticle", 41 | "doc-type:ResearchData", 42 | "doc-type:ReviewArticle", 43 | "doc-type:Software", 44 | "doc-type:SoftwarePaper", 45 | "doc-type:Sound", 46 | "doc-type:SourceEdition", 47 | "doc-type:StillImage", 48 | "doc-type:StudyThesis", 49 | "doc-type:Text", 50 | "doc-type:Website", 51 | "doc-type:WorkingPaper" 52 | ] 53 | -------------------------------------------------------------------------------- /api/rules/M_11_6.php: -------------------------------------------------------------------------------- 1 | metadata) { 14 | $this->addIssue( 15 | "GetRecord&identifier={$record->header->identifier}", 16 | '$1 is missing in $2', 17 | 'metadata', 18 | (string) $record->header->identifier, 19 | ); 20 | 21 | return; 22 | } 23 | 24 | $oaiDc = $record->metadata->children('oai_dc', true); 25 | 26 | if (! $oaiDc) { 27 | $this->addIssue( 28 | "GetRecord&identifier={$record->header->identifier}", 29 | '$1 is missing in $2', 30 | 'oai_dc', 31 | (string) $record->header->identifier, 32 | ); 33 | 34 | return; 35 | } 36 | 37 | foreach ($oaiDc->children('dc', true)->subject as $dcSubject) { 38 | if (preg_match('/^ddc:\d{3}(\.\d+)?$/', (string) $dcSubject)) { 39 | return; 40 | } 41 | } 42 | 43 | $this->addIssue( 44 | "GetRecord&identifier={$record->header->identifier}", 45 | 'No $1 with valid $2 in $3', 46 | 'dc:subject', 47 | 'DDC', 48 | (string) $record->header->identifier, 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/no-dc-type-coar.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | record-1 10 | 2022-12-18 11 | all 12 |
13 | 14 | 16 | This is the contributor 17 | 2000 18 | This is the description 19 | https://example.org/repo/record-1 20 | deu 21 | This is the publisher 22 | This are the rights 23 | This is the title 24 | This is not a COAR resource type 25 | 26 | 27 |
28 |
29 |
30 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/no-dc-creator.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | record-1 10 | 2022-12-18 11 | all 12 |
13 | 14 | 16 | This is the contributor 17 | 2000 18 | This is the description 19 | https://example.org/repo/record-1 20 | deu 21 | This is the publisher 22 | This are the rights 23 | This is the title 24 | http://purl.org/coar/resource_type/NHD0-W6SY 25 | 26 | 27 |
28 |
29 |
30 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/invalid-dc-date.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | record-1 10 | 2022-12-18 11 | all 12 |
13 | 14 | 16 | This is the contributor 17 | yesterday 18 | This is the description 19 | https://example.org/repo/record-1 20 | deu 21 | This is the publisher 22 | This are the rights 23 | This is the title 24 | http://purl.org/coar/resource_type/NHD0-W6SY 25 | 26 | 27 |
28 |
29 |
30 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require('@rushstack/eslint-patch/modern-module-resolution') 3 | 4 | module.exports = { 5 | root: true, 6 | extends: [ 7 | 'eslint:recommended', 8 | 'plugin:vue/vue3-recommended', 9 | 'plugin:vue-pug/vue3-recommended', 10 | 'plugin:vuejs-accessibility/recommended', 11 | 'plugin:unicorn/recommended', 12 | '@vue/eslint-config-standard', 13 | ], 14 | globals: { 15 | process: true, 16 | }, 17 | ignorePatterns: ['cache/*', 'dist/*', 'vendor/*'], 18 | parserOptions: { 19 | ecmaVersion: 'latest', 20 | }, 21 | rules: { 22 | 'comma-dangle': ['error', 'always-multiline'], 23 | 'import/no-extraneous-dependencies': ['error'], 24 | 'import/order': ['error'], 25 | 'no-console': ['warn', { allow: ['warn', 'error'] }], 26 | 'unicorn/filename-case': 0, 27 | 'unicorn/prevent-abbreviations': [ 28 | 'error', 29 | { 30 | allowList: { 31 | props: true, 32 | }, 33 | }, 34 | ], 35 | 'vue/attribute-hyphenation': ['warn', 'never'], 36 | 'vue/v-on-event-hyphenation': ['warn', 'never'], 37 | 'vue/max-attributes-per-line': 0, // This rule damages Pug templates, do not enable! 38 | 'vue/no-template-target-blank': ['error'], 39 | 'vue/no-v-html': 0, 40 | 'vue/require-default-prop': 0, 41 | 'vuejs-accessibility/label-has-for': [ 42 | 'error', 43 | { 44 | required: { 45 | some: ['nesting', 'id'], 46 | }, 47 | }, 48 | ], 49 | 'vue-pug/no-pug-control-flow': 0, // This rule forbids “=' '”, which we need 50 | }, 51 | } 52 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/duplicate-dc-date.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | record-1 10 | 2022-12-18 11 | all 12 |
13 | 14 | 16 | This is the contributor 17 | 2000 18 | 2000-09-30 19 | This is the description 20 | https://example.org/repo/record-1 21 | deu 22 | This is the publisher 23 | This are the rights 24 | This is the title 25 | http://purl.org/coar/resource_type/NHD0-W6SY 26 | 27 | 28 |
29 |
30 |
31 | -------------------------------------------------------------------------------- /api/test/xml/GetRecord/record-1.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-04T04:36:09Z 5 | https://example.org/oai 6 | 7 | 8 |
9 | oai:1 10 | 2022-12-18 11 | all 12 |
13 | 14 | 16 | Schwäbische Nachrichten von Oeconomie-, Cameral-, Policey-, Handlungs-, Manufactur-, mechanischen und Bergwercks-Sachen / 1. 1756 17 | Moser, Johann Jacob 18 | dem Dr. überlassen von Johann Jacob Moser 19 | Stuttgart 20 | 1756 21 | ger 22 | Text 23 | https://example.org/resolver/oai-1 24 | Dupdidup 25 | 26 | 27 |
28 |
29 |
30 | -------------------------------------------------------------------------------- /api/data/coar-resource-types-3.1.json: -------------------------------------------------------------------------------- 1 | [ 2 | "c_12cc", 3 | "c_12cd", 4 | "c_ddb1", 5 | "ACF7-8YT9", 6 | "c_cb28", 7 | "FXF3-D3G7", 8 | "AM6W-6QAW", 9 | "63NG-B465", 10 | "A8F1-NPV9", 11 | "2H0M-X761", 12 | "H41Y-FW7B", 13 | "DD58-GFSX", 14 | "FF4C-28RK", 15 | "CQMR-7K63", 16 | "W2XT-7017", 17 | "NHD0-W6SY", 18 | "542X-3S04", 19 | "JBNF-DYAD", 20 | "BW7T-YM2G", 21 | "c_c513", 22 | "c_8a7e", 23 | "c_12ce", 24 | "c_ecc8", 25 | "c_e9a0", 26 | "c_7ad9", 27 | "c_e059", 28 | "c_1843", 29 | "c_15cd", 30 | "SB3Y-W4EH", 31 | "C53B-JCY5", 32 | "Z907-YMBB", 33 | "GPQ7-G5VE", 34 | "MW8G-3CR8", 35 | "9DKX-KSAF", 36 | "c_5ce6", 37 | "c_c950", 38 | "QH80-2R4E", 39 | "c_18cc", 40 | "c_18cd", 41 | "c_18cf", 42 | "c_1162", 43 | "c_86bc", 44 | "c_6947", 45 | "c_2f33", 46 | "c_3248", 47 | "c_c94f", 48 | "c_18cp", 49 | "c_18co", 50 | "R60J-J5BD", 51 | "c_f744", 52 | "c_5794", 53 | "c_6670", 54 | "c_0640", 55 | "c_b239", 56 | "c_6501", 57 | "c_7acd", 58 | "c_beb9", 59 | "c_2df8fbb1", 60 | "c_dcae04bc", 61 | "c_7bab", 62 | "c_545b", 63 | "c_8544", 64 | "c_0857", 65 | "c_2cd9", 66 | "c_0040", 67 | "c_18cw", 68 | "c_2fe3", 69 | "c_998f", 70 | "QX5C-AR31", 71 | "c_816b", 72 | "c_93fc", 73 | "c_7877", 74 | "c_ab20", 75 | "c_18wz", 76 | "c_186u", 77 | "c_18op", 78 | "YZ1N-ZFT9", 79 | "c_18ws", 80 | "c_18gh", 81 | "c_baaf", 82 | "c_efa0", 83 | "c_ba08", 84 | "D97F-VB57", 85 | "H9BQ-739P", 86 | "c_71bd", 87 | "c_46ec", 88 | "c_7a1f", 89 | "c_db06", 90 | "c_bdcc", 91 | "6NC7-GK9S", 92 | "c_8042", 93 | "H6QP-SC1X", 94 | "c_393c" 95 | ] 96 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/no-dc-creator-orcid.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | record-1 10 | 2022-12-18 11 | all 12 |
13 | 14 | 16 | This is the contributor 17 | This is the creator, but without an ORCID iD 18 | 2000 19 | This is the description 20 | https://example.org/repo/record-1 21 | deu 22 | This is the publisher 23 | This are the rights 24 | This is the title 25 | http://purl.org/coar/resource_type/NHD0-W6SY 26 | 27 | 28 |
29 |
30 |
31 | -------------------------------------------------------------------------------- /frontend/router.js: -------------------------------------------------------------------------------- 1 | import { nextTick } from 'vue' 2 | import { createRouter, createWebHistory } from 'vue-router' 3 | 4 | import { errorHandler } from './modules/error' 5 | import { setTitle } from './modules/meta' 6 | 7 | import HomeView from './views/HomeView.vue' 8 | import ContactView from './views/ContactView.vue' 9 | import LegalView from './views/LegalView.vue' 10 | import ValidationView from './views/ValidationView.vue' 11 | import NotFoundView from './views/NotFoundView.vue' 12 | 13 | const router = createRouter({ 14 | history: createWebHistory(import.meta.env.BASE_URL), 15 | routes: [ 16 | { 17 | name: 'home', 18 | path: '/:lang([a-z]{2})?/', 19 | component: HomeView, 20 | }, 21 | { 22 | name: 'contact', 23 | path: '/:lang([a-z]{2})?/contact', 24 | component: ContactView, 25 | }, 26 | { 27 | name: 'legal', 28 | path: '/:lang([a-z]{2})?/legal', 29 | component: LegalView, 30 | }, 31 | { 32 | name: 'validation', 33 | path: '/:lang([a-z]{2})?/:taskId([^/]{1,})', 34 | component: ValidationView, 35 | }, 36 | { 37 | name: '404', 38 | path: '/:lang([a-z]{2})?/:pathMatch(.*)?', 39 | component: NotFoundView, 40 | }, 41 | ], 42 | scrollBehavior (to, from, savedPosition) { 43 | if (to.name === from.name) { 44 | return 45 | } 46 | 47 | if (savedPosition) { 48 | return savedPosition 49 | } 50 | 51 | return { 52 | behavior: 'instant', 53 | top: 0, 54 | } 55 | }, 56 | }) 57 | 58 | router.afterEach(() => { 59 | errorHandler.clear() 60 | nextTick(setTitle) 61 | }) 62 | 63 | export default router 64 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/no-dc-identifier.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | record-1 10 | 2022-12-18 11 | all 12 |
13 | 14 | 16 | This is the contributor 17 | This is the creator https://orcid.org/0000-0002-1825-0097 18 | 2000 19 | This is the description 20 | deu 21 | This is the publisher 22 | This are the rights 23 | ddc:100 24 | This is the title 25 | doc-type:Book 26 | http://purl.org/coar/resource_type/NHD0-W6SY 27 | 28 | 29 |
30 |
31 |
32 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/no-dc-title.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | record-1 10 | 2022-12-18 11 | all 12 |
13 | 14 | 16 | This is the contributor 17 | This is the creator https://orcid.org/0000-0002-1825-0097 18 | 2000 19 | This is the description 20 | https://example.org/repo/record-1 21 | deu 22 | This is the publisher 23 | This are the rights 24 | ddc:100 25 | doc-type:Book 26 | http://purl.org/coar/resource_type/NHD0-W6SY 27 | 28 | 29 |
30 |
31 |
32 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/no-dc-language.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | record-1 10 | 2022-12-18 11 | all 12 |
13 | 14 | 16 | This is the contributor 17 | This is the creator https://orcid.org/0000-0002-1825-0097 18 | 2000 19 | This is the description 20 | https://example.org/repo/record-1 21 | This is the publisher 22 | This are the rights 23 | ddc:100 24 | This is the title 25 | doc-type:Book 26 | http://purl.org/coar/resource_type/NHD0-W6SY 27 | 28 | 29 |
30 |
31 |
32 | -------------------------------------------------------------------------------- /api/rules/M_9_6Test.php: -------------------------------------------------------------------------------- 1 | assertEquals(1, $result->issuesCount); 14 | $this->assertEquals( 15 | 'adminEmail in Identify does not contain a valid email address.', 16 | $result->issues[0]->text, 17 | ); 18 | 19 | $result = runRule('Identify/invalid-admin-email'); 20 | $this->assertEquals(1, $result->issuesCount); 21 | $this->assertEquals( 22 | 'adminEmail in Identify does not contain a valid email address.', 23 | $result->issues[0]->text, 24 | ); 25 | 26 | $result = runRule('Identify/no-description'); 27 | $this->assertEquals(1, $result->issuesCount); 28 | $this->assertEquals( 29 | '$1 is missing in $2', 30 | $result->issues[0]->text, 31 | ); 32 | $this->assertEquals('description', $result->issues[0]->values[0]); 33 | $this->assertEquals('Identify', $result->issues[0]->values[1]); 34 | 35 | $result = runRule('Identify/no-english-description'); 36 | $this->assertEquals(1, $result->issuesCount); 37 | $this->assertEquals( 38 | 'Identify does not seem to have a description in English.', 39 | $result->issues[0]->text, 40 | ); 41 | 42 | $result = runRule('Identify/good'); 43 | $this->assertEquals(0, $result->issuesCount); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /doc/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | The backend is a JSON REST API which supplies data to the frontend. 4 | 5 | To update progress during a validation with minimal overhead, the `watch` route uses [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) with JSON data instead plain JSON. 6 | 7 | The API is English-only, translations are entirely done in the frontend. 8 | 9 | ## Directory Structure 10 | 11 | Base directory: `api` 12 | 13 | - `classes` 14 | The validator’s core classes. 15 | - `config` 16 | Global backend configuration. 17 | - `routes` 18 | Classes for available API routes. Each file corresponds to an API route. 19 | - `rules` 20 | Classes for all rules defined in the current ruleset that are validated programmatically. 21 | - `test` 22 | Additional files for running unit tests, including a mock OAI-PMH API. 23 | 24 | ## Validation Run 25 | 26 | For every validation, these steps are executed: 27 | 28 | 1. An `Identify` request is sent to the supplied OAI API URL. If the response is valid, the XML is saved. Otherwise the validation is cancelled. 29 | 2. If the first response was valid, requests are sent for each of the OAI verbs `ListIdentifiers`, `ListMetadataFormats`, `ListRecords`, and `ListSets`. The responses are saved as XML files. If any of these verbs have a `resumptionToken`, subsequent requests are sent and saved as XML, with the exception of `ListRecords`, where only so many batches are downloaded until the user-set record limit is fulfilled. The progress is displayed, so are errors, but the validation is not aborted. 30 | 3. For each downloaded file, all applicable rules are run, see [rulesets](./rulesets.md). 31 | 4. Once all files have been checked, the result is displayed. 32 | -------------------------------------------------------------------------------- /api/rules/E_10_3Test.php: -------------------------------------------------------------------------------- 1 | assertEquals(0, $result->issuesCount); 13 | 14 | $result = runRule('ListRecords/no-responsedate'); 15 | $this->assertEquals( 16 | 'responseDate is missing in ListRecords', 17 | getIssueText($result->issues[0]), 18 | ); 19 | 20 | $result = runRule('ListRecords/invalid-responsedate'); 21 | $this->assertEquals( 22 | 'responseDate is invalid in ListRecords', 23 | getIssueText($result->issues[0]), 24 | ); 25 | 26 | $result = runRule('ListRecords/no-expirationdate'); 27 | $this->assertEquals( 28 | 'resumptionToken without expirationDate in ListRecords', 29 | getIssueText($result->issues[0]), 30 | ); 31 | 32 | $result = runRule('ListRecords/invalid-expirationdate'); 33 | $this->assertEquals( 34 | 'resumptionToken with invalid expirationDate in ListRecords', 35 | getIssueText($result->issues[0]), 36 | ); 37 | 38 | $result = runRule('ListRecords/expiring-too-soon'); 39 | $this->assertEquals( 40 | 'The lifespan of the resumptionToken in ListRecords is 3 h.', 41 | getIssueText($result->issues[0]), 42 | ); 43 | 44 | $result = runRule('ListRecords/fake-100'); 45 | $this->assertEquals(0, $result->issuesCount); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /api/rules/M_11_5.php: -------------------------------------------------------------------------------- 1 | docTypes = json_decode($json); 17 | } 18 | 19 | public function checkRecord($record): void 20 | { 21 | if (! $record->metadata) { 22 | $this->addIssue( 23 | "GetRecord&identifier={$record->header->identifier}", 24 | '$1 is missing in $2', 25 | 'metadata', 26 | (string) $record->header->identifier, 27 | ); 28 | 29 | return; 30 | } 31 | 32 | $oaiDc = $record->metadata->children('oai_dc', true); 33 | 34 | if (! $oaiDc) { 35 | $this->addIssue( 36 | "GetRecord&identifier={$record->header->identifier}", 37 | '$1 is missing in $2', 38 | 'oai_dc', 39 | (string) $record->header->identifier, 40 | ); 41 | 42 | return; 43 | } 44 | 45 | foreach ($oaiDc->children('dc', true)->type as $dcType) { 46 | if (in_array($dcType, $this->docTypes)) { 47 | return; 48 | } 49 | } 50 | 51 | $this->addIssue( 52 | "GetRecord&identifier={$record->header->identifier}", 53 | 'No $1 with valid $2 in $3', 54 | 'dc:type', 55 | 'doc-type', 56 | (string) $record->header->identifier, 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/no-datacite.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | no-datacite 10 | 2022-12-18 11 | all 12 |
13 | 14 | 16 | This is the contributor 17 | This is the creator https://orcid.org/0000-0002-1825-0097 18 | 2000 19 | This is the description 20 | https://example.org/repo/record-1 21 | deu 22 | This is the publisher 23 | This are the rights 24 | ddc:100 25 | This is the title 26 | doc-type:Book 27 | http://purl.org/coar/resource_type/NHD0-W6SY 28 | 29 | 30 |
31 |
32 |
33 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/datacite-bad-doi.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | bad-doi 10 | 2023-06-18 11 | all 12 |
13 | 14 | 16 | This is the contributor 17 | This is the creator https://orcid.org/0000-0002-1825-0097 18 | 2000 19 | This is the description 20 | https://example.org/repo/record-1 21 | deu 22 | This is the publisher 23 | This are the rights 24 | ddc:100 25 | This is the title 26 | doc-type:Book 27 | http://purl.org/coar/resource_type/NHD0-W6SY 28 | 29 | 30 |
31 |
32 |
33 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/datacite-bad-metadata.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | bad-metadata 10 | 2023-06-18 11 | all 12 |
13 | 14 | 16 | This is the contributor 17 | This is the creator https://orcid.org/0000-0002-1825-0097 18 | 2000 19 | This is the description 20 | https://example.org/repo/record-1 21 | deu 22 | This is the publisher 23 | This are the rights 24 | ddc:100 25 | This is the title 26 | doc-type:Book 27 | http://purl.org/coar/resource_type/NHD0-W6SY 28 | 29 | 30 |
31 |
32 |
33 | -------------------------------------------------------------------------------- /api/rules/M_9_6.php: -------------------------------------------------------------------------------- 1 | languageDetector = new \LanguageDetection\Language(); 16 | } 17 | 18 | public function check($xml, $isLastBatch): void 19 | { 20 | if (! filter_var($xml->Identify->adminEmail, FILTER_VALIDATE_EMAIL)) { 21 | $this->addIssue( 22 | 'Identify', 23 | 'adminEmail in Identify does not contain a valid email address.', 24 | ); 25 | 26 | return; 27 | } 28 | 29 | if (! $xml->Identify->description) { 30 | $this->addIssue( 31 | 'Identify', 32 | '$1 is missing in $2', 33 | 'description', 34 | 'Identify', 35 | ); 36 | 37 | return; 38 | } 39 | 40 | foreach ($xml->Identify->description as $description) { 41 | // NOTE: description can contain XML, see 42 | // https://www.openarchives.org/OAI/openarchivesprotocol.html#Identify 43 | $text = @dom_import_simplexml($description)->textContent; 44 | 45 | if ($text) { 46 | $language = (string) $this->languageDetector->detect($text); 47 | 48 | if ($language === 'en') { 49 | return; 50 | } 51 | } 52 | } 53 | 54 | $this->addIssue( 55 | 'Identify', 56 | 'Identify does not seem to have a description in English.', 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/invalid-dc-language.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | record-1 10 | 2022-12-18 11 | all 12 |
13 | 14 | 16 | This is the contributor 17 | This is the creator https://orcid.org/0000-0002-1825-0097 18 | 2000 19 | This is the description 20 | https://example.org/repo/record-1 21 | some kind of strange dialect 22 | This is the publisher 23 | This are the rights 24 | ddc:100 25 | This is the title 26 | doc-type:Book 27 | http://purl.org/coar/resource_type/NHD0-W6SY 28 | 29 | 30 |
31 |
32 |
33 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/no-dc-subject-ddc.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | record-1 10 | 2022-12-18 11 | all 12 |
13 | 14 | 16 | This is the contributor 17 | This is the creator https://orcid.org/0000-0002-1825-0097 18 | 2000 19 | This is the description 20 | https://example.org/repo/record-1 21 | deu 22 | This is the publisher 23 | This are the rights 24 | This is subject, but not a DDC 25 | This is the title 26 | doc-type:Book 27 | http://purl.org/coar/resource_type/NHD0-W6SY 28 | 29 | 30 |
31 |
32 |
33 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/datacite-no-match.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | record-1 10 | 2023-06-18 11 | all 12 |
13 | 14 | 16 | This is the contributor 17 | This is the creator https://orcid.org/0000-0002-1825-0097 18 | 2000 19 | This is the description 20 | https://example.org/repo/record-1 21 | 2000 22 | deu 23 | This is the publisher 24 | This are the rights 25 | ddc:100 26 | This is the title 27 | doc-type:Book 28 | http://purl.org/coar/resource_type/NHD0-W6SY 29 | 30 | 31 |
32 |
33 |
34 | -------------------------------------------------------------------------------- /api/rules/E_11_5.php: -------------------------------------------------------------------------------- 1 | metadata) { 14 | $this->addIssue( 15 | "GetRecord&identifier={$record->header->identifier}", 16 | '$1 is missing in $2', 17 | 'metadata', 18 | (string) $record->header->identifier, 19 | ); 20 | 21 | return; 22 | } 23 | 24 | $oaiDc = $record->metadata->children('oai_dc', true); 25 | 26 | if (! $oaiDc) { 27 | $this->addIssue( 28 | "GetRecord&identifier={$record->header->identifier}", 29 | '$1 is missing in $2', 30 | 'oai_dc', 31 | (string) $record->header->identifier, 32 | ); 33 | 34 | return; 35 | } 36 | 37 | $dates = $oaiDc->children('dc', true)->date; 38 | 39 | if (! $dates) { 40 | $this->addIssue( 41 | "GetRecord&identifier={$record->header->identifier}", 42 | '$1 is missing in $2', 43 | 'dc:date', 44 | (string) $record->header->identifier, 45 | ); 46 | 47 | return; 48 | } 49 | 50 | if (count($dates) > 1) { 51 | $this->addIssue( 52 | "GetRecord&identifier={$record->header->identifier}", 53 | '$1 is used multiple times in $2', 54 | 'dc:date', 55 | (string) $record->header->identifier, 56 | ); 57 | 58 | return; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /api/rules/M_9_3_4.php: -------------------------------------------------------------------------------- 1 | validator->downloadOaiXml('GetRecord', '', $this->unknownRecordId); 19 | 20 | if (! $result || ! $result->content) { 21 | $this->addFatalIssue( 22 | "GetRecord&identifier=$this->unknownRecordId", 23 | 'Invalid response to $1', 24 | 'GetRecord', 25 | ); 26 | 27 | return; 28 | } 29 | 30 | $dom = new DOMDocument(); 31 | $domCreated = @$dom->loadXML($result->content); 32 | 33 | // @codeCoverageIgnoreStart 34 | if (! $domCreated) { 35 | $this->addFatalIssue( 36 | "GetRecord&identifier=$this->unknownRecordId", 37 | 'Invalid response to $1', 38 | 'GetRecord', 39 | ); 40 | 41 | return; 42 | } 43 | // @codeCoverageIgnoreEnd 44 | 45 | $dom->schemaValidate(Config::$dataDir . '/schemas/OAI-PMH.xsd'); 46 | $xmlErrors = libxml_get_errors(); 47 | libxml_clear_errors(); 48 | $errorHtml = $this->xmlErrorsToHtml($xmlErrors); 49 | 50 | if ($errorHtml) { 51 | $this->addIssue( 52 | "GetRecord&identifier=$this->unknownRecordId", 53 | 'Schema validation errors in $1:
$2', 54 | 'GetRecord', 55 | $errorHtml, 56 | ); 57 | } 58 | 59 | $this->finish(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /frontend/components/VError.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 23 | 24 | 80 | -------------------------------------------------------------------------------- /api/rules/M_11_8.php: -------------------------------------------------------------------------------- 1 | metadata) { 14 | $this->addIssue( 15 | "GetRecord&identifier={$record->header->identifier}", 16 | '$1 is missing in $2', 17 | 'metadata', 18 | (string) $record->header->identifier, 19 | ); 20 | 21 | return; 22 | } 23 | 24 | $oaiDc = $record->metadata->children('oai_dc', true); 25 | 26 | if (! $oaiDc) { 27 | $this->addIssue( 28 | "GetRecord&identifier={$record->header->identifier}", 29 | '$1 is missing in $2', 30 | 'oai_dc', 31 | (string) $record->header->identifier, 32 | ); 33 | 34 | return; 35 | } 36 | 37 | $date = (string) $oaiDc->children('dc', true)->date; 38 | 39 | if (! $date) { 40 | $this->addIssue( 41 | "GetRecord&identifier={$record->header->identifier}", 42 | '$1 is missing in $2', 43 | 'dc:date', 44 | (string) $record->header->identifier, 45 | ); 46 | 47 | return; 48 | } 49 | 50 | $parts = explode('-', $date); 51 | 52 | if (! checkdate((int) ($parts[1] ?? 1), (int) ($parts[2] ?? 1), (int) ($parts[0]))) { 53 | $this->addIssue( 54 | "GetRecord&identifier={$record->header->identifier}", 55 | '$1 is invalid in $2', 56 | 'dc:date', 57 | (string) $record->header->identifier, 58 | ); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /api/rules/M_10_5Test.php: -------------------------------------------------------------------------------- 1 | assertEquals(0, $result->issuesCount); 13 | 14 | $result = runRule( 15 | 'ListRecords/good', 16 | ['secondListRecordsFile' => ''], 17 | ); 18 | $this->assertEquals( 19 | 'XML file not found', 20 | getIssueText($result->issues[0]), 21 | ); 22 | 23 | $result = runRule( 24 | 'ListRecords/good', 25 | ['savedResultFile' => __DIR__ . '/../test/xml/ListRecords/invalid.xml'], 26 | ); 27 | $this->assertEquals( 28 | 'Invalid response to ListRecords (1)', 29 | getIssueText($result->issues[0]), 30 | ); 31 | 32 | $result = runRule( 33 | 'ListRecords/bad-resumptiontoken', 34 | ['savedResultFile' => __DIR__ . '/../test/xml/ListRecords/bad-resumptiontoken.xml'], 35 | ); 36 | $this->assertEquals( 37 | 'Invalid response to ListRecords (2)', 38 | getIssueText($result->issues[0]), 39 | ); 40 | 41 | $result = runRule( 42 | 'ListRecords/unstable-resumptiontoken', 43 | ['savedResultFile' => __DIR__ . '/../test/xml/ListRecords/unstable-resumptiontoken.xml'], 44 | ); 45 | $this->assertEquals( 46 | 'Consecutive ListRecords requests do not match.', 47 | getIssueText($result->issues[0]), 48 | ); 49 | 50 | $result = runRule( 51 | 'ListRecords/good', 52 | ['savedResultFile' => __DIR__ . '/../test/xml/ListRecords/good-2.xml'], 53 | ); 54 | $this->assertEquals(0, $result->issuesCount); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /api/rules/E_11_8.php: -------------------------------------------------------------------------------- 1 | coarResourceTypes = json_decode($json); 17 | 18 | foreach ($this->coarResourceTypes as &$type) { 19 | $type = "http://purl.org/coar/resource_type/$type"; 20 | } 21 | } 22 | 23 | public function checkRecord($record): void 24 | { 25 | if (! $record->metadata) { 26 | $this->addIssue( 27 | "GetRecord&identifier={$record->header->identifier}", 28 | '$1 is missing in $2', 29 | 'metadata', 30 | (string) $record->header->identifier, 31 | ); 32 | 33 | return; 34 | } 35 | 36 | $oaiDc = $record->metadata->children('oai_dc', true); 37 | 38 | if (! $oaiDc) { 39 | $this->addIssue( 40 | "GetRecord&identifier={$record->header->identifier}", 41 | '$1 is missing in $2', 42 | 'oai_dc', 43 | (string) $record->header->identifier, 44 | ); 45 | } else { 46 | foreach ($oaiDc->children('dc', true)->type as $dcType) { 47 | if (in_array($dcType, $this->coarResourceTypes)) { 48 | return; 49 | } 50 | } 51 | 52 | $this->addIssue( 53 | "GetRecord&identifier={$record->header->identifier}", 54 | 'No $1 with valid $2 in $3', 55 | 'dc:type', 56 | 'COAR Resource Type', 57 | (string) $record->header->identifier, 58 | ); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /frontend/modules/http.js: -------------------------------------------------------------------------------- 1 | import { ref, onUnmounted } from 'vue' 2 | 3 | import { cache } from './cache' 4 | import { errorHandler } from './error' 5 | 6 | export const loading = ref(false) 7 | 8 | export function createEventSource (url) { 9 | const eventSource = new EventSource(url) 10 | 11 | loading.value = true 12 | 13 | eventSource.addEventListener('message', async () => { 14 | loading.value = false 15 | }) 16 | 17 | eventSource.addEventListener('error', () => { 18 | eventSource.close() 19 | loading.value = false 20 | errorHandler.set('Server error') 21 | }) 22 | 23 | // Without this, EventSource throws an error on reload 24 | window.addEventListener('beforeunload', closeEventSource) 25 | 26 | onUnmounted(() => { 27 | eventSource.close() 28 | loading.value = false 29 | window.removeEventListener('beforeunload', closeEventSource) 30 | }) 31 | 32 | function closeEventSource () { 33 | eventSource.close() 34 | } 35 | 36 | return eventSource 37 | } 38 | 39 | export async function fetchJson (url, options = {}, useCache = true) { 40 | const cacheKey = url + JSON.stringify(options) 41 | 42 | if (useCache && cache.get(cacheKey)) { 43 | return cache.get(cacheKey) 44 | } 45 | 46 | loading.value = true 47 | 48 | const response = await fetch(url, options).catch(() => { 49 | errorHandler.set('Network error') 50 | }) 51 | 52 | loading.value = false 53 | 54 | if (response?.ok) { 55 | try { 56 | const content = await response.json() 57 | 58 | if (useCache) { 59 | cache.set(cacheKey, content) 60 | } 61 | 62 | return content 63 | } catch { 64 | errorHandler.set('Received invalid JSON') 65 | 66 | return false 67 | } 68 | } else if (response?.body) { 69 | try { 70 | const error = await response.json() 71 | errorHandler.set(error.error || error) 72 | } catch { 73 | errorHandler.set('Server error') 74 | } 75 | } 76 | 77 | return false 78 | } 79 | -------------------------------------------------------------------------------- /api/rules/M_9_3_3.php: -------------------------------------------------------------------------------- 1 | ListRecords->record[0]?->header?->identifier ?? null; 16 | 17 | if (! $recordId) { 18 | $this->addFatalIssue( 19 | 'ListRecords', 20 | 'Invalid response to $1', 21 | 'ListRecords', 22 | ); 23 | 24 | return; 25 | } 26 | 27 | $result = $this->validator->downloadOaiXml('GetRecord', '', $recordId); 28 | 29 | if (! $result || ! $result->content) { 30 | $this->addFatalIssue( 31 | "GetRecord&identifier=$recordId", 32 | 'Invalid response to $1', 33 | $recordId, 34 | ); 35 | 36 | return; 37 | } 38 | 39 | $dom = new DOMDocument(); 40 | $domCreated = @$dom->loadXML($result->content); 41 | 42 | // @codeCoverageIgnoreStart 43 | if (! $domCreated) { 44 | $this->addFatalIssue( 45 | "GetRecord&identifier=$recordId", 46 | 'Invalid response to $1', 47 | $recordId, 48 | ); 49 | 50 | return; 51 | } 52 | // @codeCoverageIgnoreEnd 53 | 54 | $dom->schemaValidate(Config::$dataDir . '/schemas/OAI-PMH.xsd'); 55 | $xmlErrors = libxml_get_errors(); 56 | libxml_clear_errors(); 57 | $errorHtml = $this->xmlErrorsToHtml($xmlErrors); 58 | 59 | if ($errorHtml) { 60 | $this->addIssue( 61 | "GetRecord&identifier=$recordId", 62 | 'Schema validation errors in $1:
$2', 63 | $recordId, 64 | $errorHtml, 65 | ); 66 | } 67 | 68 | $this->finish(); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { URL, fileURLToPath } from 'node:url' 2 | 3 | import { defineConfig } from 'vite' 4 | 5 | import componentsAutoImport from 'unplugin-vue-components/vite' 6 | import eslint from 'vite-plugin-eslint' 7 | import rewriteAll from 'vite-plugin-rewrite-all' // Required for routes containing "." 8 | import stylelint from 'vite-plugin-stylelint' 9 | import unfonts from 'unplugin-fonts/vite' 10 | import vue from '@vitejs/plugin-vue' 11 | 12 | // https://vitejs.dev/config/ 13 | export default defineConfig({ 14 | css: { 15 | preprocessorOptions: { 16 | scss: { 17 | // This gets prepended to every SCSS file and 92 | -------------------------------------------------------------------------------- /frontend/styles/settings.scss: -------------------------------------------------------------------------------- 1 | // Base colors 2 | $blue: #344999; 3 | $green: #68ba80; 4 | $orange: #f0821d; 5 | $red: #d72f89; 6 | $black: #000; 7 | $white: #fff; 8 | 9 | // Shades of gray 10 | $gray-1: #eee; 11 | $gray-2: #ccc; 12 | $gray-3: #888; 13 | $gray-4: #555; 14 | $gray-5: #333; 15 | $gray-6: #222; 16 | $shade-1: var(--shade-1, rgba(#000, .05)); 17 | $shade-2: var(--shade-2, rgba(#000, .1)); 18 | $shade-3: var(--shade-3, rgba(#000, .2)); 19 | $shade-4: var(--shade-4, rgba(#000, .3)); 20 | $shine: var(--shine, rgba(#fff, .8)); 21 | 22 | // Semantic colors 23 | $bg-color: var(--bg-color, $white); 24 | $bg-muted-color: var(--bg-muted-color, $gray-1); 25 | $border-color: $shade-3; 26 | $button-hover-bg: var(--link-light-color, color.mix(#fff, $blue, 10%)); 27 | $border-shade: $shade-2; 28 | $button-shade: var(--button-shade, rgba($blue, 20%)); 29 | $error-color: var(--error-color, color.mix(#000, $orange, 30%)); 30 | $link-color: var(--link-color, $blue); 31 | $link-shade: var(--link-shade, rgba($blue, 10%)); 32 | $text-color: var(--text-color, $black); 33 | $text-muted-color: var(--text-muted-color, $gray-4); 34 | $text-invert-color: var(--text-invert-color, $white); 35 | 36 | // Dimensions 37 | $grid: 24px; 38 | $font-size-small: .882em; 39 | 40 | // Timings 41 | $td: .2s; // transition duration 42 | 43 | // Breakpoints for @media queries 44 | $small: '(min-width: 360px)'; 45 | $medium: '(min-width: 576px)'; 46 | $large: '(min-width: 1200px)'; 47 | 48 | // Combined values 49 | $drop-shadow: 0 0 .5rem rgba(#000, .2); 50 | 51 | .dark { 52 | --shade-1: #{rgba(#fff, .05)}; 53 | --shade-2: #{rgba(#fff, .1)}; 54 | --shade-3: #{rgba(#fff, .2)}; 55 | --shade-4: #{rgba(#fff, .3)}; 56 | --shine: #{rgba(#fff, .1)}; 57 | --bg-color: #{$gray-6}; 58 | --bg-muted-color: #{$gray-5}; 59 | --button-shade: #{rgba($green, 20%)}; 60 | --error-color: #{$orange}; 61 | --link-color: #{$green}; 62 | --link-light-color: #{color.mix(#fff, $green, 10%)}; 63 | --link-shade: #{rgba($green, 10%)}; 64 | --text-color: #{$white}; 65 | --text-invert-color: #{$black}; 66 | --text-muted-color: #{$gray-2}; 67 | } 68 | -------------------------------------------------------------------------------- /api/rules/M_11_7.php: -------------------------------------------------------------------------------- 1 | languageCodes = json_decode($json); 17 | } 18 | 19 | public function checkRecord($record): void 20 | { 21 | if (! $record->metadata) { 22 | $this->addIssue( 23 | "GetRecord&identifier={$record->header->identifier}", 24 | '$1 is missing in $2', 25 | 'metadata', 26 | (string) $record->header->identifier, 27 | ); 28 | 29 | return; 30 | } 31 | 32 | $oaiDc = $record->metadata->children('oai_dc', true); 33 | 34 | if (! $oaiDc) { 35 | $this->addIssue( 36 | "GetRecord&identifier={$record->header->identifier}", 37 | '$1 is missing in $2', 38 | 'oai_dc', 39 | (string) $record->header->identifier, 40 | ); 41 | 42 | return; 43 | } 44 | 45 | $languageCode = (string) $oaiDc->children('dc', true)->language; 46 | 47 | if (! $languageCode) { 48 | $this->addIssue( 49 | "GetRecord&identifier={$record->header->identifier}", 50 | '$1 is missing in $2', 51 | 'dc:language', 52 | (string) $record->header->identifier, 53 | ); 54 | 55 | return; 56 | } 57 | 58 | if (! in_array($languageCode, $this->languageCodes)) { 59 | $this->addIssue( 60 | "GetRecord&identifier={$record->header->identifier}", 61 | '$1 is invalid in $2', 62 | 'dc:language', 63 | (string) $record->header->identifier, 64 | ); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /frontend/views/ResultView.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 67 | 68 | 84 | -------------------------------------------------------------------------------- /api/routes/RouteXml.php: -------------------------------------------------------------------------------- 1 | sanitizeTaskId($_GET['taskId'] ?? ''); 12 | $url = @base64_decode($_GET['url'] ?? ''); 13 | 14 | $cacheDir = Config::$cacheDir . "/$taskId"; 15 | 16 | if (! $taskId || ! is_dir($cacheDir)) { 17 | $this->fail('Invalid request', 400); 18 | } 19 | 20 | if (! $url || ! filter_var($url, FILTER_VALIDATE_URL)) { 21 | $this->fail('Invalid request', 400); 22 | } 23 | 24 | // Only accept requests if we got a finished validation for this OAI URL 25 | $status = $this->readJson("$cacheDir/status.json"); 26 | 27 | if (! $status 28 | || $status->stage !== 'finished' 29 | || ! str_starts_with($url, $status->oaiApiUrl) 30 | ) { 31 | $this->fail('Invalid request', 400); 32 | } 33 | 34 | $streamContext = stream_context_create([ 35 | 'http' => [ 36 | 'header' => 'User-Agent: ' . Config::$userAgent, 37 | 'timeout' => Config::$oaiApiTimeout, 38 | ], 39 | ]); 40 | 41 | $xml = @file_get_contents($url, false, $streamContext); 42 | 43 | if (! $xml) { 44 | $this->fail('Error loading remote XML', 502); 45 | } 46 | 47 | // Prettify XML 48 | $dom = new \DOMDocument(); 49 | $dom->preserveWhiteSpace = false; 50 | $dom->formatOutput = true; 51 | 52 | $domCreated = @$dom->loadXML($xml); 53 | 54 | if ($domCreated) { 55 | $prettyXml = $dom->saveXML(); 56 | $resumptionToken = $dom->getElementsByTagName('resumptionToken')->item(0)?->textContent; 57 | } else { 58 | // This is probably not actual XML, still return what we got from the server 59 | $prettyXml = $xml; 60 | } 61 | 62 | $this->sendJson([ 63 | 'xml' => $prettyXml, 64 | 'resumptionToken' => $resumptionToken ?? '', 65 | ]); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /frontend/components/VDecoration.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 44 | 45 | 90 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dini-validator", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "lint": "npm run lint:php; npm run lint:js; npm run lint:scss", 10 | "lint:js": "eslint . --ext .vue,.js --ignore-path .gitignore", 11 | "lint:php": "vendor/bin/phpstan; vendor/bin/pint --test -v", 12 | "lint:scss": "stylelint 'frontend/**/*.{scss,vue}' --rd --risd", 13 | "lint-fix": "npm run lint-fix:php; npm run lint-fix:js; npm run lint-fix:scss", 14 | "lint-fix:js": "eslint . --ext .vue,.js --fix --ignore-path .gitignore", 15 | "lint-fix:php": "vendor/bin/phpstan; vendor/bin/pint -v", 16 | "lint-fix:scss": "stylelint 'frontend/**/*.{scss,vue}' --fix --rd --risd", 17 | "test": "php -dpcov.enabled=1 -dpcov.directory=api vendor/bin/phpunit --color --testdox --coverage-text" 18 | }, 19 | "dependencies": { 20 | "@fontsource/barlow": "^5.0.8", 21 | "@fontsource/noto-sans-mono": "^5.0.12", 22 | "@highlightjs/vue-plugin": "^2.1.0", 23 | "highlight.js": "^11.8.0", 24 | "js-confetti": "^0.11.0", 25 | "vue": "^3.3.4", 26 | "vue-material-design-icons": "^5.2.0", 27 | "vue-router": "^4.2.4" 28 | }, 29 | "devDependencies": { 30 | "@rushstack/eslint-patch": "^1.4.0", 31 | "@vitejs/plugin-vue": "^4.3.4", 32 | "@volar/vue-language-plugin-pug": "^1.6.5", 33 | "@vue/eslint-config-standard": "^8.0.1", 34 | "@vue/language-plugin-pug": "^1.8.11", 35 | "eslint": "^8.49.0", 36 | "eslint-plugin-unicorn": "^48.0.1", 37 | "eslint-plugin-vue": "^9.17.0", 38 | "eslint-plugin-vue-pug": "^0.6.0", 39 | "eslint-plugin-vuejs-accessibility": "^2.2.0", 40 | "jsdom": "^22.1.0", 41 | "pug": "^3.0.2", 42 | "stylelint": "^15.10.3", 43 | "stylelint-config-recommended-vue": "^1.5.0", 44 | "stylelint-config-standard-scss": "^11.0.0", 45 | "stylelint-config-standard-vue": "^1.0.0", 46 | "stylelint-order": "^6.0.3", 47 | "stylelint-stylistic": "^0.4.3", 48 | "unplugin-fonts": "^1.0.3", 49 | "unplugin-vue-components": "^0.25.2", 50 | "vite": "^4.4.9", 51 | "vite-plugin-eslint": "^1.8.1", 52 | "vite-plugin-rewrite-all": "^1.0.1", 53 | "vite-plugin-stylelint": "^5.1.1" 54 | }, 55 | "engines" : { 56 | "node" : ">=18", 57 | "npm" : ">=9" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "preset": "laravel", 4 | "exclude": [ 5 | "cache" 6 | ], 7 | "rules": { 8 | "align_multiline_comment": true, 9 | "array_indentation": true, 10 | "array_push": true, 11 | "array_syntax": true, 12 | "assign_null_coalescing_to_coalesce_equal": true, 13 | "blank_line_after_namespace": true, 14 | "blank_line_after_opening_tag": true, 15 | "combine_consecutive_issets": true, 16 | "concat_space": { 17 | "spacing": "one" 18 | }, 19 | "declare_parentheses": true, 20 | "declare_strict_types": true, 21 | "explicit_indirect_variable": true, 22 | "fully_qualified_strict_types": true, 23 | "is_null": true, 24 | "lambda_not_used_import": true, 25 | "logical_operators": true, 26 | "method_argument_space": { 27 | "on_multiline": "ensure_fully_multiline" 28 | }, 29 | "method_chaining_indentation": true, 30 | "modernize_strpos": true, 31 | "modernize_types_casting": true, 32 | "new_with_braces": true, 33 | "no_empty_comment": true, 34 | "no_multiple_statements_per_line": true, 35 | "no_superfluous_elseif": true, 36 | "no_useless_else": true, 37 | "nullable_type_declaration_for_default_null_value": true, 38 | "ordered_class_elements": { 39 | "order": [ 40 | "use_trait", 41 | "case", 42 | "constant", 43 | "constant_public", 44 | "constant_protected", 45 | "constant_private", 46 | "property_public", 47 | "property_protected", 48 | "property_private", 49 | "construct", 50 | "destruct", 51 | "magic", 52 | "phpunit", 53 | "method_abstract", 54 | "method_public_static", 55 | "method_public", 56 | "method_protected_static", 57 | "method_protected", 58 | "method_private_static", 59 | "method_private" 60 | ], 61 | "sort_algorithm": "none" 62 | }, 63 | "ordered_imports": { 64 | "sort_algorithm": "alpha" 65 | }, 66 | "ordered_traits": true, 67 | "psr_autoloading": true, 68 | "simplified_if_return": true, 69 | "strict_comparison": true, 70 | "ternary_to_null_coalescing": true, 71 | "trailing_comma_in_multiline": { 72 | "elements": ["arguments", "arrays", "match", "parameters"] 73 | }, 74 | "trim_array_spaces": true, 75 | "use_arrow_functions": true 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /api/rules/M_10_5.php: -------------------------------------------------------------------------------- 1 | savedResultFile = "{$this->validator->cacheDir}/xml/ListRecords/2.xml"; 16 | } 17 | 18 | public function check($xml, $isLastBatch): void 19 | { 20 | if (empty($xml->ListRecords->resumptionToken)) { 21 | $this->finish('Not relevant due to small repository size'); 22 | 23 | return; 24 | } 25 | 26 | if (! file_exists($this->savedResultFile)) { 27 | $this->addFatalIssue('ListRecords', 'XML file not found'); 28 | 29 | return; 30 | } 31 | 32 | $savedContents = file_get_contents($this->savedResultFile); 33 | $savedXml = @simplexml_load_string((string) $savedContents); 34 | 35 | $newResult = $this->validator->downloadOaiXml('ListRecords', (string) $xml->ListRecords->resumptionToken); 36 | $newXml = @simplexml_load_string((string) $newResult->content); 37 | 38 | if (! $savedXml) { 39 | $this->addIssue( 40 | "ListRecords&resumptionToken={$xml->ListRecords->resumptionToken}", 41 | 'Invalid response to $1', 42 | 'ListRecords (1)', 43 | ); 44 | } elseif (! $newXml) { 45 | $this->addIssue( 46 | "ListRecords&resumptionToken={$xml->ListRecords->resumptionToken}", 47 | 'Invalid response to $1', 48 | 'ListRecords (2)', 49 | ); 50 | } else { 51 | $index = 0; 52 | foreach ($savedXml->ListRecords->record as $record) { 53 | if ( 54 | ! $newXml->ListRecords?->record 55 | || json_encode($record) !== json_encode($newXml->ListRecords->record[$index]) 56 | ) { 57 | $this->addIssue( 58 | "ListRecords&resumptionToken={$xml->ListRecords->resumptionToken}", 59 | 'Consecutive ListRecords requests do not match.', 60 | ); 61 | break; 62 | } 63 | 64 | $index++; 65 | } 66 | } 67 | 68 | $this->finish(); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/good-2.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | record-3 10 | 2023-04-18 11 | all 12 |
13 | 14 | 16 | This is the title 17 | This is the contributor 18 | This is the description 19 | This is the publisher 20 | 2000 21 | deu 22 | This is the type 23 | https://example.org/repo/record-3 24 | 25 | 26 |
27 | 28 |
29 | record-4 30 | 2023-11-11 31 | all 32 |
33 | 34 | 36 | This is the title 37 | This is the contributor 38 | This is the description 39 | This is the publisher 40 | 2001 41 | deu 42 | This is the type 43 | https://example.org/repo/record-4 44 | 45 | 46 |
47 |
48 |
49 | -------------------------------------------------------------------------------- /api/test/xml/ListRecords/batchsize-too-small.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 2023-07-03T02:36:19Z 5 | https://example.org/opi 6 | 7 | 8 |
9 | record-1 10 | 2022-12-18 11 | all 12 |
13 | 14 | 16 | This is the title 17 | This is the contributor 18 | This is the description 19 | This is the publisher 20 | 2000 21 | deu 22 | This is the type 23 | oai: 24 | 25 | 26 |
27 | 28 |
29 | record-2 30 | 2023-01-19 31 | all 32 |
33 | 34 | 36 | This is the title 37 | This is the contributor 38 | This is the description 39 | This is the publisher 40 | 2001 41 | deu 42 | This is the type 43 | oai: 44 | 45 | 46 |
47 | token-1 48 |
49 |
50 | -------------------------------------------------------------------------------- /api/rules/M_11_9Test.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | 'Invalid response to ListRecords', 14 | getIssueText($result->issues[0]), 15 | ); 16 | 17 | $result = runRule('ListRecords/no-datacite'); 18 | $this->assertEquals('DataCite not available', $result->notice); 19 | 20 | $result = runRule('ListRecords/no-metadata'); 21 | $this->assertEquals( 22 | 'metadata is invalid in record-1', 23 | getIssueText($result->issues[0]), 24 | ); 25 | 26 | $result = runRule('ListRecords/datacite-bad-metadata'); 27 | $this->assertEquals( 28 | 'metadata is invalid in bad-metadata (DataCite)', 29 | getIssueText($result->issues[0]), 30 | ); 31 | 32 | $result = runRule('ListRecords/datacite-bad-doi'); 33 | $this->assertEquals( 34 | 'DataCite identifier is not a valid DOI in bad-doi (DataCite)', 35 | getIssueText($result->issues[0]), 36 | ); 37 | 38 | $result = runRule('ListRecords/datacite-no-match'); 39 | $this->assertEquals( 40 | 'creator does not match dc:creator in record-1 (DataCite)', 41 | getIssueText($result->issues[0]), 42 | ); 43 | $this->assertEquals( 44 | 'title does not match dc:title in record-1 (DataCite)', 45 | getIssueText($result->issues[1]), 46 | ); 47 | $this->assertEquals( 48 | 'publisher does not match dc:publisher in record-1 (DataCite)', 49 | getIssueText($result->issues[2]), 50 | ); 51 | $this->assertEquals( 52 | 'publicationYear does not match dc:issued in record-1 (DataCite)', 53 | getIssueText($result->issues[3]), 54 | ); 55 | $this->assertEquals( 56 | 'resourceType does not match dc:type in record-1 (DataCite)', 57 | getIssueText($result->issues[4]), 58 | ); 59 | 60 | $result = runRule('ListRecords-datacite/good'); 61 | $this->assertEquals(0, $result->issuesCount); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /api/classes/Route.php: -------------------------------------------------------------------------------- 1 | method"); 27 | header("Content-Type: $this->contentType"); 28 | } 29 | 30 | /** 31 | * Fail the request, i.e. send an HTTP error code and an error message, 32 | * and terminate the script. 33 | */ 34 | protected function fail(string $message, int $statusCode = 500): never 35 | { 36 | http_response_code($statusCode); 37 | $this->sendJson(['error' => $message]); 38 | exit; 39 | } 40 | 41 | /** 42 | * Read and decode a JSON file. 43 | */ 44 | protected function readJson(string $filename): mixed 45 | { 46 | $string = file_get_contents($filename); 47 | 48 | if ($string === false) { 49 | return false; 50 | } 51 | 52 | $result = json_decode($string); 53 | 54 | return $result; 55 | } 56 | 57 | /** 58 | * Sanitize a rule ID to be used as class name. 59 | */ 60 | protected function sanitizeRuleId(string $ruleId): ?string 61 | { 62 | return preg_replace('#[^a-zA-Z0-9_]#', '', $ruleId); 63 | } 64 | 65 | /** 66 | * Sanitize a task ID to be used as directory name. 67 | */ 68 | protected function sanitizeTaskId(string $taskId): ?string 69 | { 70 | return preg_replace('#[^\p{L}0-9.,;!=\[\]@%~+-]#', '', $taskId); 71 | } 72 | 73 | /** 74 | * Output payload to the browser as JSON. 75 | */ 76 | protected function sendJson(mixed $payload): void 77 | { 78 | $json = json_encode($payload, Config::$jsonFlags); 79 | 80 | if ($json) { 81 | echo $json; 82 | } else { 83 | $this->fail('Encoding error: ' . json_last_error_msg()); 84 | } 85 | } 86 | 87 | /** 88 | * Serve route 89 | * 90 | * For being overridden in child routes. 91 | */ 92 | protected function serve(): void 93 | { 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /public/dini-logo.svg: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------