Source: ragno/Attribute.js

/**
 * Attribute.js - RDF-based Attribute implementation for Ragno
 * 
 * This class represents ragno:Attribute as an RDF resource following the ragno ontology
 * specification. Attributes describe properties or characteristics of entities,
 * providing detailed information that enhances entity understanding.
 * 
 * Key Features:
 * - First-class RDF resource following ragno:Attribute
 * - SKOS Concept compliance for semantic interoperability
 * - Entity association via ragno:hasAttribute relationship
 * - Sub-type classification (Overview, Technical, Historical, etc.)
 * - Provenance tracking for source information
 * - Summary and detailed content management
 */

import rdf from 'rdf-ext'
import RDFElement from './models/RDFElement.js'
import { logger } from '../Utils.js'

export default class Attribute extends RDFElement {
    constructor(options = {}) {
        // Initialize with attribute type
        super({
            ...options,
            type: 'attribute'
        })
        
        // Add ragno:Attribute type
        this.addType(this.ns.classes.Attribute)
        
        // Set text content if provided
        if (options.text || options.content) {
            const content = options.text || options.content
            this.setContent(content)
        }
        
        // Set summary if provided (as SKOS definition)
        if (options.summary) {
            this.setSummary(options.summary)
        }
        
        // Set entity association if provided
        if (options.entity) {
            this.setEntity(options.entity)
        }
        
        // Set sub-type if provided (e.g., "Overview", "Technical", "Historical")
        if (options.subType) {
            this.setSubType(options.subType)
        }
        
        // Set provenance information if provided
        if (options.provenance) {
            this.setProvenance(options.provenance)
        }
        
        // Attributes are typically not entry points (entities are)
        this.setEntryPoint(options.isEntryPoint !== undefined ? options.isEntryPoint : false)
        
        // Set confidence score if provided
        if (options.confidence !== undefined) {
            this.setConfidence(options.confidence)
        }
        
        logger.debug(`Created ragno:Attribute: ${this.uri}`)
    }
    
    /**
     * Set the main text content for this attribute
     * @param {string} text - Text content
     */
    setText(text) {
        this.setContent(text)
    }
    
    /**
     * Get the main text content of this attribute
     * @returns {string|null} Text content
     */
    getText() {
        return this.getContent()
    }
    
    /**
     * Set summary for this attribute (stored as SKOS definition)
     * @param {string} summary - Summary text
     * @param {string} [lang='en'] - Language tag
     */
    setSummary(summary, lang = 'en') {
        this.removeTriple(this.ns.skosProperties.definition)
        this.addTriple(this.ns.skosProperties.definition, rdf.literal(summary, lang))
    }
    
    /**
     * Get summary for this attribute
     * @returns {string|null} Summary text
     */
    getSummary() {
        const quads = this.getTriplesWithPredicate(this.ns.skosProperties.definition)
        return quads.length > 0 ? quads[0].object.value : null
    }
    
    /**
     * Set the entity this attribute describes
     * @param {Entity|NamedNode|string} entity - Entity reference
     */
    setEntity(entity) {
        this.removeTriple(this.ns.ex('describesEntity'))
        const entityNode = this._normalizeEntityReference(entity)
        this.addTriple(this.ns.ex('describesEntity'), entityNode)
    }
    
    /**
     * Get the entity this attribute describes
     * @returns {NamedNode|null} Entity node
     */
    getEntity() {
        const quads = this.getTriplesWithPredicate(this.ns.ex('describesEntity'))
        return quads.length > 0 ? quads[0].object : null
    }
    
    /**
     * Set provenance information for this attribute
     * @param {string|NamedNode} provenance - Provenance source URI or node
     */
    setProvenance(provenance) {
        const provenanceNode = typeof provenance === 'string' ? rdf.namedNode(provenance) : provenance
        this.addProvenance(provenanceNode.value)
    }
    
    /**
     * Get provenance information for this attribute
     * @returns {Array<NamedNode>} Provenance nodes
     */
    getProvenance() {
        return this.getTriplesWithPredicate(this.ns.provProperties.wasDerivedFrom)
            .map(quad => quad.object)
    }
    
