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
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();
|
|
}
|
|
} |