/* Buttons */ import { IndexedFormula, NamedNode, st, sym, uri, Util } from 'rdflib' import { icons } from '../iconBase' import * as ns from '../ns' import * as style from '../style' import * as debug from '../debug' import { info } from '../log' import { getClasses } from '../jss' import { uploadFiles, makeDraggable, makeDropTarget } from './dragAndDrop' import { store } from '../logic' import * as utils from '../utils' import { errorMessageBlock } from './error' import { addClickListenerToElement, createImageDiv, wrapDivInATR } from './widgetHelpers' import { linkIcon, createLinkForURI } from './buttons/iconLinks' /** * UI Widgets such as buttons * @packageDocumentation */ /* global alert */ const { iconBase } = icons const cancelIconURI = iconBase + 'noun_1180156.svg' // black X const checkIconURI = iconBase + 'noun_1180158.svg' // green checkmark; Continue export type StatusAreaContext = { statusArea?: HTMLElement div?: HTMLElement dom?: HTMLDocument } export type ButtonType = 'Primary' | 'Secondary' export type ButtonWidgetOptions = { buttonColor?: ButtonType, needsBorder?: boolean } export type RenderAsDivOptions = { image?: HTMLImageElement, title?: string, deleteFunction?: () => void, link?: boolean, noun?: string, draggable?: boolean, clickable?: boolean, onClickFunction?: () => void, wrapInATR?: boolean } function getStatusArea (context?: StatusAreaContext) { let box = (context && context.statusArea) || (context && context.div) || null if (box) return box let dom = context && context.dom if (!dom && typeof document !== 'undefined') { dom = document } if (dom) { const body = dom.getElementsByTagName('body')[0] box = dom.createElement('div') body.insertBefore((box as unknown as HTMLElement), body.firstElementChild) if (context) { context.statusArea = (box as unknown as HTMLElement) } return box } return null } /** * Display an error message block */ export function complain (context?: StatusAreaContext, err?: string) { if (!err) return // only if error const ele = getStatusArea(context) debug.log('Complaint: ' + err) if (ele) ele.appendChild(errorMessageBlock((context && context.dom) || document, err)) else alert(err) } /** * Remove all the children of an HTML element */ export function clearElement (ele: HTMLElement) { while (ele.firstChild) { ele.removeChild(ele.firstChild) } return ele } /** * To figure out the log URI from the full URI used to invoke the reasoner */ export function extractLogURI (fullURI) { const logPos = fullURI.search(/logFile=/) const rulPos = fullURI.search(/&rulesFile=/) return fullURI.substring(logPos + 8, rulPos) } /** * By default, converts e.g. '2020-02-19T19:35:28.557Z' to '19:35' * if today is 19 Feb 2020, and to 'Feb 19' if not. * @@@ TODO This needs to be changed to local time * @param noTime Return a string like 'Feb 19' even if it's today. */ export function shortDate (str?: string, noTime?: boolean): string { if (!str) return '???' const month = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ] try { const nowZ = new Date().toISOString() // var nowZ = $rdf.term(now).value // var n = now.getTimezoneOffset() // Minutes if (str.slice(0, 10) === nowZ.slice(0, 10) && !noTime) { return str.slice(11, 16) } if (str.slice(0, 4) === nowZ.slice(0, 4)) { return ( month[parseInt(str.slice(5, 7), 10) - 1] + ' ' + parseInt(str.slice(8, 10), 10) ) } return str.slice(0, 10) } catch (e) { return 'shortdate:' + e } } /** * Format a date and time * @param date for instance `new Date()` * @param format for instance '{FullYear}-{Month}-{Date}T{Hours}:{Minutes}:{Seconds}.{Milliseconds}' * @returns for instance '2000-01-15T23:14:23.002' */ export function formatDateTime (date: Date, format: string): string { return format .split('{') .map(function (s) { const k = s.split('}')[0] const width = { Milliseconds: 3, FullYear: 4 } const d = { Month: 1 } return s ? ('000' + (date['get' + k]() + (d[k] || 0))).slice(-(width[k] || 2)) + s.split('}')[1] : '' }) .join('') } /** * Get a string representation of the current time * @returns for instance '2000-01-15T23:14:23.002' */ export function timestamp (): string { return formatDateTime( new Date(), '{FullYear}-{Month}-{Date}T{Hours}:{Minutes}:{Seconds}.{Milliseconds}' ) } /** * Get a short string representation of the current time * @returns for instance '23:14:23.002' */ export function shortTime (): string { return formatDateTime( new Date(), '{Hours}:{Minutes}:{Seconds}.{Milliseconds}' ) } // ///////////////////// Handy UX widgets /** * Sets the best name we have and looks up a better one */ export function setName (element: HTMLElement, x: NamedNode) { const kb = store const findName = function (x) { const name = kb.any(x, ns.vcard('fn')) || kb.any(x, ns.foaf('name')) || kb.any(x, ns.vcard('organization-name')) return name ? name.value : null } const name = x.sameTerm(ns.foaf('Agent')) ? 'Everyone' : findName(x) element.textContent = name || utils.label(x) if (!name && x.uri) { if (!kb.fetcher) { throw new Error('kb has no fetcher') } // Note this is only a fetch, not a lookUP of all sameAs etc kb.fetcher.nowOrWhenFetched(x.doc(), undefined, function (_ok) { element.textContent = findName(x) || utils.label(x) // had: (ok ? '' : '? ') + }) } } /** * Set of suitable images * See also [[findImage]] * @param x The thing for which we want to find an image * @param kb The RDF store to look in * @returns It goes looking for triples in `kb`, * `(subject: x), (predicate: see list below) (object: image-url)` * to find any image linked from the thing with one of the following * predicates (in order): * * ns.sioc('avatar') * * ns.foaf('img') * * ns.vcard('logo') * * ns.vcard('hasPhoto') * * ns.vcard('photo') * * ns.foaf('depiction') */ export function imagesOf (x: NamedNode | null, kb: IndexedFormula): any[] { return kb .each(x, ns.sioc('avatar')) .concat(kb.each(x, ns.foaf('img'))) .concat(kb.each(x, ns.vcard('logo'))) .concat(kb.each(x, ns.vcard('hasPhoto'))) .concat(kb.each(x, ns.vcard('photo'))) .concat(kb.each(x, ns.foaf('depiction'))) } /** * Best logo or avatar or photo etc to represent someone or some group etc */ export const iconForClass = { // Potentially extendable by other apps, panes, etc // Relative URIs to the iconBase 'solid:AppProviderClass': 'noun_144.svg', // @@ classs name should not contain 'Class' 'solid:AppProvider': 'noun_15177.svg', // @@ 'solid:Pod': 'noun_Cabinet_1434380.svg', 'vcard:Group': 'noun_339237.svg', 'vcard:Organization': 'noun_143899.svg', 'vcard:Individual': 'noun_15059.svg', 'schema:Person': 'noun_15059.svg', 'foaf:Person': 'noun_15059.svg', 'foaf:Agent': 'noun_98053.svg', 'acl:AuthenticatedAgent': 'noun_99101.svg', 'prov:SoftwareAgent': 'noun_Robot_849764.svg', // Bot 'vcard:AddressBook': 'noun_15695.svg', 'trip:Trip': 'noun_581629.svg', 'meeting:LongChat': 'noun_1689339.svg', 'meeting:Meeting': 'noun_66617.svg', 'meeting:Project': 'noun_1036577.svg', 'ui:Form': 'noun_122196.svg', 'rdfs:Class': 'class-rectangle.svg', // For RDF developers 'rdf:Property': 'property-diamond.svg', 'owl:Ontology': 'noun_classification_1479198.svg', 'wf:Tracker': 'noun_122196.svg', 'wf:Task': 'noun_17020_gray-tick.svg', 'wf:Open': 'noun_17020_sans-tick.svg', 'wf:Closed': 'noun_17020.svg' } /** * Returns the origin of the URI of a NamedNode */ function tempSite (x: NamedNode) { // use only while one in rdflib fails with origins 2019 const str = x.uri.split('#')[0] const p = str.indexOf('//') if (p < 0) throw new Error('This URI does not have a web site part (origin)') const q = str.indexOf('/', p + 2) if (q < 0) { // no third slash? return str.slice(0) + '/' // Add slash to a bare origin } else { return str.slice(0, q + 1) } } /** * Find an image for this thing as a class */ export function findImageFromURI (x: NamedNode | string): string | null { const iconDir = iconBase // Special cases from URI scheme: if (typeof x !== 'string' && x.uri) { if ( x.uri.split('/').length === 4 && !x.uri.split('/')[1] && !x.uri.split('/')[3] ) { return iconDir + 'noun_15177.svg' // App -- this is an origin } // Non-HTTP URI types imply types if (x.uri.startsWith('message:') || x.uri.startsWith('mid:')) { // message: is apple bug-- should be mid: return iconDir + 'noun_480183.svg' // envelope noun_567486 } if (x.uri.startsWith('mailto:')) { return iconDir + 'noun_567486.svg' // mailbox - an email desitination } // For HTTP(s) documents, we could look at the MIME type if we know it. if (x.uri.startsWith('https:') && x.uri.indexOf('#') < 0) { return tempSite(x) + 'favicon.ico' // was x.site().uri + ... // Todo: make the document icon a fallback for if the favicon does not exist // todo: pick up a possible favicon for the web page itself from a link // was: return iconDir + 'noun_681601.svg' // document - under solid assumptions } return null } return iconDir + 'noun_10636_grey.svg' // Grey Circle - some thing } /** * Find something we have as explicit image data for the thing * See also [[imagesOf]] * @param thing The thing for which we want to find an image * @returns The URL of a globe icon if thing equals `ns.foaf('Agent')` * or `ns.rdf('Resource')`. Otherwise, it goes looking for * triples in `store`, * `(subject: thing), (predicate: see list below) (object: image-url)` * to find any image linked from the thing with one of the following * predicates (in order): * * ns.sioc('avatar') * * ns.foaf('img') * * ns.vcard('logo') * * ns.vcard('hasPhoto') * * ns.vcard('photo') * * ns.foaf('depiction') */ export function findImage (thing: NamedNode): string { const kb = store const iconDir = iconBase if (thing.sameTerm(ns.foaf('Agent')) || thing.sameTerm(ns.rdf('Resource'))) { return iconDir + 'noun_98053.svg' // Globe } const image = kb.any(thing, ns.sioc('avatar')) || kb.any(thing, ns.foaf('img')) || kb.any(thing, ns.vcard('logo')) || kb.any(thing, ns.vcard('hasPhoto')) || kb.any(thing, ns.vcard('photo')) || kb.any(thing, ns.foaf('depiction')) return image ? (image as any).uri : null } /** * Do the best you can with the data available * * @return {Boolean} Are we happy with this icon? * Sets src AND STYLE of the image. */ function trySetImage (element, thing, iconForClassMap) { const kb = store const explitImage = findImage(thing) if (explitImage) { element.setAttribute('src', explitImage) return true } // This is one of the classes we know about - the class itself? const typeIcon = iconForClassMap[thing.uri] if (typeIcon) { element.setAttribute('src', typeIcon) element.style = style.classIconStyle // element.style.border = '0.1em solid green;' // element.style.backgroundColor = '#eeffee' // pale green return true } const schemeIcon = findImageFromURI(thing) if (schemeIcon) { element.setAttribute('src', schemeIcon) return true // happy with this -- don't look it up } // Do we have a generic icon for something in any class its in? const types = kb.findTypeURIs(thing) for (const typeURI in types) { if (iconForClassMap[typeURI]) { element.setAttribute('src', iconForClassMap[typeURI]) return false // maybe we can do better } } element.setAttribute('src', iconBase + 'noun_10636_grey.svg') // Grey Circle - some thing return false // we can do better } /** * ToDo: Also add icons for *properties* like home, work, email, range, domain, comment, */ export function setImage (element: HTMLElement, thing: NamedNode) { // 20191230a const kb = store const iconForClassMap = {} for (const k in iconForClass) { const pref = k.split(':')[0] const id = k.split(':')[1] const theClass = ns[pref](id) iconForClassMap[theClass.uri] = uri.join(iconForClass[k], iconBase) } const happy = trySetImage(element, thing, iconForClassMap) if (!happy && thing.uri) { if (!kb.fetcher) { throw new Error('kb has no fetcher') } kb.fetcher.nowOrWhenFetched(thing.doc(), undefined, (ok) => { if (ok) { trySetImage(element, thing, iconForClassMap) } }) } } // If a web page, then a favicon, with a fallback to ??? // See, e.g., http://stackoverflow.com/questions/980855/inputting-a-default-image export function faviconOrDefault (dom: HTMLDocument, x: NamedNode) { const image = dom.createElement('img') ;(image as any).style = style.iconStyle const isOrigin = function (x) { if (!x.uri) return false const parts = x.uri.split('/') return parts.length === 3 || (parts.length === 4 && parts[3] === '') } image.setAttribute( 'src', iconBase + (isOrigin(x) ? 'noun_15177.svg' : 'noun_681601.svg') // App symbol vs document ) if (x.uri && x.uri.startsWith('https:') && x.uri.indexOf('#') < 0) { const res = dom.createElement('object') // favico with a fallback of a default image if no favicon res.setAttribute('data', tempSite(x) + 'favicon.ico') res.setAttribute('type', 'image/x-icon') res.appendChild(image) // fallback return res } else { setImage(image, x) return image } } /* Two-option dialog pop-up */ function renderDeleteConfirmPopup (dom, refererenceElement, prompt, deleteFunction) { function removePopup () { refererenceElement.parentElement.removeChild(refererenceElement) } function removePopupAndDoDeletion () { removePopup() deleteFunction() } const popup = dom.createElement('div') popup.style = style.confirmPopupStyle popup.style.position = 'absolute' popup.style.top = '-1em' // try leaving original button clear popup.style.display = 'grid' popup.style.gridTemplateColumns = 'auto auto' const affirm = dom.createElement('div') affirm.style.gridColumn = '1/2' affirm.style.gridRow = '1' // @@ sigh; TS. could pass number in fact const cancel = dom.createElement('div') cancel.style.gridColumn = '1/2' cancel.style.gridRow = '2' const xButton = cancelButton(dom, removePopup) popup.appendChild(xButton) xButton.style.gridColumn = '1' xButton.style.gridRow = '2' const cancelPrompt = popup.appendChild(dom.createElement('button')) cancelPrompt.style = style.buttonStyle cancelPrompt.style.gridRow = '2' cancelPrompt.style.gridColumn = '2' cancelPrompt.textContent = 'Cancel' // @@ I18n const affirmIcon = button(dom, icons.iconBase + 'noun_925021.svg', 'Delete it') // trashcan popup.appendChild(affirmIcon) affirmIcon.style.gridRow = '1' affirmIcon.style.gridColumn = '1' const sureButtonElt = popup.appendChild(dom.createElement('button')) sureButtonElt.style = style.buttonStyle sureButtonElt.style.gridRow = '1' sureButtonElt.style.gridColumn = '2' sureButtonElt.textContent = prompt popup.appendChild(sureButtonElt) affirmIcon.addEventListener('click', removePopupAndDoDeletion) sureButtonElt.addEventListener('click', removePopupAndDoDeletion) // xButton.addEventListener('click', removePopup) cancelPrompt.addEventListener('click', removePopup) return popup } /** * Delete button with a check you really mean it * @@ Supress check if command key held down? */ export function deleteButtonWithCheck ( dom: HTMLDocument, container: HTMLElement, noun: string, deleteFunction: () => any ) { function createPopup () { const refererenceElement = dom.createElement('div') container.insertBefore(refererenceElement, deleteButton) refererenceElement.style.position = 'relative' // Needed as reference for popup refererenceElement.appendChild(renderDeleteConfirmPopup(dom, refererenceElement, prompt, deleteFunction)) } const minusIconURI = iconBase + 'noun_2188_red.svg' // white minus in red #cc0000 circle const deleteButton = dom.createElement('img') deleteButton.setAttribute('src', minusIconURI) deleteButton.setAttribute('style', style.smallButtonStyle) // @@tsc - would set deleteButton.style deleteButton.style.float = 'right' // Historically this has alwaus floated right const prompt = 'Remove this ' + noun deleteButton.title = prompt // @@ In an ideal world, make use of hover an accessibility option deleteButton.classList.add('hoverControlHide') deleteButton.addEventListener('click', createPopup) container.classList.add('hoverControl') container.appendChild(deleteButton) deleteButton.setAttribute('data-testid', 'deleteButtonWithCheck') return deleteButton // or button div? caller may change size of image } /** * Get the button style, based on options. * See https://design.inrupt.com/atomic-core/?cat=Atoms#Buttons */ function getButtonStyle (options: ButtonWidgetOptions = {}) { // default to primary color const color: string = (options.buttonColor === 'Secondary') ? '#01c9ea' : '#7c4dff' let backgroundColor: string = color let fontColor: string = '#ffffff' let borderColor: string = color // default to primary color let hoverBackgroundColor: string = (options.buttonColor === 'Secondary') ? '#37cde6' : '#9f7dff' let hoverFontColor: string = fontColor if (options.needsBorder) { backgroundColor = '#ffffff' fontColor = color borderColor = color hoverBackgroundColor = color hoverFontColor = backgroundColor } return { 'background-color': `${backgroundColor}`, color: `${fontColor}`, 'font-family': 'Raleway, Roboto, sans-serif', 'border-radius': '0.25em', 'border-color': `${borderColor}`, border: '1px solid', cursor: 'pointer', 'font-size': '.8em', 'text-decoration': 'none', padding: '0.5em 4em', transition: '0.25s all ease-in-out', outline: 'none', '&:hover': { 'background-color': `${hoverBackgroundColor}`, color: `${hoverFontColor}`, transition: '0.25s all ease-in-out' } } } /* Make a button * * @param dom - the DOM document object * @Param iconURI - the URI of the icon to use (if any) * @param text - the tooltip text or possibly button contents text * @param handler - A handler to called when button is clicked * * @returns - the button */ export function button (dom: HTMLDocument, iconURI: string | undefined, text: string, handler?: (_event: any) => void, options: ButtonWidgetOptions = { buttonColor: 'Primary', needsBorder: false }) { const button = dom.createElement('button') button.setAttribute('type', 'button') // button.innerHTML = text // later, user preferences may make text preferred for some if (iconURI) { const img = button.appendChild(dom.createElement('img')) img.setAttribute('src', iconURI) img.setAttribute('style', 'width: 2em; height: 2em;') // trial and error. 2em disappears img.title = text button.setAttribute('style', style.buttonStyle) } else { button.textContent = text.toLocaleUpperCase() const style = getButtonStyle(options) const { classes } = getClasses(dom.head, { textButton: style }) button.classList.add(classes.textButton) } if (handler) { button.addEventListener('click', handler, false) } return button } /* Make a cancel button * * @param dom - the DOM document object * @param handler - A handler to called when button is clicked * * @returns - the button */ export function cancelButton (dom: HTMLDocument, handler: (_event?: any) => void) { const b = button(dom, cancelIconURI, 'Cancel', handler) if (b.firstChild) { // sigh for tsc (b.firstChild as HTMLElement).style.opacity = '0.3' // Black X is too harsh: current language is grey X } return b } /* Make a continue button * * @param dom - the DOM document object * @param handler - A handler to called when button is clicked * * @returns - the button */ export function continueButton (dom: HTMLDocument, handler: (_event: any) => void) { return button(dom, checkIconURI, 'Continue', handler) } /* Grab a name for a new thing * * Form to get the name of a new thing before we create it * @params theClass Misspelt to avoid clashing with the JavaScript keyword * @returns: a promise of (a name or null if cancelled) */ export function askName ( dom: HTMLDocument, kb: IndexedFormula, container: HTMLDivElement, predicate?: NamedNode, theClass?: NamedNode, noun?: string) { // eslint-disable-next-line promise/param-names return new Promise(function (resolve, _reject) { const form = dom.createElement('div') // form is broken as HTML behaviour can resurface on js error // classLabel = utils.label(ns.vcard('Individual')) predicate = predicate || ns.foaf('name') // eg 'name' in user's language noun = noun || (theClass ? utils.label(theClass) : ' ') // eg 'folder' in users's language const prompt = noun + ' ' + utils.label(predicate) + ': ' form.appendChild(dom.createElement('p')).textContent = prompt const namefield = dom.createElement('input') namefield.setAttribute('type', 'text') namefield.setAttribute('size', '100') namefield.setAttribute('maxLength', '2048') // No arbitrary limits namefield.setAttribute('style', style.textInputStyle) namefield.select() // focus next user input form.appendChild(namefield) container.appendChild(form) // namefield.focus() function gotName () { ((form as HTMLElement).parentNode as HTMLElement).removeChild(form) resolve(namefield.value.trim()) } namefield.addEventListener('keyup', function (e) { if (e.keyCode === 13) { gotName() } }, false) form.appendChild(dom.createElement('br')) form.appendChild(cancelButton(dom, function (_event) { ((form as HTMLElement).parentNode as HTMLElement).removeChild(form) resolve(null) })) form.appendChild(continueButton(dom, function (_event) { gotName() })) namefield.focus() }) // Promise } /** * A TR to represent a draggable person, etc in a list * * pred is unused param at the moment */ export const personTR = renderAsRow // The legacy name is used in a lot of places export function renderAsRow (dom: HTMLDocument, pred: NamedNode, obj: NamedNode, options: any): HTMLTableRowElement { const tr = dom.createElement('tr') options = options || {} // tr.predObj = [pred.uri, obj.uri] moved to acl-control const td1 = tr.appendChild(dom.createElement('td')) const td2 = tr.appendChild(dom.createElement('td')) const td3 = tr.appendChild(dom.createElement('td')) // const image = td1.appendChild(dom.createElement('img')) const image = options.image || faviconOrDefault(dom, obj) td1.setAttribute('style', 'vertical-align: middle; width:2.5em; padding:0.5em; height: 2.5em;') td2.setAttribute('style', 'vertical-align: middle; text-align:left;') td3.setAttribute('style', 'vertical-align: middle; width:2em; padding:0.5em; height: 4em;') td1.appendChild(image) if (options.title) { td2.textContent = options.title } else { setName(td2, obj) // This is async } if (options.deleteFunction) { deleteButtonWithCheck(dom, td3, options.noun || 'one', options.deleteFunction) } if (obj.uri) { // blank nodes need not apply if (options.link !== false) { const anchor = td3.appendChild(linkIcon(dom, obj)) anchor.classList.add('HoverControlHide') td3.appendChild(dom.createElement('br')) } if (options.draggable !== false) { // default is on image.setAttribute('draggable', 'false') // Stop the image being dragged instead - just the TR makeDraggable(tr, obj) } } ;(tr as any).subject = obj return tr } /* A helper function for renderAsDiv * creates the NameDiv for the person * Note: could not move it to the helper file because they call exported functions * from buttons * @internal exporting this only for unit tests */ export function createNameDiv (dom: HTMLDocument, div: HTMLDivElement, title: string | undefined, obj: NamedNode) { const nameDiv = div.appendChild(dom.createElement('div')) if (title) { nameDiv.textContent = title } else { setName(nameDiv, obj) // This is async } } /* A helper function for renderAsDiv * creates the linkDiv for the person * Note: could not move it to the helper file because they call exported functions * from buttons * @internal exporting this only for unit tests */ export function createLinkDiv (dom: HTMLDocument, div: HTMLDivElement, obj: NamedNode, options: RenderAsDivOptions) { const linkDiv = div.appendChild(dom.createElement('div')) linkDiv.setAttribute('style', style.linkDivStyle) if (options.deleteFunction) { deleteButtonWithCheck(dom, linkDiv, options.noun || 'one', options.deleteFunction) } if (obj.uri) { // blank nodes need not apply if (options.link !== false) { createLinkForURI(dom, linkDiv, obj) } makeDraggable(div, obj) } } /** * A Div to represent a draggable person, etc in a list * configurable to add an onClick listener */ export function renderAsDiv (dom: HTMLDocument, obj: NamedNode, options: RenderAsDivOptions): HTMLElement { const div = dom.createElement('div') div.setAttribute('style', style.renderAsDivStyle) options = options || {} const image = options.image || faviconOrDefault(dom, obj) createImageDiv(dom, div, image) createNameDiv(dom, div, options.title, obj) createLinkDiv(dom, div, obj, options) if (options.clickable && options.onClickFunction) { addClickListenerToElement(div, options.onClickFunction) } // to be compatible with the SolidOS table layout if (options.wrapInATR) { const tr = wrapDivInATR(dom, div, obj) return tr } return div } /** * Refresh a DOM tree recursively */ export function refreshTree (root: any): void { if (root.refresh) { root.refresh() return } for (let i = 0; i < root.children.length; i++) { refreshTree(root.children[i]) } } /** * Options argument for [[attachmentList]] function */ export type attachmentListOptions = { doc?: NamedNode modify?: boolean promptIcon?: string predicate?: NamedNode uploadFolder?: NamedNode noun?: string } /** * Component that displays a list of resources, for instance * the attachments of a message, or the various documents related * to a meeting. * Accepts dropping URLs onto it to add attachments to it. */ export function attachmentList (dom: HTMLDocument, subject: NamedNode, div: HTMLElement, options: attachmentListOptions = {}) { // options = options || {} const deleteAttachment = function (target) { if (!kb.updater) { throw new Error('kb has no updater') } kb.updater.update(st(subject, predicate, target, doc) as any, [], function ( uri, ok, errorBody, _xhr ) { if (ok) { refresh() } else { complain(undefined, 'Error deleting one: ' + errorBody) } }) } function createNewRow (target) { const theTarget = target const opt: any = { noun: noun } if (modify) { opt.deleteFunction = function () { deleteAttachment(theTarget) } } return personTR(dom, predicate, target, opt) } const refresh = function () { const things = kb.each(subject, predicate) things.sort() utils.syncTableToArray(attachmentTable, things, createNewRow) } function droppedURIHandler (uris) { const ins: any = [] uris.forEach(function (u) { const target = sym(u) // Attachment needs text label to disinguish I think not icon. debug.log('Dropped on attachemnt ' + u) // icon was: iconBase + 'noun_25830.svg' ins.push(st(subject, predicate, target, doc)) }) if (!kb.updater) { throw new Error('kb has no updater') } kb.updater.update([], ins, function (uri, ok, errorBody, _xhr) { if (ok) { refresh() } else { complain(undefined, 'Error adding one: ' + errorBody) } }) } function droppedFileHandler (files) { uploadFiles( kb.fetcher, files, options.uploadFolder?.uri, // Files options.uploadFolder?.uri, // Pictures function (theFile, destURI) { const ins = [st(subject, predicate, kb.sym(destURI), doc)] if (!kb.updater) { throw new Error('kb has no updater') } kb.updater.update([], ins, function (uri, ok, errorBody, _xhr) { if (ok) { refresh() } else { complain(undefined, 'Error adding link to uploaded file: ' + errorBody) } }) } ) } const doc = options.doc || subject.doc() if (options.modify === undefined) options.modify = true const modify = options.modify const promptIcon: string = options.promptIcon || (iconBase + 'noun_748003.svg' as string) // target // const promptIcon = options.promptIcon || (iconBase + 'noun_25830.svg') // paperclip const predicate = options.predicate || ns.wf('attachment') const noun = options.noun || 'attachment' const kb = store const attachmentOuter = div.appendChild(dom.createElement('table')) attachmentOuter.setAttribute('style', 'margin-top: 1em; margin-bottom: 1em;') const attachmentOne = attachmentOuter.appendChild(dom.createElement('tr')) const attachmentLeft = attachmentOne.appendChild(dom.createElement('td')) const attachmentRight = attachmentOne.appendChild(dom.createElement('td')) const attachmentTable = attachmentRight.appendChild(dom.createElement('table')) attachmentTable.appendChild(dom.createElement('tr')) // attachmentTableTop ;(attachmentOuter as any).refresh = refresh // Participate in downstream changes // ;(attachmentTable as any).refresh = refresh <- outer should be best? refresh() if (modify) { // const buttonStyle = 'width; 2em; height: 2em; margin: 0.5em; padding: 0.1em;' const paperclip = button(dom, promptIcon, 'Drop attachments here') // paperclip.style = buttonStyle // @@ needed? default has white background attachmentLeft.appendChild(paperclip) const fhandler = options.uploadFolder ? droppedFileHandler : null makeDropTarget(paperclip, droppedURIHandler, fhandler) // beware missing the wire of the paparclip! makeDropTarget(attachmentLeft, droppedURIHandler, fhandler) // just the outer won't do it if (options.uploadFolder) { // Addd an explicit file upload button as well const buttonDiv = fileUploadButtonDiv(dom, droppedFileHandler) attachmentLeft.appendChild(buttonDiv) // buttonDiv.children[1].style = buttonStyle } } return attachmentOuter } // ///////////////////////////////////////////////////////////////////////////// /** * Event Handler for links within solid apps. * * Note that native links have constraints in Firefox, they * don't work with local files for instance (2011) */ export function openHrefInOutlineMode (e: Event) { e.preventDefault() e.stopPropagation() const target = utils.getTarget(e) const uri = target.getAttribute('href') if (!uri) return debug.log('openHrefInOutlineMode: No href found!\n') const dom = window.document if ((dom as any).outlineManager) { // @@ TODO Remove the use of document as a global object ;(dom as any).outlineManager.GotoSubject(store.sym(uri), true, undefined, true, undefined) } else if (window && (window as any).panes && (window as any).panes.getOutliner) { // @@ TODO Remove the use of window as a global object ;(window as any).panes .getOutliner() .GotoSubject(store.sym(uri), true, undefined, true, undefined) } else { debug.log('ERROR: Can\'t access outline manager in this config') } // dom.outlineManager.GotoSubject(store.sym(uri), true, undefined, true, undefined) } /** * Make a URI in the Tabulator.org annotation store out of the URI of the thing to be annotated. * * @@ Todo: make it a personal preference. */ export function defaultAnnotationStore (subject:NamedNode):NamedNode | undefined { if (subject.uri === undefined) return undefined let s = subject.uri if (s.slice(0, 7) !== 'http://') return undefined s = s.slice(7) // Remove const hash = s.indexOf('#') if (hash >= 0) s = s.slice(0, hash) // Strip trailing else { const slash = s.lastIndexOf('/') if (slash < 0) return undefined s = s.slice(0, slash) } return store.sym('http://tabulator.org/wiki/annnotation/' + s) } /** * Retrieve all RDF class URIs from solid-ui's RDF store * @returns an object `ret` such that `Object.keys(ret)` is * the list of all class URIs. */ export function allClassURIs (): { [uri: string]: boolean } { const set = {} store .statementsMatching(undefined, ns.rdf('type'), undefined) .forEach(function (st) { if (st.object.value) set[st.object.value] = true }) store .statementsMatching(undefined, ns.rdfs('subClassOf'), undefined) .forEach(function (st) { if (st.object.value) set[st.object.value] = true if (st.subject.value) set[st.subject.value] = true }) store .each(undefined, ns.rdf('type'), ns.rdfs('Class')) .forEach(function (c) { if (c.value) set[c.value] = true }) return set } /** * Figuring which properties we know about * * When the user inputs an RDF property, like for a form field * or when specifying the relationship between two arbitrary things, * then er can prompt them with properties the session knows about * * TODO: Look again by catching this somewhere. (On the kb?) * TODO: move to diff module? Not really a button. * @param {Store} kb The quadstore to be searched. */ export function propertyTriage (kb: IndexedFormula): any { const possibleProperties: any = {} // if (possibleProperties === undefined) possibleProperties = {} // const kb = store const dp = {} const op = {} let no = 0 let nd = 0 let nu = 0 const pi = (kb as any).predicateIndex // One entry for each pred for (const p in pi) { const object = pi[p][0].object if (object.termType === 'Literal') { dp[p] = true nd++ } else { op[p] = true no++ } } // If nothing discovered, then could be either: const ps = kb.each(undefined, ns.rdf('type'), ns.rdf('Property')) for (let i = 0; i < ps.length; i++) { const p = ps[i].toNT() if (!op[p] && !dp[p]) { dp[p] = true op[p] = true nu++ } } possibleProperties.op = op possibleProperties.dp = dp info(`propertyTriage: ${no} non-lit, ${nd} literal. ${nu} unknown.`) return possibleProperties } /** * General purpose widgets */ /** * A button for jumping */ export function linkButton (dom: HTMLDocument, object: NamedNode): HTMLElement { const b = dom.createElement('button') b.setAttribute('type', 'button') b.textContent = 'Goto ' + utils.label(object) b.addEventListener('click', function (_event) { // b.parentNode.removeChild(b) ;(dom as any).outlineManager.GotoSubject(object, true, undefined, true, undefined) }, true) return b } /** * A button to remove some other element from the page */ export function removeButton (dom: HTMLDocument, element: HTMLElement) { const b = dom.createElement('button') b.setAttribute('type', 'button') b.textContent = '✕' // MULTIPLICATION X b.addEventListener('click', function (_event) { ;(element as any).parentNode.removeChild(element) }, true) return b } // Description text area // // Make a box to demand a description or display existing one // // @param dom - the document DOM for the user interface // @param kb - the graph which is the knowledge base we are working with // @param subject - a term, the subject of the statement(s) being edited. // @param predicate - a term, the predicate of the statement(s) being edited // @param store - The web document being edited // @param callbackFunction - takes (boolean ok, string errorBody) // /////////////////////////////////////// Random I/O widgets ///////////// // //// Column Header Buttons // // These are for selecting different modes, sources,styles, etc. // /* buttons.headerButtons = function (dom, kb, name, words) { const box = dom.createElement('table') var i, word, s = '' box.setAttribute('style', 'width: 90%; height: 1.5em') for (i=0; i' } */ // //////////////////////////////////////////////////////////// // // selectorPanel // // A vertical panel for selecting connections to left or right. // // @param inverse means this is the object rather than the subject // export function selectorPanel ( dom: HTMLDocument, kb: IndexedFormula, type: NamedNode, predicate: NamedNode, inverse: boolean, possible: NamedNode[], options: { connectIcon?: string }, callbackFunction: (_x: NamedNode, _e: Event, _selected: boolean) => void, linkCallback: (_x: NamedNode, _e: Event, _inverse: boolean, _setStyleFunction: () => void) => void ) { return selectorPanelRefresh( dom.createElement('div'), dom, kb, type, predicate, inverse, possible, options, callbackFunction, linkCallback ) } export function selectorPanelRefresh ( list: HTMLElement, dom: HTMLDocument, kb: IndexedFormula, type: NamedNode, predicate: NamedNode, inverse: boolean, possible: NamedNode[], options: { connectIcon?: string }, callbackFunction: (_x: NamedNode, _e: Event, _selected: boolean) => void, linkCallback: (_x: NamedNode, _e: Event, _inverse: boolean, _setStyleFunction: () => void) => void ) { const style0 = 'border: 0.1em solid #ddd; border-bottom: none; width: 95%; height: 2em; padding: 0.5em;' let selected: any = null list.innerHTML = '' const refreshItem = function (box: HTMLElement, x: NamedNode) { // Scope to hold item and x let item: any // eslint-disable-next-line prefer-const let image: HTMLImageElement const setStyle = function () { const already = inverse ? kb.each(undefined, predicate, x) : kb.each(x, predicate) iconDiv.setAttribute('class', already.length === 0 ? 'hideTillHover' : '') // See tabbedtab.css image.setAttribute( 'src', options.connectIcon || iconBase + 'noun_25830.svg' ) image.setAttribute('title', already.length ? already.length : 'attach' as any) } const f = index.twoLine.widgetForClass(type) // eslint-disable-next-line prefer-const item = f(dom, x) item.setAttribute('style', style0) const nav = dom.createElement('div') nav.setAttribute('class', 'hideTillHover') // See tabbedtab.css nav.setAttribute('style', 'float:right; width:10%') const a = dom.createElement('a') a.setAttribute('href', x.uri) a.setAttribute('style', 'float:right') nav.appendChild(a).textContent = '>' box.appendChild(nav) const iconDiv = dom.createElement('div') iconDiv.setAttribute( 'style', (inverse ? 'float:left;' : 'float:right;') + ' width:30px;' ) image = dom.createElement('img') setStyle() iconDiv.appendChild(image) box.appendChild(iconDiv) item.addEventListener('click', function (event) { if (selected === item) { // deselect item.setAttribute('style', style0) selected = null } else { if (selected) selected.setAttribute('style', style0) item.setAttribute( 'style', style0 + 'background-color: #ccc; color:black;' ) selected = item } callbackFunction(x, event, selected === item) setStyle() }, false) image.addEventListener('click', function (event) { linkCallback(x, event, inverse, setStyle) }, false) box.appendChild(item) return box } for (let i = 0; i < possible.length; i++) { const box = dom.createElement('div') list.appendChild(box) refreshItem(box, possible[i]) } return list } // ########################################################################### // // Small compact views of things // export let index: any = {} // /////////////////////////////////////////////////////////////////////////// // We need these for anything which is a subject of an attachment. // // These should be moved to type-dependeent UI code. Related panes maybe function twoLineDefault (dom: HTMLDocument, x: NamedNode): HTMLElement { // Default const box = dom.createElement('div') box.textContent = utils.label(x) return box } /** * Find a function that can create a widget for a given class * @param c The RDF class for which we want a widget generator function */ function twoLineWidgetForClass (c: NamedNode): (_dom: HTMLDocument, _x: NamedNode) => HTMLElement { let widget = index.twoLine[c.uri] const kb = store if (widget) return widget const sup = kb.findSuperClassesNT(c) for (const cl in sup) { widget = index.twoLine[kb.fromNT(cl).uri] if (widget) return widget } return index.twoLine[''] } /** * Display a transaction * @param x Should have attributes through triples in store: * * ns.qu('payee') -> a named node * * ns.qu('date) -> a literal * * ns.qu('amount') -> a literal */ function twoLineTransaction (dom: HTMLDocument, x: NamedNode): HTMLElement { let failed = '' const enc = function (p) { const y = store.any(x, ns.qu(p)) if (!y) failed += '@@ No value for ' + p + '! ' return y ? utils.escapeForXML(y.value) : '?' // @@@@ } const box = dom.createElement('table') box.innerHTML = ` ${enc('payee')} < /tr> < tr > ${enc('date').slice(0, 10)} ${enc('amount')} ` if (failed) { box.innerHTML = ` ${utils.escapeForXML(failed)} ` } return box } /** * Display a trip * @param x Should have attributes through triples in store: * * ns.dc('title') -> a literal * * ns.cal('dtstart') -> a literal * * ns.cal('dtend') -> a literal */ function twoLineTrip ( dom: HTMLDocument, x: NamedNode ): HTMLElement { const enc = function (p) { const y = store.any(x, p) return y ? utils.escapeForXML(y.value) : '?' } const box = dom.createElement('table') box.innerHTML = ` ${enc(ns.dc('title'))} ${enc(ns.cal('dtstart'))} ${enc(ns.cal('dtend'))} ` return box } /** * Stick a stylesheet link the document if not already there */ export function addStyleSheet (dom: HTMLDocument, href: string): void { const links = dom.querySelectorAll('link') for (let i = 0; i < links.length; i++) { if ( (links[i].getAttribute('rel') || '') === 'stylesheet' && (links[i].getAttribute('href') || '') === href ) { return } } const link = dom.createElement('link') link.setAttribute('rel', 'stylesheet') link.setAttribute('type', 'text/css') link.setAttribute('href', href) dom.getElementsByTagName('head')[0].appendChild(link) } // Figure (or guess) whether this is an image, etc // export function isAudio (file?: NamedNode) { return isImage(file, 'audio') } export function isVideo (file?: NamedNode) { return isImage(file, 'video') } /** * */ export function isImage (file?: NamedNode, kind?: string): boolean { const dcCLasses = { audio: 'http://purl.org/dc/dcmitype/Sound', image: 'http://purl.org/dc/dcmitype/Image', video: 'http://purl.org/dc/dcmitype/MovingImage' } const what = kind || 'image' // See https://github.com/linkeddata/rdflib.js/blob/e367d5088c/src/formula.ts#L554 // const typeURIs = store.findTypeURIs(file as any) // See https://github.com/linkeddata/rdflib.js/blob/d5000f/src/utils-js.js#L14 // e.g.'http://www.w3.org/ns/iana/media-types/audio' const prefix: string = Util.mediaTypeClass(what + '/*').uri.split('*')[0] for (const t in typeURIs) { if (t.startsWith(prefix)) return true } if (dcCLasses[what] in typeURIs) return true return false } /** * File upload button * @param dom The DOM aka document * @param droppedFileHandler Same handler function as drop, takes array of file objects * @returns {Element} - a div with a button and a inout in it * The input is hidden, as it is uglky - the user clicks on the nice icons and fires the input. */ // See https://developer.mozilla.org/en-US/docs/Web/API/File/Using_files_from_web_applications export function fileUploadButtonDiv ( dom: HTMLDocument, droppedFileHandler: (_files: FileList) => void ) { const div = dom.createElement('div') const input = div.appendChild(dom.createElement('input')) input.setAttribute('type', 'file') input.setAttribute('multiple', 'true') input.addEventListener( 'change', (event: any) => { debug.log('File drop event: ', event) if (event.files) { droppedFileHandler(event.files) } else if (event.target && event.target.files) { droppedFileHandler(event.target.files) } else { alert('Sorry no files .. internal error?') } }, false ) ;(input as any).style = 'display:none' const buttonElt = div.appendChild( button( dom, iconBase + 'noun_Upload_76574_000000.svg', 'Upload files', _event => { input.click() } ) ) makeDropTarget(buttonElt, null, droppedFileHandler) // Can also just drop on button return div } index = { line: { // Approx 80em }, twoLine: { // Approx 40em * 2.4em '': twoLineDefault, 'http://www.w3.org/2000/10/swap/pim/qif#Transaction': twoLineTransaction, 'http://www.w3.org/ns/pim/trip#Trip': twoLineTrip, widgetForClass: twoLineWidgetForClass } }