diff --git a/helpers.js b/helpers.js index ce70d07..96678c0 100644 --- a/helpers.js +++ b/helpers.js @@ -76,3 +76,56 @@ export function generateDimension(argDimension, dimensionName) { return dimension } +// This function is a little nuts, and probably not worth it. Use it as a +// tagged template literal (e.g. printIfExists`foo ${var} bar`) to print +// the string if `var` "exists" where "exists" is some pseudo definition +// that I made up. So yeah. There's that. +export function printIfExists(strings, input, ...otherArgs) { + if (otherArgs.length > 0) { + throw new Error(`Unexpected usage! Use like ${printIfExists.name}\`before ${variable} after\`.`) + } + + function wrap(str) { + return strings[0] + str + strings[1] + } + + if (input !== undefined) { + if (input && Array.isArray(input) && input.length > 0) { + return wrap(input.join(', ')) + } + + else if (input && typeof input === 'object' && Object.keys(input).length > 0) { + try { + return wrap(JSON.stringify(input)) + } catch (e) { + return wrap(input) + } + } + + else if ( + (typeof input === 'string' && input !== '') || + (typeof input === 'number') || + (typeof input === 'boolean') + ) { + return wrap(input) + } + } + + // if we've fallen all the way through without returning, display nothing + return '' +} + +export function handleValueAndTags(value, tags) { + // for a dumb edge-case where Number('') returns 0 + let valueAsNumber = value === '' ? NaN : Number(value) + let valueIsNotNumber = Number.isNaN(valueAsNumber) + // prevents us from making an "undefined" tag when value is not provided + let valueIsTag = valueIsNotNumber && value !== undefined && value !== '' + + let finalValue = valueIsNotNumber ? 1 : valueAsNumber + let finalTags = (valueIsTag ? [value, ...tags] : tags) + .map(tag => tag.trim()) // trim out spacing for the case where someone does `note ' tag'` + .filter(tag => tag !== '') + + return { value: finalValue, tags: finalTags } +} \ No newline at end of file diff --git a/nb.js b/nb.js index abeeef8..cf04b9e 100755 --- a/nb.js +++ b/nb.js @@ -30,19 +30,22 @@ import { formatTime, indexOrNot, parseTimestamp, - generateDimension + generateDimension, + printIfExists, + handleValueAndTags } from './helpers.js' updateNotifier({ pkg }).notify() yargs(hideBin(process.argv)) .command({ - command: 'note ', + command: 'note [value] [tags...]', description: 'record something worth remembering', builder: yargs => { return yargs .positional('stream', { describe: 'the id of the stream to write to', type: 'string' }) - .positional('value', { describe: 'the value to remember' }) + .positional('value', { describe: 'the value to remember', type: "string" }) + .positional('tags', { describe: 'tags to categorize this value', type: "array" }) .option('timestamp', { alias: 'ts' }) }, handler: args => { @@ -52,14 +55,16 @@ yargs(hideBin(process.argv)) console.log(`stream ${args.stream} does not exist; creating it now.`) } + const { value, tags } = handleValueAndTags(args.value, args.tags) + if (args.timestamp) { const parsedTimestamp = parseTimestamp(args.timestamp) - let values = [...stream.values, [ parsedTimestamp, args.value ]].sort((a, b) => a[0] - b[0]) + let values = [...stream.values, [parsedTimestamp, value, ...tags]].sort((a, b) => a[0] - b[0]) db.put(args.stream, { ...stream, values }) - console.log(`noted ${args.value} in stream ${formatStreamName(stream)} at time ${args.timestamp}.`) + console.log(`noted ${value}${printIfExists` (${tags})`} in stream ${formatStreamName(stream)} at time ${args.timestamp}.`) } else { - db.put(args.stream, { ...stream, values: [...stream.values, [ Date.now(), args.value ]] }) - console.log(`noted ${args.value} in stream ${formatStreamName(stream)}.`) + db.put(args.stream, { ...stream, values: [...stream.values, [Date.now(), value, ...tags]] }) + console.log(`noted ${value}${printIfExists` (${tags})`} in stream ${formatStreamName(stream)}.`) } } }) @@ -94,15 +99,17 @@ yargs(hideBin(process.argv)) } }) .command({ - command: 'correct [index] [note]', + command: 'correct [index] [value] [tags...]', description: 'correct a memory', builder: yargs => { return yargs .positional('stream', { describe: 'the stream containing the memory to replace' }) .positional('index', { describe: 'the memory to replace; reference it by its timestamp or index. if not specified, the newest note will be corrected' }) - .option('note', { describe: 'the new note value' }) + .positional('value', { describe: 'the new note value' }) + .positional('tags', { describe: 'the new note\'s tags', type: "array" }) }, handler: args => { + let stream = db.get(args.stream) if (stream === undefined) throw Error(`stream ${args.stream} does not exist.`) @@ -124,13 +131,15 @@ yargs(hideBin(process.argv)) throw Error(`stream does not contain memory with index ${index}.`) const newValues = values - newValues[correctionIndex] = [values[correctionIndex][0], args.note] + + const { value, tags } = handleValueAndTags(args.value, args.tags) + newValues[correctionIndex] = [values[correctionIndex][0], value, ...tags] db.put(args.stream, { ...stream, values: newValues }) if (args.index === undefined) { - console.log(`corrected latest note ${values.length} in stream ${formatStreamName(stream)}.`) + console.log(`corrected latest note ${values.length} (${value}${printIfExists`, [${tags}]`}) in stream ${formatStreamName(stream)}.`) } else { - console.log(`corrected note ${args.index} in stream ${formatStreamName(stream)}.`) + console.log(`corrected note ${args.index} (${value}${printIfExists`, [${tags}]`}) in stream ${formatStreamName(stream)}.`) } } }) @@ -194,30 +203,45 @@ yargs(hideBin(process.argv)) // TODO: Implement timeline format .option('format', { describe: 'the format of the output', choices: ['csv', 'table', 'chart', 'graph', 'json', 'timeline'], default: 'csv' }) .option('time-format', { describe: 'the format of timestamps in the output', choices: ['unix', 'relative', 'date'], default: 'relative' }) + .option('reverse', { describe: "reverse the order of the returned data", type: "boolean", default: false }) + .option('limit', { describe: "how many entries to display", type: "number"}) }, handler: args => { const stream = db.get(args.stream) if (stream === undefined) throw Error(`stream ${args.stream} does not exist.`) + // attach the index to the note before (potentially) reversing it and trimming it + let notes = stream.values.map((value, index) => [index, ...value]) + + if (args.reverse) notes = notes.reverse() + + if (typeof args.limit === 'number' && args.limit > 0) { + notes = notes.slice(0, args.limit) + } + if (args.format === 'csv') { - for(let i = 0; i < stream.values.length; i++) { - console.log(`${i}, ${formatTime(stream.values[i][0], args.timeFormat)}, ${stream.values[i][1]}`) - } + notes.forEach((note) => { + let [index, time, value, ...otherValues] = note + console.log(`${index}, ${formatTime(time, args.timeFormat)}, ${value}${printIfExists`, (${otherValues})`}`) + }) } else if (args.format === 'chart') { - console.log(chart(stream.values.map((value) => value[1]), { + console.log(chart(notes.map((value) => value[2]), { width: generateDimension(args.width, 'width'), height: generateDimension(args.height, 'height'), dense: true })) } else if (args.format === 'graph') { - console.log(sparkly(stream.values.map((value) => value[1]), { minimum: 0 })) + console.log(sparkly(notes.map((value) => value[2]), { minimum: 0 })) } else if (args.format === 'table') { const table = new AsciiTable(formatStreamName(stream)) - table.setHeading('index', 'time', 'value') - table.addRowMatrix(stream.values.map((value, index) => [index, formatTime(value[0], args.timeFormat), value[1]])) + table.setHeading('index', 'time', 'value', 'tags') + table.addRowMatrix(notes.map(note => { + let [index, time, value, ...otherValues] = note + return [index, formatTime(time, args.timeFormat), value, printIfExists`${otherValues}`] + })) console.log(table.toString()) } else if (args.format === 'json') { - console.log(JSON.stringify({ ...stream, values: stream.values.map((value, index) => [index, ...value]) }, null, 2)) + console.log(JSON.stringify({ ...stream, values: notes }, null, 2)) } else { throw Error(`format ${args.format} not implemented yet.`) } @@ -233,7 +257,7 @@ yargs(hideBin(process.argv)) handler: args => { const stream = db.get(args.stream) if (stream === undefined) throw Error(`stream ${args.stream} does not exist.`) - if (args.name) { db.put(args.stream, {... stream, name: args.name }) } + if (args.name) { db.put(args.stream, { ...stream, name: args.name }) } console.log(`updated stream ${formatStreamName(stream)}.`) } }) @@ -276,7 +300,7 @@ yargs(hideBin(process.argv)) for (let metaRow = 0; metaRow < rowCount; metaRow++) { for (let literalRow = 0; literalRow < height - 2; literalRow++) { let row = '' - for(let column = 0; column < chartsPerRow; column++) { + for (let column = 0; column < chartsPerRow; column++) { if (metaRow * chartsPerRow + column < streams.length) { row += charts[metaRow * chartsPerRow + column][literalRow] } @@ -287,6 +311,7 @@ yargs(hideBin(process.argv)) } }) .demandCommand() + .strictCommands() .help() }, handler: args => { @@ -322,7 +347,7 @@ yargs(hideBin(process.argv)) let fromStreams = from.keys() - for(let i = 0; i < fromStreams.length; i++) { + for (let i = 0; i < fromStreams.length; i++) { if (!to.has(fromStreams[i])) { to.put(fromStreams[i], { id: fromStreams[i], ...defaultStream }) console.log(`stream ${fromStreams[i]} does not exist; creating it now.`) @@ -352,11 +377,13 @@ yargs(hideBin(process.argv)) } }) .demandCommand() + .strictCommands() .help() }, handler: args => { } }) .demandCommand() + .strictCommands() .help() .argv diff --git a/server.js b/server.js index 06490cc..5ff4941 100644 --- a/server.js +++ b/server.js @@ -2,47 +2,168 @@ import { execSync } from 'child_process' import path from 'path' import { fileURLToPath } from 'url' +import flatfile from 'flat-file-db' // docs: https://github.com/mafintosh/flat-file-db#api + import server from 'server' const { get, post } = server.router const __dirname = path.dirname(fileURLToPath(import.meta.url)) let nb = path.resolve(__dirname, 'nb.js') -// this function is only used by the web code -// do not add any node-specific stuff in it -// do not run it outside of the rendered output -async function doCommand(event) { - event.preventDefault() - let text = await (await fetch(window.location.pathname, { method: 'POST' })).text() - let outputElement = document.querySelector('#output') - outputElement.innerHTML += text +const db = flatfile.sync(path.join(__dirname, 'server-config.db')) + +function injectScripts(...fns) { + return `` } -function getCommandArgs(ctx, args = [], extra = '') { - return `${args.map(arg => ctx.params[arg]).join(' ')} ${extra}`.replace(/\s{2,}/g,' ').trim() +function sanitizeHtml(input) { + return input.replace(//g, '>') } -function runAndRenderPage(ctx, args = [], extra = '') { - return `
\n` +
-    runAndRender(ctx, args, `${extra} --help`) +
-    `\n\n` +
-    `run nb.js ${getCommandArgs(ctx, args, extra)}` +
-    `\n
` + - `
`
+function runAndRenderPage(ctx, commandString) {
+  return ``
+    + '
' + + form(`> nb.js `, sanitizeHtml(commandString)) + + '
' + + `
` + + `
${formatCommandOutput(ctx, commandString, sanitizeHtml(runCommand(ctx, commandString)))}
` + + `` +} + +function form(prefix, defaultValue) { + function onInput(value) { + document.querySelector('#spacer').innerHTML = ` ${value} ` + } + + async function doCommand(event, link) { + event.preventDefault() + let command = link || event.target[0].value + let outputElement = document.querySelector('#output') + window.history.pushState({}, '', encodeURIComponent(command)) + + document.querySelector('#command-input').value = command + onInput(command) + + let text = await (await fetch(`/nb/${encodeURIComponent(command)}`, { method: 'POST' })).text() + outputElement.innerHTML = formatCommandOutput({}, command, text) + '\n\n' + outputElement.innerHTML + } + + // this hack makes the cursor move to the end of the text on focus + function onFocus(event) { + let val = event.target.value + event.target.value = '' + event.target.value = val + } + + function addListeners() { + window.addEventListener('keydown', (event) => { + console.log(event) + if ( + event.key.match(/^[a-zA-Z0-9]$/) + && event.altKey === false + && event.metaKey === false + && event.ctrlKey === false + && event.shiftKey === false + ) { + document.querySelector('#command-input').focus() + } + }) + } + + let formStyle = `style="white-space: normal; font-family: monospace;" ` + let spanStyle = `style="white-space: pre;" ` + let wrapperStyle = `style="position: relative; display: inline-block;" ` + let inputStyle = `style="position: absolute; width: 100%; left: 0; border: 0; padding: 0; margin: 0; font-family: inherit; font-size: inherit; text-align: center;" ` + + return `${injectScripts(onInput, doCommand, onFocus, `(${addListeners.toString()}())`, formatCommandOutput, sanitizeHtml)} +
+ ${prefix.trim()} + ${defaultValue} + + +
` +} + +function formatCommandOutput(ctx, command, input) { + let isInCommandSection = false + let commandRegExp = /(nb\.js)\s+((?:(?!&|<|\[| ).)+)\s/ + + function makeLink(link, text, help = true) { + return `${text}` + } + + let lines = input.split('\n') + .map(line => { + + if (line.startsWith('nb.js')) { + let parsedLine = '' + line.split(' ').reduce((itemsSoFar, item) => { + let isAnArg = item.startsWith('&') || item.startsWith('[') + if (item !== 'nb.js' && !isAnArg) { + itemsSoFar.push(item) + } + if (!isAnArg) { + parsedLine += `${makeLink(itemsSoFar.join(' '), item)} ` + } else { + parsedLine += `${item} ` + } + return itemsSoFar + }, []) + return parsedLine + } + + if (line.startsWith('Commands:')) { + isInCommandSection = true + } + + else if (isInCommandSection && line.startsWith(' ')) { + return line.replace(commandRegExp, `$1 ${makeLink('$2', '$2')} `) + } + + else if (isInCommandSection && !line.startsWith(' ')) { + isInCommandSection = false + } + + return line + }) + return `> nb.js ${sanitizeHtml(command)} ${makeLink(command, '(repeat)', false)}\n\n` + lines.join("\n") } -function runAndRender(ctx, args = [], extra = '') { - let command = `${nb} ${getCommandArgs(ctx, args, extra)}` +function runCommand(ctx, commandString) { + let command = `${nb} ${commandString.split(/\s/) + // best effort safety -- wrap all args in single quotes + .map(part => `'${part.replace(/'/g, "'\\''")}'`).join(' ')}` let output try { output = execSync(command, { encoding: 'utf8' }) } catch (e) { output = e.stdout + e.stderr } + console.log(`> ${command}`) console.log(output) - return `> nb.js ${getCommandArgs(ctx, args, extra)}\n\n` - + output.replace(//g, '>') + + return output +} + +function getCommand(ctx) { + let commandString = ctx.params.command + + let routes = db.get('recent') || {} + if (!routes[commandString]) routes[commandString] = 0 + Object.keys(routes).forEach(key => { + if (key !== commandString) { + routes[key]--; + if (routes[key] < 0) delete routes[key] + } + }) + routes[commandString] += 4; + db.put('recent', routes) + + return commandString } const port = 8080 @@ -50,32 +171,37 @@ const port = 8080 // because we're trying to be shape-agnostic, we don't care what the args are called so long as they exist // this functions makes handler routes for both the --help and the regular commands for a certain length -// if we have 2 args, that means we need `/nb`, `/nb/arg1`, and `/nb/arg1/arg2`. -// we need to create two methods like this for each of them: -// get('/nb/:arg1/:arg2', ctx => runAndRenderPage(ctx, ["arg1", "arg2"])) -// post('/nb/:arg1/:arg2', ctx => runAndRender(ctx, ["arg1", "arg2"])) - -function makeRoutes(root, totalNumberOfArgs = 0) { - // for each sub-length in the totalNumberOfArgs, make a new set of get/post - // (e.g. if totalNumberOfArgs === 2, we need to make routes with 0, 1, and 2 args) - return new Array(totalNumberOfArgs + 1).fill('arg').map((arg, index) => index) - .map(numberOfArgs => { - let args = new Array(numberOfArgs).fill('arg').map((arg, index) => arg + index) - let path = args.map(arg => `:${arg}`).join('/') || '' - return [ - get(root + (path ? "/" : "") + path, async ctx => runAndRenderPage(ctx, args)), - post(root + (path ? "/" : "") + path, async ctx => runAndRender(ctx, args)) - ] - }) - .flat(2) -} - server({ port, security: { csrf: false }}, [ - get('/', ctx => `nb`), + get('/', ctx => { + let recents = db.get('recent') + let recentsText = '' + if (recents) { + recentsText = Object.entries(recents) + .sort((a, b) => b[1] - a[1]).map(([key]) => { + return `- ${key}` + }) + .join('
') + } + + return `nb
${recentsText}` + }), - // handle a given number of args: - // this is total depth -- right now, this works - makeRoutes('/nb', 10) -]); + get('/nb', ctx => runAndRenderPage(ctx, '')), + post('/nb', ctx => server.reply + .type("text/plain") + .send(runCommand(ctx, ''))), + + get('/nb/:command', ctx => runAndRenderPage(ctx, getCommand(ctx))), + post('/nb/:command', ctx => { + return server.reply + .type("text/plain") + .send(runCommand(ctx, getCommand(ctx)))} + ) +], + server.router.error(ctx => { + console.log(ctx.error) + return server.reply.status(500).send(ctx.error.message) + }) +); -console.log(`Listening on port ${port}...`) \ No newline at end of file +console.log(`Listening on http://localhost:${port}`) \ No newline at end of file