diff --git a/package-lock.json b/package-lock.json index 7be0936..978ffc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "@citation-js/core": "^0.7.18", "@citation-js/plugin-cff": "^0.6.1", - "js-yaml": "^4.1.0" + "js-yaml": "^4.1.0", + "n3": "^1.21.0" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.8", @@ -1306,6 +1307,18 @@ "dev": true, "license": "MIT" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1553,6 +1566,24 @@ "@jridgewell/sourcemap-codec": "^1.4.15" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1970,6 +2001,43 @@ "dev": true, "license": "MIT" }, + "node_modules/n3": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", + "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=12.0" + } + }, + "node_modules/n3/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -2058,6 +2126,55 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readable-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2127,6 +2244,26 @@ "node": ">=6" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/set-cookie-parser": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", @@ -2159,6 +2296,15 @@ "node": ">=0.10.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/svelte": { "version": "5.39.6", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.39.6.tgz", diff --git a/package.json b/package.json index 1224e09..a685140 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "dependencies": { "@citation-js/core": "^0.7.18", "@citation-js/plugin-cff": "^0.6.1", - "js-yaml": "^4.1.0" + "js-yaml": "^4.1.0", + "n3": "^1.21.0" } } diff --git a/src/lib/exporters/doap-exporter.ts b/src/lib/exporters/doap-exporter.ts new file mode 100644 index 0000000..82fc638 --- /dev/null +++ b/src/lib/exporters/doap-exporter.ts @@ -0,0 +1,633 @@ +// DOAP (Description of a Project) RDF exporter + +import { BaseExporter } from './base-exporter.js'; +import type { UnifiedSoftwareMetadata, Author } from '../unified-metadata.js'; + +export interface DoapConfig { + maintainer?: Author; + format?: 'turtle' | 'rdfxml'; +} + +export class DoapExporter extends BaseExporter { + format = 'doap'; + displayName = 'DOAP'; + description = 'RDF vocabulary for describing software projects (Description of a Project)'; + mimeType = 'application/rdf+xml'; + fileExtension = 'rdf'; + + // DOAP namespaces + private readonly DOAP_NS = 'http://usefulinc.com/ns/doap#'; + private readonly FOAF_NS = 'http://xmlns.com/foaf/0.1/'; + private readonly RDF_NS = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; + private readonly RDFS_NS = 'http://www.w3.org/2000/01/rdf-schema#'; + private readonly SPDX_NS = 'http://spdx.org/licenses/'; + + export(data: UnifiedSoftwareMetadata, doapConfig?: DoapConfig): string { + console.log('🔄 Exporting to DOAP format...'); + + const format = doapConfig?.format || 'turtle'; + const projectUri = this.generateProjectUri(data); + const triples: Array<{ subject: string; predicate: string; object: string; isUri: boolean }> = []; + + // Add type + triples.push({ + subject: projectUri, + predicate: `${this.RDF_NS}type`, + object: `${this.DOAP_NS}Project`, + isUri: true + }); + + // Required fields + triples.push({ + subject: projectUri, + predicate: `${this.DOAP_NS}name`, + object: data.name, + isUri: false + }); + + triples.push({ + subject: projectUri, + predicate: `${this.DOAP_NS}description`, + object: data.description, + isUri: false + }); + + // Short description (first sentence) + const shortDesc = this.getShortDescription(data.description); + if (shortDesc) { + triples.push({ + subject: projectUri, + predicate: `${this.DOAP_NS}shortdesc`, + object: shortDesc, + isUri: false + }); + } + + // Maintainer (required) + const maintainer = this.resolveMaintainer(data, doapConfig); + if (maintainer) { + const maintainerUri = this.generatePersonUri(maintainer, 'maintainer'); + this.addPersonTriples(triples, maintainerUri, maintainer); + triples.push({ + subject: projectUri, + predicate: `${this.DOAP_NS}maintainer`, + object: maintainerUri, + isUri: true + }); + } + + // Homepage + const homepage = data.citation.url || data.repository.htmlUrl; + if (homepage) { + triples.push({ + subject: projectUri, + predicate: `${this.DOAP_NS}homepage`, + object: homepage, + isUri: true + }); + } + + // License (SPDX URIs) + if (data.license.license) { + const licenses = Array.isArray(data.license.license) + ? data.license.license + : [data.license.license]; + + for (const license of licenses) { + const spdxUri = this.convertLicenseToSpdxUri(license); + if (spdxUri) { + triples.push({ + subject: projectUri, + predicate: `${this.DOAP_NS}license`, + object: spdxUri, + isUri: true + }); + } + } + } + + // Programming language + if (data.programmingLanguage && data.programmingLanguage !== 'Unknown') { + triples.push({ + subject: projectUri, + predicate: `${this.DOAP_NS}programming-language`, + object: data.programmingLanguage, + isUri: false + }); + } + + // Categories (from keywords) + if (data.keywords && data.keywords.length > 0) { + for (const keyword of data.keywords) { + triples.push({ + subject: projectUri, + predicate: `${this.DOAP_NS}category`, + object: keyword, + isUri: false + }); + } + } + + // Repository + if (data.repository.cloneUrl || data.repository.htmlUrl) { + const repoUri = this.generateRepositoryUri(data); + triples.push({ + subject: projectUri, + predicate: `${this.DOAP_NS}repository`, + object: repoUri, + isUri: true + }); + triples.push({ + subject: repoUri, + predicate: `${this.RDF_NS}type`, + object: `${this.DOAP_NS}GitRepository`, + isUri: true + }); + + if (data.repository.cloneUrl) { + triples.push({ + subject: repoUri, + predicate: `${this.DOAP_NS}location`, + object: data.repository.cloneUrl, + isUri: true + }); + } + + if (data.repository.htmlUrl) { + triples.push({ + subject: repoUri, + predicate: `${this.DOAP_NS}browse`, + object: data.repository.htmlUrl, + isUri: true + }); + } + } + + // Developers (authors) + if (data.authors && data.authors.length > 0) { + for (const author of data.authors) { + const authorUri = this.generatePersonUri(author, 'developer'); + this.addPersonTriples(triples, authorUri, author); + triples.push({ + subject: projectUri, + predicate: `${this.DOAP_NS}developer`, + object: authorUri, + isUri: true + }); + } + } + + // Revision + if (data.version) { + triples.push({ + subject: projectUri, + predicate: `${this.DOAP_NS}revision`, + object: data.version, + isUri: false + }); + } + + // Release (structured version) + if (data.version && data.citation.dateReleased) { + const releaseUri = this.generateReleaseUri(data); + triples.push({ + subject: projectUri, + predicate: `${this.DOAP_NS}release`, + object: releaseUri, + isUri: true + }); + triples.push({ + subject: releaseUri, + predicate: `${this.RDF_NS}type`, + object: `${this.DOAP_NS}Version`, + isUri: true + }); + triples.push({ + subject: releaseUri, + predicate: `${this.DOAP_NS}revision`, + object: data.version, + isUri: false + }); + triples.push({ + subject: releaseUri, + predicate: `${this.DOAP_NS}created`, + object: data.citation.dateReleased, + isUri: false + }); + } + + // Serialize to requested format + if (format === 'turtle') { + console.log('✅ DOAP export completed'); + return this.serializeToTurtle(triples); + } else { + console.log('✅ DOAP export completed'); + return this.serializeToRdfXml(triples); + } + } + + /** + * Resolve maintainer with fallback chain + */ + private resolveMaintainer(data: UnifiedSoftwareMetadata, doapConfig?: DoapConfig): Author | null { + // 1. From doapConfig (user-editable) + if (doapConfig?.maintainer) { + return doapConfig.maintainer; + } + + // 2. Extract owner from repository fullName + if (data.repository.fullName) { + const parts = data.repository.fullName.split('/'); + if (parts.length >= 1 && parts[0]) { + return { + name: parts[0] + }; + } + } + + // 3. Use first author as fallback + if (data.authors && data.authors.length > 0) { + return data.authors[0]; + } + + return null; + } + + /** + * Add FOAF Person triples + */ + private addPersonTriples(triples: Array<{ subject: string; predicate: string; object: string; isUri: boolean }>, personUri: string, person: Author): void { + triples.push({ + subject: personUri, + predicate: `${this.RDF_NS}type`, + object: `${this.FOAF_NS}Person`, + isUri: true + }); + + // Name + if (person.name) { + triples.push({ + subject: personUri, + predicate: `${this.FOAF_NS}name`, + object: person.name, + isUri: false + }); + } else if (person.givenNames || person.familyNames) { + const fullName = [person.givenNames, person.familyNames].filter(Boolean).join(' '); + if (fullName) { + triples.push({ + subject: personUri, + predicate: `${this.FOAF_NS}name`, + object: fullName, + isUri: false + }); + } + } + + // Given name + if (person.givenNames) { + triples.push({ + subject: personUri, + predicate: `${this.FOAF_NS}givenName`, + object: person.givenNames, + isUri: false + }); + } + + // Family name + if (person.familyNames) { + triples.push({ + subject: personUri, + predicate: `${this.FOAF_NS}familyName`, + object: person.familyNames, + isUri: false + }); + } + + // Email + if (person.email) { + triples.push({ + subject: personUri, + predicate: `${this.FOAF_NS}mbox`, + object: `mailto:${person.email}`, + isUri: true + }); + } + + // ORCID + if (person.orcid) { + const orcidUri = person.orcid.startsWith('http') ? person.orcid : `https://orcid.org/${person.orcid}`; + triples.push({ + subject: personUri, + predicate: `${this.FOAF_NS}account`, + object: orcidUri, + isUri: true + }); + } + } + + /** + * Convert license string to SPDX URI + */ + private convertLicenseToSpdxUri(license: string): string | null { + if (!license) return null; + + // Normalize license string + const normalized = license + .trim() + .replace(/\s+/g, '-') + .replace(/[()]/g, '') + .toUpperCase(); + + // Common license mappings + const licenseMap: Record = { + 'MIT': 'MIT', + 'APACHE-2.0': 'Apache-2.0', + 'APACHE2': 'Apache-2.0', + 'APACHE': 'Apache-2.0', + 'GPL-2.0': 'GPL-2.0', + 'GPL-3.0': 'GPL-3.0', + 'GPL2': 'GPL-2.0', + 'GPL3': 'GPL-3.0', + 'BSD-2-CLAUSE': 'BSD-2-Clause', + 'BSD-3-CLAUSE': 'BSD-3-Clause', + 'BSD2': 'BSD-2-Clause', + 'BSD3': 'BSD-3-Clause', + 'LGPL-2.1': 'LGPL-2.1', + 'LGPL-3.0': 'LGPL-3.0', + 'MPL-2.0': 'MPL-2.0', + 'AGPL-3.0': 'AGPL-3.0', + 'UNLICENSE': 'Unlicense', + 'CC0-1.0': 'CC0-1.0' + }; + + const spdxId = licenseMap[normalized] || normalized; + return `${this.SPDX_NS}${spdxId}`; + } + + /** + * Get short description (first sentence) + */ + private getShortDescription(description: string): string { + if (!description) return ''; + + const match = description.match(/^[^.!?]+[.!?]/); + if (match) { + return match[0].trim(); + } + + return description.substring(0, 100).trim(); + } + + /** + * Generate project URI + */ + private generateProjectUri(data: UnifiedSoftwareMetadata): string { + if (data.repository.htmlUrl) { + return `${data.repository.htmlUrl}#project`; + } + return `https://example.com/${data.name.replace(/[^a-zA-Z0-9]/g, '-')}#project`; + } + + /** + * Generate person URI + */ + private generatePersonUri(person: Author, role: string): string { + if (person.orcid) { + const orcidUri = person.orcid.startsWith('http') ? person.orcid : `https://orcid.org/${person.orcid}`; + return `${orcidUri}#${role}`; + } + if (person.email) { + return `mailto:${person.email}#${role}`; + } + const name = person.name || [person.givenNames, person.familyNames].filter(Boolean).join(' ') || 'unknown'; + return `https://example.com/person/${encodeURIComponent(name)}#${role}`; + } + + /** + * Generate repository URI + */ + private generateRepositoryUri(data: UnifiedSoftwareMetadata): string { + if (data.repository.htmlUrl) { + return `${data.repository.htmlUrl}#repository`; + } + return `https://example.com/repo/${data.name.replace(/[^a-zA-Z0-9]/g, '-')}#repository`; + } + + /** + * Generate release URI + */ + private generateReleaseUri(data: UnifiedSoftwareMetadata): string { + const baseUri = data.repository.htmlUrl || `https://example.com/${data.name.replace(/[^a-zA-Z0-9]/g, '-')}`; + return `${baseUri}#release-${data.version}`; + } + + /** + * Serialize triples to Turtle format + */ + private serializeToTurtle(triples: Array<{ subject: string; predicate: string; object: string; isUri: boolean }>): string { + let output = `@prefix doap: <${this.DOAP_NS}> . +@prefix foaf: <${this.FOAF_NS}> . +@prefix rdf: <${this.RDF_NS}> . +@prefix rdfs: <${this.RDFS_NS}> . + +`; + + // Group triples by subject + const bySubject = new Map>(); + for (const triple of triples) { + if (!bySubject.has(triple.subject)) { + bySubject.set(triple.subject, []); + } + bySubject.get(triple.subject)!.push({ + predicate: triple.predicate, + object: triple.object, + isUri: triple.isUri + }); + } + + // Write each subject block + for (const [subject, predicates] of bySubject.entries()) { + output += this.formatUri(subject) + '\n'; + for (let i = 0; i < predicates.length; i++) { + const pred = predicates[i]; + const isLast = i === predicates.length - 1; + const indent = ' '; + output += indent + this.formatPredicate(pred.predicate) + ' ' + this.formatObject(pred.object, pred.isUri); + output += isLast ? ' .\n\n' : ' ;\n'; + } + } + + return output; + } + + /** + * Serialize triples to RDF/XML format + */ + private serializeToRdfXml(triples: Array<{ subject: string; predicate: string; object: string; isUri: boolean }>): string { + let output = ` + +`; + + // Group triples by subject + const bySubject = new Map>(); + for (const triple of triples) { + if (!bySubject.has(triple.subject)) { + bySubject.set(triple.subject, []); + } + bySubject.get(triple.subject)!.push({ + predicate: triple.predicate, + object: triple.object, + isUri: triple.isUri + }); + } + + // Write each subject as an element + for (const [subject, predicates] of bySubject.entries()) { + // Determine element type from first predicate if it's rdf:type + let elementType = 'rdf:Description'; + const typePred = predicates.find(p => p.predicate === `${this.RDF_NS}type`); + if (typePred && typePred.isUri) { + elementType = this.formatQName(typePred.object); + } + + output += ` <${elementType} rdf:about="${this.escapeXml(subject)}">\n`; + + for (const pred of predicates) { + if (pred.predicate === `${this.RDF_NS}type`) continue; // Already used for element type + + const qname = this.formatQName(pred.predicate); + if (pred.isUri) { + output += ` <${qname} rdf:resource="${this.escapeXml(pred.object)}"/>\n`; + } else { + output += ` <${qname}>${this.escapeXml(pred.object)}\n`; + } + } + + output += ` \n`; + } + + output += '\n'; + return output; + } + + /** + * Format URI with prefix if possible + */ + private formatUri(uri: string): string { + if (uri.startsWith(this.DOAP_NS)) { + return `doap:${uri.substring(this.DOAP_NS.length)}`; + } + if (uri.startsWith(this.FOAF_NS)) { + return `foaf:${uri.substring(this.FOAF_NS.length)}`; + } + if (uri.startsWith(this.RDF_NS)) { + return `rdf:${uri.substring(this.RDF_NS.length)}`; + } + return `<${uri}>`; + } + + /** + * Format predicate with prefix if possible + */ + private formatPredicate(predicate: string): string { + return this.formatUri(predicate); + } + + /** + * Format object (URI or literal) + */ + private formatObject(obj: string, isUri: boolean): string { + if (isUri) { + return this.formatUri(obj); + } + // Escape special characters in literals + const escaped = obj.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); + return `"${escaped}"`; + } + + /** + * Format QName for RDF/XML + */ + private formatQName(uri: string): string { + if (uri.startsWith(this.DOAP_NS)) { + return `doap:${uri.substring(this.DOAP_NS.length)}`; + } + if (uri.startsWith(this.FOAF_NS)) { + return `foaf:${uri.substring(this.FOAF_NS.length)}`; + } + if (uri.startsWith(this.RDF_NS)) { + return `rdf:${uri.substring(this.RDF_NS.length)}`; + } + if (uri.startsWith(this.RDFS_NS)) { + return `rdfs:${uri.substring(this.RDFS_NS.length)}`; + } + return uri; + } + + /** + * Escape XML special characters + */ + private escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + validate(data: UnifiedSoftwareMetadata, doapConfig?: DoapConfig): string[] { + const errors = super.validate(data); + + // DOAP specific validations + const maintainer = this.resolveMaintainer(data, doapConfig); + if (!maintainer) { + errors.push('Maintainer is required for DOAP (could not resolve from repository owner or authors)'); + } + + // Validate URIs if present + if (data.citation.url && !this.isValidUri(data.citation.url)) { + errors.push('Invalid URL in citation'); + } + + if (data.repository.htmlUrl && !this.isValidUri(data.repository.htmlUrl)) { + errors.push('Invalid repository URL'); + } + + return errors; + } + + /** + * Check if string is a valid URI + */ + private isValidUri(uri: string): boolean { + try { + new URL(uri); + return true; + } catch { + return false; + } + } + + /** + * Override download to accept DOAP config + */ + download(data: UnifiedSoftwareMetadata, filename?: string, doapConfig?: DoapConfig): void { + const content = this.export(data, doapConfig); + const blob = new Blob([content], { type: this.mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + const ext = doapConfig?.format === 'turtle' ? 'ttl' : 'rdf'; + link.download = filename || `${data.name.replace(/[^a-zA-Z0-9]/g, '_')}.${ext}`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } +} diff --git a/src/lib/exporters/export-registry.ts b/src/lib/exporters/export-registry.ts index c274670..0d3b696 100644 --- a/src/lib/exporters/export-registry.ts +++ b/src/lib/exporters/export-registry.ts @@ -2,6 +2,7 @@ import { CodeMetaExporter } from './codemeta-exporter.js'; import { GalaxyExporter } from './galaxy-exporter.js'; +import { DoapExporter } from './doap-exporter.js'; import type { ExportFormat, MetadataExporter } from './base-exporter.js'; // Export format registry @@ -19,6 +20,13 @@ const exportFormats: ExportFormat[] = [ description: 'Galaxy tool.xml format for tool integration', icon: '🌌', exporter: new GalaxyExporter() + }, + { + id: 'doap', + name: 'DOAP', + description: 'RDF vocabulary for describing software projects (Description of a Project)', + icon: '📋', + exporter: new DoapExporter() } // Future formats will be added here: // { diff --git a/src/lib/unified-metadata.ts b/src/lib/unified-metadata.ts index 243470a..0230268 100644 --- a/src/lib/unified-metadata.ts +++ b/src/lib/unified-metadata.ts @@ -121,6 +121,11 @@ export interface UnifiedSoftwareMetadata { // Galaxy-specific configuration (optional) galaxyConfig?: GalaxyExportConfig; + + // DOAP-specific configuration (optional) + doapConfig?: { + maintainer?: Author; + }; } /** @@ -217,6 +222,22 @@ export function createUnifiedMetadata( containerVersion: repositoryVersion, outputs: [], profile: '24.0' + }, + + // DOAP-specific configuration (initialized with resolved maintainer) + doapConfig: { + maintainer: (() => { + // Priority: repository owner → first author + if (repoInfo?.owner) { + return { + name: repoInfo.owner + }; + } + if (authors.length > 0) { + return authors[0]; + } + return undefined; + })() } }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 7837800..6701eac 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -3,7 +3,8 @@ import { analysisStore, startAnalysis, cancelAnalysis, resetAnalysis } from '$lib/analysis-engine.js'; import type { AnalysisState } from '$lib/types.js'; import { getExportFormats, getDefaultExportFormat } from '$lib/exporters/export-registry.js'; - import type { UnifiedSoftwareMetadata, GalaxyOutput, GalaxyExportConfig } from '$lib/unified-metadata.js'; + import type { UnifiedSoftwareMetadata, GalaxyOutput, GalaxyExportConfig, Author } from '$lib/unified-metadata.js'; + import type { DoapConfig } from '$lib/exporters/doap-exporter.js'; let searchValue = $state(''); let isSearching = $state(false); @@ -37,6 +38,10 @@ let galaxyContainer: string = $state(''); let galaxyContainerVersion: string = $state(''); + // DOAP-specific state + let doapMaintainer: Author | null = $state(null); + let doapOutputFormat: 'turtle' | 'rdfxml' = $state('turtle'); + // Subscribe to analysis store analysisStore.subscribe(state => { analysisState = state; @@ -59,6 +64,22 @@ galaxyContainer = `ghcr.io/${state.metadata.repository.fullName}:latest`; } + // Initialize DOAP maintainer from metadata or resolve from repository + if (state.metadata) { + if (state.metadata.doapConfig?.maintainer) { + doapMaintainer = state.metadata.doapConfig.maintainer; + } else if (state.metadata.repository?.fullName) { + // Extract owner from fullName (owner/repo format) + const owner = state.metadata.repository.fullName.split('/')[0]; + if (owner) { + doapMaintainer = { name: owner }; + } + } else if (state.metadata.authors && state.metadata.authors.length > 0) { + // Use first author as fallback + doapMaintainer = state.metadata.authors[0]; + } + } + // Auto-collapse analysis after 2 seconds if (autoCollapseTimer) { clearTimeout(autoCollapseTimer); @@ -137,6 +158,15 @@ if ('download' in format.exporter) { (format.exporter as any).download(analysisState.metadata, undefined, galaxyConfig); } + } else if (selectedExportFormat === 'doap') { + const doapConfig: DoapConfig = { + maintainer: doapMaintainer || undefined, + format: doapOutputFormat + }; + + if ('download' in format.exporter) { + (format.exporter as any).download(analysisState.metadata, undefined, doapConfig); + } } else { if ('download' in format.exporter) { (format.exporter as any).download(analysisState.metadata); @@ -163,6 +193,16 @@ return (format.exporter as any).export(analysisState.metadata, galaxyConfig); } + // For DOAP export, we need to pass the config + if (selectedExportFormat === 'doap') { + const doapConfig: DoapConfig = { + maintainer: doapMaintainer || undefined, + format: doapOutputFormat + }; + + return (format.exporter as any).export(analysisState.metadata, doapConfig); + } + return format.exporter.export(analysisState.metadata); } @@ -549,6 +589,88 @@ {/if} + + {#if selectedExportFormat === 'doap'} + +
+ +
+
+ + doapMaintainer = { ...(doapMaintainer || {}), name: e.currentTarget.value } as Author} + placeholder="Full name or given/family names" + class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+
+ + doapMaintainer = { ...(doapMaintainer || {}), givenNames: e.currentTarget.value } as Author} + placeholder="First name" + class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + doapMaintainer = { ...(doapMaintainer || {}), familyNames: e.currentTarget.value } as Author} + placeholder="Last name" + class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+
+ + doapMaintainer = { ...(doapMaintainer || {}), email: e.currentTarget.value } as Author} + placeholder="maintainer@example.com" + class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + doapMaintainer = { ...(doapMaintainer || {}), orcid: e.currentTarget.value } as Author} + placeholder="0000-0000-0000-0000" + class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+

+ If left empty, the maintainer will be automatically resolved from the repository owner or first author. +

+
+ + +
+ + +

+ Choose the RDF serialization format for the DOAP export. +

+
+ {/if} +