source-pane
Version:
Solid-compatible Panes: Source editor
418 lines (388 loc) • 15.1 kB
JavaScript
/* Source editor Pane
**
** This pane allows the original source of a resource to be edited by hand
**
*/
const $rdf = require('rdflib')
const UI = require('solid-ui')
const mime = require('mime-types')
module.exports = {
icon: UI.icons.iconBase + 'noun_109873.svg', // noun_109873_51A7F9.svg
name: 'source',
label: function (subject, context) {
const kb = context.session.store
const typeURIs = kb.findTypeURIs(subject)
const prefix = $rdf.Util.mediaTypeClass('text/*').uri.split('*')[0]
for (const t in typeURIs) {
if (t.startsWith(prefix)) return 'Source'
if (t.includes('xml')) return 'XML Source'
if (t.includes('json')) return 'JSON Source' // Like eg application/ld+json
if (t.includes('javascript')) return 'Javascript Source'
}
return null
},
// Create a new text file in a Solid system,
mintNew: function (context, newPaneOptions) {
const kb = context.session.store
let newInstance = newPaneOptions.newInstance
if (!newInstance) {
let uri = newPaneOptions.newBase
if (uri.endsWith('/')) {
uri = uri.slice(0, -1)
newPaneOptions.newBase = uri
}
newInstance = kb.sym(uri)
newPaneOptions.newInstance = newInstance
}
const contentType = mime.lookup(newInstance.uri)
if (
!contentType ||
!(contentType.startsWith('text') || contentType.includes('xml') || contentType.includes('json') || contentType.includes('javascript'))
) {
const msg =
'A new text file has to have an file extension like .txt .ttl .json etc.'
alert(msg)
throw new Error(msg)
}
function contentForNew (contentType) {
let content = '\n'
if (contentType.includes('json')) content = '{}\n'
else if (contentType.includes('rdf+xml')) content = '<rdf:RDF\n xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">\n\n</rdf:RDF>'
return content
}
return new Promise(function (resolve, reject) {
kb.fetcher
.webOperation('PUT', newInstance.uri, {
data: contentForNew(contentType),
contentType: contentType
})
.then(
function (_response) {
console.log('New text file created: ' + newInstance.uri)
newPaneOptions.newInstance = newInstance
resolve(newPaneOptions)
},
err => {
alert('Cant make new file: ' + err)
reject(err)
}
)
})
},
render: function (subject, context) {
const dom = context.dom
const kb = context.session.store
const fetcher = kb.fetcher
const editStyle =
'font-family: monospace; font-size: 100%; min-width:60em; margin: 1em 0.2em 1em 0.2em; padding: 1em; border: 0.1em solid #888; border-radius: 0.5em;'
let readonly = true
let editing = false
let broken = false
// Set in refresh()
let contentType, allowed, eTag // Note it when we read and use it when we save
const div = dom.createElement('div')
div.setAttribute('class', 'sourcePane')
const table = div.appendChild(dom.createElement('table'))
const main = table.appendChild(dom.createElement('tr'))
const statusRow = table.appendChild(dom.createElement('tr'))
const controls = table.appendChild(dom.createElement('tr'))
controls.setAttribute('style', 'text-align: right;')
const textArea = main.appendChild(dom.createElement('textarea'))
textArea.setAttribute('style', editStyle)
function editButton (dom) {
return UI.widgets.button(
dom,
UI.icons.iconBase + 'noun_253504.svg',
'Edit'
)
}
function compactButton (dom) {
return UI.widgets.button(
dom,
undefined,
'Compact',
compactHandler,
{ needsBorder: true }
)
}
const myCompactButton = controls.appendChild(compactButton(dom))
const cancelButton = controls.appendChild(UI.widgets.cancelButton(dom))
const saveButton = controls.appendChild(UI.widgets.continueButton(dom))
const myEditButton = controls.appendChild(editButton(dom))
function setUnedited () {
if (broken) return
editing = false
myEditButton.style.visibility = subject.uri.endsWith('/') ? 'collapse' : 'visible'
textArea.style.color = '#888'
cancelButton.style.visibility = 'visible'
saveButton.style.visibility = 'collapse'
myCompactButton['style'] = "visibility: visible; width: 100px; padding: 10.2px; transform: translate(0, -30%)"
if (!compactable[contentType.split(';')]) { myCompactButton.style.visibility = "collapse" }
textArea.setAttribute('readonly', 'true')
}
function setEditable () {
if (broken) return
editing = true
textArea.style.color = 'black'
cancelButton.style.visibility = 'visible' // not logically needed but may be comforting
saveButton.style.visibility = 'collapse'
myEditButton.style.visibility = 'collapse'
myCompactButton.style.visibility = 'collapse' // do not allow compact while editing
textArea.removeAttribute('readonly')
}
function setEdited (_event) {
if (broken || !editing) return
textArea.style.color = 'green'
cancelButton.style.visibility = 'visible'
saveButton.style.visibility = 'visible'
myEditButton.style.visibility = 'collapse'
myCompactButton.style.visibility = 'collapse'
textArea.removeAttribute('readonly')
}
const parseable = {
'text/n3': true,
'text/turtle': true,
'application/rdf+xml': true,
'application/xhtml+xml': true, // For RDFa?
'text/html': true, // For data island
// 'application/sparql-update': true,
'application/json': true,
'application/ld+json': true
// 'application/nquads' : true,
// 'application/n-quads' : true
}
/** Set Caret position in a text box
* @param {Element} elem - the element to be tweaked
* @param {Integer} caretPos - the poisition starting at zero
* @credit https://stackoverflow.com/questions/512528/set-keyboard-caret-position-in-html-textbox
*/
function setCaretPosition (elem, cause) {
if (elem != null) {
if (cause.characterInFile === -1 && cause.lineNo) cause.lineNo += 1
const pos = cause.lineNo ? elem.value.split('\n', cause.lineNo).join('\n').length : 0
let caretPos = pos + cause.characterInFile
if (elem.createTextRange) {
const range = elem.createTextRange()
range.move('character', caretPos)
range.select()
} else {
elem.focus()
if (elem.selectionStart) {
elem.setSelectionRange(caretPos, caretPos)
}
}
}
}
function HTMLDataIsland (data) {
let dataIslandContentType = ''
let dataIsland = ''
const scripts = data.split('</script')
if (scripts && scripts.length) {
for (let script of scripts) {
script = '<script' + script.split('<script')[1] + '</script>'
const RDFType = ['text/turtle', 'text/n3', 'application/ld+json', 'application/rdf+xml']
const contentType = RDFType.find(type => script.includes(`type="${type}"`))
if (contentType) {
dataIsland = script.replace(/^<script(.*?)>/gm, '').replace(/<\/script>$/gm, '')
dataIslandContentType = contentType
break
}
}
}
return [dataIsland, dataIslandContentType]
}
function checkSyntax (data, contentType, base) {
if (!parseable[contentType]) return true // don't check things we don't understand
if (contentType === 'text/html') {
[data, contentType, pos] = HTMLDataIsland(data)
if (!contentType) return true
}
try {
statusRow.innerHTML = ''
if (contentType === 'application/json') return JSON.parse(data)
else {
try {
kb.removeDocument(subject)
} catch (err) {
// this is a hack until issue is resolved in rdflib
if (!err.message.includes('Statement to be removed is not on store')) throw err
console.log(err)
}
delete fetcher.requested[subject.value]
// rdflib parse jsonld do not return parsing errors
if (contentType === 'application/ld+json') {
JSON.parse(data)
$rdf.parse(data, kb, base.uri, contentType, (err, res) => {
if (err) throw err
let serialized = $rdf.serialize(base, res, base.uri, contentType)
if (data.includes('@id') && !serialized.includes('@id')) {
const e = new Error('Invalid jsonld : predicate do not expand to an absolute IRI')
statusRow.appendChild(UI.widgets.errorMessageBlock(dom, e))
// throw e
return false
}
return true
})
} else {
$rdf.parse(data, kb, base.uri, contentType)
}
}
return true
} catch (e) {
statusRow.appendChild(UI.widgets.errorMessageBlock(dom, e))
for (let cause = e; (cause = cause.cause); cause) {
if (cause.characterInFile) {
setCaretPosition(textArea, cause)
}
}
return false
}
return true
}
async function saveBack (_event) {
const data = textArea.value
if (!checkSyntax(data, contentType, subject)) {
setEdited() // failed to save -> different from web
textArea.style.color = 'red'
return
}
const options = { data, contentType }
if (eTag) options.headers = { 'if-match': eTag } // avoid overwriting changed files -> status 412
try {
const response = await fetcher.webOperation('PUT', subject.uri, options)
if (!happy(response, 'PUT')) return
/// @@ show edited: make save button disabled until edited again.
try {
const response = await fetcher.webOperation('HEAD', subject.uri) // , defaultFetchHeaders())
if (!happy(response, 'HEAD')) return
getResponseHeaders(response) // get new eTag
setUnedited() // used to be setEdited()
} catch (err) {
throw err
}
} catch (err) {
div.appendChild(
UI.widgets.errorMessageBlock(dom, 'Error saving back: ' + err))
}
}
function happy (response, method) {
if (!response.ok) {
let msg = 'HTTP error on ' + method + '! Status: ' + response.status
console.log(msg)
if (response.status === 412) msg = 'Error: File changed by someone else'
statusRow.appendChild(UI.widgets.errorMessageBlock(dom, msg))
}
return response.ok
}
const compactable = {
'text/n3': true,
'text/turtle': true,
'application/ld+json': true
}
function compactHandler (_event) {
if (compactable[contentType]) {
try {
$rdf.parse(textArea.value, kb, subject.uri, contentType)
// for jsonld serialize which is a Promise. New rdflib
const serialized = Promise.resolve($rdf.serialize(kb.sym(subject.uri), kb, subject.uri, contentType))
serialized.then(result => { textArea.value = result; /*return div*/ })
cancelButton.style.visibility = 'visible'
} catch (e) {
statusRow.appendChild(UI.widgets.errorMessageBlock(dom, e))
}
}
}
// function refresh (_event) {
// Use default fetch headers (such as Accept)
/* function defaultFetchHeaders () {
const options = fetcher.initFetchOptions(subject.uri, {})
const { headers } = options
options.headers = new Headers()
for (const header in headers) {
if (typeof headers[header] === 'string') {
options.headers.set(header, headers[header])
}
}
return options
} */
// get response headers
function getResponseHeaders (response) {
if (response.headers && response.headers.get('content-type')) {
contentType = response.headers.get('content-type').split(';')[0] // Should work but headers may be empty
allowed = response.headers.get('allow') // const cts = kb.fetcher.getHeader(subject.doc(), 'content-type')
eTag = response.headers.get('etag')
} else {
const reqs = kb.each(
null,
kb.sym('http://www.w3.org/2007/ont/link#requestedURI'),
subject.uri
)
reqs.forEach(req => {
const rrr = kb.any(
req,
kb.sym('http://www.w3.org/2007/ont/link#response')
)
if (rrr && rrr.termType === 'NamedNode') {
contentType = kb.anyValue(rrr, UI.ns.httph('content-type'))
allowed = kb.anyValue(rrr, UI.ns.httph('allow'))
eTag = kb.anyValue(rrr, UI.ns.httph('etag'))
if (!eTag) console.log('sourcePane: No eTag on GET')
}
})
}
}
function refresh (_event) {
// see https://github.com/linkeddata/rdflib.js/issues/629
// const options = defaultFetchHeaders()
fetcher
.webOperation('GET', subject.uri) // , options)
.then(function (response) {
if (!happy(response, 'GET')) return
const desc = response.responseText
if (desc === undefined) { // Defensive https://github.com/linkeddata/rdflib.js/issues/506
const msg = 'source pane: No text in response object!!'
statusRow.appendChild(UI.widgets.errorMessageBlock(dom, msg))
return // Never mis-represent the contents of the file.
}
textArea.rows = desc.split('\n').length + 2
textArea.cols = 80
textArea.value = desc
getResponseHeaders (response)
if (!contentType) {
readonly = true
broken = true
statusRow.appendChild(
UI.widgets.errorMessageBlock(
dom,
'Error: No content-type available!'
)
)
return
}
setUnedited()
// console.log(' source content-type ' + contentType)
// let allowed = response.headers['allow']
if (!allowed) {
console.log('@@@@@@@@@@ No Allow: header from this server')
readonly = false // better allow just in case
} else {
readonly = allowed.indexOf('PUT') < 0 // In future more info re ACL allow?
}
textArea.readonly = readonly
})
.catch(err => {
div.appendChild(
UI.widgets.errorMessageBlock(dom, 'Error reading file: ' + err)
)
})
}
textArea.addEventListener('keyup', setEdited)
myCompactButton.addEventListener('click', compactHandler)
myEditButton.addEventListener('click', setEditable)
cancelButton.addEventListener('click', refresh)
saveButton.addEventListener('click', saveBack)
refresh()
return div
}
}
// ENDS