timeld-cli
Version:
Live shared timesheets command interface
375 lines (361 loc) • 12.3 kB
JavaScript
import { Repl } from '@m-ld/m-ld-cli/lib/Repl.js';
import { JsonSinkProc, SyncProc } from '@m-ld/m-ld-cli/lib/Proc.js';
import fileCmd from '@m-ld/m-ld-cli/cmd/repl/file.js';
import { createReadStream } from 'fs';
import { truncate as truncateFile } from 'fs/promises';
import { ResultsProc } from './ResultsProc.mjs';
import {
parseDate, parseDuration, toDate, toDuration, toIri
} from './util.mjs';
import { Entry, Session, durationFromInterval } from 'timeld-common';
import { DefaultFormat, ENTRY_FORMAT_OPTIONS, getSubjectFormat } from './DisplayFormat.mjs';
import { PromiseProc } from './PromiseProc.mjs';
import { Writable } from 'stream';
import { mergeMap, toArray } from 'rxjs';
import { consume } from 'rx-flowable/consume';
/** @typedef {import('@m-ld/m-ld-cli/lib/Proc.js').Proc} Proc */
export default class TimesheetSession extends Repl {
/**
* @param {string} spec.id
* @param {string} spec.timesheet
* @param {string} spec.providerId
* @param {import('@m-ld/m-ld').MeldClone} spec.meld
* @param {string} spec.logFile
* @param {string|number} spec.logLevel
*/
constructor(spec) {
super({ logLevel: spec.logLevel, prompt: `${(spec.timesheet)}>` });
this.session = new Session(spec.id);
this.name = spec.timesheet;
this.providerId = spec.providerId;
this.meld = spec.meld;
this.logFile = spec.logFile;
/**
* Index of recently-viewed Entry IDs
* @type {string[]}
*/
this.workingSet = [];
}
buildCommands(yargs, ctx) {
const COMPLETES_ENTRY = '. Using this option will mark the entry complete.';
// noinspection JSCheckFunctionSignatures
return yargs
.command(fileCmd(ctx))
.command(
'log',
'fetch the timesheet system log',
yargs => yargs
.boolean('truncate')
.boolean('status')
.conflicts('truncate', 'status'),
argv => ctx.exec(
() => this.logProc(argv))
)
.command(
['add <activity> [duration]', 'a', '+'],
'Add a new timesheet entry',
yargs => yargs
.positional('activity', {
describe: 'The name of the activity being worked on',
type: 'string'
})
.positional('duration', {
describe: 'The duration of the activity e.g. 1h' + COMPLETES_ENTRY,
type: 'string',
coerce: parseDuration
})
.option('start', {
describe: 'The start date/time of the activity',
type: 'array',
default: ['now'],
coerce: parseDate
})
.option('end', {
describe: 'The end date & time of the activity' + COMPLETES_ENTRY,
type: 'array',
coerce: parseDate
}),
argv => ctx.exec(
() => this.addEntryProc(argv))
)
.command(
['modify <selector> [duration]', 'mod', 'm'],
'Change the value of an existing entry',
yargs => yargs
.positional('selector', {
// TODO: entry by [activity and] date-time e.g. "work yesterday 12pm"
describe: 'Entry to modify, using a number or a activity name'
})
.positional('duration', {
describe: 'The new duration of the activity e.g. 1h',
type: 'string',
coerce: parseDuration
})
.option('activity', {
describe: 'The new name of the activity being worked on',
type: 'string',
alias: 'task'
})
.option('start', {
describe: 'The new start date & time of the activity',
type: 'array',
coerce: parseDate
})
.option('end', {
describe: 'The new end date & time of the activity',
type: 'array',
coerce: parseDate
})
.check(argv => {
if (argv.start == null && argv.end == null && argv.duration == null && argv.activity == null)
return 'Please specify something to modify: duration, --activity, --start, or --end';
return true;
}),
argv => ctx.exec(
() => this.modifyEntryProc(argv))
)
.command(
['report [selector]', 'list', 'ls'],
'Report on a selection of time entries',
yargs => yargs
.positional('selector', {
describe: 'A time range, like "today" or "this month"',
type: 'string'
})
.option('format', ENTRY_FORMAT_OPTIONS),
argv => ctx.exec(
() => this.reportEntriesProc(argv))
)
.command(
'import [path]',
'Import time entries.\n' +
'Used with piped input, see examples',
yargs => yargs
.example([
['entries.json > $0', 'Import from a JSON-LD file containing an array'],
['entry.json > $0 $', 'Import from a file containing just one entry'],
['$0 --data \'{"activity": "trying it out", "start": "now"}\'', 'Import data'],
['fetch my-time-tracker > $0', 'Import from another system (coming soon!)']
])
.positional('path', {
default: '*',
describe: 'JSONPath to pick out data from the input.\n' +
'For example, to transact just one entry, use "$"'
})
.option('data', {
describe: 'literal data to import',
type: 'string'
})
.option('dry-run', {
describe: 'Just read the first few entries and echo them',
type: 'boolean'
}),
argv => ctx.exec(
() => this.importEntriesProc(ctx.stdin, argv))
);
}
/**
* @param {string} selector
* @param {EntryFormatName} format
* @returns {Proc}
*/
reportEntriesProc({ selector, format }) {
// TODO: selectors
const query = this.describeSelected(selector || '?');
return new ResultsProc(
consume(this.meld.read(query)
.pipe(toArray(), mergeMap(all => this.setWorkingSet(all)))),
this.getSubjectFormat(format));
}
/**
* Local variant of getting a subject format supporting the local working set
* @param {EntryFormatName} format
* @returns {Format}
*/
getSubjectFormat(format) {
return getSubjectFormat(format, id => this.workingSet.indexOf(id) + 1);
}
/**
* Sets the current indexed working set of Entries
* @param {import('@m-ld/m-ld').GraphSubject[]} all
* @param {Proc} [proc] used to warn the user if the working set has changed
* @returns {import('@m-ld/m-ld').GraphSubject[]}
*/
setWorkingSet(all, proc) {
all.sort((s1, s2) =>
s1['start']?.['@value']?.localeCompare(s2['start']?.['@value']) ?? -1);
// If not all given items are in the old list in the same order, update the
// working set and warn the user
let lastOldIndex = 0;
if (!all.every((src) =>
(lastOldIndex = this.workingSet.indexOf(src['@id'], lastOldIndex)) > -1)) {
if (proc != null && this.workingSet.length > 0)
proc.emit('message',
'NOTE: Index numbers have changed. Use "list" to review.');
this.workingSet = all.map(src => src['@id']);
}
return all;
}
/**
* @param {string | number} selector Entry to modify, using a number or an activity name
* @param {number} [duration] in minutes
* @param {string} [activity]
* @param {Date} [start]
* @param {Date} [end]
* @returns {Proc}
*/
modifyEntryProc({
selector,
duration,
activity,
start,
end
}) {
// TODO: selector is not specific enough?
const proc = new PromiseProc(this.meld.write(async state => {
const query = this.describeSelected(selector);
const all = query ? await state.read(query) : [];
if (all.length === 0) {
proc.emit('message',
`${selector} does not match an entry. Use "list" to review.`);
} else if (all.length === 1) {
const entry = Entry.fromJSON(all[0]);
if (activity)
entry.activity = activity;
if (start != null)
entry.start = start;
if (end != null && duration == null)
entry.duration = durationFromInterval(entry.start, end);
if (duration != null)
entry.duration = duration;
proc.emit('message', DefaultFormat.entryLabel(entry));
return state.write({
'@delete': all[0],
'@insert': entry.toJSON()
});
} else {
proc.emit('message', 'Multiple entries matched. Please re-do, selecting a number:');
const format = this.getSubjectFormat('default');
const entries = await Promise.all(
this.setWorkingSet(all, proc).map(src => format.stringify(src)));
entries.forEach(description => proc.emit('message', description));
}
}));
return proc;
}
/**
* @param {string | number} selector Entry to modify, using a number or an activity name
* @returns {import('@m-ld/m-ld').Describe} or `undefined` if not interpretable
*/
describeSelected(selector) {
if (typeof selector == 'number') {
const id = this.workingSet[selector - 1];
if (id != null)
return { '@describe': id };
}
return {
'@describe': '?id',
'@where': {
'@id': '?id',
'@type': 'Entry',
activity: selector
}
};
}
/**
* Tries to construct a valid entry from the given object. Accepts valid entry
* JSON-LD and also looser constructs; see implementation for details.
*
* @param {*} activity
* @param {*} provider
* @param {*} start
* @param {*} [duration]
* @param {*} [end]
* @param {*} [external]
* @returns {Entry}
*/
toEntry({ activity, provider, start, duration, end, external }) {
if (typeof activity != 'string')
throw new RangeError('Activity must be a string');
start = toDate(start);
if (duration != null)
duration = toDuration(duration);
else if (end != null)
duration = durationFromInterval(start, toDate(end));
return new Entry({
seqNo: `${this.session.claimEntryId()}`,
sessionId: this.session.id,
providerId: toIri(provider) ?? this.providerId,
activity, start, duration,
externalId: toIri(external)
});
}
/**
* @param {Entry} entry
* @returns {Promise<Entry>}
*/
async addEntry(entry) {
await this.meld.write({
'@graph': [entry.toJSON(), this.session.toJSON()]
});
return entry;
}
/**
* @param {object} argv
* @returns {Proc}
*/
addEntryProc(argv) {
const proc = new PromiseProc(this.addEntry(this.toEntry(argv)).then(entry => {
proc.emit('message', DefaultFormat.entryLabel(entry));
proc.emit('message', 'Use a "modify" command if this is wrong.');
}));
return proc;
}
/**
* @param {import('stream').Readable} stdin
* @param {string} path JSONPath path into the input stream or data
* @param {string} [data] literal data (overrides stdin)
* @param {boolean} [dryRun] just echo some entries
* @returns {Proc}
*/
importEntriesProc(stdin, { path, data, dryRun }) {
const echo = new DefaultFormat(this.getSession);
const proc = new JsonSinkProc(new Writable({
objectMode: true,
write: async (object, encoding, callback) => {
try {
const entry = this.toEntry(object);
if (dryRun)
proc.emit('message', echo.entryDescription(entry));
else
await this.addEntry(entry);
callback();
} catch (e) {
callback(e);
}
}
}), path, stdin, data);
return proc;
}
/**
* @param {boolean} [truncate]
* @param {boolean} [status]
* @returns {Proc}
*/
logProc({ truncate, status }) {
if (truncate) {
return new PromiseProc(truncateFile(this.logFile));
} else if (status) {
const proc = new PromiseProc(Promise.resolve().then(() => {
proc.emit('message', 'Status:', this.meld.status.value);
}));
return proc;
} else {
return new SyncProc(createReadStream(this.logFile));
}
}
async close() {
await this.meld?.close();
await super.close();
}
}