    /**
     * Set confidence score for this attribute
     * @param {number} confidence - Confidence score (0-1)
     */
    setConfidence(confidence) {
        this.removeTriple(this.ns.ex('confidence'))
        this.addTriple(this.ns.ex('confidence'), rdf.literal(confidence))
    }
    
    /**
     * Get confidence score for this attribute
     * @returns {number|null} Confidence score
     */
    getConfidence() {
        const quads = this.getTriplesWithPredicate(this.ns.ex('confidence'))
        return quads.length > 0 ? parseFloat(quads[0].object.value) : null
    }
    
    /**
     * Set the attribute category/type (more specific than subType)
     * @param {string} category - Attribute category
     */
    setCategory(category) {
        this.removeTriple(this.ns.ex('category'))
        this.addTriple(this.ns.ex('category'), rdf.literal(category))
    }
    
    /**
     * Get the attribute category/type
     * @returns {string|null} Attribute category
     */
    getCategory() {
        const quads = this.getTriplesWithPredicate(this.ns.ex('category'))
        return quads.length > 0 ? quads[0].object.value : null
    }
    
    /**
     * Set temporal information for this attribute (when it was true/relevant)
     * @param {Date|string} timestamp - Temporal information
     */
    setTemporal(timestamp) {
        this.removeTriple(this.ns.ex('temporal'))
        const date = timestamp instanceof Date ? timestamp : new Date(timestamp)
        this.addTriple(
            this.ns.ex('temporal'),
            rdf.literal(date.toISOString(), this.ns.xsd.dateTime)
        )
    }
    
    /**
     * Get temporal information for this attribute
     * @returns {Date|null} Temporal information
     */
    getTemporal() {
        const quads = this.getTriplesWithPredicate(this.ns.ex('temporal'))
        return quads.length > 0 ? new Date(quads[0].object.value) : null
    }
    
    /**
     * Set corpus association for this attribute
     * @param {string|NamedNode} corpus - Corpus URI or node
     */
    setCorpus(corpus) {
        this.removeTriple(this.ns.properties.inCorpus)
        const corpusNode = typeof corpus === 'string' ? rdf.namedNode(corpus) : corpus
        this.addTriple(this.ns.properties.inCorpus, corpusNode)
    }
    
    /**
     * Get corpus association for this attribute
     * @returns {NamedNode|null} Corpus node
     */
    getCorpus() {
        const quads = this.getTriplesWithPredicate(this.ns.properties.inCorpus)
        return quads.length > 0 ? quads[0].object : null
    }
    
    /**
     * Add supporting evidence for this attribute
     * @param {SemanticUnit|NamedNode|string} evidence - Evidence source (e.g., semantic unit)
     */
    addEvidence(evidence) {
        const evidenceNode = this._normalizeUnitReference(evidence)
        this.addTriple(this.ns.provProperties.used, evidenceNode)
    }
    
    /**
     * Get all evidence sources for this attribute
     * @returns {Array<NamedNode>} Evidence nodes
     */
    getEvidence() {
        return this.getTriplesWithPredicate(this.ns.provProperties.used)
            .map(quad => quad.object)
    }
    
    /**
     * Set language for this attribute
     * @param {string} language - Language code (e.g., 'en', 'es')
     */
    setLanguage(language) {
        this.removeTriple(this.ns.dcProperties.language)
        this.addTriple(this.ns.dcProperties.language, rdf.literal(language))
    }
    
    /**
     * Get language for this attribute
     * @returns {string|null} Language code
     */
    getLanguage() {
        const quads = this.getTriplesWithPredicate(this.ns.dcProperties.language)
        return quads.length > 0 ? quads[0].object.value : null
    }
    
    /**
     * Add a keyword/tag to this attribute
     * @param {string} keyword - Keyword or tag
     */
    addKeyword(keyword) {
        this.addTriple(this.ns.ex('keyword'), rdf.literal(keyword))
    }
    
