692 lines
18 KiB
JavaScript
692 lines
18 KiB
JavaScript
import { Node } from 'node'
|
|
|
|
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 NodeStore {
|
|
constructor() {//{{{
|
|
if (!('indexedDB' in window)) {
|
|
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', 12)
|
|
|
|
// 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
|
|
let filesNodelink
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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.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, {})
|
|
|
|
await this.files.add({ data: { UUID: metadata.UUID, file } })
|
|
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({ file: null, metadata: null })
|
|
return
|
|
}
|
|
|
|
// Retrieve file as well.
|
|
const filereq = this.db.
|
|
transaction('files', 'readonly')
|
|
.objectStore('files')
|
|
.get(data.UUID)
|
|
|
|
filereq.onsuccess = (e) => {
|
|
const f = e.target.result
|
|
|
|
// File should always be in IndexedDB when metadata is.
|
|
if (f === undefined) {
|
|
reject(new Error(`File with UUID '${data.UUID}' is missing.`))
|
|
return
|
|
}
|
|
|
|
resolve({
|
|
file: f.file,
|
|
metadata: new FileMetadata(f.file, 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.log(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 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 })
|
|
}
|
|
}// }}}
|
|
}
|
|
|
|
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)}`
|
|
}// }}}
|
|
|
|
class FileMetadata {
|
|
constructor(file, data) {// {{{
|
|
this.UUID = data.UUID || uuidv7()
|
|
this.uploaded = data.uploaded || 'only_on_device'
|
|
|
|
this.name = file.name
|
|
this.size = file.size
|
|
this.type = file.type || 'application/octet-stream'
|
|
this.modified = file.lastModified
|
|
}// }}}
|
|
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,
|
|
}
|
|
}// }}}
|
|
}
|
|
|
|
// vim: foldmethod=marker
|