[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/bourgeoa/JavaScriptSolidServer/databrowser/src/patch/n3-patch.js [Back]  [Original]

/**
 * 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;
}

Web Proxy Viewer  |  New URL  |  Original Page