/**
* N3 Patch Parser and Applier
*
* Implements Solid's N3 Patch format for updating RDF resources.
* https://solid.github.io/specification/protocol#n3-patch
*
* Supported format:
* @prefix solid: .
* _:patch a solid:InsertDeletePatch;
* solid:inserts { "object" };
* solid:deletes { "old" }.
*/
const SOLID_NS = 'http://www.w3.org/ns/solid/terms#';
/**
* Parse an N3 Patch document
* @param {string} patchText - The N3 patch content
* @param {string} baseUri - Base URI for resolving relative references
* @returns {{inserts: Array, deletes: Array, where: Array}}
*/
export function parseN3Patch(patchText, baseUri) {
const result = {
inserts: [],
deletes: [],
where: []
};
// Extract prefixes
const prefixes = {};
const prefixRegex = /@prefix\s+(\w*):?\s*]+)>\s*\./g;
let match;
while ((match = prefixRegex.exec(patchText)) !== null) {
prefixes[match[1]] = match[2];
}
// Find solid:inserts block
const insertsMatch = patchText.match(/solid:inserts\s*\{([^}]*)\}/s);
if (insertsMatch) {
result.inserts = parseTriples(insertsMatch[1], prefixes, baseUri);
}
// Find solid:deletes block
const deletesMatch = patchText.match(/solid:deletes\s*\{([^}]*)\}/s);
if (deletesMatch) {
result.deletes = parseTriples(deletesMatch[1], prefixes, baseUri);
}
// Find solid:where block (for conditions - simplified support)
const whereMatch = patchText.match(/solid:where\s*\{([^}]*)\}/s);
if (whereMatch) {
result.where = parseTriples(whereMatch[1], prefixes, baseUri);
}
return result;
}
/**
* Parse triples from N3 block content
* Handles Turtle semicolon shorthand (same subject, different predicate-object)
*/
function parseTriples(content, prefixes, baseUri) {
const triples = [];
// Clean up content
content = content.trim();
if (!content) return triples;
// Split by '.' but be careful with strings containing '.'
const statements = splitStatements(content);
let lastSubject = null;
for (const stmt of statements) {
const trimmed = stmt.trim();
if (!trimmed) continue;
const tokens = tokenize(trimmed);
if (tokens.length < 2) continue;
let subject, predicate, object;
if (tokens.length >= 3) {
// Full triple: subject predicate object
subject = resolveValue(tokens[0], prefixes, baseUri);
predicate = resolveValue(tokens[1], prefixes, baseUri);
object = resolveValue(tokens.slice(2).join(' '), prefixes, baseUri);
lastSubject = subject;
} else if (tokens.length === 2 && lastSubject) {
// Semicolon continuation: predicate object (reuse last subject)
subject = lastSubject;
predicate = resolveValue(tokens[0], prefixes, baseUri);
object = resolveValue(tokens[1], prefixes, baseUri);
} else {
continue;
}
// Handle 'a' as rdf:type
if (predicate === 'a') {
predicate = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
}
triples.push({ subject, predicate, object });
}
return triples;
}
/**
* Split content into statements (handling quoted strings)
*/
function splitStatements(content) {
const statements = [];
let current = '';
let inString = false;
let stringChar = null;
let inIri = false;
for (let i = 0; i < content.length; i++) {
const char = content[i];
if (!inString && !inIri && char === '') {
inIri = false;
current += char;
} else if (!inIri && !inString && (char === '"' || char === "'")) {
inString = true;
stringChar = char;
current += char;
} else if (inString && char === stringChar && content[i - 1] !== '\\') {
inString = false;
stringChar = null;
current += char;
} else if (!inString && !inIri && char === '.') {
if (current.trim()) {
statements.push(current);
}
current = '';
} else if (!inString && !inIri && char === ';') {
// Turtle shorthand - same subject, different predicate
if (current.trim()) {
statements.push(current);
}
current = '';
} else {
current += char;
}
}
if (current.trim()) {
statements.push(current);
}
return statements;
}
/**
* Tokenize a statement respecting quoted strings
*/
function tokenize(stmt) {
const tokens = [];
let current = '';
let inString = false;
let stringChar = null;
for (let i = 0; i < stmt.length; i++) {
const char = stmt[i];
if (!inString && (char === '"' || char === "'")) {
inString = true;
stringChar = char;
current += char;
} else if (inString && char === stringChar && stmt[i - 1] !== '\\') {
inString = false;
stringChar = null;
current += char;
} else if (!inString && /\s/.test(char)) {
if (current) {
tokens.push(current);
current = '';
}
} else {
current += char;
}
}
if (current) {
tokens.push(current);
}
return tokens;
}
/**
* Resolve a value (URI, prefixed name, or literal)
*/
function resolveValue(value, prefixes, baseUri) {
value = value.trim();
// Full URI
if (value.startsWith('')) {
const uri = value.slice(1, -1);
// Resolve relative URIs
if (!uri.includes('://') && !uri.startsWith('#')) {
return new URL(uri, baseUri).href;
}
if (uri.startsWith('#')) {
return baseUri + uri;
}
return uri;
}
// Prefixed name
if (value.includes(':') && !value.startsWith('"')) {
const [prefix, local] = value.split(':', 2);
if (prefixes[prefix]) {
return prefixes[prefix] + local;
}
// Common prefixes
const commonPrefixes = {
'solid': SOLID_NS,
'rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
'rdfs': 'http://www.w3.org/2000/01/rdf-schema#',
'foaf': 'http://xmlns.com/foaf/0.1/',
'dc': 'http://purl.org/dc/terms/',
'schema': 'http://schema.org/',
'ldp': 'http://www.w3.org/ns/ldp#'
};
if (commonPrefixes[prefix]) {
return commonPrefixes[prefix] + local;
}
}
// String literal
if (value.startsWith('"')) {
// Handle typed literals "value"^^
const typedMatch = value.match(/^"(.*)"\^\^]+)>$/);
if (typedMatch) {
return { value: typedMatch[1], type: typedMatch[2] };
}
// Handle language tags "value"@en
const langMatch = value.match(/^"(.*)"@(\w+)$/);
if (langMatch) {
return { value: langMatch[1], language: langMatch[2] };
}
// Plain string
const plainMatch = value.match(/^"(.*)"$/);
if (plainMatch) {
return plainMatch[1];
}
}
// Variable (for WHERE patterns)
if (value.startsWith('?')) {
return { variable: value.slice(1) };
}
// Blank node
if (value.startsWith('_:')) {
return { blankNode: value.slice(2) };
}
return value;
}
/**
* Apply an N3 Patch to a JSON-LD document
* @param {object} document - The JSON-LD document
* @param {object} patch - Parsed patch {inserts, deletes}
* @param {string} baseUri - Base URI of the document
* @returns {object} Updated JSON-LD document
*/
export function applyN3Patch(document, patch, baseUri) {
// Clone the document
let doc = JSON.parse(JSON.stringify(document));
// Handle @graph array or single object
const isGraph = Array.isArray(doc['@graph']);
let nodes = isGraph ? doc['@graph'] : [doc];
// Apply deletes first
for (const triple of patch.deletes) {
nodes = deleteTriple(nodes, triple, baseUri);
}
// Then apply inserts
for (const triple of patch.inserts) {
nodes = insertTriple(nodes, triple, baseUri);
}
// Reconstruct document
if (isGraph) {
doc['@graph'] = nodes;
} else {
doc = nodes[0] || doc;
}
return doc;
}
/**
* Delete a triple from JSON-LD nodes
*/
function deleteTriple(nodes, triple, baseUri) {
const { subject, predicate, object } = triple;
for (const node of nodes) {
const nodeId = node['@id'] || '';
const resolvedNodeId = nodeId.startsWith('#') ? baseUri + nodeId : nodeId;
// Check if this node matches the subject
if (resolvedNodeId === subject || nodeId === subject) {
// Find the predicate
for (const key of Object.keys(node)) {
if (key.startsWith('@')) continue;
// Check if key matches predicate (could be full URI or prefixed)
if (key === predicate || expandPredicate(key) === predicate) {
const values = Array.isArray(node[key]) ? node[key] : [node[key]];
const newValues = values.filter(v => !valueMatches(v, object));
if (newValues.length === 0) {
delete node[key];
} else if (newValues.length === 1) {
node[key] = newValues[0];
} else {
node[key] = newValues;
}
}
}
}
}
return nodes;
}
/**
* Insert a triple into JSON-LD nodes
*/
function insertTriple(nodes, triple, baseUri) {
const { subject, predicate, object } = triple;
// Find or create the subject node
let subjectNode = nodes.find(n => {
const nodeId = n['@id'] || '';
const resolvedNodeId = nodeId.startsWith('#') ? baseUri + nodeId : nodeId;
return resolvedNodeId === subject || nodeId === subject;
});
if (!subjectNode) {
// Create new node
subjectNode = { '@id': subject };
nodes.push(subjectNode);
}
// Add the predicate-object
const predicateKey = compactPredicate(predicate);
const objectValue = convertToJsonLd(object);
if (subjectNode[predicateKey]) {
// Add to existing values
const existing = Array.isArray(subjectNode[predicateKey])
? subjectNode[predicateKey]
: [subjectNode[predicateKey]];
// Check if value already exists
if (!existing.some(v => valueMatches(v, object))) {
subjectNode[predicateKey] = [...existing, objectValue];
}
} else {
subjectNode[predicateKey] = objectValue;
}
return nodes;
}
/**
* Check if a JSON-LD value matches an N3 object
*/
function valueMatches(jsonLdValue, n3Object) {
// Handle null/undefined
if (jsonLdValue === null || jsonLdValue === undefined) {
return false;
}
// Number comparison - N3 numbers may come as strings
if (typeof jsonLdValue === 'number') {
if (typeof n3Object === 'number') {
return jsonLdValue === n3Object;
}
if (typeof n3Object === 'string') {
return jsonLdValue === parseFloat(n3Object) || jsonLdValue.toString() === n3Object;
}
}
// String comparison
if (typeof jsonLdValue === 'string' && typeof n3Object === 'string') {
return jsonLdValue === n3Object;
}
// @id comparison
if (jsonLdValue && jsonLdValue['@id']) {
return jsonLdValue['@id'] === n3Object;
}
// @value comparison
if (jsonLdValue && jsonLdValue['@value']) {
if (typeof n3Object === 'object' && n3Object.value) {
return jsonLdValue['@value'] === n3Object.value;
}
return jsonLdValue['@value'] === n3Object;
}
// Direct equality (for booleans, numbers parsed as numbers, etc.)
return jsonLdValue === n3Object || String(jsonLdValue) === String(n3Object);
}
/**
* Convert N3 object to JSON-LD value
*/
function convertToJsonLd(object) {
if (typeof object === 'string') {
// Check if it's a URI
if (object.startsWith('http://') || object.startsWith('https://')) {
return { '@id': object };
}
return object;
}
if (typeof object === 'object') {
if (object.value && object.type) {
return { '@value': object.value, '@type': object.type };
}
if (object.value && object.language) {
return { '@value': object.value, '@language': object.language };
}
if (object.value) {
return object.value;
}
}
return object;
}
/**
* Expand a potentially prefixed predicate to full URI
*/
function expandPredicate(predicate) {
const commonPrefixes = {
'solid': SOLID_NS,
'rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
'rdfs': 'http://www.w3.org/2000/01/rdf-schema#',
'foaf': 'http://xmlns.com/foaf/0.1/',
'dc': 'http://purl.org/dc/terms/',
'schema': 'http://schema.org/',
'ldp': 'http://www.w3.org/ns/ldp#'
};
if (predicate.includes(':')) {
const [prefix, local] = predicate.split(':', 2);
if (commonPrefixes[prefix]) {
return commonPrefixes[prefix] + local;
}
}
return predicate;
}
/**
* Compact a full URI predicate to prefixed form if possible
*/
function compactPredicate(predicate) {
const prefixMap = {
[SOLID_NS]: 'solid:',
'http://www.w3.org/1999/02/22-rdf-syntax-ns#': 'rdf:',
'http://www.w3.org/2000/01/rdf-schema#': 'rdfs:',
'http://xmlns.com/foaf/0.1/': 'foaf:',
'http://purl.org/dc/terms/': 'dc:',
'http://schema.org/': 'schema:',
'http://www.w3.org/ns/ldp#': 'ldp:'
};
for (const [uri, prefix] of Object.entries(prefixMap)) {
if (predicate.startsWith(uri)) {
return prefix + predicate.slice(uri.length);
}
}
return predicate;
}
/**
* Validate that a patch can be applied
* @param {object} document - The JSON-LD document
* @param {object} patch - Parsed patch
* @param {string} baseUri - Base URI
* @returns {{valid: boolean, error: string|null}}
*/
export function validatePatch(document, patch, baseUri) {
// Check that all deletes exist in the document
for (const triple of patch.deletes) {
if (!tripleExists(document, triple, baseUri)) {
return {
valid: false,
error: `Triple to delete not found: ${JSON.stringify(triple)}`
};
}
}
return { valid: true, error: null };
}
/**
* Check if a triple exists in a document
*/
function tripleExists(document, triple, baseUri) {
const nodes = document['@graph'] || [document];
const { subject, predicate, object } = triple;
for (const node of nodes) {
const nodeId = node['@id'] || '';
const resolvedNodeId = nodeId.startsWith('#') ? baseUri + nodeId : nodeId;
if (resolvedNodeId === subject || nodeId === subject) {
for (const key of Object.keys(node)) {
if (key.startsWith('@')) continue;
if (key === predicate || expandPredicate(key) === predicate) {
const values = Array.isArray(node[key]) ? node[key] : [node[key]];
if (values.some(v => valueMatches(v, object))) {
return true;
}
}
}
}
}
return false;
}