Notes2/static/js/node_store.mjs

948 lines
25 KiB
JavaScript

export const ROOT_NODE = '00000000-0000-0000-0000-000000000000'
export const ORPHANED_NODE = '00000000-0000-0000-0000-000000000001'
export const DELETED_NODE = '00000000-0000-0000-0000-000000000002'
export class Node {
static sort(a, b) {//{{{
// Nodes with children ("folders") are sorted first.
if (a._has_children && !b._has_children) return -1
if (!a._has_children && b._has_children) return 1
// Otherwise sort by lowercased name.
const an = a.data.Name.toLowerCase()
const bn = b.data.Name.toLowerCase()
if (an < bn) return -1
if (an > bn) return 1
return 0
}//}}}
static create(name, parentUUID) {// {{{
const node = new Node({
UUID: uuidv7(),
Created: (new Date()).toISOString(),
Content: '',
Name: name,
ParentUUID: parentUUID,
Markdown: false,
})
// Newly created node (not constructed from existing data) is considered modified
// since node.save returns early if it isn't modified.
node._modified = true
return node
}// }}}
constructor(nodeData, level) {//{{{
this.Level = level
this.data = nodeData
this.UUID = nodeData.UUID
// Toplevel nodes are normalized to have the ROOT_NODE as parent.
if (nodeData.UUID !== ROOT_NODE && nodeData.ParentUUID === '') {
this.ParentUUID = ROOT_NODE
this.data.ParentUUID = ROOT_NODE
} else
this.ParentUUID = nodeData.ParentUUID
this._children_fetched = false
this._has_children = null // this will be set by nodeStore.getTreeNodes
this.Children = []
this.Ancestors = []
this._sibling_before = null
this._sibling_after = null
this._parent = null
this.reset()
}//}}}
reset() {// {{{
// this._content is the runtime content in the editor.
// this.data.Content is the original data.
this._content = this.data.Content
this._modified = false
}// }}}
get(prop) {//{{{
return this.data[prop]
}//}}}
updated() {//{{{
// '2024-12-17T17:33:48.85939Z
return new Date(Date.parse(this.data.Updated))
}//}}}
isModified() {// {{{
return this._modified
}// }}}
hasFetchedChildren() {//{{{
return this._children_fetched
}//}}}
async fetchChildren() {//{{{
this.Children = await globalThis.nodeStore.getTreeNodes(this.UUID, this.Level + 1)
this._children_fetched = true
// Children are sorted to allow for storing siblings befare and after.
// These are used with keyboard navigation in the tree.
this.Children.sort(Node.sort)
const numChildren = this.Children.length
this.setHasChildren(numChildren > 0)
for (let i = 0; i < numChildren; i++) {
if (i > 0)
this.Children[i]._sibling_before = this.Children[i - 1]
if (i < numChildren - 1)
this.Children[i]._sibling_after = this.Children[i + 1]
this.Children[i]._parent = this
}
return this.Children
}//}}}
setHasChildren(v) {// {{{
this._has_children = v
}// }}}
hasChildren() {//{{{
return this._has_children
}//}}}
getSiblingBefore() {// {{{
return this._sibling_before
}// }}}
getSiblingAfter() {// {{{
return this._sibling_after
}// }}}
getParent() {//{{{
return this._parent
}//}}}
moveToParent(newParentUUID) {// {{{
if (this.UUID === newParentUUID)
throw new Error("New parent UUID is the same as node UUID. Can't be your own parent.")
this.ParentUUID = newParentUUID
this.data.ParentUUID = newParentUUID
this._modified = true
}// }}}
isLastSibling() {//{{{
return this._sibling_after === null
}//}}}
isFirstSibling() {//{{{
return this._sibling_before === null
}//}}}
isSpecial() {// {{{
return this.data.Special
}// }}}
content() {//{{{
// TODO - implement crypto
return this._content
}//}}}
setContent(new_content) {//{{{
this._content = new_content
this._modified = true
_mbus.dispatch('NODE_MODIFIED', { node: this })
}//}}}
setName(new_name) {// {{{
if (new_name.trim() === '')
throw new Error(`The name can't be empty`)
this.data.Name = new_name
this._modified = true
_mbus.dispatch('NODE_MODIFIED', { node: this })
}// }}}
async save() {//{{{
try {
const dblink = /db:\/\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi
// Just safeguarding not using the root node,
// which sort of exist but isn't supposed to communicate to server.
if (this.UUID == ROOT_NODE)
return
// File links from the original, unmodified content are found to compare to links from the new content.
// All files (images) needs to be indexed to nodes in order to track orphaned files.
const before = new Set([...this.data.Content.matchAll(dblink)].map(m => m[1]))
const after = new Set([...this._content.matchAll(dblink)].map(m => m[1]))
const removed = before.difference(after)
const added = after.difference(before)
await globalThis.nodeStore.filesNodelink.removeFileUUIDs(this.UUID, removed)
await globalThis.nodeStore.filesNodelink.addFileUUIDs(this.UUID, added)
// Authoritative content and metadata is set to prepare it for IndexedDB update.
this.data.Content = this._content
this.data.Updated = new Date().toISOString()
this.data.HistoryUUID = uuidv7() // every time the node is saved a new history UUID identifies the changed node.
_mbus.dispatch('NODE_UNMODIFIED')
this._modified = false
// When stored into database and ancestry was changed,
// the ancestry path could be interesting.
/*
const ancestors = await nodeStore.getNodeAncestry(this)
this.data.Ancestors = ancestors.map(a => a.get('Name')).reverse()
*/
/* The node history is a local store for node history.
* This could be provisioned from the server or cleared if
* deemed unnecessary.
*
* The send queue is what will be sent back to the server
* to have a recorded history of the notes.
*
* A setting to be implemented in the future could be to
* not save the history locally at all. */
// Current node is added to history. It will be duplicated with the "nodes" store
// for simplicity, to hopefully avoid bugs.
const history = globalThis.nodeStore.nodesHistory.add(this)
// Updated node is added to the send queue to be stored on server.
const sendQueue = globalThis.nodeStore.sendQueue.add(this)
// Updated node is saved to the primary node store.
const nodeStoreAdding = globalThis.nodeStore.add([this])
await Promise.all([history, sendQueue, nodeStoreAdding])
} catch (err) {
console.error(err)
alert(err.message)
}
return
}//}}}
}
export class NodeStore {
constructor() {//{{{
if (!('indexedDB' in self)) {
throw 'Missing IndexedDB'
}
this.db = null
this.nodes = {}
this.sendQueue = null
this.nodesHistory = null
this.files = null
this.filesMetadata = null
this.initializeSpecialNodes()
}//}}}
initializeDB() {//{{{
return new Promise((resolve, reject) => {
const req = indexedDB.open('notes', 14)
// Schema upgrades for IndexedDB.
// These can start from different points depending on updates to Notes2 since a device was online.
req.onupgradeneeded = (event) => {
let nodes
let appState
let sendQueue
let nodesHistory
let files
const db = event.target.result
const trx = event.target.transaction
for (let i = event.oldVersion + 1; i <= event.newVersion; i++) {
console.log(`Upgrade to schema ${i}`)
// The schema transformations.
switch (i) {
case 1:
nodes = db.createObjectStore('nodes', { keyPath: 'UUID' })
nodes.createIndex('byName', 'Name', { unique: false })
break
case 2:
trx.objectStore('nodes').createIndex('byParent', 'ParentUUID', { unique: false })
break
case 3:
appState = db.createObjectStore('app_state', { keyPath: 'key' })
break
case 4:
trx.objectStore('nodes').createIndex('byModified', 'modified', { unique: false })
break
case 5:
sendQueue = db.createObjectStore('send_queue', { keyPath: 'ClientSequence', autoIncrement: true })
sendQueue.createIndex('updated', 'Updated', { unique: false })
break
case 6:
nodesHistory = db.createObjectStore('nodes_history', { keyPath: ['UUID', 'HistoryUUID'] })
break
case 7:
trx.objectStore('nodes_history').createIndex('byUUID', 'UUID', { unique: false })
break
case 8:
files = db.createObjectStore('files', { keyPath: 'UUID' })
break
case 9:
db.createObjectStore('files_metadata', { keyPath: 'UUID' })
break
case 10:
trx.objectStore('files_metadata').createIndex('byUploaded', 'uploaded', { unique: false })
break
case 11:
db.createObjectStore('files_nodelink', { keyPath: 'UUID' })
break
case 12:
trx.objectStore('files_nodelink').createIndex('byNodeCount', 'NodeCount', { unique: false })
break
case 13:
db.createObjectStore('file_segments', { keyPath: ['UUID', 'Sequence'] })
break
case 14:
db.deleteObjectStore('files')
break
}
}
}
req.onsuccess = (event) => {
this.db = event.target.result
this.sendQueue = new SimpleNodeStore(this.db, 'send_queue')
this.nodesHistory = new NodeHistoryStore(this.db, 'nodes_history')
this.files = new SimpleNodeStore(this.db, 'files')
this.fileSegments = new FileSegmentsStore(this.db, 'file_segments')
this.filesMetadata = new FilesMetadataStore(this.db, 'files_metadata')
this.filesNodelink = new FileNodesLinkStore(this.db, 'files_nodelink')
resolve()
}
req.onerror = (event) => {
reject(event.target.error)
}
})
}//}}}
initializeSpecialNodes() {// {{{
this.nodes[ROOT_NODE] = new Node({ UUID: ROOT_NODE, Name: 'Start', Special: true }, -1)
this.nodes[DELETED_NODE] = new Node({ UUID: DELETED_NODE, Name: 'Deleted nodes', Special: true }, -1)
this.nodes[ORPHANED_NODE] = new Node({ UUID: ORPHANED_NODE, Name: 'Orphaned nodes', Special: true }, -1)
}// }}}
node(uuid, dataIfUndefined, newLevel) {//{{{
let n = this.nodes[uuid]
if (n === undefined && dataIfUndefined !== undefined)
n = this.nodes[uuid] = new Node(dataIfUndefined, newLevel)
return n
}//}}}
getAppState(key) {//{{{
return new Promise((resolve, reject) => {
const trx = this.db.transaction('app_state', 'readonly')
const appState = trx.objectStore('app_state')
const getRequest = appState.get(key)
getRequest.onsuccess = (event) => {
if (event.target.result !== undefined) {
resolve(event.target.result)
} else {
resolve(null)
}
}
getRequest.onerror = (event) => reject(event.target.error)
})
}//}}}
setAppState(key, value) {//{{{
return new Promise((resolve, reject) => {
try {
const t = this.db.transaction('app_state', 'readwrite')
const appState = t.objectStore('app_state')
t.onerror = (event) => {
console.log('transaction error', event.target.error)
reject(event.target.error)
}
t.oncomplete = () => {
resolve()
}
const record = { key, value }
const addReq = appState.put(record)
addReq.onerror = (event) => {
console.log('Error!', event.target.error, key, value)
}
} catch (e) {
reject(e)
}
})
}//}}}
getTreeNodes(parent, newLevel) {//{{{
return new Promise((resolve, reject) => {
// Parent of toplevel nodes is ROOT_NODE in indexedDB.
// Only the root node has '' as parent.
let storeParent = parent
const trx = this.db.transaction('nodes', 'readonly')
const nodeStore = trx.objectStore('nodes')
const index = nodeStore.index('byParent')
const req = index.getAll(storeParent)
const hasChildrenPromises = []
req.onsuccess = (event) => {
const nodes = []
for (const i in event.target.result) {
const nodeData = event.target.result[i]
const node = this.node(nodeData.UUID, nodeData, newLevel)
// Look for the key of any children, a hopefully fast way
// to tell if any children exists at all and this node is a
// "folder". Needed quite early on for sorting.
const promise = new Promise((resolve, reject) => {
const countReq = index.getKey(nodeData.UUID)
countReq.onsuccess = event => {
node.setHasChildren(event.target.result !== undefined)
resolve()
}
})
hasChildrenPromises.push(promise)
nodes.push(node)
}
Promise.all(hasChildrenPromises)
.then(() => resolve(nodes))
}
req.onerror = (event) => reject(event.target.error)
})
}//}}}
search(searchfor, parent) {//{{{
return new Promise((resolve, reject) => {
const trx = this.db.transaction('nodes', 'readonly')
const nodeStore = trx.objectStore('nodes')
const index = nodeStore.index('byParent')
const req = index.getAll(parent)
req.onsuccess = async (event) => {
let nodes = []
for (const i in event.target.result) {
const nodeData = event.target.result[i]
const children = await this.search(searchfor, nodeData.UUID)
// Data is searched for the provided text.
// TODO: implement an array of words to search?
if (nodeData.Content.toLowerCase().includes(searchfor))
nodes.push({
uuid: nodeData.UUID,
name: nodeData.Name,
ancestry: await this.getNodeAncestry(nodeData, []),
})
nodes = nodes.concat(children)
}
resolve(nodes)
return
}
req.onerror = (event) => reject(event.target.error)
})
}//}}}
add(records, objstore) {//{{{
return new Promise((resolve, reject) => {
try {
// A nodestore can be provided in order to
// avoid creating new transactions.
let nodeStore = objstore
let t
if (nodeStore === undefined) {
t = this.db.transaction('nodes', 'readwrite')
nodeStore = t.objectStore('nodes')
t.oncomplete = (_event) => {
resolve()
}
t.onerror = (event) => {
console.error('transaction error', event.target.error)
reject(event.target.error)
}
}
// records is an object, not an array.
for (const recordIdx in records) {
const record = records[recordIdx]
nodeStore.put(record.data)
}
resolve()
} catch (e) {
console.error(e)
reject(e)
}
})
}//}}}
get(uuid, suppliedNodestore) {//{{{
return new Promise((resolve, reject) => {
switch (uuid) {
case ROOT_NODE:
case DELETED_NODE:
case ORPHANED_NODE:
resolve(this.nodes[uuid])
return
}
// A nodestore can be provided in order to
// avoid creating new transactions.
let trx
let nodeStore = suppliedNodestore
if (nodeStore === undefined) {
trx = this.db.transaction('nodes', 'readonly')
nodeStore = trx.objectStore('nodes')
}
const getRequest = nodeStore.get(uuid)
getRequest.onsuccess = (event) => {
// Node not found in IndexedDB.
if (event.target.result === undefined) {
reject("No such node")
return
}
const node = this.node(uuid, event.target.result, -1)
resolve(node)
}
})
}//}}}
getNodeAncestry(node, accumulated) {//{{{
return new Promise((resolve, reject) => {
if (accumulated === undefined)
accumulated = []
const nodeParentIndex = this.db
.transaction('nodes', 'readonly')
.objectStore('nodes')
if (node.UUID === ROOT_NODE || node.ParentUUID === ROOT_NODE) {
resolve(accumulated)
return
}
if (node.UUID === DELETED_NODE || node.ParentUUID === DELETED_NODE) {
resolve(accumulated)
return
}
if (node.UUID === ORPHANED_NODE || node.ParentUUID === ORPHANED_NODE) {
resolve(accumulated)
return
}
const getRequest = nodeParentIndex.get(node.ParentUUID)
getRequest.onsuccess = (event) => {
// Node not found in IndexedDB.
// Not expected to happen.
const parentNodeData = event.target.result
if (parentNodeData === undefined) {
reject("No such node")
return
}
const parentNode = this.node(parentNodeData.UUID, parentNodeData, -1)
this.getNodeAncestry(parentNode, accumulated.concat(parentNode))
.then(accumulated => resolve(accumulated))
}
})
}//}}}
newTransaction(objectStore, mode) {// {{{
return this.db.transaction(objectStore, mode)
}// }}}
nodeCount() {//{{{
return new Promise((resolve, reject) => {
const t = this.db.transaction('nodes', 'readwrite')
const nodeStore = t.objectStore('nodes')
const countReq = nodeStore.count()
countReq.onsuccess = event => {
resolve(event.target.result)
}
})
}//}}}
async storeFile(file) {// {{{
const metadata = new FileMetadata(file)
try {
// Do potentially larger blocks.
const BLOCKSIZE = 1048576
const stream = file.stream()
const reader = stream.getReader({ mode: 'byob' })
let buffer = new Uint8Array(BLOCKSIZE)
let seq = 0
while (true) {
const { done, value } = await reader.read(buffer)
if (done) break
// Block is added as a file segment.
await this.fileSegments.add({
data: {
UUID: metadata.UUID,
Sequence: seq,
Data: value,
}
})
buffer = new Uint8Array(value.buffer)
seq++
}
reader.releaseLock()
} catch (e) {
console.error(e)
alert(e.message)
}
await this.filesMetadata.add({ data: metadata })
return metadata
}// }}}
nextFileToSync() {// {{{
return new Promise((resolve, reject) => {
const req = this.db
.transaction('files_metadata', 'readonly')
.objectStore('files_metadata')
.index('byUploaded')
.get('only_on_device')
req.onerror = (e) => reject(e)
req.onsuccess = (e) => {
const data = e.target.result
// No more files to sync.
if (data === undefined) {
resolve(null)
return
}
resolve(new FileMetadata(data))
}
})
}// }}}
}
class SimpleNodeStore {
constructor(db, storeName) {//{{{
this.db = db
this.storeName = storeName
}//}}}
add(node) {//{{{
return new Promise((resolve, reject) => {
const t = this.db.transaction(['nodes', this.storeName], 'readwrite')
const store = t.objectStore(this.storeName)
t.onerror = (event) => {
console.log('transaction error', event.target.error)
reject(event.target.error)
}
// Node to be moved is first stored in the new queue.
const req = store.put(node.data)
req.onsuccess = () => {
resolve()
}
req.onerror = (event) => {
console.log(`Error adding ${node.UUID}`, event.target.error)
reject(event.target.error)
}
})
}//}}}
get(key) {//{{{
return new Promise((resolve, _reject) => {
const req = this.db
.transaction(['nodes', this.storeName], 'readonly')
.objectStore(this.storeName)
.get(key)
req.onsuccess = (event) => {
resolve(event.target.result)
}
})
}//}}}
retrieve(limit) {//{{{
return new Promise((resolve, _reject) => {
const cursorReq = this.db
.transaction(['nodes', this.storeName], 'readonly')
.objectStore(this.storeName)
.index('updated')
.openCursor()
let retrieved = 0
const nodes = []
cursorReq.onsuccess = (event) => {
const cursor = event.target.result
if (!cursor) {
resolve(nodes)
return
}
retrieved++
nodes.push(cursor.value)
if (retrieved === limit) {
resolve(nodes)
return
}
cursor.continue()
}
})
}//}}}
delete(keys) {//{{{
const store = this.db
.transaction(['nodes', this.storeName], 'readwrite')
.objectStore(this.storeName)
const promises = []
for (const key of keys) {
const p = new Promise((resolve, reject) => {
// TODO - implement a way to add an error to a page-global error log.
const request = store.delete(key)
request.onsuccess = (event) => resolve(event)
request.onerror = (event) => reject(event)
})
promises.push(p)
}
return Promise.all(promises)
}//}}}
count() {//{{{
const store = this.db
.transaction(['nodes', this.storeName], 'readonly')
.objectStore(this.storeName)
return new Promise((resolve, reject) => {
const request = store.count()
request.onsuccess = (event) => resolve(event.target.result)
request.onerror = (event) => reject(event.target.error)
})
}//}}}
}
class NodeHistoryStore extends SimpleNodeStore {
constructor(db, storeName) {//{{{
super(db, storeName)
}//}}}
count(uuid) {//{{{
if (uuid === undefined)
return super.count()
const index = this.db
.transaction(['nodes', this.storeName], 'readonly')
.objectStore(this.storeName)
.index('byUUID')
return new Promise((resolve, reject) => {
const request = index.count(uuid)
request.onsuccess = (event) => resolve(event.target.result)
request.onerror = (event) => reject(event.target.error)
})
}//}}}
hasNode(uuid, updated) {// {{{
return new Promise((resolve, reject) => {
const req = this.db
.transaction(['nodes', this.storeName], 'readonly')
.objectStore(this.storeName)
.getKey([uuid, updated])
req.onsuccess = (event) => {
resolve(event.target.result !== undefined)
}
req.onerror = (event) => {
console.error(event.target.error)
reject(event.target.error)
}
})
}// }}}
retrievePage(uuid, perPage, page) {// {{{
return new Promise((resolve, _reject) => {
const lowerBound = [uuid, '00000000-0000-0000-0000-000000000000']
const upperBound = [uuid, 'ffffffff-ffff-ffff-ffff-ffffffffffff']
const range = IDBKeyRange.bound(lowerBound, upperBound)
const cursor = this.db
.transaction(['nodes', this.storeName], 'readonly')
.objectStore(this.storeName)
.openCursor(range, 'prev')
let retrieved = 0
let first = true
const nodes = []
cursor.onsuccess = (event) => {
const cursor = event.target.result
if (!cursor) {
resolve(nodes)
return
}
// openCursor returns the first value which is only useful
// if the first page is requested.
if (page == 1 || !first) {
retrieved++
nodes.push(new Node(cursor.value))
if (retrieved === perPage) {
resolve(nodes)
return
}
cursor.continue()
return
}
// Jump to the start of the requested page.
// Minus one since the first record was already returned.
if (page > 1 && first) {
first = false
cursor.advance((perPage * (page - 1)))
return
}
}
})
}// }}}
}
class FileSegmentsStore extends SimpleNodeStore {
constructor(db, storeName) {//{{{
super(db, storeName)
}//}}}
async getStream(uuid) {// {{{
let seq = 0
const meself = this
return new ReadableStream({
type: 'bytes',
async pull(controller) {
try {
while (true) {
const chunk = await meself.get([uuid, seq])
if (!chunk) {
controller.close()
return
}
controller.enqueue(chunk.Data)
seq++
}
} catch (e) {
console.error(e)
}
}
})
}// }}}
}
class FilesMetadataStore extends SimpleNodeStore {
countToUpload() {// {{{
const store = this.db
.transaction(this.storeName, 'readonly')
.objectStore(this.storeName)
.index('byUploaded')
return new Promise((resolve, reject) => {
const request = store.count('only_on_device')
request.onsuccess = (event) => resolve(event.target.result)
request.onerror = (event) => reject(event.target.error)
})
}// }}}
}
class FileNodesLinkStore extends SimpleNodeStore {
async removeFileUUIDs(nodeUUID, fileUUIDs) {// {{{
for (const fileUUID of fileUUIDs) {
let nodelink = await this.get(fileUUID)
// Nothing to remove from.
if (nodelink === undefined)
continue
const idx = nodelink.Nodes.indexOf(nodeUUID)
if (idx > -1)
nodelink.Nodes.splice(idx, 1)
// Maintains an index to quickly find orphaned files.
nodelink.NodeCount = nodelink.Nodes.length
await this.add({ data: nodelink })
}
}// }}}
async addFileUUIDs(nodeUUID, fileUUIDs) {// {{{
for (const fileUUID of fileUUIDs) {
let nodelink = await this.get(fileUUID)
// Needs to be initialized if not existing already.
if (nodelink === undefined)
nodelink = {
UUID: fileUUID,
Nodes: [nodeUUID],
}
// Can get called with the same UUID as already there.
// No duplicates, only append if missing.
if (!nodelink.Nodes.includes(nodeUUID))
nodelink.Nodes.push(nodeUUID)
// Maintains an index to quickly find orphaned files.
nodelink.NodeCount = nodelink.Nodes.length
await this.add({ data: nodelink })
}
}// }}}
}
class FileMetadata {
constructor(data) {// {{{
console.log('md', data)
this.UUID = data.UUID || uuidv7()
this.uploaded = data.uploaded || 'only_on_device'
this.name = data.name
this.size = data.size
this.type = data.type || 'application/octet-stream'
this.modified = data.modified || data.lastModified || Date.now()
}// }}}
setUploaded() {// {{{
this.uploaded = 'on_server'
}// }}}
get data() {// {{{
return {
UUID: this.UUID,
uploaded: this.uploaded,
name: this.name,
size: this.size,
type: this.type,
modified: this.modified,
}
}// }}}
}
export function uuidv7() {// {{{
// random bytes
const value = new Uint8Array(16)
crypto.getRandomValues(value)
// current timestamp in ms
const timestamp = BigInt(Date.now())
// timestamp
value[0] = Number((timestamp >> 40n) & 0xffn)
value[1] = Number((timestamp >> 32n) & 0xffn)
value[2] = Number((timestamp >> 24n) & 0xffn)
value[3] = Number((timestamp >> 16n) & 0xffn)
value[4] = Number((timestamp >> 8n) & 0xffn)
value[5] = Number(timestamp & 0xffn)
// version and variant
value[6] = (value[6] & 0x0f) | 0x70
value[8] = (value[8] & 0x3f) | 0x80
const str = Array.from(value)
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
return `${str.slice(0, 8)}-${str.slice(8, 12)}-${str.slice(12, 16)}-${str.slice(16, 20)}-${str.slice(20)}`
}// }}}
// vim: foldmethod=marker