Source: ragno/models/RDFElement.js

/**
 * RDFElement.js - Base class for all Ragno RDF elements
 * 
 * This abstract base class provides common functionality for all ragno:Element
 * instances, including RDF triple management, SKOS properties, and provenance
 * tracking following the ragno ontology specification.
 * 
 * Key Features:
 * - RDF-Ext integration for triple management
 * - SKOS Concept compliance
 * - Provenance tracking with PROV-O
 * - URI management and validation
 * - Common ragno properties implementation
 */

import rdf from 'rdf-ext'
import NamespaceManager from '../core/NamespaceManager.js'
import { logger } from '../../Utils.js'

export default class RDFElement {
    /**
     * Get the URI of this element
     * @returns {string} The URI string
     */
    getURI() {
        return this.uri;
    }
    constructor(options = {}) {
        // Initialize namespace manager
        this.ns = new NamespaceManager(options)

        // Create or use provided dataset
        this.dataset = options.dataset || rdf.dataset()

        // Generate or use provided URI
        this.uri = options.uri || this.generateURI(options.type || 'element')
        this.node = rdf.namedNode(this.uri)

        // Track creation metadata
        this.created = new Date()
        this.modified = new Date()

        // Initialize as ragno:Element and skos:Concept
        this.addType(this.ns.classes.Element)
        this.addType(this.ns.skos.Concept)

        // Add creation timestamp
        this.addTriple(
            this.ns.dcProperties.created,
            rdf.literal(this.created.toISOString(), this.ns.xsd.dateTime)
        )

        logger.debug(`Created RDFElement: ${this.uri}`)
    }

    /**
     * Generate a unique URI for this element
     * @param {string} type - Element type
     * @returns {string} Generated URI
     */
    generateURI(type) {
        const timestamp = Date.now()
        const random = Math.random().toString(36).substring(2, 8)
        return `${this.ns.uriBase}${type}/${timestamp}-${random}`
    }

    /**
     * Add a triple to the dataset with this element as subject
     * @param {NamedNode} predicate - Predicate
     * @param {NamedNode|Literal} object - Object
     * @param {NamedNode} [graph] - Optional graph context
     */
    addTriple(predicate, object, graph) {
        const quad = graph
            ? rdf.quad(this.node, predicate, object, graph)
            : rdf.quad(this.node, predicate, object)
        this.dataset.add(quad)
        this.updateModified()
    }

    /**
     * Remove a triple from the dataset
     * @param {NamedNode} predicate - Predicate
     * @param {NamedNode|Literal} [object] - Object (optional for removing all)
     */
    removeTriple(predicate, object = null) {
        const toRemove = [...this.dataset.match(this.node, predicate, object)]
        for (const quad of toRemove) {
            this.dataset.delete(quad)
        }
        this.updateModified()
    }

    /**
     * Add RDF type to this element
     * @param {NamedNode} type - RDF type
     */
    addType(type) {
        this.addTriple(this.ns.rdf.type, type)
    }

    /**
     * Set content for this element
     * @param {string} content - Text content
     */
    setContent(content) {
        this.removeTriple(this.ns.properties.content)
        this.addTriple(this.ns.properties.content, rdf.literal(content))
    }

    /**
     * Get content of this element (or empty string if not set)
     * @returns {string} Content text
     */
    getContent() {
        const quads = [...this.dataset.match(this.node, this.ns.properties.content)];
        return quads.length > 0 && quads[0].object.value ? quads[0].object.value : '';
    }

    /**
     * Set SKOS preferred label
     * @param {string} label - Label text
     * @param {string} [lang='en'] - Language tag
     */
    setPrefLabel(label, lang = 'en') {
        this.removeTriple(this.ns.skosProperties.prefLabel)
        this.addTriple(this.ns.skosProperties.prefLabel, rdf.literal(label, lang))
    }

    /**
     * Get SKOS preferred label
     * @returns {string|null} Label text
     */
    getPrefLabel() {
        const quads = [...this.dataset.match(this.node, this.ns.skosProperties.prefLabel)]
        return quads.length > 0 ? quads[0].object.value : null
    }

    /**
     * Add SKOS alternative label
     * @param {string} label - Alternative label
     * @param {string} [lang='en'] - Language tag
     */
    addAltLabel(label, lang = 'en') {
        this.addTriple(this.ns.skosProperties.altLabel, rdf.literal(label, lang))
    }

