Mari's guided journal software.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

386 lines
14 KiB

/*
* Adapted from https://github.com/haversnail/inquirer-date-prompt:
* MIT License
*
* Copyright (c) 2021 Alex Havermale
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import chalk from "chalk";
import {DateTime, DurationObjectUnits} from "luxon";
import inquirer, {Answers, DateQuestion, Question} from "inquirer";
import {Interface as ReadLineInterface, Key} from "readline";
import Prompt from "inquirer/lib/prompts/base.js";
import observe from "inquirer/lib/utils/events.js";
import {map, takeUntil} from "rxjs/operators/index.js";
import cliCursor from "cli-cursor"
import SuccessfulPromptStateData = inquirer.prompts.SuccessfulPromptStateData;
import FailedPromptStateData = inquirer.prompts.FailedPromptStateData;
declare module "inquirer" {
export interface DateQuestionOptions<AnswerT extends Answers> {
/**
* Transforms the value to display to the user.
*
* @param date
* The currently selected date in string format.
*
* @param answers
* The answers provided by the users.
*
* @param flags
* Additional information about the value.
*
* @returns
* The value to display to the user.
*/
transformer?(
date: string,
answers: AnswerT,
flags: { isDirty?: boolean; isCleared?: boolean; isFinal?: boolean },
): string | Promise<string>;
/**
* A Boolean value indicating whether the prompt is clearable.
* If `true`, pressing `backspace` or `delete` will replace the current value with `null`.
*/
clearable?: boolean;
/**
* A Boolean value indicating whether the prompt should start cleared even though a default is provided.
* If `true`, the value will start off null, but clearing it will change to the default date.
*/
startCleared?: boolean;
/**
* A specific locale to use when formatting the date.
* If no locale is provided, it will default to the user's current locale.
* @see the {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat|Intl.DateTimeFormat} docs for more info.
*/
locale?: string;
/**
* A set of options for customizing the date format.
* @see the {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat|Intl.DateTimeFormat} docs for more info.
*/
format?: Intl.DateTimeFormatOptions;
/** The date part that should be highlighted when the prompt is shown. */
startingDatePart?: Intl.DateTimeFormatPartTypes
/** The amount by which each value should change. If null, the value will not be selectable. */
deltas?: {
[key in Intl.DateTimeFormatPartTypes|"default"]?: number|[number]|[number,number]|[number,number,number]|[number,number,number,number]|0
}
}
export interface DateQuestion<AnswerT extends Answers = Answers> extends Question<AnswerT>, DateQuestionOptions<AnswerT> {
/** @inheritDoc */
type: "date"
/** If the default is not provided or null, the value will be the present date at the time that the prompt is shown. */
default?: Date|null
}
export interface QuestionMap {
["date"]: DateQuestion
}
}
/**
* A lookup object that maps each date part type to the corresponding field of a duration.
*/
const offsetLookup: Partial<Record<Intl.DateTimeFormatPartTypes, keyof DurationObjectUnits>> = {
year: "years",
month: "months",
day: "days",
hour: "hours",
minute: "minutes",
second: "seconds",
weekday: "days",
};
/**
* Returns the index of the _last_ element in the array where predicate is true, and -1 otherwise.
*/
function findLastIndex<T>(array: T[], predicate: (value: T, index: number, obj: T[]) => boolean) {
let l = array.length;
while (l--) {
if (predicate(array[l], l, array)) return l;
}
return -1;
}
/**
* Represents a date prompt.
*/
export class DateInput<AnswerT extends Answers = Answers, QuestionT extends DateQuestion<AnswerT> = DateQuestion> extends Prompt<QuestionT> {
date: DateTime
readonly transformer: DateQuestion["transformer"]
readonly clearable: boolean
readonly format: Intl.DateTimeFormatOptions
readonly deltas: NonNullable<DateQuestion["deltas"]>
done: ((state: unknown) => void)|null = null
isDirty: boolean
isCleared: boolean
cursorIndex: number
firstEditableIndex: number
lastEditableIndex: number
constructor(questions: QuestionT, rl: ReadLineInterface, answers: AnswerT) {
super(questions, rl, answers);
// Set the format object based on the user's specified options:
const { transformer, clearable, startCleared, locale, format = {}, default: date, deltas, startingDatePart } = this.opt;
this.transformer = transformer
this.deltas = deltas ?? {}
this.clearable = clearable ?? false
this.format = {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
...format,
};
// Set the date object with either the default value or the current date:
this.date = DateTime.fromJSDate(date ?? new Date());
if (typeof locale === "string") {
this.date = this.date.setLocale(locale)
}
// Clear the default value option (so it won't be printed by the Prompt class):
this.opt.default = null;
this.isDirty = false;
this.isCleared = startCleared ?? false;
// Set the first and last indices of the editable date parts:
this.firstEditableIndex = this.dateParts.findIndex((part) => this.isDatePartEditable(part.type));
this.lastEditableIndex = findLastIndex(this.dateParts, (part) => this.isDatePartEditable(part.type));
// Set the cursor index to the first editable part:
this.cursorIndex = !!startingDatePart && this.isDatePartEditable(startingDatePart) ? this.dateParts.findIndex((part) => part.type === startingDatePart) : this.firstEditableIndex;
}
// Called by parent class:
_run(cb: DateInput["done"]) {
this.done = cb;
// Observe events:
const events = observe(this.rl);
const submit = events.line.pipe(map(() => (this.isCleared ? null : this.date.toJSDate())));
const validation = this.handleSubmitEvents(submit);
validation.success.forEach(this.onEnd.bind(this));
validation.error.forEach(this.onError.bind(this));
events.keypress.pipe(takeUntil(validation.success)).forEach(this.onKeypress.bind(this));
// Init the prompt:
cliCursor.hide();
this.render();
return this;
}
/**
* Renders the prompt.
* @param {string} [error]
*/
render(error?: string) {
let message = this.getQuestion(); // The question portion of the output, including any prefix and suffix
const { isDirty, isCleared } = this;
const isFinal = this.status === "answered";
if (!isCleared) {
const dateString = this.dateParts
.map(({ value }, index) =>
isFinal
? chalk.cyan(value)
: index === this.cursorIndex
? chalk.inverse(value)
: !isDirty
? chalk.dim(value)
: value,
)
.join("");
// Apply the transformer function if one was provided:
message += this.opt.transformer
? this.opt.transformer(dateString, this.answers as AnswerT, { isDirty, isCleared, isFinal })
: dateString;
// Display info on how to clear if the prompt is clearable:
if (this.opt.clearable && !isFinal) {
message += chalk.dim(" (<delete> to clear) ");
}
}
const bottomContent = error ? chalk.red(">> ") + error : "";
// Render the final message:
this.screen.render(message, bottomContent);
}
/**
* The end event handler.
*/
onEnd({ value }: SuccessfulPromptStateData<Date|null>) {
this.status = "answered";
// Re-render prompt
this.render();
this.screen.done();
cliCursor.show();
if (this.done !== null) {
this.done(value);
}
}
/**
* The error event handler.
*/
onError({ isValid }: FailedPromptStateData) {
this.render(isValid || undefined);
}
/**
* The array of date part objects according to the user's specified format.
*/
get dateParts() {
return this.date.toLocaleParts(this.format);
}
/**
* The currently selected date part.
*/
get currentDatePart() {
return this.dateParts[this.cursorIndex];
}
isDatePartEditable(part: Intl.DateTimeFormatPartTypes): boolean {
return offsetLookup.hasOwnProperty(part) && ((this.deltas[part] ?? this.deltas["default"] ?? 1) !== 0)
}
/**
* A Boolean value indicating whether the currently selected date part is editable.
*/
get isCurrentDatePartEditable() {
return this.isDatePartEditable(this.currentDatePart.type);
}
/**
* Moves the cursor index to the right.
*/
incrementCursorIndex() {
if (this.cursorIndex < this.lastEditableIndex) {
this.cursorIndex++;
}
}
/**
* Moves the cursor index to the left.
*/
decrementCursorIndex() {
if (this.cursorIndex > this.firstEditableIndex) {
this.cursorIndex--;
}
}
/**
* Shifts the currently selected date part to the specified offset value.
* The default value is `0`.
* @param {number} offset
*/
shiftDatePartValue(offset = 0) {
const { type } = this.currentDatePart;
const duration: DurationObjectUnits = {}
const offsetProperty = offsetLookup[type]
if (offset !== 0 && typeof offsetProperty === "string") {
duration[offsetProperty] = offset
// Set the input as "dirty" now that the initial date is being changed:
this.isDirty = true;
this.date = this.date.plus(duration)
}
}
/**
* Increments the currently selected date part by one.
*/
incrementDatePartValueBy(value = 1) {
this.shiftDatePartValue(value);
}
/**
* Decrements the currently selected date part by one.
*/
decrementDatePartValueBy(value = 1) {
this.shiftDatePartValue(-1 * value);
}
/**
* The keypress event handler.
*/
onKeypress({ key }: {key: Key}) {
// Reset cleared state if any other key is pressed:
if (this.isCleared) {
this.isCleared = false;
this.isDirty = true;
return
}
// Calculate the amount to increment/decrement by based on modifiers:
const deltas = this.deltas[this.currentDatePart.type] ?? this.deltas["default"] ?? [1, 10, 100]
const amount = ((): number => {
if (typeof deltas === "number") {
return deltas
} else {
switch (deltas.length) {
case 1:
return deltas[0]
case 2:
return (key.shift || key.meta) ? deltas[1] : deltas[0]
case 3:
return (key.shift || key.meta) ? (key.shift && key.meta) ? deltas[2] : deltas[1] : deltas[0]
case 4:
return key.shift ? key.meta ? deltas[3] : deltas[1] : key.meta ? deltas[2] : deltas[0]
}
}
})()
switch (key.name) {
case "right":
do {
this.incrementCursorIndex();
} while (!this.isCurrentDatePartEditable); // increments the cursor index until it hits an editable value
break;
case "left":
do {
this.decrementCursorIndex();
} while (!this.isCurrentDatePartEditable); // decrements the cursor index until it hits an editable value
break;
case "up":
this.incrementDatePartValueBy(amount);
break;
case "down":
this.decrementDatePartValueBy(amount);
break;
case "delete":
case "backspace":
if (this.clearable) this.isCleared = true;
break;
}
this.render();
}
}