    /**
     * Get all keywords/tags for this attribute
     * @returns {Array<string>} Keywords
     */
    getKeywords() {
        return this.getTriplesWithPredicate(this.ns.ex('keyword'))
            .map(quad => quad.object.value)
    }
    
    /**
     * Check if this attribute is relevant to a specific time period
     * @param {Date} date - Date to check
     * @returns {boolean} True if attribute is relevant at the given date
     */
    isRelevantAt(date) {
        const temporal = this.getTemporal()
        if (!temporal) {
            return true // No temporal constraint means always relevant
        }
        // Simple check - could be enhanced with time ranges
        return temporal <= date
    }
    
    /**
     * Normalize different entity reference formats to NamedNode
     * @private
     * @param {Entity|NamedNode|string} entity - Entity reference
     * @returns {NamedNode} Normalized entity node
     */
    _normalizeEntityReference(entity) {
        if (typeof entity === 'string') {
            return rdf.namedNode(entity)
        } else if (entity && typeof entity === 'object' && entity.node) {
            // Entity instance
            return entity.node
        } else if (entity && entity.termType === 'NamedNode') {
            // Already a NamedNode
            return entity
        } else {
            throw new Error(`Invalid entity reference: ${entity}`)
        }
    }
    
    /**
     * Normalize different unit reference formats to NamedNode
     * @private
     * @param {SemanticUnit|NamedNode|string} unit - Unit reference
     * @returns {NamedNode} Normalized unit node
     */
    _normalizeUnitReference(unit) {
        if (typeof unit === 'string') {
            return rdf.namedNode(unit)
        } else if (unit && typeof unit === 'object' && unit.node) {
            // SemanticUnit instance
            return unit.node
        } else if (unit && unit.termType === 'NamedNode') {
            // Already a NamedNode
            return unit
        } else {
            throw new Error(`Invalid unit reference: ${unit}`)
        }
    }
    
    /**
     * Validate this attribute according to ragno ontology
     * @returns {Object} Validation result
     */
    validate() {
        const baseValidation = super.validate()
        const errors = [...baseValidation.errors]
        
        // Check ragno:Attribute type
        if (!this.hasType(this.ns.classes.Attribute)) {
            errors.push('Attribute must have ragno:Attribute type')
        }
        
        // Check required content
        if (!this.getText()) {
            errors.push('Attribute must have text content')
        }
        
        // Check entity association (attributes should describe an entity)
        if (!this.getEntity()) {
            errors.push('Attribute should be associated with an entity')
        }
        
        // Check confidence score is valid range
        const confidence = this.getConfidence()
        if (confidence !== null && (confidence < 0 || confidence > 1)) {
            errors.push('Attribute confidence score must be between 0 and 1')
        }
        
        return {
            valid: errors.length === 0,
            errors
        }
    }
    
    /**
     * Get attribute metadata including ragno-specific properties
     * @returns {Object} Attribute metadata
     */
    getMetadata() {
        const baseMetadata = super.getMetadata()
        
        return {
            ...baseMetadata,
            text: this.getText(),
            summary: this.getSummary(),
            entity: this.getEntity()?.value,
            category: this.getCategory(),
            confidence: this.getConfidence(),
            temporal: this.getTemporal(),
            language: this.getLanguage(),
            corpus: this.getCorpus()?.value,
            provenanceCount: this.getProvenance().length,
            evidenceCount: this.getEvidence().length,
            keywordCount: this.getKeywords().length
        }
    }
    
    /**
     * Convert to simple object representation (for backward compatibility)
     * @returns {Object} Simple object representation
     */
    toSimpleObject() {
        return {
            uri: this.uri,
            text: this.getText(),
            summary: this.getSummary(),
            entity: this.getEntity()?.value,
            category: this.getCategory(),
            subType: this.getSubType(),
            confidence: this.getConfidence(),
            temporal: this.getTemporal()?.toISOString(),
            language: this.getLanguage(),
            corpus: this.getCorpus()?.value,
            provenance: this.getProvenance().map(p => p.value),
            keywords: this.getKeywords(),
            isEntryPoint: this.isEntryPoint()
        }
    }
    