    /**
     * Set whether this element is an entry point
     * @param {boolean} isEntryPoint - Entry point status
     */
    setEntryPoint(isEntryPoint) {
        this.removeTriple(this.ns.properties.isEntryPoint)
        this.addTriple(this.ns.properties.isEntryPoint, rdf.literal(isEntryPoint))
    }

    /**
     * Check if this element is an entry point
     * @returns {boolean} Entry point status
     */
    isEntryPoint() {
        const quads = [...this.dataset.match(this.node, this.ns.properties.isEntryPoint)]
        if (quads.length > 0) {
            return quads[0].object.value === 'true'
        }
        return false
    }

    /**
     * Set sub-type for this element
     * @param {string} subType - Sub-type identifier
     */
    setSubType(subType) {
        this.removeTriple(this.ns.properties.subType)
        this.addTriple(this.ns.properties.subType, this.ns.ex(subType))
    }

    /**
     * Get sub-type of this element
     * @returns {string|null} Sub-type identifier
     */
    getSubType() {
        const quads = [...this.dataset.match(this.node, this.ns.properties.subType)]
        if (quads.length > 0) {
            const uri = quads[0].object.value
            return uri.substring(this.ns.uriBase.length)
        }
        return null
    }

    /**
     * Connect this element to another element
     * @param {RDFElement|NamedNode} target - Target element
     * @param {number} [weight] - Optional weight
     */
    connectTo(target, weight) {
        const targetNode = target instanceof RDFElement ? target.node : target
        this.addTriple(this.ns.properties.connectsTo, targetNode)

        if (weight !== undefined) {
            // Create reified statement for weight
            const connection = rdf.namedNode(this.generateURI('connection'))
            this.dataset.add(rdf.quad(connection, this.ns.rdf.subject, this.node))
            this.dataset.add(rdf.quad(connection, this.ns.rdf.predicate, this.ns.properties.connectsTo))
            this.dataset.add(rdf.quad(connection, this.ns.rdf.object, targetNode))
            this.dataset.add(rdf.quad(connection, this.ns.properties.hasWeight, rdf.literal(weight)))
        }
    }

    /**
     * Add provenance information
     * @param {string} sourceURI - Source document/entity URI
     */
    addProvenance(sourceURI) {
        this.addTriple(this.ns.provProperties.wasDerivedFrom, rdf.namedNode(sourceURI))
    }

    /**
     * Set PPR score for this element
     * @param {number} score - PPR score
     */
    setPPRScore(score) {
        this.removeTriple(this.ns.properties.hasPPRScore)
        this.addTriple(this.ns.properties.hasPPRScore, rdf.literal(score))
    }

    /**
     * Get PPR score for this element
     * @returns {number|null} PPR score
     */
    getPPRScore() {
        const quads = [...this.dataset.match(this.node, this.ns.properties.hasPPRScore)]
        return quads.length > 0 ? parseFloat(quads[0].object.value) : null
    }

    /**
     * Set similarity score for this element
     * @param {number} score - Similarity score
     */
    setSimilarityScore(score) {
        this.removeTriple(this.ns.properties.hasSimilarityScore)
        this.addTriple(this.ns.properties.hasSimilarityScore, rdf.literal(score))
    }

    /**
     * Get similarity score for this element
     * @returns {number|null} Similarity score
     */
    getSimilarityScore() {
        const quads = [...this.dataset.match(this.node, this.ns.properties.hasSimilarityScore)]
        return quads.length > 0 ? parseFloat(quads[0].object.value) : null
    }

    /**
     * Get all triples where this element is the subject
     * @returns {Array} Array of quads
     */
    getTriples() {
        return [...this.dataset.match(this.node)]
    }

    /**
     * Get all triples with specific predicate
     * @param {NamedNode} predicate - Predicate to match
     * @returns {Array} Array of matching quads
     */
    getTriplesWithPredicate(predicate) {
        return [...this.dataset.match(this.node, predicate)]
    }

    /**
     * Get all connected elements
     * @returns {Array<NamedNode>} Array of connected element URIs
     */
    getConnectedElements() {
        return this.getTriplesWithPredicate(this.ns.properties.connectsTo)
            .map(quad => quad.object)
    }

    /**
     * Check if this element has a specific type
     * @param {NamedNode} type - RDF type to check
     * @returns {boolean} True if element has this type
     */
    hasType(type) {
        return this.getTriplesWithPredicate(this.ns.rdf.type)
            .some(quad => quad.object.equals(type))
    }

