From 26c2a7c052f6a915b4d4b005ac881ed82800270b Mon Sep 17 00:00:00 2001 From: Jonathan Vajda <36548200+jonathanvajda@users.noreply.github.com> Date: Tue, 26 May 2026 18:45:10 -0400 Subject: [PATCH] export data and update query --- public/app/axiolotl-query.js | 166 ++++++++++++++++- public/app/comunica-indexeddb-bridge.js | 229 +++++++++++++++++++++--- public/index.html | 34 +++- public/styles/axiolotl.css | 35 +++- 4 files changed, 432 insertions(+), 32 deletions(-) diff --git a/public/app/axiolotl-query.js b/public/app/axiolotl-query.js index 710ab58..1681ece 100644 --- a/public/app/axiolotl-query.js +++ b/public/app/axiolotl-query.js @@ -107,6 +107,147 @@ async function serializeStoreToNTriples(store) { }); } +function getWorkspaceExportOptions() { + return { + scope: document.getElementById('workspace-export-scope')?.value || 'default', + mime: document.getElementById('workspace-export-format')?.value || 'text/turtle', + }; +} + +function getWorkspaceExportFormats(scope) { + if (scope === 'default') { + return [ + ['text/turtle', 'Turtle'], + ['application/n-triples', 'N-Triples'], + ['application/ld+json', 'JSON-LD'], + ]; + } + + return [ + ['application/trig', 'TriG'], + ['application/n-quads', 'N-Quads'], + ['application/ld+json', 'JSON-LD'], + ]; +} + +function syncWorkspaceExportFormatOptions() { + const scope = document.getElementById('workspace-export-scope')?.value || 'default'; + const formatSelect = document.getElementById('workspace-export-format'); + const hint = document.getElementById('workspace-export-hint'); + if (!formatSelect) return; + + const previous = formatSelect.value; + const formats = getWorkspaceExportFormats(scope); + formatSelect.innerHTML = formats + .map(([value, label]) => ``) + .join(''); + formatSelect.value = formats.some(([value]) => value === previous) ? previous : formats[0][0]; + + if (hint) { + hint.textContent = scope === 'default' + ? 'Default graph exports support Turtle, N-Triples, and JSON-LD.' + : 'Named graph exports support TriG, N-Quads, and JSON-LD.'; + } +} + +async function handleDownloadActiveWorkspace() { + try { + const { scope, mime } = getWorkspaceExportOptions(); + const store = await getWorkspaceExportStore(scope); + const text = await serializeWorkspaceExportStore(store, mime); + const count = store.getQuads(null, null, null, null).length; + + if (!count) { + showToast('No triples found for that export scope.', 'info'); + return; + } + + downloadText( + `active-workspace-${scope}-${timestampUTC()}.${workspaceExportExtension(mime)}`, + text, + mime + ); + showToast(`Downloaded ${count} triple${count === 1 ? '' : 's'}.`, 'success'); + } catch (err) { + if (debuggingConsoleEnabled) { + console.error('[handleDownloadActiveWorkspace] failed:', err); + } + showToast(err.message || String(err), 'error'); + } +} + +async function getWorkspaceExportStore(scope) { + const store = await loadGraphFromIndexedDB(); + if (scope === 'all') return store; + + const { Store, DataFactory } = N3; + const { defaultGraph } = DataFactory; + const scoped = new Store(); + const quads = scope === 'default' + ? store.getQuads(null, null, null, defaultGraph()) + : store.getQuads(null, null, null, null).filter(q => q.graph.termType !== 'DefaultGraph'); + + scoped.addQuads(quads); + return scoped; +} + +async function serializeWorkspaceExportStore(store, mime) { + if (mime === 'application/ld+json') { + const nq = await serializeWorkspaceWithN3(store, 'application/n-quads'); + return await serializeJsonLdFromNQuads(nq); + } + + return await serializeWorkspaceWithN3(store, mime); +} + +async function serializeWorkspaceWithN3(store, mime) { + const formatByMime = { + 'text/turtle': 'Turtle', + 'application/n-triples': 'N-Triples', + 'application/n-quads': 'N-Quads', + 'application/trig': 'TriG', + }; + const format = formatByMime[mime]; + if (!format) throw new Error(`Unsupported workspace export format: ${mime}`); + + return await new Promise((resolve, reject) => { + const writer = new N3.Writer({ format }); + writer.addQuads(store.getQuads(null, null, null, null)); + writer.end((error, result) => { + if (error) reject(error); + else resolve(result || ''); + }); + }); +} + +async function serializeJsonLdFromNQuads(nquads) { + const jsonld = globalThis.jsonld; + if (jsonld && typeof jsonld.fromRDF === 'function') { + const expanded = await jsonld.fromRDF(nquads, { format: 'application/n-quads' }); + return JSON.stringify(expanded, null, 2); + } + + return JSON.stringify(nquadsToSimpleJsonLd(nquads), null, 2); +} + +function nquadsToSimpleJsonLd(nquads) { + return nquads + .split(/\r?\n/u) + .map(line => line.trim()) + .filter(Boolean) + .map(line => ({ '@value': line })); +} + +function workspaceExportExtension(mime) { + return ({ + 'text/turtle': 'ttl', + 'application/n-triples': 'nt', + 'application/n-quads': 'nq', + 'application/trig': 'trig', + 'application/ld+json': 'jsonld', + })[mime] || 'rdf'; +} + /** * Get/set active prefixes from localStorage * Assumes: @@ -793,7 +934,12 @@ document.getElementById('query-results').addEventListener('click', function (eve */ const commitUpdateByMaterialization = async (updateStr, targetMode='default') => { const previews = makePreviewConstructs(updateStr); - if (!previews.length) throw new Error('Unsupported UPDATE shape for commit.'); + if (!previews.length) { + const detail = typeof describeUpdateShape === 'function' + ? describeUpdateShape(updateStr) + : { bodyPreview: String(updateStr ?? '').slice(0, 160) }; + throw new Error(`Unsupported UPDATE shape for commit. Parsed first keyword: ${detail.firstKeyword || '(none)'}. Body preview: ${detail.bodyPreview || detail.textPreview || '(empty)'}`); + } // We separate delete-like vs insert-like by their labels const delQs = previews.filter(p=>/deleted/i.test(p.label)).map(p=>p.query); @@ -1090,6 +1236,13 @@ async function handleUploadSavedQueriesCsv(file) { } window.addEventListener('DOMContentLoaded', () => { + syncWorkspaceExportFormatOptions(); + document.getElementById('workspace-export-scope') + ?.addEventListener('change', syncWorkspaceExportFormatOptions); + + document.getElementById('download-active-workspace') + ?.addEventListener('click', handleDownloadActiveWorkspace); + document.getElementById('save-query-for-later') ?.addEventListener('click', handleSaveQueryForLater); @@ -1396,9 +1549,14 @@ document.getElementById('run-query').onclick = async () => { // ---- small local helper for preview rendering (pure string builder) const makePreviewHtml = (sections) => { // sections: Array<{label:string, text:string}> - const esc = (s) => s; // caller passes plain text for
; no HTML needed
+    const esc = (s) => String(s ?? '')
+      .replaceAll('&', '&')
+      .replaceAll('<', '<')
+      .replaceAll('>', '>')
+      .replaceAll('"', '"')
+      .replaceAll("'", ''');
     const blocks = sections.map(({ label, text }) =>
-      `\n

${label}

\n
${esc(text)}
` + `\n

${esc(label)}

\n
${esc(text)}
` ); return blocks.join('\n'); }; @@ -1534,4 +1692,4 @@ document.getElementById('get-all-triples').addEventListener('click', function() // Set the value of the textbox. document.getElementById('sparql-query').value = suggestedText; -}); \ No newline at end of file +}); diff --git a/public/app/comunica-indexeddb-bridge.js b/public/app/comunica-indexeddb-bridge.js index d218f38..97d9fcb 100644 --- a/public/app/comunica-indexeddb-bridge.js +++ b/public/app/comunica-indexeddb-bridge.js @@ -566,9 +566,18 @@ function buildQuery(prefixes, queryText) { */ const runConstructPreview = async (constructQuery, format='text/turtle') => { if (debuggingConsoleEnabled) {console.info('[runConstructPreview] Executing CONSTRUCT preview...')}; - const store = loadGraphFromIndexedDB(); + const store = await loadGraphFromIndexedDB(); const res = await engine.query(constructQuery, { sources:[{ type:'rdfjsSource', value: store }] }); - if (!res || !res.quadStream) return ''; + if (!res) return ''; + + if (typeof engine.resultToString === 'function') { + const mime = format === 'application/n-triples' ? 'application/n-triples' : 'text/turtle'; + const serialized = await engine.resultToString(res, mime); + if (serialized?.data) return await collectStreamText(serialized.data); + } + + if (!res.quadStream) return ''; + const { Writer } = N3; // available in your build const writer = new Writer({ format: format === 'application/n-triples' ? 'N-Triples' : 'Turtle' }); return await new Promise((resolve, reject) => { @@ -578,6 +587,17 @@ const runConstructPreview = async (constructQuery, format='text/turtle') => { }); }; +async function collectStreamText(stream) { + return await new Promise((resolve, reject) => { + let text = ''; + stream.on('data', chunk => { + text += String(chunk); + }); + stream.on('end', () => resolve(text)); + stream.on('error', reject); + }); +} + // Execute a SPARQL query on a remote SPARQL endpoint async function runQueryOnEndpoint(endpoint, query) { const headers = { @@ -606,7 +626,7 @@ async function runQueryOnEndpoint(endpoint, query) { * @returns {Array<{label:string, query:string}>} */ const makePreviewConstructs = (updateStr) => { - const s = String(updateStr ?? '').replace(/^\s*#.*$/mg,'').trim(); + const { prologue, body: s } = splitSparqlPrologue(updateStr); const out = []; // INSERT DATA { GRAPH ? { ... } } @@ -615,10 +635,10 @@ const makePreviewConstructs = (updateStr) => { const mInsertData = s.match(/^INSERT\s+DATA\s*\{([\s\S]+)\}\s*;?\s*$/i); if (mInsertData) { const body = mInsertData[1]; - // Fallback: show the raw body as CONSTRUCT by wrapping as template+WHERE { body }. + // INSERT DATA has no WHERE pattern, so construct the constant template once. out.push({ label: 'Triples that would be inserted', - query: `CONSTRUCT { ${body} } WHERE { ${body} }` + query: `${prologue}\nCONSTRUCT { ${body} } WHERE {}` }); return out; } @@ -629,40 +649,207 @@ const makePreviewConstructs = (updateStr) => { const P = mDeleteWhere[1]; out.push({ label: 'Triples that would be deleted', - query: `CONSTRUCT { ${P} } WHERE { ${P} }` + query: `${prologue}\nCONSTRUCT { ${P} } WHERE { ${P} }` }); return out; } // DELETE { T } INSERT { U } WHERE { P } - const mDelIns = s.match(/^DELETE\s*\{([\s\S]+?)\}\s*INSERT\s*\{([\s\S]+?)\}\s*WHERE\s*\{([\s\S]+?)\}\s*;?\s*$/i); - if (mDelIns) { - const T = mDelIns[1], U = mDelIns[2], P = mDelIns[3]; - out.push({ label:'Triples that would be deleted', query:`CONSTRUCT { ${T} } WHERE { ${P} }` }); - out.push({ label:'Triples that would be inserted', query:`CONSTRUCT { ${U} } WHERE { ${P} }` }); + const deleteInsert = parseDeleteInsertWhereUpdate(s); + if (deleteInsert) { + out.push({ label:'Triples that would be deleted', query:`${prologue}\nCONSTRUCT { ${deleteInsert.deleteTemplate} } WHERE { ${deleteInsert.wherePattern} }` }); + out.push({ label:'Triples that would be inserted', query:`${prologue}\nCONSTRUCT { ${deleteInsert.insertTemplate} } WHERE { ${deleteInsert.wherePattern} }` }); return out; } // INSERT { T } WHERE { P } - const mInsert = s.match(/^INSERT\s*\{([\s\S]+?)\}\s*WHERE\s*\{([\s\S]+?)\}\s*;?\s*$/i); - if (mInsert) { - const T = mInsert[1], P = mInsert[2]; - out.push({ label:'Triples that would be inserted', query:`CONSTRUCT { ${T} } WHERE { ${P} }` }); + const insertWhere = parseInsertWhereUpdate(s); + if (insertWhere) { + out.push({ label:'Triples that would be inserted', query:`${prologue}\nCONSTRUCT { ${insertWhere.insertTemplate} } WHERE { ${insertWhere.wherePattern} }` }); return out; } // DELETE { T } WHERE { P } - const mDelete = s.match(/^DELETE\s*\{([\s\S]+?)\}\s*WHERE\s*\{([\s\S]+?)\}\s*;?\s*$/i); - if (mDelete) { - const T = mDelete[1], P = mDelete[2]; - out.push({ label:'Triples that would be deleted', query:`CONSTRUCT { ${T} } WHERE { ${P} }` }); + const deleteWhere = parseDeleteWhereUpdate(s); + if (deleteWhere) { + out.push({ label:'Triples that would be deleted', query:`${prologue}\nCONSTRUCT { ${deleteWhere.deleteTemplate} } WHERE { ${deleteWhere.wherePattern} }` }); return out; } - if (debuggingConsoleEnabled) {console.info('[makePreviewConstructs] No supported preview pattern matched.')}; + if (debuggingConsoleEnabled) { + console.info('[makePreviewConstructs] No supported preview pattern matched.', describeUpdateShape(updateStr)); + } return out; }; +function describeUpdateShape(updateStr) { + const { prologue, body } = splitSparqlPrologue(updateStr); + const firstKeyword = body.match(/^([A-Za-z]+)/)?.[1] || ''; + return { + prologueLength: prologue.length, + firstKeyword, + bodyPreview: body.slice(0, 160), + textPreview: String(updateStr ?? '').slice(0, 160), + }; +} + +function splitSparqlPrologue(queryText) { + const text = stripSparqlComments(String(queryText ?? '')).trim(); + const prologueMatch = text.match(/^((?:\s*(?:PREFIX\s+[\w-]*:\s*<[^>]+>|BASE\s*<[^>]+>)\s*)*)/i); + const prologue = (prologueMatch?.[1] || '').trim(); + const body = text.slice(prologueMatch?.[0]?.length || 0).trim(); + return { prologue, body }; +} + +function stripSparqlComments(queryText) { + let out = ''; + let quote = null; + let inIri = false; + let escaped = false; + + for (let i = 0; i < queryText.length; i += 1) { + const ch = queryText[i]; + + if (quote) { + out += ch; + if (escaped) { + escaped = false; + } else if (ch === '\\') { + escaped = true; + } else if (ch === quote) { + quote = null; + } + continue; + } + + if (inIri) { + out += ch; + if (ch === '>') inIri = false; + continue; + } + + if (ch === '"' || ch === "'") { + quote = ch; + out += ch; + continue; + } + + if (ch === '<') { + inIri = true; + out += ch; + continue; + } + + if (ch === '#') { + while (i < queryText.length && queryText[i] !== '\n') i += 1; + if (i < queryText.length) out += queryText[i]; + continue; + } + + out += ch; + } + + return out; +} + +function parseInsertWhereUpdate(updateBody) { + const cursor = consumeKeyword(updateBody, 0, 'INSERT'); + if (cursor < 0) return null; + const insertBlock = readBraceBlock(updateBody, cursor); + if (!insertBlock) return null; + const whereCursor = consumeKeyword(updateBody, insertBlock.end, 'WHERE'); + if (whereCursor < 0) return null; + const whereBlock = readBraceBlock(updateBody, whereCursor); + if (!whereBlock || hasTrailingUpdateText(updateBody, whereBlock.end)) return null; + return { insertTemplate: insertBlock.content, wherePattern: whereBlock.content }; +} + +function parseDeleteWhereUpdate(updateBody) { + const cursor = consumeKeyword(updateBody, 0, 'DELETE'); + if (cursor < 0) return null; + const deleteBlock = readBraceBlock(updateBody, cursor); + if (!deleteBlock) return null; + const whereCursor = consumeKeyword(updateBody, deleteBlock.end, 'WHERE'); + if (whereCursor < 0) return null; + const whereBlock = readBraceBlock(updateBody, whereCursor); + if (!whereBlock || hasTrailingUpdateText(updateBody, whereBlock.end)) return null; + return { deleteTemplate: deleteBlock.content, wherePattern: whereBlock.content }; +} + +function parseDeleteInsertWhereUpdate(updateBody) { + const cursor = consumeKeyword(updateBody, 0, 'DELETE'); + if (cursor < 0) return null; + const deleteBlock = readBraceBlock(updateBody, cursor); + if (!deleteBlock) return null; + const insertCursor = consumeKeyword(updateBody, deleteBlock.end, 'INSERT'); + if (insertCursor < 0) return null; + const insertBlock = readBraceBlock(updateBody, insertCursor); + if (!insertBlock) return null; + const whereCursor = consumeKeyword(updateBody, insertBlock.end, 'WHERE'); + if (whereCursor < 0) return null; + const whereBlock = readBraceBlock(updateBody, whereCursor); + if (!whereBlock || hasTrailingUpdateText(updateBody, whereBlock.end)) return null; + return { + deleteTemplate: deleteBlock.content, + insertTemplate: insertBlock.content, + wherePattern: whereBlock.content, + }; +} + +function consumeKeyword(text, start, keyword) { + const rest = text.slice(start).trimStart(); + const skipped = text.length - start - rest.length; + const pattern = new RegExp(`^${keyword}\\b`, 'i'); + const match = rest.match(pattern); + return match ? start + skipped + match[0].length : -1; +} + +function readBraceBlock(text, start) { + const open = text.indexOf('{', start); + if (open < 0) return null; + + let depth = 0; + let quote = null; + let escaped = false; + + for (let i = open; i < text.length; i += 1) { + const ch = text[i]; + + if (quote) { + if (escaped) { + escaped = false; + } else if (ch === '\\') { + escaped = true; + } else if (ch === quote) { + quote = null; + } + continue; + } + + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + return { + content: text.slice(open + 1, i), + end: i + 1, + }; + } + } + } + + return null; +} + +function hasTrailingUpdateText(text, start) { + return !/^;?\s*$/u.test(text.slice(start)); +} + function isUpdateQuery(q) { if (debuggingConsoleEnabled) {console.info('[isUpdateQuery] Checking if query is UPDATE...');} const s = String(q).trim().replace(/^\s*#.*$/mg,''); // strip leading comments @@ -1045,4 +1232,4 @@ window.clearGraph = clearGraph; window.applyUpdateWithComunica = applyUpdateWithComunica; window.loadGraphFromIndexedDB = loadGraphFromIndexedDB; window.stashGraphToIndexedDB = stashGraphToIndexedDB; -window.queryAllNamedGraphs = queryAllNamedGraphs; \ No newline at end of file +window.queryAllNamedGraphs = queryAllNamedGraphs; diff --git a/public/index.html b/public/index.html index 71213bb..acfb849 100644 --- a/public/index.html +++ b/public/index.html @@ -1395,6 +1395,28 @@

Add Custom Ontology Files




+
+

Export Data

+ + +
+ + + + + Default graph exports support Turtle, N-Triples, and JSON-LD. + +
+
+

Clear Workspace

@@ -1681,12 +1703,12 @@

Save Inferred Output

- - - - - - + + + + + +