nostralink 0.2.1

Linked data library for nostr
Documentation
//! Functions to transform nostr Events to RDF using oxjsonld

use nostr_sdk::prelude::*;
use serde_json::{json, Value};

use oxigraph::model::Quad;
use oxigraph::store::Store;
use oxjsonld::{
    JsonLdLoadDocumentOptions, JsonLdParser as OxJsonLdParser,
    JsonLdRemoteDocument,
};

use ::url::Url;
use urn::UrnBuilder;

use crate::err::LDError;
use crate::ldevents::{EVKIND_JSONLD, EVKIND_JSONLD_META0, EVKIND_JSONLD_SUBS};
use crate::loaders::Contexts;
use crate::niri::{content_iri_for_value, ToNamedNode};

/// Event to JSON-LD
pub fn event_to_jsonld(event: &Event) -> Result<String, LDError> {
    /*
     * If the event's "content" is supposed to be a stringified JSON, deserialize
     * it. This is the case for:
     *
     * - JSON-LD events
     * - Nostr events like Metadata events
     */
    let mut obj: Value = if event.kind == EVKIND_JSONLD
        || event.kind == EVKIND_JSONLD_META0
        || event.kind == EVKIND_JSONLD_SUBS
        || event.kind == Kind::Metadata
        || event.kind == Kind::ContactList
        || event.kind == Kind::Repost
    {
        let mut object: Value = serde_json::from_str(event.as_json().as_str())
            .map_err(|_| LDError::JsonParseError)?;

        if let Ok(mut content) =
            serde_json::from_str::<Value>(event.content.as_str())
                .map_err(|_| LDError::JsonParseError)
        {
            // Sets a custom @id for every relay in the contact list's content
            if event.kind == Kind::ContactList {
                for (key, value) in content.as_object_mut().unwrap() {
                    value["@type"] = json!("Relay");
                    if let Ok(mut url) = Url::parse(key) {
                        url.set_fragment(Some(&event.pubkey.to_hex()));
                        value["@id"] = json!(url.to_string());
                        value["relay_url"] = json!(key);
                    }
                }
            }

            if content.get("@id").is_none() {
                content["@id"] = json!(content_iri_for_value(content.clone())?);
            }

            object["content"] = content.clone();
        }

        object
    } else {
        serde_json::from_str(event.as_json().as_str())
            .map_err(|_| LDError::JsonParseError)?
    };

    obj["@context"] = json!("http://nostralink.org/nostr");
    obj["@type"] = json!("Event");

    /* "id" is kept as is. We add a "url" field which is mapped to @id in the context */
    obj["url"] = json!(event.id.named_node()?.into_string());

    // nip21
    obj["nip21"] = json!(event.pubkey.named_node()?.into_string());

    // Reformat the tags (array of arrays) to make them easy to query with sparql
    // as a set of tag objects
    let mut rfmt: Vec<Value> = Vec::new();
    for tags in obj["tags"].as_array().iter() {
        for (i, tagv) in tags.iter().enumerate() {
            let Some(tag) = tagv.as_array() else { continue };

            if tag.len() == 0 {
                continue;
            }

            let urn = UrnBuilder::new("nostr-tag", &event.id.to_hex())
                .f_component(Some(format!("{}", i).as_str()))
                .build()
                .map_err(|_| LDError::URNError)?;

            let mut ld_tag = json!({});

            ld_tag["@id"] = json!(urn.as_str());
            ld_tag["@type"] = json!("Tag");

            for (idx, v) in tag.into_iter().enumerate() {
                if idx == 0 {
                    ld_tag["tag_letter"] = json!(v);
                } else {
                    ld_tag[format!("tag_value_{}", (idx - 1))] = json!(v);
                }
            }

            rfmt.push(ld_tag);
        }
    }

    obj["tags_set"] = json!(rfmt);

    Ok(serde_json::to_string(&obj)
        .map_err(|_| LDError::JsonSerializationError)?)
}

/// Oxjsonld document loader
pub fn oxjsonld_document_loader(
    url: &str,
    _options: &JsonLdLoadDocumentOptions,
) -> Result<JsonLdRemoteDocument, Box<dyn std::error::Error + Send + Sync>> {
    let Ok(durl) = Url::parse(url) else {
        return Err(Box::from("Invalid document URL"));
    };

    // Look for a JSON-LD context with this path
    match Contexts::get(durl.path().trim_start_matches("/")) {
        Some(asset) => Ok(JsonLdRemoteDocument {
            document: asset.data.into(),
            document_url: url.to_string(),
        }),
        None => Err(Box::from("Cannot load document for URL")),
    }
}

/// Transform an event to RDF and return a vector of quads
pub fn event_quads(event: &Event) -> Result<Vec<Quad>, LDError> {
    Ok(OxJsonLdParser::new()
        .for_reader(event_to_jsonld(&event)?.as_bytes())
        .with_load_document_callback(oxjsonld_document_loader)
        .into_iter()
        .filter_map(|quad| quad.ok())
        .collect())
}

/// Use an oxjsonld parser to serialize this event to RDF and save it to a store
/// Returns the number of quads stored
pub fn store_event(event: &Event, store: &Store) -> Result<usize, LDError> {
    let mut inserted_quads_cn = 0;

    for quad in OxJsonLdParser::new()
        .for_reader(event_to_jsonld(&event)?.as_bytes())
        .with_load_document_callback(oxjsonld_document_loader)
    {
        if let Ok(evquad) = quad {
            // Insert the quad
            match store.insert(&evquad) {
                Ok(_) => inserted_quads_cn += 1,
                Err(_) => {}
            }
        }
    }

    Ok(inserted_quads_cn)
}