    /**
     * Get all types of this element
     * @returns {Array<NamedNode>} Array of RDF types
     */
    getTypes() {
        return this.getTriplesWithPredicate(this.ns.rdf.type)
            .map(quad => quad.object)
    }

    /**
     * Update the modified timestamp
     */
    updateModified() {
        this.modified = new Date()

        // Remove existing modified triples without triggering updateModified recursion
        const toRemove = [...this.dataset.match(this.node, this.ns.dcProperties.modified)]
        for (const quad of toRemove) {
            this.dataset.delete(quad)
        }

        // Add new modified timestamp
        const quad = rdf.quad(
            this.node,
            this.ns.dcProperties.modified,
            rdf.literal(this.modified.toISOString(), this.ns.xsd.dateTime)
        )
        this.dataset.add(quad)
    }

    /**
     * Validate this element against ragno ontology constraints
     * @returns {Object} Validation result with errors array
     */
    validate() {
        const errors = []

        // Check required types
        if (!this.hasType(this.ns.classes.Element)) {
            errors.push('Element must have ragno:Element type')
        }

        if (!this.hasType(this.ns.skos.Concept)) {
            errors.push('Element must have skos:Concept type')
        }

        // Check URI format
        if (!this.ns.validateRagnoURI || !this.ns.validateRagnoURI(this.uri, 'individual')) {
            errors.push('Invalid URI format for ragno element')
        }

        return {
            valid: errors.length === 0,
            errors
        }
    }

    /**
     * Export this element as N-Triples
     * @returns {string} N-Triples representation
     */
    toNTriples() {
        return this.getTriples().map(quad => {
            const subject = `<${quad.subject.value}>`
            const predicate = `<${quad.predicate.value}>`
            const object = quad.object.termType === 'Literal'
                ? `"${quad.object.value}"${quad.object.language ? `@${quad.object.language}` : ''}${quad.object.datatype ? `^^<${quad.object.datatype.value}>` : ''}`
                : `<${quad.object.value}>`
            return `${subject} ${predicate} ${object} .`
        }).join('\n')
    }

    /**
     * Export this element as Turtle (simplified)
     * @returns {string} Turtle representation
     */
    toTurtle() {
        const prefixes = this.ns.getTurtlePrefixes()
        const compressedURI = this.ns.compress(this.uri)

        const triples = this.getTriples().map(quad => {
            const predicate = this.ns.compress(quad.predicate.value)
            const object = quad.object.termType === 'Literal'
                ? `"${quad.object.value}"${quad.object.language ? `@${quad.object.language}` : ''}${quad.object.datatype ? `^^${this.ns.compress(quad.object.datatype.value)}` : ''}`
                : this.ns.compress(quad.object.value)
            return `    ${predicate} ${object}`
        }).join(' ;\n')

        return `${prefixes}\n\n${compressedURI}\n${triples} .`
    }

    /**
     * Set a metadata property as an RDF triple
     * @param {string} property - Property name
     * @param {any} value - Property value
     */
    setMetadataProperty(property, value) {
        const predicate = this.ns.properties[property] || this.ns.ex(property)
        this.removeTriple(predicate)
        
        // Handle different value types
        let object
        if (typeof value === 'boolean') {
            object = rdf.literal(value.toString(), this.ns.xsd.boolean)
        } else if (typeof value === 'number') {
            if (Number.isInteger(value)) {
                object = rdf.literal(value.toString(), this.ns.xsd.integer)
            } else {
                object = rdf.literal(value.toString(), this.ns.xsd.double)
            }
        } else if (value instanceof Date) {
            object = rdf.literal(value.toISOString(), this.ns.xsd.dateTime)
        } else if (typeof value === 'string' && value.startsWith('http')) {
            // Assume URIs should be stored as named nodes
            object = rdf.namedNode(value)
        } else {
            // Default to string literal
            object = rdf.literal(value.toString())
        }
        
        this.addTriple(predicate, object)
    }

    /**
     * Get a metadata property value
     * @param {string} property - Property name
     * @returns {any} Property value or undefined
     */
    getMetadataProperty(property) {
        const predicate = this.ns.properties[property] || this.ns.ex(property)
        const quads = [...this.dataset.match(this.node, predicate)]
        
        if (quads.length === 0) {
            return undefined
        }
        
        const object = quads[0].object
        
        // Convert back to appropriate JavaScript type
        if (object.termType === 'Literal') {
            if (object.datatype) {
                const datatypeURI = object.datatype.value
                if (datatypeURI === this.ns.xsd.boolean.value) {
                    return object.value === 'true'
                } else if (datatypeURI === this.ns.xsd.integer.value) {
                    return parseInt(object.value)
                } else if (datatypeURI === this.ns.xsd.double.value || datatypeURI === this.ns.xsd.float.value) {
                    return parseFloat(object.value)
                } else if (datatypeURI === this.ns.xsd.dateTime.value) {
                    return new Date(object.value)
                }
            }
            return object.value
        } else if (object.termType === 'NamedNode') {
            return object.value
        }
        
        return object.value
    }