    /**
     * Create attribute from simple object (migration helper)
     * @param {Object} obj - Simple object representation
     * @param {Object} [options] - Additional options
     * @returns {Attribute} RDF-based attribute
     */
    static fromSimpleObject(obj, options = {}) {
        const attribute = new Attribute({
            ...options,
            text: obj.text,
            summary: obj.summary,
            entity: obj.entity,
            category: obj.category,
            subType: obj.subType,
            confidence: obj.confidence,
            temporal: obj.temporal,
            language: obj.language,
            corpus: obj.corpus,
            isEntryPoint: obj.isEntryPoint
        })
        
        // Add provenance if provided
        if (obj.provenance) {
            if (Array.isArray(obj.provenance)) {
                obj.provenance.forEach(p => attribute.setProvenance(p))
            } else {
                attribute.setProvenance(obj.provenance)
            }
        }
        
        // Add keywords if provided
        if (obj.keywords && Array.isArray(obj.keywords)) {
            obj.keywords.forEach(keyword => attribute.addKeyword(keyword))
        }
        
        return attribute
    }
    
    /**
     * Create an attribute with automatic URI generation
     * @param {string} text - Attribute text content
     * @param {Entity|NamedNode|string} entity - Associated entity
     * @param {Object} [options] - Additional options
     * @returns {Attribute} Created attribute
     */
    static create(text, entity, options = {}) {
        return new Attribute({
            ...options,
            text,
            entity
        })
    }
    
    /**
     * Create an overview attribute for an entity (common pattern)
     * @param {Entity|NamedNode|string} entity - Associated entity
     * @param {string} text - Overview text
     * @param {Object} [options] - Additional options
     * @returns {Attribute} Created overview attribute
     */
    static createOverview(entity, text, options = {}) {
        return new Attribute({
            ...options,
            text,
            entity,
            subType: 'Overview',
            category: 'Description'
        })
    }
    
    /**
     * Clone this attribute with optional modifications
     * @param {Object} [modifications] - Properties to modify in the clone
     * @returns {Attribute} Cloned attribute
     */
    clone(modifications = {}) {
        const cloned = new Attribute({
            dataset: rdf.dataset(), // New dataset for clone
            text: modifications.text || this.getText(),
            summary: modifications.summary || this.getSummary(),
            entity: modifications.entity || this.getEntity(),
            category: modifications.category || this.getCategory(),
            subType: modifications.subType || this.getSubType(),
            confidence: modifications.confidence !== undefined ? modifications.confidence : this.getConfidence(),
            temporal: modifications.temporal || this.getTemporal(),
            language: modifications.language || this.getLanguage(),
            corpus: modifications.corpus || this.getCorpus(),
            isEntryPoint: modifications.isEntryPoint !== undefined ? modifications.isEntryPoint : this.isEntryPoint()
        })
        
        // Copy additional properties that aren't handled by constructor
        for (const quad of this.getTriples()) {
            // Skip properties that are handled by constructor
            if (!quad.predicate.equals(this.ns.properties.content) &&
                !quad.predicate.equals(this.ns.skosProperties.definition) &&
                !quad.predicate.equals(this.ns.ex('describesEntity')) &&
                !quad.predicate.equals(this.ns.ex('category')) &&
                !quad.predicate.equals(this.ns.properties.subType) &&
                !quad.predicate.equals(this.ns.ex('confidence')) &&
                !quad.predicate.equals(this.ns.ex('temporal')) &&
                !quad.predicate.equals(this.ns.dcProperties.language) &&
                !quad.predicate.equals(this.ns.properties.inCorpus) &&
                !quad.predicate.equals(this.ns.properties.isEntryPoint) &&
                !quad.predicate.equals(this.ns.dcProperties.created)) {
                cloned.addTriple(quad.predicate, quad.object)
            }
        }
        
        return cloned
    }
}