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
-
-
-
-
-
-
+
+
+
+
+
+