    /**
     * Get all custom metadata properties (excluding standard properties)
     * @returns {Object} Object with all custom metadata
     */
    getAllCustomMetadata() {
        const metadata = {}
        const standardPredicates = new Set([
            this.ns.rdf.type.value,
            this.ns.properties.content.value,
            this.ns.skosProperties.prefLabel.value,
            this.ns.skosProperties.altLabel.value,
            this.ns.properties.isEntryPoint.value,
            this.ns.properties.subType.value,
            this.ns.dcProperties.created.value,
            this.ns.dcProperties.modified.value,
            this.ns.properties.connectsTo.value,
            this.ns.properties.hasPPRScore.value,
            this.ns.properties.hasSimilarityScore.value
        ])
        
        for (const quad of this.getTriples()) {
            const predicateURI = quad.predicate.value
            
            // Skip standard properties
            if (standardPredicates.has(predicateURI)) {
                continue
            }
            
            // Extract property name from URI
            let propertyName
            if (predicateURI.startsWith(this.ns.uriBase)) {
                propertyName = predicateURI.substring(this.ns.uriBase.length)
            } else {
                propertyName = predicateURI.split('/').pop() || predicateURI.split('#').pop()
            }
            
            // Convert value
            const object = quad.object
            let value
            if (object.termType === 'Literal') {
                if (object.datatype) {
                    const datatypeURI = object.datatype.value
                    if (datatypeURI === this.ns.xsd.boolean.value) {
                        value = object.value === 'true'
                    } else if (datatypeURI === this.ns.xsd.integer.value) {
                        value = parseInt(object.value)
                    } else if (datatypeURI === this.ns.xsd.double.value || datatypeURI === this.ns.xsd.float.value) {
                        value = parseFloat(object.value)
                    } else if (datatypeURI === this.ns.xsd.dateTime.value) {
                        value = new Date(object.value)
                    } else {
                        value = object.value
                    }
                } else {
                    value = object.value
                }
            } else if (object.termType === 'NamedNode') {
                value = object.value
            } else {
                value = object.value
            }
            
            metadata[propertyName] = value
        }
        
        return metadata
    }

    /**
     * Set multiple metadata properties at once
     * @param {Object} metadata - Object with property/value pairs
     */
    setAllMetadata(metadata) {
        if (!metadata || typeof metadata !== 'object') {
            return
        }
        
        for (const [property, value] of Object.entries(metadata)) {
            this.setMetadataProperty(property, value)
        }
    }

    /**
     * Get element metadata (enhanced to include custom metadata)
     * @returns {Object} Element metadata including custom properties
     */
    getMetadata() {
        const standardMetadata = {
            uri: this.uri,
            types: this.getTypes().map(type => this.ns.compress(type.value)),
            prefLabel: this.getPrefLabel(),
            content: this.getContent(),
            isEntryPoint: this.isEntryPoint(),
            subType: this.getSubType(),
            created: this.created,
            modified: this.modified,
            tripleCount: this.getTriples().length,
            connections: this.getConnectedElements().length
        }
        
        // Merge with custom metadata
        const customMetadata = this.getAllCustomMetadata()
        return { ...standardMetadata, ...customMetadata }
    }

    /**
     * Clone this element with a new URI
     * @param {Object} [options] - Options for the cloned element
     * @returns {RDFElement} Cloned element
     */
    clone(options = {}) {
        const cloned = new RDFElement({
            ...options,
            type: options.type || 'element'
        })

        // Copy all triples except URI-specific ones
        for (const quad of this.getTriples()) {
            if (!quad.predicate.equals(this.ns.dcProperties.created)) {
                cloned.addTriple(quad.predicate, quad.object)
            }
        }

        return cloned
    }
    /**
     * Export all triples of this element to a target dataset
     * @param {Dataset} targetDataset - The RDF-Ext dataset to export to
     */
    exportToDataset(targetDataset) {
        for (const quad of this.getTriples()) {
            targetDataset.add(quad)
        }
    }
}