Compare commits
7 Commits
a9124244fe
...
main
Author | SHA1 | Date |
---|---|---|
Mari | f8f5fc4e7c | 2 years ago |
Mari | c6d3032be8 | 3 years ago |
Mari | b1f03c0cf4 | 3 years ago |
Mari | 548f6766e4 | 3 years ago |
Mari | a9cbd24e0c | 3 years ago |
Mari | d3d820e60a | 3 years ago |
Mari | 68593b1cf0 | 3 years ago |
@ -0,0 +1,5 @@ |
||||
{ |
||||
"flags": { |
||||
"gulpfile": "gulpfile.cjs" |
||||
} |
||||
} |
@ -0,0 +1,7 @@ |
||||
<component name="ProjectRunConfigurationManager"> |
||||
<configuration default="false" name="add-entry" type="NodeJSConfigurationType" application-parameters="add-entry" path-to-js-file="dist/index.js" working-dir="$PROJECT_DIR$"> |
||||
<method v="2"> |
||||
<option name="RunConfigurationTask" enabled="true" run_configuration_name="build" run_configuration_type="js.build_tools.npm" /> |
||||
</method> |
||||
</configuration> |
||||
</component> |
@ -0,0 +1,12 @@ |
||||
<component name="ProjectRunConfigurationManager"> |
||||
<configuration default="false" name="build" type="js.build_tools.npm"> |
||||
<package-json value="$PROJECT_DIR$/package.json" /> |
||||
<command value="run" /> |
||||
<scripts> |
||||
<script value="build" /> |
||||
</scripts> |
||||
<node-interpreter value="project" /> |
||||
<envs /> |
||||
<method v="2" /> |
||||
</configuration> |
||||
</component> |
@ -0,0 +1,7 @@ |
||||
<component name="ProjectRunConfigurationManager"> |
||||
<configuration default="false" name="update-empathy-guide" type="NodeJSConfigurationType" application-parameters="update-empathy-guide" path-to-js-file="dist/index.js" working-dir="$PROJECT_DIR$"> |
||||
<method v="2"> |
||||
<option name="RunConfigurationTask" enabled="true" run_configuration_name="build" run_configuration_type="js.build_tools.npm" /> |
||||
</method> |
||||
</configuration> |
||||
</component> |
@ -0,0 +1,6 @@ |
||||
const gulp = require("gulp"); |
||||
const ts = require("gulp-typescript"); |
||||
const tsProject = ts.createProject("tsconfig.json"); |
||||
gulp.task("default", function () { |
||||
return tsProject.src().pipe(tsProject()).js.pipe(gulp.dest("dist")); |
||||
}); |
File diff suppressed because it is too large
Load Diff
@ -1,30 +1,54 @@ |
||||
{ |
||||
"name": "typescript-nodejs-template", |
||||
"name": "mari-guided-journal", |
||||
"version": "1.0.0", |
||||
"description": "A simple template for typescript Node.js project", |
||||
"description": "The guided journaling software used to track Mari's status.", |
||||
"main": "index.js", |
||||
"scripts": { |
||||
"start": "npm run serve", |
||||
"build": "npm run build-ts", |
||||
"serve": "node dist/index.js", |
||||
"watch-node": "nodemon dist/index.js", |
||||
"watch": "tsc && concurrently -k -p \"[{name}]\" -n \"TypeScript,Node\" -c \"yellow.bold,cyan.bold,green.bold\" \"npm run watch-ts\" \"npm run watch-node\"", |
||||
"build-ts": "tsc", |
||||
"watch-ts": "tsc -w" |
||||
"start": "npm run --silent app", |
||||
"app": "node dist/index.js", |
||||
"update-empathy-guide": "npm run --silent app -- update-empathy-guide", |
||||
"add-entry": "npm run --silent app -- add-entry", |
||||
"build": "gulp", |
||||
"prestart": "npm run --silent build", |
||||
"debug": "npm run --silent start --inspect" |
||||
}, |
||||
"type": "module", |
||||
"keywords": [], |
||||
"author": "", |
||||
"author": "Mari", |
||||
"license": "ISC", |
||||
"dependencies": { |
||||
"typescript": "^4.0.3" |
||||
"ajv": "^8.6.2", |
||||
"capitalize": "^2.0.3", |
||||
"chalk": "^2.4.2", |
||||
"cli-cursor": "^4.0.0", |
||||
"env-paths": "^2.2.1", |
||||
"figures": "^3.2.0", |
||||
"fuzzy": "^0.1.3", |
||||
"inquirer": "^8.1.2", |
||||
"js-yaml": "^4.1.0", |
||||
"luxon": "^2.0.2", |
||||
"make-dir": "^2.1.0", |
||||
"pluralize": "^8.0.0", |
||||
"rxjs": "^6.6.7", |
||||
"wordcount": "^1.1.1", |
||||
"yargs": "^17.0.0" |
||||
}, |
||||
"devDependencies": { |
||||
"@babel/cli": "^7.7.5", |
||||
"@babel/core": "^7.7.5", |
||||
"@babel/preset-env": "^7.7.6", |
||||
"@types/capitalize": "^2.0.0", |
||||
"@types/concurrently": "^5.2.1", |
||||
"@types/inquirer": "^7.3.3", |
||||
"@types/js-yaml": "^4.0.2", |
||||
"@types/luxon": "^2.0.4", |
||||
"@types/node-fetch": "^2.5.4", |
||||
"@types/pluralize": "^0.0.29", |
||||
"@types/yargs": "^17.0.0", |
||||
"concurrently": "^5.0.0", |
||||
"nodemon": "^2.0.4" |
||||
"gulp": "^4.0.2", |
||||
"gulp-typescript": "^6.0.0-alpha.1", |
||||
"nodemon": "^2.0.4", |
||||
"typescript": "^4.0.3" |
||||
} |
||||
} |
||||
|
@ -0,0 +1,117 @@ |
||||
import {Separator} from "../prompts/Inquire.js"; |
||||
import {inquire} from "../prompts/Inquire.js"; |
||||
import {summaryPrompt} from "../prompts/implementations/SummaryPrompt.js"; |
||||
import {journalEntryPrompt} from "../prompts/implementations/JournalEntryPrompt.js"; |
||||
import {guidedEmpathyListPrompt} from "../prompts/implementations/GuidedEmpathyListPrompt.js"; |
||||
import {entryMainMenuPrompt} from "../prompts/implementations/EntryMainMenuPrompt.js"; |
||||
import {CommandModule} from "yargs"; |
||||
import { registerPrompts } from "../prompts/types/index.js"; |
||||
import {LocalRepository} from "../repository/LocalRepository.js"; |
||||
import {conditionPrompt} from "../prompts/implementations/ConditionPrompt.js"; |
||||
import {entryPrompt} from "../prompts/implementations/EntryPrompt.js"; |
||||
import {guidedEmpathyPrompt} from "../prompts/implementations/GuidedEmpathyPrompt.js"; |
||||
import {suicidalityPrompt} from "../prompts/implementations/SuicidalityPrompt.js"; |
||||
import {yamlPrompt} from "../schemata/YAMLPrompt.js"; |
||||
import {sleepRecordPrompt} from "../prompts/implementations/SleepRecordPrompt.js"; |
||||
|
||||
export function addEntryCommand(): CommandModule { |
||||
return { |
||||
command: ["add-entry", "*"], |
||||
describe: "Adds a new entry to the journal.", |
||||
handler: async () => { |
||||
registerPrompts() |
||||
const storage = new LocalRepository() |
||||
const empathyGuide = await storage.loadEmpathyGuide() |
||||
|
||||
const condition = conditionPrompt({inquire}) |
||||
const summary = summaryPrompt({inquire}) |
||||
const journal = journalEntryPrompt({inquire}) |
||||
const suicidality = suicidalityPrompt({inquire}) |
||||
const sleep = sleepRecordPrompt({inquire}) |
||||
const empathy = guidedEmpathyPrompt({ |
||||
inquire, |
||||
guideFactory: () => empathyGuide, |
||||
show: async (value) => console.log(value), |
||||
}) |
||||
const empathyList = guidedEmpathyListPrompt({inquire, promptForEmpathy: empathy}) |
||||
|
||||
const mainMenuItems = [ |
||||
// TODO: add ability to amend previous entry
|
||||
// TODO: add To-Do list items, which can be in four states (Pending, Done, Suspended, Canceled)
|
||||
// A list item with the same ID as a previous one overrides it, allowing items to be edited or
|
||||
// marked as done - the current state of the list is written to a separate part of the journal
|
||||
// file (or a separate file) on save, so that we can easily edit it on future visits, but the
|
||||
// entry also contains a changelog of entries added, changed, and removed
|
||||
condition.mainMenu, |
||||
summary.mainMenu, |
||||
new Separator(), |
||||
journal.mainMenu, |
||||
empathyList.mainMenu, |
||||
sleep.mainMenu, |
||||
// TODO: Modified HierarchicalCheckboxInput to allow for putting more things in the main menu than
|
||||
// there are letters
|
||||
// TODO: gratitude journaling
|
||||
// TODO: thinking about the future - where do you want to be in x years type thing
|
||||
// TODO: breathing regulation exercises
|
||||
// TODO: Goals for the day and checking in on how yesterday's goals went
|
||||
/* |
||||
{ |
||||
name: typeof entry.needs === "object" ? "Check back in on needs" : "Check in on needs", |
||||
// TODO: physical needs: food, water, exercise tracking, possibly with autocompletes for each saved in a file like the empathy guide
|
||||
value: NEEDS, |
||||
key: "n" |
||||
}, |
||||
{ |
||||
name: typeof entry.personas === "object" ? "Check back in on personas" : "Check in on personas", |
||||
// TODO: Personas' individual journal entries
|
||||
value: PERSONA, |
||||
key: "p" |
||||
}, |
||||
{name: typeof entry.rpg === "object" ? "Change RPG stats" : "Add RPG stats", value: RPG, key: "r"}, |
||||
{ |
||||
name: typeof entry.activities === "object" ? "Change record of recent activities" : "Add record of recent activities", |
||||
// TODO: Add general activity set with historical activities saved in a file like the empathy guide
|
||||
value: ACTIVITIES, |
||||
key: "a" |
||||
}, |
||||
{ |
||||
name: typeof entry.music === "object" ? "Change record of recently played music" : "Add record of recently played music", |
||||
// TODO: Add music
|
||||
value: MUSIC, |
||||
key: "m" |
||||
}, |
||||
*/ |
||||
suicidality.mainMenu, |
||||
/* |
||||
{ |
||||
name: typeof entry.recoveries === "object" ? "Try more recovery methods" : "Try some recovery methods", |
||||
// TODO: Add list of recovery methods
|
||||
value: RECOVERIES, |
||||
key: "y" |
||||
}, |
||||
*/ |
||||
] |
||||
|
||||
const promptYaml = yamlPrompt({ |
||||
inquire, |
||||
showError: async (text: string) => console.log(text) |
||||
}) |
||||
|
||||
const mainMenu = entryMainMenuPrompt({ |
||||
inquire, |
||||
choices: mainMenuItems, |
||||
promptYaml |
||||
}) |
||||
|
||||
const entry = entryPrompt({ |
||||
promptForCondition: condition, |
||||
promptForSummary: summary, |
||||
promptForEntryMainMenu: mainMenu, |
||||
}) |
||||
|
||||
const writtenEntry = await entry({}) |
||||
await storage.saveEntry(writtenEntry) |
||||
console.log("Entry saved! Good work!") |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,43 @@ |
||||
import {CommandModule} from "yargs"; |
||||
import {inquire} from "../prompts/Inquire.js"; |
||||
import {registerPrompts} from "../prompts/types/index.js"; |
||||
import {LocalRepository} from "../repository/LocalRepository.js"; |
||||
import {empathyGroupPrompt} from "../prompts/implementations/EmpathyGroupPrompt.js"; |
||||
import {empathyGroupListPrompt} from "../prompts/implementations/EmpathyGroupListPrompt.js"; |
||||
import {empathyGuidePrompt} from "../prompts/implementations/EmpathyGuidePrompt.js"; |
||||
import {yamlPrompt} from "../schemata/YAMLPrompt.js"; |
||||
|
||||
export function updateEmpathyGuideCommand(): CommandModule { |
||||
return { |
||||
command: "update-empathy-guide", |
||||
describe: "Makes changes to the empathy guide.", |
||||
handler: async (): Promise<void> => { |
||||
registerPrompts() |
||||
const storage = new LocalRepository() |
||||
const empathyGuide = await storage.loadEmpathyGuide() |
||||
|
||||
const promptYaml = yamlPrompt({ |
||||
inquire, |
||||
showError: async (text) => console.error(text), |
||||
}) |
||||
|
||||
const empathyGroup = empathyGroupPrompt({ |
||||
inquire, |
||||
promptYaml, |
||||
}) |
||||
|
||||
const empathyList = empathyGroupListPrompt({ |
||||
inquire, |
||||
promptForEmpathyGroup: empathyGroup |
||||
}) |
||||
|
||||
const newGuide = await empathyGuidePrompt({ |
||||
inquire, |
||||
promptYaml, |
||||
promptForEmpathyGroupList: empathyList, |
||||
})({default: empathyGuide}) |
||||
await storage.saveEmpathyGuide(newGuide) |
||||
console.log("Empathy guide saved!") |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,15 @@ |
||||
import {EnumSerializer, OptionalSerializer} from "../schemata/Serialization.js"; |
||||
|
||||
export enum Condition { |
||||
CRITICAL = "Critical", |
||||
POOR = "Poor", |
||||
SOSO = "So-so", |
||||
GOOD = "Good", |
||||
EXCELLENT = "Excellent", |
||||
UNSURE = "Unsure", |
||||
NUMB = "Numb", |
||||
} |
||||
export const CONDITIONS = [Condition.CRITICAL, Condition.POOR, Condition.SOSO, Condition.GOOD, Condition.EXCELLENT, Condition.UNSURE, Condition.NUMB] |
||||
|
||||
export const ConditionSerializer = new EnumSerializer<Condition>(CONDITIONS) |
||||
export const OptionalConditionSerializer = new OptionalSerializer(ConditionSerializer) |
@ -0,0 +1,20 @@ |
||||
import {schema} from "../schemata/SchemaData.js"; |
||||
|
||||
export interface EmpathyGroup { |
||||
readonly header: string |
||||
readonly items: readonly string[] |
||||
} |
||||
|
||||
export type EmpathyGroupJTD = typeof EmpathyGroupJTD |
||||
export const EmpathyGroupJTD = schema({ |
||||
schema: { |
||||
properties: { |
||||
header: {type: "string"}, |
||||
items: {elements: {type: "string"}} |
||||
} |
||||
}, |
||||
typeHint: null as EmpathyGroup | null, |
||||
key: "empathyGroup", |
||||
references: [], |
||||
}) |
||||
|
@ -0,0 +1,6 @@ |
||||
import {EmpathyGroup} from "./EmpathyGroup.js"; |
||||
|
||||
export function totalItems(groups: readonly EmpathyGroup[]): number { |
||||
return groups.map((group) => group.items.length).reduce((a, b) => a + b) |
||||
} |
||||
|
@ -0,0 +1,35 @@ |
||||
import {schema} from "../schemata/SchemaData.js"; |
||||
import {EmpathyGroup, EmpathyGroupJTD} from "./EmpathyGroup.js"; |
||||
|
||||
export interface EmpathyGuide { |
||||
readonly feelings?: { |
||||
readonly pleasant?: readonly EmpathyGroup[] |
||||
readonly unpleasant?: readonly EmpathyGroup[] |
||||
} |
||||
readonly needs?: readonly EmpathyGroup[] |
||||
} |
||||
|
||||
export type EmpathyGuideJTD = typeof EmpathyGuideJTD |
||||
export const EmpathyGuideJTD = schema({ |
||||
schema: { |
||||
optionalProperties: { |
||||
feelings: { |
||||
optionalProperties: { |
||||
pleasant: { |
||||
elements: EmpathyGroupJTD.reference |
||||
}, |
||||
unpleasant: { |
||||
elements: EmpathyGroupJTD.reference |
||||
} |
||||
} |
||||
}, |
||||
needs: { |
||||
elements: EmpathyGroupJTD.reference |
||||
} |
||||
} |
||||
}, |
||||
typeHint: null as EmpathyGuide | null, |
||||
key: "empathyGuide", |
||||
references: [EmpathyGroupJTD], |
||||
}) |
||||
|
@ -0,0 +1,42 @@ |
||||
import {Condition, OptionalConditionSerializer} from "./Condition.js"; |
||||
import {GuidedEmpathy} from "./GuidedEmpathy.js"; |
||||
import {OptionalSuicidalitySerializer, Suicidality} from "./Suicidality.js"; |
||||
import {SleepRecord} from "./SleepRecord.js"; |
||||
import {DateTime} from "luxon"; |
||||
import {DateTimeSerializer, ObjectSerializer, OptionalStringSerializer} from "../schemata/Serialization.js"; |
||||
|
||||
export interface BaseEntry { |
||||
readonly condition?: Condition |
||||
readonly summary?: string |
||||
readonly journalEntry?: string |
||||
readonly guidedEmpathy?: readonly GuidedEmpathy[] |
||||
readonly sleepRecords?: readonly SleepRecord[] |
||||
// readonly needs?: Needs
|
||||
// readonly music?: readonly string[]
|
||||
// readonly rpg?: RPGStats
|
||||
// readonly personas?: Personas
|
||||
// readonly activities?: readonly Activity[]
|
||||
readonly suicidality?: Suicidality |
||||
// readonly recoveries?: readonly Recovery[]
|
||||
} |
||||
|
||||
export interface Entry extends BaseEntry { |
||||
readonly startedAt: DateTime |
||||
readonly finishedAt: DateTime |
||||
} |
||||
|
||||
export interface SerializedEntry extends BaseEntry { |
||||
readonly startedAt: string |
||||
readonly finishedAt: string |
||||
} |
||||
|
||||
export const EntrySerializer = new ObjectSerializer<Entry, SerializedEntry, keyof Entry>({ |
||||
startedAt: DateTimeSerializer, |
||||
finishedAt: DateTimeSerializer, |
||||
condition: OptionalConditionSerializer, |
||||
summary: OptionalStringSerializer, |
||||
journalEntry: OptionalStringSerializer, |
||||
guidedEmpathy: OptionalArrayGuidedEmpathySerializer, |
||||
sleepRecords: OptionalArraySleepRecordSerializer, |
||||
suicidality: OptionalSuicidalitySerializer, |
||||
}, ["startedAt", "finishedAt", "condition", "summary", "journalEntry", "guidedEmpathy", "sleepRecords", "suicidality"]) |
@ -0,0 +1,51 @@ |
||||
import {isPopulatedArray} from "../utils/Arrays.js"; |
||||
import { |
||||
Persona, |
||||
personaName, |
||||
personaPronoun, |
||||
personaPronounObject, |
||||
personaPronounPossessive, |
||||
personaVerb |
||||
} from "./Persona.js"; |
||||
import chalk from "chalk"; |
||||
import capitalize from "capitalize"; |
||||
import { |
||||
ArraySerializer, |
||||
ObjectSerializer, |
||||
OptionalArrayStringSerializer, |
||||
OptionalSerializer |
||||
} from "../schemata/Serialization.js"; |
||||
|
||||
export interface GuidedEmpathy { |
||||
readonly feelings?: readonly string[] |
||||
readonly needs?: readonly string[] |
||||
readonly events?: readonly string[] |
||||
readonly requests?: readonly string[] |
||||
} |
||||
export const GuidedEmpathySerializer = new ObjectSerializer<GuidedEmpathy, GuidedEmpathy, keyof GuidedEmpathy>({ |
||||
feelings: OptionalArrayStringSerializer, |
||||
needs: OptionalArrayStringSerializer, |
||||
events: OptionalArrayStringSerializer, |
||||
requests: OptionalArrayStringSerializer, |
||||
}, ["feelings", "needs", "events", "requests"]) |
||||
export const ArrayGuidedEmpathySerializer = new ArraySerializer(GuidedEmpathySerializer) |
||||
export const OptionalArrayGuidedEmpathySerializer = new OptionalSerializer(ArrayGuidedEmpathySerializer) |
||||
|
||||
export function isPopulatedGuidedEmpathy(empathy: GuidedEmpathy | undefined): boolean { |
||||
return !!empathy && (isPopulatedArray(empathy.feelings) |
||||
|| isPopulatedArray(empathy.needs) |
||||
|| isPopulatedArray(empathy.events) |
||||
|| isPopulatedArray(empathy.requests)) |
||||
} |
||||
|
||||
export function guidedEmpathyToString(empathy: GuidedEmpathy, persona: Persona | undefined): string { |
||||
return (`${(capitalize(personaName(persona), true))} ${personaVerb(persona, "feel", "feels")}: ${empathy.feelings?.join(", ") || chalk.dim("(???)")}...\n` |
||||
+ `Because ${personaPronoun(persona)} ${personaVerb(persona, "need", "needs")}: ${empathy.needs?.join(", ") || chalk.dim("(???)")}...\n` |
||||
+ `And ${personaPronounPossessive(persona)} needs were affected when: ${empathy.events?.join("; ") || chalk.dim("(???)")}...\n` |
||||
+ `So to get good outcomes for ${personaPronounObject(persona)} in the future, can we try: ${empathy.requests?.join("; ") || chalk.dim("(???)")}?`) |
||||
} |
||||
|
||||
export function guidedEmpathyToStringShort(empathy: GuidedEmpathy): string { |
||||
return `Feeling ${empathy.feelings?.join(", ") || chalk.dim("(none)")} // Because ${empathy.needs?.join(", ") || chalk.dim("(none)")} // When ${empathy.events?.join("; ") || chalk.dim("(none)")} // So ${empathy.requests?.join("; ") || chalk.dim("(none)")}` |
||||
} |
||||
|
@ -0,0 +1,64 @@ |
||||
import {Entry, EntryJTD} from "./Entry.js"; |
||||
import {schema} from "../schemata/SchemaData.js"; |
||||
|
||||
/* export interface PersonaState { |
||||
readonly persona: Persona |
||||
readonly condition: Condition |
||||
readonly summary?: string |
||||
readonly journalEntry?: string |
||||
readonly guidedEmpathy?: readonly GuidedEmpathy[] |
||||
} */ |
||||
|
||||
/* export interface RPGStats { |
||||
readonly hpp?: number |
||||
readonly mpp?: number |
||||
readonly tpp?: number |
||||
readonly hope?: number |
||||
readonly determination?: number |
||||
} */ |
||||
|
||||
/* export interface Personas { |
||||
readonly active?: Persona |
||||
readonly states?: readonly PersonaState[] |
||||
} */ |
||||
|
||||
/* export interface Needs { |
||||
readonly food?: string |
||||
readonly water?: string |
||||
readonly sleep?: string |
||||
readonly hygiene?: string |
||||
} */ |
||||
|
||||
/* export interface Recovery { |
||||
readonly name: string |
||||
} */ |
||||
|
||||
/* export interface Activity { |
||||
readonly name: string |
||||
} */ |
||||
|
||||
/* export interface RecoveryMethod { |
||||
readonly name: string |
||||
readonly description: string |
||||
} */ |
||||
|
||||
/* |
||||
// readonly activities?: readonly string[]
|
||||
readonly empathyGuide?: EmpathyGuide |
||||
// readonly recoveryMethods?: readonly RecoveryMethod[]
|
||||
*/ |
||||
|
||||
export interface Journal { |
||||
readonly entries?: readonly Entry[] |
||||
} |
||||
export type JournalJTD = typeof JournalJTD |
||||
export const JournalJTD = schema({ |
||||
schema: { |
||||
optionalProperties: { |
||||
entries: { elements: EntryJTD.reference }, |
||||
}, |
||||
}, |
||||
typeHint: null as Journal|null, |
||||
key: "journal", |
||||
references: [EntryJTD, ...EntryJTD.requiredReferences] |
||||
}) |
@ -0,0 +1,82 @@ |
||||
import {schema} from "../schemata/SchemaData.js"; |
||||
|
||||
export enum Persona { |
||||
HEART = "Heart", |
||||
SUCCUBUS = "Succubus", |
||||
CHILD = "Child", |
||||
NURSE = "Nurse", |
||||
SORCERESS = "Sorceress", |
||||
LADY = "Lady", |
||||
SHADOW = "Shadow", |
||||
} |
||||
export const PERSONAS: Persona[] = [Persona.HEART, Persona.SUCCUBUS, Persona.CHILD, Persona.NURSE, Persona.SORCERESS, Persona.LADY, Persona.SHADOW] |
||||
export type PersonaJTD = typeof PersonaJTD |
||||
export const PersonaJTD = schema({ |
||||
schema: { |
||||
enum: PERSONAS |
||||
}, |
||||
key: "persona", |
||||
references: [], |
||||
}) |
||||
|
||||
export interface PersonaPrompt { |
||||
readonly persona?: Persona |
||||
} |
||||
|
||||
export function personaPronoun(persona: Persona | undefined): string { |
||||
switch (persona) { |
||||
case undefined: |
||||
return "you" |
||||
default: |
||||
return "she" |
||||
} |
||||
} |
||||
|
||||
export function personaPronounPossessive(persona: Persona | undefined): string { |
||||
switch (persona) { |
||||
case undefined: |
||||
return "your" |
||||
default: |
||||
return "her" |
||||
} |
||||
} |
||||
|
||||
export function personaPronounObject(persona: Persona | undefined): string { |
||||
switch (persona) { |
||||
case undefined: |
||||
return "you" |
||||
default: |
||||
return "her" |
||||
} |
||||
} |
||||
|
||||
export function personaName(persona: Persona | undefined): string { |
||||
switch (persona) { |
||||
case undefined: |
||||
return "you" |
||||
case Persona.HEART: |
||||
return "Reya Heart" |
||||
default: |
||||
return persona + " Reya" |
||||
} |
||||
} |
||||
|
||||
export function personaPossessive(persona: Persona | undefined): string { |
||||
switch (persona) { |
||||
case undefined: |
||||
return "your" |
||||
case Persona.HEART: |
||||
return "Reya Heart's" |
||||
default: |
||||
return persona + " Reya's" |
||||
} |
||||
} |
||||
|
||||
export function personaVerb(persona: Persona | undefined, secondPerson: string, thirdPerson: string): string { |
||||
switch (persona) { |
||||
case undefined: |
||||
return secondPerson |
||||
default: |
||||
return thirdPerson |
||||
} |
||||
} |
@ -0,0 +1,96 @@ |
||||
import {schema} from "../schemata/SchemaData.js"; |
||||
|
||||
export enum SleepQuality { |
||||
ACIDIC = "Acidic", |
||||
RESTLESS = "Restless", |
||||
NIGHTMARISH = "Nightmarish", |
||||
TIMESKIP = "Timeskip", |
||||
DREAMLESS = "Dreamless", |
||||
DREAMY = "Dreamy", |
||||
ECSTASY = "Ecstasy", |
||||
} |
||||
export const SLEEP_QUALITIES: SleepQuality[] = [SleepQuality.ACIDIC, SleepQuality.RESTLESS, SleepQuality.NIGHTMARISH, SleepQuality.TIMESKIP, SleepQuality.DREAMLESS, SleepQuality.DREAMY, SleepQuality.ECSTASY] |
||||
|
||||
export type SleepQualityJTD = typeof SleepQualityJTD |
||||
export const SleepQualityJTD = schema({ |
||||
schema: { |
||||
enum: SLEEP_QUALITIES |
||||
}, |
||||
key: "sleepQuality", |
||||
references: [], |
||||
}) |
||||
|
||||
export enum WakeQuality { |
||||
AGONIZED = "Agonized", |
||||
EXHAUSTED = "Exhausted", |
||||
DROWSY = "Drowsy", |
||||
RESTED = "Rested", |
||||
ENERGIZED = "Energized", |
||||
} |
||||
export const WAKE_QUALITIES: WakeQuality[] = [WakeQuality.AGONIZED, WakeQuality.EXHAUSTED, WakeQuality.DROWSY, WakeQuality.RESTED, WakeQuality.ENERGIZED] |
||||
|
||||
export type WakeQualityJTD = typeof WakeQualityJTD |
||||
export const WakeQualityJTD = schema({ |
||||
schema: { |
||||
enum: WAKE_QUALITIES |
||||
}, |
||||
key: "wakeQuality", |
||||
references: [], |
||||
}) |
||||
|
||||
export interface SleepRecord { |
||||
readonly sleepAt?: Date |
||||
readonly sleepQuality?: SleepQuality |
||||
readonly interruptions?: number |
||||
readonly wakeAt?: Date |
||||
readonly wakeQuality?: WakeQuality |
||||
readonly dreams?: string |
||||
} |
||||
export type SleepRecordJTD = typeof SleepRecordJTD |
||||
export const SleepRecordJTD = schema({ |
||||
schema: { |
||||
optionalProperties: { |
||||
sleepAt: { type: "timestamp" }, |
||||
sleepQuality: SleepQualityJTD.reference, |
||||
interruptions: { type: "uint32" }, |
||||
wakeAt: { type: "timestamp" }, |
||||
wakeQuality: WakeQualityJTD.reference, |
||||
dreams: { type: "string" }, |
||||
} |
||||
}, |
||||
typeHint: null as SleepRecord|null, |
||||
key: "sleepRecord", |
||||
references: [SleepQualityJTD, WakeQualityJTD], |
||||
}) |
||||
|
||||
export type SleepRecordListJTD = typeof SleepRecordListJTD |
||||
export const SleepRecordListJTD = schema({ |
||||
schema: { |
||||
elements: SleepRecordJTD.reference |
||||
}, |
||||
typeHint: null as (readonly SleepRecord[])|null, |
||||
key: "sleepRecords", |
||||
references: [SleepRecordJTD, ...SleepRecordJTD.requiredReferences], |
||||
}) |
||||
|
||||
const TimeFormat = Intl.DateTimeFormat([], { |
||||
dateStyle: undefined, |
||||
timeStyle: undefined, |
||||
year: undefined, |
||||
month: undefined, |
||||
day: undefined, |
||||
weekday: undefined, |
||||
hour: "numeric", |
||||
minute: "2-digit", |
||||
second: undefined, |
||||
fractionalSecondDigits: undefined, |
||||
timeZone: undefined, |
||||
hour12: true, |
||||
hourCycle: "h12", |
||||
}) |
||||
|
||||
export function sleepRecordToString(record: SleepRecord) { |
||||
const sleepAt = record.sleepAt === undefined ? null : TimeFormat.format(record.sleepAt) |
||||
const wakeAt = record.wakeAt === undefined ? null : TimeFormat.format(record.wakeAt) |
||||
return `${sleepAt ?? "?:??"} (${record.sleepQuality ?? "???"}) -${record.interruptions ?? "?"}-> ${wakeAt ?? "?:??"} (${record.wakeQuality ?? "???"})${typeof record.dreams === "string" ? " with dream journal" : ""}` |
||||
} |
@ -0,0 +1,18 @@ |
||||
import {schema} from "../schemata/SchemaData.js"; |
||||
import {EnumSerializer, OptionalSerializer} from "../schemata/Serialization.js"; |
||||
import {Condition, CONDITIONS} from "./Condition.js"; |
||||
|
||||
export enum Suicidality { |
||||
NONE = "None", |
||||
PASSIVE = "Passive", |
||||
INTRUSIVE = "Intrusive", |
||||
ACTIVE = "Active", |
||||
RESIGNED = "Resigned", |
||||
PLANNING = "Planning", |
||||
PLANNED = "Planned", |
||||
DANGER = "Danger", |
||||
} |
||||
export const SUICIDALITIES: Suicidality[] = [Suicidality.NONE, Suicidality.PASSIVE, Suicidality.INTRUSIVE, Suicidality.ACTIVE, Suicidality.RESIGNED, Suicidality.PLANNING, Suicidality.PLANNED, Suicidality.DANGER] |
||||
|
||||
export const SuicidalitySerializer = new EnumSerializer<Suicidality>(SUICIDALITIES) |
||||
export const OptionalSuicidalitySerializer = new OptionalSerializer(SuicidalitySerializer) |
@ -1,5 +1,15 @@ |
||||
function init(firstName: string, lastName: string) { |
||||
return `${firstName} ${lastName}`; |
||||
} |
||||
import {addEntryCommand} from "./commands/AddEntry.js"; |
||||
import {updateEmpathyGuideCommand} from "./commands/UpdateEmpathyGuide.js"; |
||||
import {Argv, default as yargs} from "yargs" |
||||
|
||||
console.log(init('Hello', 'world')); |
||||
const Yargs = yargs as unknown as {(): Argv} |
||||
|
||||
Yargs() |
||||
.scriptName("mari-status-bar") |
||||
.command(addEntryCommand()) |
||||
.command(updateEmpathyGuideCommand()) |
||||
.demandCommand() |
||||
.parseAsync() |
||||
.catch((err: unknown) => { |
||||
console.error(err) |
||||
}) |
@ -0,0 +1,13 @@ |
||||
import {QuestionMap} from "inquirer"; |
||||
import inquirer from "inquirer"; |
||||
|
||||
export const prompt = inquirer.prompt; |
||||
export const registerPrompt = inquirer.registerPrompt; |
||||
export const Separator = inquirer.Separator; |
||||
export type InquireFunction<QuestionT extends QuestionMap[keyof QuestionMap], AnswerT extends QuestionT["default"]> = (question: QuestionT) => Promise<AnswerT> |
||||
export type ShowFunction = (text: string) => Promise<void> |
||||
|
||||
export async function inquire<QuestionT extends QuestionMap[keyof QuestionMap], AnswerT extends QuestionT["default"]>(question: QuestionT): Promise<AnswerT> { |
||||
const result = await prompt([{...question, name: "answer"}]) |
||||
return result.answer |
||||
} |
@ -0,0 +1,69 @@ |
||||
import {ListQuestion} from "inquirer"; |
||||
import {personaPossessive, PersonaPrompt} from "../../datatypes/Persona.js"; |
||||
import chalk from "chalk"; |
||||
import {EntryMainMenuChoice, makeEntryMainMenuChoice} from "./EntryMainMenuPrompt.js"; |
||||
import {asDefault, identity} from "../../utils/Objects.js"; |
||||
import {InquireFunction, Separator} from "../Inquire.js"; |
||||
import {Condition} from "../../datatypes/Condition.js"; |
||||
|
||||
export interface ConditionPromptOptions extends Partial<Omit<ListQuestion, "name" | "type" | "choices">>, PersonaPrompt { |
||||
} |
||||
|
||||
export interface ConditionPromptDependencies { |
||||
readonly inquire: InquireFunction<ListQuestion, Condition> |
||||
} |
||||
|
||||
export function conditionPrompt(deps: ConditionPromptDependencies): { |
||||
(options: ConditionPromptOptions): Promise<Condition>, |
||||
mainMenu: EntryMainMenuChoice<"condition">, |
||||
} { |
||||
return makeEntryMainMenuChoice({ |
||||
property: "condition", |
||||
name: (input) => typeof input === "string" ? `Change condition ${chalk.dim(`(currently ${chalk.greenBright(input)})`)}` : `Add condition`, |
||||
key: "c", |
||||
injected: (options) => promptForCondition(options, deps), |
||||
toOptions: asDefault, |
||||
toProperty: identity, |
||||
}) |
||||
} |
||||
|
||||
export async function promptForCondition(options: ConditionPromptOptions = {}, {inquire}: ConditionPromptDependencies): Promise<Condition> { |
||||
return await inquire({ |
||||
type: "list", |
||||
message: `How's ${personaPossessive(options.persona)} current condition?`, |
||||
choices: [ |
||||
{ |
||||
value: Condition.CRITICAL, |
||||
name: `Critical ${chalk.dim("(very uncomfortable/very unpleasant feelings/completely out of control)")}` |
||||
}, |
||||
{ |
||||
value: Condition.POOR, |
||||
name: `Poor ${chalk.dim("(uncomfortable/unpleasant feelings/losing control)")}` |
||||
}, |
||||
{ |
||||
value: Condition.SOSO, |
||||
name: `So-so ${chalk.dim("(OK/neutral mood/mixed feelings/close to losing control)")}` |
||||
}, |
||||
{ |
||||
value: Condition.GOOD, |
||||
name: `Good ${chalk.dim("(comfortable/pleasant feelings/mostly in control)")}` |
||||
}, |
||||
{ |
||||
value: Condition.EXCELLENT, |
||||
name: `Excellent ${chalk.dim("(very comfortable/very pleasant feelings/secure/in control)")}` |
||||
}, |
||||
new Separator(), |
||||
{ |
||||
value: Condition.NUMB, |
||||
name: `Numb ${chalk.dim("(depressive void state/crystallizing/petrifying/encased in ice)")}` |
||||
}, |
||||
{ |
||||
value: Condition.UNSURE, |
||||
name: `Unsure ${chalk.dim("(strong mixed feelings/low connection to self)")}` |
||||
}, |
||||
], |
||||
default: 2, |
||||
pageSize: 999, |
||||
...options, |
||||
}) |
||||
} |
@ -0,0 +1,67 @@ |
||||
import {EmpathyGroup} from "../../datatypes/EmpathyGroup.js"; |
||||
import {isPopulatedArray} from "../../utils/Arrays.js"; |
||||
import chalk from "chalk"; |
||||
import pluralize from "pluralize"; |
||||
import {InquireFunction} from "../Inquire.js"; |
||||
import {EmpathyGroupPromptOptions} from "./EmpathyGroupPrompt.js"; |
||||
import {ListQuestion} from "inquirer"; |
||||
|
||||
export interface EmpathyGroupListPromptOptions { |
||||
readonly default?: readonly EmpathyGroup[] |
||||
readonly listName: string |
||||
} |
||||
|
||||
export interface EmpathyGroupListPromptDependencies { |
||||
readonly inquire: InquireFunction<ListQuestion, number> |
||||
readonly promptForEmpathyGroup: (opts: EmpathyGroupPromptOptions) => Promise<EmpathyGroup | null> |
||||
} |
||||
|
||||
export function empathyGroupListPrompt(deps: EmpathyGroupListPromptDependencies): (opts: EmpathyGroupListPromptOptions) => Promise<readonly EmpathyGroup[]> { |
||||
return (opts) => promptForEmpathyGroupList(opts, deps) |
||||
} |
||||
|
||||
export async function promptForEmpathyGroupList(opts: EmpathyGroupListPromptOptions, deps: EmpathyGroupListPromptDependencies): Promise<readonly EmpathyGroup[]> { |
||||
const {default: value = [], listName} = opts |
||||
const {inquire, promptForEmpathyGroup} = deps |
||||
const option = isPopulatedArray(value) ? await inquire({ |
||||
type: "list", |
||||
message: `Which group of ${listName} would you like to edit?`, |
||||
default: value.length + 1, |
||||
choices: [ |
||||
...value.map((group, index) => ({ |
||||
value: index, |
||||
name: `${group.header} ${isPopulatedArray(group.items) ? chalk.dim(`(currently ${pluralize("item", group.items.length, true)})`) : ""}` |
||||
})), |
||||
{value: -1, name: chalk.dim("(Add New Group)")}, |
||||
{value: -2, name: "Done"} |
||||
] |
||||
}) : -1 |
||||
switch (option) { |
||||
case -1: |
||||
const newGroup = await promptForEmpathyGroup({listName}) |
||||
if (newGroup === null) { |
||||
if (isPopulatedArray(value)) { |
||||
return promptForEmpathyGroupList(opts, deps) |
||||
} else { |
||||
return [] |
||||
} |
||||
} |
||||
return promptForEmpathyGroupList({...opts, default: [...value, newGroup]}, deps) |
||||
case -2: |
||||
return value |
||||
default: |
||||
const updatedGroup = await promptForEmpathyGroup({default: value[option], listName}) |
||||
if (value.length > 1 || updatedGroup !== null) { |
||||
return promptForEmpathyGroupList({ |
||||
...opts, |
||||
default: [ |
||||
...value.slice(0, option), |
||||
...updatedGroup === null ? [] : [updatedGroup], |
||||
...value.slice(option + 1) |
||||
] |
||||
}, deps) |
||||
} else { |
||||
return [] |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,80 @@ |
||||
import capitalize from "capitalize"; |
||||
import {YamlPromptFunction} from "../../schemata/YAMLPrompt.js"; |
||||
import chalk from "chalk"; |
||||
import {InquireFunction} from "../Inquire.js"; |
||||
import {EmpathyGroup, EmpathyGroupJTD} from "../../datatypes/EmpathyGroup.js"; |
||||
import {EditorQuestion, ExpandQuestion, InputQuestion, MultiTextInputQuestion} from "inquirer"; |
||||
|
||||
export interface EmpathyGroupPromptOptions { |
||||
default?: EmpathyGroup |
||||
listName: string |
||||
} |
||||
|
||||
export interface EmpathyGroupPromptDependencies { |
||||
inquire: InquireFunction<ExpandQuestion, typeof PROMPT|typeof AUTO|typeof YAML> & InquireFunction<InputQuestion, string> & InquireFunction<MultiTextInputQuestion, string[]> & InquireFunction<EditorQuestion, string> |
||||
promptYaml: YamlPromptFunction |
||||
} |
||||
|
||||
export function empathyGroupPrompt(deps: EmpathyGroupPromptDependencies): (opts: EmpathyGroupPromptOptions) => Promise<EmpathyGroup | null> { |
||||
return (opts) => promptForEmpathyGroup(opts, deps) |
||||
} |
||||
|
||||
const PROMPT = "Prompt" |
||||
const AUTO = "Auto" |
||||
const YAML = "YAML" |
||||
|
||||
export async function promptForEmpathyGroup(opts: EmpathyGroupPromptOptions, deps: EmpathyGroupPromptDependencies): Promise<EmpathyGroup | null> { |
||||
const {listName, default: {header = undefined, items = []} = {}} = opts |
||||
const {inquire, promptYaml} = deps |
||||
const mode = await inquire({ |
||||
type: "expand", |
||||
message: `How would you like to ${header ? "edit" : "create"} this ${listName} group?`, |
||||
choices: [ |
||||
{key: "p", name: "Be prompted and use the interactive process", value: PROMPT}, |
||||
{key: "t", name: "Auto-process a plain-text list pasted from another source", value: AUTO}, |
||||
{key: "y", name: `Directly ${header ? "edit" : "write"} YAML`, value: YAML}, |
||||
] |
||||
}) |
||||
switch (mode) { |
||||
case AUTO: |
||||
const text: string = await inquire({ |
||||
type: "editor", |
||||
message: "Enter the header on the first line, and each item on separate lines:", |
||||
default: (header ? [header, ...items] : ["", ...items]).join("\n") |
||||
}) |
||||
const trimmed = text.trim() |
||||
if (trimmed === "") { |
||||
return null |
||||
} |
||||
const [parsedHeader, ...parsedItems] = trimmed.split("\n").map((line) => line.trim()).filter((line) => line !== "") |
||||
return { |
||||
header: capitalize.words(parsedHeader, false), |
||||
items: parsedItems.map((item) => item.toLocaleLowerCase()) |
||||
} |
||||
case YAML: |
||||
return await promptYaml({ |
||||
schema: EmpathyGroupJTD, |
||||
currentValue: opts.default, |
||||
name: `this ${listName} group`, |
||||
}) || null |
||||
case PROMPT: |
||||
default: |
||||
const newHeader = await inquire({ |
||||
type: "input", |
||||
message: `What should this ${listName} group be called?${typeof header === "string" ? chalk.dim(" (leave blank to delete this group)") : ""}`, |
||||
default: header, |
||||
}) |
||||
if (newHeader === "") { |
||||
return null |
||||
} |
||||
const newItems = await inquire({ |
||||
type: "multitext", |
||||
message: `What ${listName} should the ${newHeader} group contain?`, |
||||
default: items, |
||||
}) |
||||
return { |
||||
header: newHeader, |
||||
items: newItems, |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,118 @@ |
||||
import {isPopulatedArray} from "../../utils/Arrays.js"; |
||||
import chalk from "chalk"; |
||||
import pluralize from "pluralize"; |
||||
import {totalItems} from "../../datatypes/EmpathyGroupList.js"; |
||||
import {ExpandQuestion} from "inquirer"; |
||||
import {YamlPromptFunction} from "../../schemata/YAMLPrompt.js"; |
||||
import {InquireFunction, Separator} from "../Inquire.js"; |
||||
import {EmpathyGroupListPromptOptions} from "./EmpathyGroupListPrompt.js"; |
||||
import {EmpathyGroup} from "../../datatypes/EmpathyGroup.js"; |
||||
import {EmpathyGuide, EmpathyGuideJTD} from "../../datatypes/EmpathyGuide.js"; |
||||
|
||||
export interface EmpathyGuidePromptOptions { |
||||
readonly default?: EmpathyGuide |
||||
} |
||||
|
||||
export interface EmpathyGuidePromptDependencies { |
||||
readonly inquire: InquireFunction<ExpandQuestion, typeof PLEASANT | typeof UNPLEASANT | typeof NEEDS | typeof INSPECT | typeof SAVE> |
||||
readonly promptForEmpathyGroupList: (opts: EmpathyGroupListPromptOptions) => Promise<readonly EmpathyGroup[]> |
||||
readonly promptYaml: YamlPromptFunction |
||||
} |
||||
|
||||
export function empathyGuidePrompt(deps: EmpathyGuidePromptDependencies): (opts: EmpathyGuidePromptOptions) => Promise<EmpathyGuide> { |
||||
return (opts) => promptForEmpathyGuide(opts, deps) |
||||
} |
||||
|
||||
const PLEASANT = "Pleasant" |
||||
const UNPLEASANT = "Unpleasant" |
||||
const NEEDS = "Needs" |
||||
const INSPECT = "Inspect" |
||||
const SAVE = "Save" |
||||
|
||||
export async function promptForEmpathyGuide(opts: EmpathyGuidePromptOptions, deps: EmpathyGuidePromptDependencies): Promise<EmpathyGuide> { |
||||
const {default: value} = opts |
||||
const {inquire, promptForEmpathyGroupList, promptYaml} = deps |
||||
const result = await inquire({ |
||||
type: "expand", |
||||
message: "What would you like to modify?", |
||||
pageSize: 999, |
||||
choices: [ |
||||
{ |
||||
key: "p", |
||||
name: `Pleasant (met needs) feelings${isPopulatedArray(value?.feelings?.pleasant) ? chalk.dim(` (currently ${pluralize("group", value?.feelings?.pleasant?.length || 0, true)} totaling ${pluralize("item", totalItems(value?.feelings?.pleasant || []), true)})`) : ""}`, |
||||
value: PLEASANT, |
||||
}, |
||||
{ |
||||
key: "u", |
||||
name: `Unpleasant (unmet needs) feelings${isPopulatedArray(value?.feelings?.unpleasant) ? chalk.dim(` (currently ${pluralize("group", value?.feelings?.unpleasant?.length || 0, true)} totaling ${pluralize("item", totalItems(value?.feelings?.unpleasant || []), true)})`) : ""}`, |
||||
value: UNPLEASANT, |
||||
}, |
||||
{ |
||||
key: "n", |
||||
name: `Needs${isPopulatedArray(value?.needs) ? chalk.dim(` (currently ${pluralize("group", value?.needs?.length || 0, true)} totaling ${pluralize("item", totalItems(value?.needs || []), true)})`) : ""}`, |
||||
value: NEEDS, |
||||
}, |
||||
new Separator(), |
||||
{ |
||||
key: "i", |
||||
name: "Inspect and modify the current empathy guide in the editor", |
||||
value: INSPECT, |
||||
}, |
||||
{ |
||||
key: "s", |
||||
name: "Save the empathy guide", |
||||
value: SAVE |
||||
}, |
||||
new Separator(), |
||||
] |
||||
}) |
||||
switch (result) { |
||||
case PLEASANT: |
||||
const newPleasant = await promptForEmpathyGroupList({ |
||||
default: value?.feelings?.pleasant, |
||||
listName: "pleasant (met needs) feelings" |
||||
}) |
||||
return promptForEmpathyGuide({ |
||||
default: { |
||||
...value, |
||||
feelings: { |
||||
...value?.feelings || {}, |
||||
pleasant: newPleasant, |
||||
} |
||||
} |
||||
}, deps) |
||||
case UNPLEASANT: |
||||
const newUnpleasant = await promptForEmpathyGroupList({ |
||||
default: value?.feelings?.unpleasant, |
||||
listName: "unpleasant (unmet needs) feelings" |
||||
}) |
||||
return promptForEmpathyGuide({ |
||||
default: { |
||||
...value, |
||||
feelings: { |
||||
...value?.feelings || {}, |
||||
unpleasant: newUnpleasant, |
||||
} |
||||
} |
||||
}, deps) |
||||
case NEEDS: |
||||
const newNeeds = await promptForEmpathyGroupList({default: value?.needs, listName: "needs"}) |
||||
return promptForEmpathyGuide({ |
||||
default: { |
||||
...value, |
||||
needs: newNeeds |
||||
} |
||||
}, deps) |
||||
case SAVE: |
||||
return value || {} |
||||
default: |
||||
case INSPECT: |
||||
return promptForEmpathyGuide({ |
||||
default: await promptYaml({ |
||||
schema: EmpathyGuideJTD, |
||||
currentValue: value, |
||||
name: "the current empathy guide", |
||||
}) |
||||
}, deps) |
||||
} |
||||
} |
@ -0,0 +1,115 @@ |
||||
import {InquireFunction} from "../Inquire.js"; |
||||
import {ExpandChoiceOptions, ExpandQuestion, SeparatorOptions} from "inquirer"; |
||||
import inquirer from "inquirer"; |
||||
import {merge} from "../../utils/Merge.js"; |
||||
import {Entry, EntryJTD} from "../../datatypes/Entry.js"; |
||||
import {YamlPromptFunction} from "../../schemata/YAMLPrompt.js"; |
||||
|
||||
const Separator = inquirer.Separator |
||||
|
||||
const VIEW = ".View" |
||||
const DONE = ".Done" |
||||
|
||||
type EntryMainMenuChoiceKey = string & keyof EntryMainMenuOptions |
||||
|
||||
export interface EntryMainMenuChoice<PropertyT extends EntryMainMenuChoiceKey> { |
||||
readonly type: "mainMenu" |
||||
readonly property: PropertyT |
||||
choice(input: EntryMainMenuOptions[PropertyT]): ExpandChoiceOptions & { value: PropertyT } |
||||
onSelected(input: EntryMainMenuOptions[PropertyT]): Promise<EntryMainMenuOptions[PropertyT]> |
||||
} |
||||
function getChoice<PropertyT extends string & keyof EntryMainMenuOptions>(choice: EntryMainMenuChoice<PropertyT>, entry: EntryMainMenuOptions): ExpandChoiceOptions & {value: PropertyT} { |
||||
return choice.choice(entry[choice.property]) |
||||
} |
||||
async function onSelected<PropertyT extends string & keyof EntryMainMenuOptions>(choice: EntryMainMenuChoice<PropertyT>, entry: EntryMainMenuOptions): Promise<Partial<EntryMainMenuOptions>> { |
||||
return { |
||||
[choice.property]: await choice.onSelected(entry[choice.property]) |
||||
} |
||||
} |
||||
|
||||
export function makeEntryMainMenuChoice<PropertyT extends string & keyof EntryMainMenuOptions, OptionsT, OutputT>({property, key, name, short, injected, toOptions, toProperty}: { |
||||
property: PropertyT, |
||||
key: string, |
||||
name: string|((value: EntryMainMenuOptions[PropertyT]) => string), |
||||
short?: string|((value: EntryMainMenuOptions[PropertyT]) => string), |
||||
injected: (options: OptionsT) => Promise<OutputT>, |
||||
toOptions: (input: EntryMainMenuOptions[PropertyT]) => OptionsT, |
||||
toProperty: (output: OutputT) => EntryMainMenuOptions[PropertyT] |
||||
}): { |
||||
(options: OptionsT): Promise<OutputT>, |
||||
mainMenu: EntryMainMenuChoice<PropertyT> |
||||
} { |
||||
return Object.assign(injected, { |
||||
"mainMenu": { |
||||
type: "mainMenu" as const, |
||||
property, |
||||
choice(input: EntryMainMenuOptions[PropertyT]) { |
||||
return { |
||||
name: typeof name === "string" ? name : name(input), |
||||
short: typeof short === "string" ? short : typeof short === "function" ? short(input) : typeof name === "string" ? name : name(input), |
||||
value: property as PropertyT, |
||||
key, |
||||
} |
||||
}, |
||||
async onSelected(input: EntryMainMenuOptions[PropertyT]): Promise<EntryMainMenuOptions[PropertyT]> { |
||||
const output = await injected(toOptions(input)) |
||||
return toProperty(output) |
||||
}, |
||||
} |
||||
}) |
||||
} |
||||
|
||||
export interface EntryMainMenuOptions extends Omit<Entry, "finishedAt"> {} |
||||
|
||||
export interface EntryMainMenuDependencies { |
||||
readonly inquire: InquireFunction<ExpandQuestion, typeof DONE | typeof VIEW | EntryMainMenuChoiceKey> |
||||
readonly choices: readonly (EntryMainMenuChoice<EntryMainMenuChoiceKey>|SeparatorOptions)[] |
||||
readonly promptYaml: YamlPromptFunction |
||||
} |
||||
|
||||
export function entryMainMenuPrompt(deps: EntryMainMenuDependencies): (options: EntryMainMenuOptions) => Promise<Entry> { |
||||
return (options) => promptForEntryMainMenu(options, deps) |
||||
} |
||||
|
||||
export async function promptForEntryMainMenu(entry: EntryMainMenuOptions, deps: EntryMainMenuDependencies): Promise<Entry> { |
||||
const { |
||||
inquire, |
||||
choices, |
||||
promptYaml, |
||||
} = deps |
||||
const result = await inquire({ |
||||
type: "expand", |
||||
message: "Anything else you want to add?", |
||||
pageSize: 999, |
||||
choices: [ |
||||
...choices.map((choice) => choice.type === "separator" ? choice : getChoice(choice, entry)), |
||||
new Separator(), |
||||
{name: "View and edit this entry as yaml", value: VIEW, key: "v"}, |
||||
{name: "Save and upload this entry", value: DONE, key: "q"}, |
||||
new Separator(), |
||||
] |
||||
}) |
||||
|
||||
function continueWith(update: Partial<EntryMainMenuOptions>): Promise<Entry> { |
||||
const data = merge(update, entry) |
||||
return promptForEntryMainMenu(data, deps) |
||||
} |
||||
|
||||
if (result === DONE) { |
||||
return { |
||||
...entry, |
||||
finishedAt: new Date(), |
||||
} |
||||
} else if (result === VIEW) { |
||||
const updated = await promptYaml({ |
||||
schema: EntryJTD, |
||||
currentValue: {...entry, finishedAt: new Date()}, |
||||
name: "the current entry", |
||||
}) |
||||
const withoutFinishedAt = {...updated, finishedAt: undefined} |
||||
return continueWith(withoutFinishedAt) |
||||
} else { |
||||
const selectedChoice = choices.find((choice) => choice.type === "mainMenu" && choice.property === result) as EntryMainMenuChoice<string & keyof EntryMainMenuOptions> |
||||
return continueWith(await onSelected(selectedChoice, entry)) |
||||
} |
||||
} |
@ -0,0 +1,34 @@ |
||||
import {ConditionPromptOptions} from "./ConditionPrompt.js"; |
||||
import {Condition} from "../../datatypes/Condition.js"; |
||||
import {SummaryPromptOptions} from "./SummaryPrompt.js"; |
||||
import {EntryMainMenuOptions} from "./EntryMainMenuPrompt.js"; |
||||
import {Entry} from "../../datatypes/Entry.js"; |
||||
|
||||
interface EntryPromptOptions { |
||||
} |
||||
|
||||
interface EntryPromptDependencies { |
||||
promptForCondition: (options: ConditionPromptOptions) => Promise<Condition> |
||||
promptForSummary: (options: SummaryPromptOptions) => Promise<string | null> |
||||
promptForEntryMainMenu: (options: EntryMainMenuOptions) => Promise<Entry> |
||||
} |
||||
|
||||
export function entryPrompt(deps: EntryPromptDependencies): (options: EntryPromptOptions) => Promise<Entry> { |
||||
return (options) => promptForEntry(options, deps) |
||||
} |
||||
|
||||
export async function promptForEntry(options: EntryPromptOptions, dependencies: EntryPromptDependencies): Promise<Entry> { |
||||
const { |
||||
promptForCondition, |
||||
promptForSummary, |
||||
promptForEntryMainMenu, |
||||
} = dependencies |
||||
const startedAt = new Date() |
||||
const condition = await promptForCondition({}) |
||||
const summary = await promptForSummary({}) |
||||
if (summary === null) { |
||||
return promptForEntryMainMenu({startedAt, condition}) |
||||
} else { |
||||
return promptForEntryMainMenu({startedAt, condition, summary}) |
||||
} |
||||
} |
@ -0,0 +1,106 @@ |
||||
import {PersonaPrompt} from "../../datatypes/Persona.js"; |
||||
import {guidedEmpathyToStringShort, GuidedEmpathy} from "../../datatypes/GuidedEmpathy.js"; |
||||
import {InquireFunction} from "../Inquire.js"; |
||||
import {EntryMainMenuChoice, makeEntryMainMenuChoice} from "./EntryMainMenuPrompt.js"; |
||||
import chalk from "chalk"; |
||||
import pluralize from "pluralize"; |
||||
import {isPopulatedArray} from "../../utils/Arrays.js"; |
||||
import {ListChoiceOptions, ListQuestion} from "inquirer"; |
||||
import {asDefault} from "../../utils/Objects.js"; |
||||
import {GuidedEmpathyPromptOptions} from "./GuidedEmpathyPrompt.js"; |
||||
|
||||
export interface GuidedEmpathyListPromptOptions extends PersonaPrompt { |
||||
readonly default?: readonly GuidedEmpathy[] |
||||
} |
||||
|
||||
export interface GuidedEmpathyListPromptDependencies { |
||||
readonly inquire: InquireFunction<ListQuestion, number> |
||||
readonly promptForEmpathy: (input: GuidedEmpathyPromptOptions) => Promise<GuidedEmpathy|null> |
||||
} |
||||
|
||||
export function guidedEmpathyListPrompt(deps: GuidedEmpathyListPromptDependencies): { |
||||
(options: GuidedEmpathyListPromptOptions): Promise<readonly GuidedEmpathy[]>; mainMenu: EntryMainMenuChoice<"guidedEmpathy"> } { |
||||
return makeEntryMainMenuChoice({ |
||||
property: "guidedEmpathy", |
||||
name: (input) => typeof input === "object" ? `Continue guided empathy ${chalk.dim(`(currently ${chalk.greenBright(`${pluralize("entry", input.length, true)}`)})`)}` : "Do some guided empathy", |
||||
key: "e", |
||||
injected: (options) => promptForGuidedEmpathyList(options, deps), |
||||
toOptions: asDefault, |
||||
toProperty: (input) => input.length === 0 ? undefined : input, |
||||
}) |
||||
} |
||||
|
||||
export async function promptForGuidedEmpathyList(options: GuidedEmpathyListPromptOptions, deps: GuidedEmpathyListPromptDependencies): Promise<readonly GuidedEmpathy[]> { |
||||
const {inquire, promptForEmpathy} = deps |
||||
if (!isPopulatedArray(options.default)) { |
||||
const empathy = await promptForEmpathy(typeof options.persona === "string" ? {persona: options.persona} : {}) |
||||
if (empathy === null) { |
||||
return [] |
||||
} else { |
||||
return promptForGuidedEmpathyList({ |
||||
...options, |
||||
default: [empathy], |
||||
}, deps) |
||||
} |
||||
} else { |
||||
const result = await inquire({ |
||||
type: "list", |
||||
message: "Want to change an existing entry or add a new one?", |
||||
default: options.default.length, |
||||
choices: [ |
||||
...options.default.map((item, index): ListChoiceOptions => ({ |
||||
name: guidedEmpathyToStringShort(item), |
||||
value: index, |
||||
short: guidedEmpathyToStringShort(item) |
||||
})), |
||||
{ |
||||
name: "Add New Entry", |
||||
value: -1, |
||||
short: "Add New Entry" |
||||
}, |
||||
{ |
||||
name: "Done", |
||||
value: -2, |
||||
short: "Done" |
||||
} |
||||
] |
||||
}) |
||||
if (result === -1) { |
||||
const empathy = await promptForEmpathy(typeof options.persona === "string" ? {persona: options.persona} : {}) |
||||
if (empathy === null) { |
||||
if (!isPopulatedArray(options.default)) { |
||||
return [] |
||||
} else { |
||||
return promptForGuidedEmpathyList({ |
||||
...options, |
||||
default: options.default, |
||||
}, deps) |
||||
} |
||||
} else { |
||||
return promptForGuidedEmpathyList({ |
||||
...options, |
||||
default: [...options.default, empathy], |
||||
}, deps) |
||||
} |
||||
} else if (result === -2) { |
||||
return options.default |
||||
} else { |
||||
const empathy = await promptForEmpathy(typeof options.persona === "string" ? {persona: options.persona, default: options.default[result]} : {default: options.default[result]}) |
||||
if (empathy === null) { |
||||
if (options.default.length === 1) { |
||||
return [] |
||||
} else { |
||||
return promptForGuidedEmpathyList({ |
||||
...options, |
||||
default: [...options.default.slice(0, result), ...options.default.slice(result + 1)] |
||||
}, deps) |
||||
} |
||||
} else { |
||||
return promptForGuidedEmpathyList({ |
||||
...options, |
||||
default: [...options.default.slice(0, result), empathy, ...options.default.slice(result + 1)] |
||||
}, deps) |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,95 @@ |
||||
import { |
||||
personaName, |
||||
PersonaPrompt, |
||||
personaPronounObject, |
||||
personaPronounPossessive, |
||||
personaVerb |
||||
} from "../../datatypes/Persona.js"; |
||||
import {InquireFunction, ShowFunction} from "../Inquire.js"; |
||||
import {EmpathyGuide} from "../../datatypes/EmpathyGuide.js"; |
||||
import {isPopulatedArray} from "../../utils/Arrays.js"; |
||||
import {Separator} from "../Inquire.js"; |
||||
import {EmpathyGroup} from "../../datatypes/EmpathyGroup.js"; |
||||
import {GuidedEmpathy, guidedEmpathyToString, isPopulatedGuidedEmpathy} from "../../datatypes/GuidedEmpathy.js"; |
||||
import {HierarchicalCheckboxChildChoice, HierarchicalCheckboxParentChoice, HierarchicalCheckboxQuestion, MultiTextInputQuestion } from "inquirer"; |
||||
|
||||
export interface GuidedEmpathyPromptOptions extends PersonaPrompt { |
||||
readonly default?: GuidedEmpathy |
||||
} |
||||
|
||||
export interface GuidedEmpathyPromptDependencies { |
||||
readonly inquire: InquireFunction<HierarchicalCheckboxQuestion, readonly string[]> & InquireFunction<MultiTextInputQuestion, readonly string[]> |
||||
readonly guideFactory: () => EmpathyGuide, |
||||
readonly show: ShowFunction, |
||||
} |
||||
|
||||
export function guidedEmpathyPrompt(deps: GuidedEmpathyPromptDependencies): (options: GuidedEmpathyPromptOptions) => Promise<GuidedEmpathy | null> { |
||||
return (options) => promptForGuidedEmpathy(options, deps) |
||||
} |
||||
|
||||
function toChoices(item: EmpathyGroup): HierarchicalCheckboxParentChoice | HierarchicalCheckboxChildChoice { |
||||
const children = item.items |
||||
if (isPopulatedArray(children)) { |
||||
return { |
||||
name: item.header, |
||||
children: item.items.map((child) => ({ |
||||
name: child, |
||||
value: child, |
||||
})) as [HierarchicalCheckboxChildChoice, ...HierarchicalCheckboxChildChoice[]], |
||||
showChildren: true, |
||||
} |
||||
} else { |
||||
return { |
||||
name: item.header, |
||||
value: null |
||||
} |
||||
} |
||||
} |
||||
|
||||
export async function promptForGuidedEmpathy(options: GuidedEmpathyPromptOptions, deps: GuidedEmpathyPromptDependencies): Promise<GuidedEmpathy | null> { |
||||
const {inquire, guideFactory, show} = deps |
||||
const guide = guideFactory() |
||||
const {default: value = {}, persona} = options |
||||
if (isPopulatedGuidedEmpathy(value)) { |
||||
await show(guidedEmpathyToString(value, persona)) |
||||
} |
||||
const feelings = await inquire({ |
||||
type: "hierarchical-checkbox", |
||||
message: `What ${personaVerb(persona, "are", "is")} ${personaName(persona)} feeling?`, |
||||
default: value.feelings, |
||||
choices: [ |
||||
...guide.feelings?.pleasant?.map(toChoices) || [], |
||||
...isPopulatedArray(guide.feelings?.pleasant) && isPopulatedArray(guide.feelings?.unpleasant) ? [new Separator()] : [], |
||||
...guide.feelings?.unpleasant?.map(toChoices) || [], |
||||
] |
||||
}) |
||||
const needs = await inquire({ |
||||
type: "hierarchical-checkbox", |
||||
message: `What ${personaVerb(persona, "do", "does")} ${personaName(persona)} need that causes ${personaPronounObject(persona)} to feel that way?`, |
||||
default: value.needs, |
||||
choices: [ |
||||
...guide.needs?.map(toChoices) || [], |
||||
] |
||||
}) |
||||
const events = await inquire({ |
||||
type: "multitext", |
||||
message: `What happened that interacted with ${personaPronounPossessive(persona)} needs?`, |
||||
default: value.events, |
||||
}) |
||||
const requests = await inquire({ |
||||
type: "multitext", |
||||
message: `What would help get the best outcomes for ${personaPronounObject(persona)} in the future?`, |
||||
default: value.requests, |
||||
}) |
||||
const result: GuidedEmpathy = { |
||||
feelings, |
||||
needs, |
||||
events, |
||||
requests, |
||||
} |
||||
if (isPopulatedGuidedEmpathy(result)) { |
||||
return result |
||||
} else { |
||||
return null |
||||
} |
||||
} |
@ -0,0 +1,43 @@ |
||||
import {EditorQuestion} from "inquirer"; |
||||
import {InquireFunction} from "../Inquire.js"; |
||||
import {personaPossessive, PersonaPrompt} from "../../datatypes/Persona.js"; |
||||
import {EntryMainMenuChoice, makeEntryMainMenuChoice} from "./EntryMainMenuPrompt.js"; |
||||
import chalk from "chalk"; |
||||
import pluralize from "pluralize"; |
||||
import wordcount from "wordcount"; |
||||
import {asDefault} from "../../utils/Objects.js"; |
||||
|
||||
export interface JournalEntryPromptOptions extends Partial<Omit<EditorQuestion, "name" | "type">>, PersonaPrompt {} |
||||
|
||||
export interface JournalEntryPromptDependencies { |
||||
readonly inquire: InquireFunction<EditorQuestion, string> |
||||
} |
||||
|
||||
export function journalEntryPrompt(deps: JournalEntryPromptDependencies): { |
||||
(options: JournalEntryPromptOptions): Promise<string|null>, |
||||
mainMenu: EntryMainMenuChoice<"journalEntry">, |
||||
} { |
||||
return makeEntryMainMenuChoice({ |
||||
property: "journalEntry", |
||||
name: (input) => typeof input === "string" ? `Change journal entry ${chalk.dim(`(currently ${chalk.greenBright(`${pluralize("word", wordcount(input), true)} long`)})`)}` : "Add journal entry", |
||||
key: "j", |
||||
injected: (options) => promptForJournalEntry(options, deps), |
||||
toOptions: asDefault, |
||||
toProperty: (input) => input === null ? undefined : input, |
||||
}) |
||||
} |
||||
|
||||
export async function promptForJournalEntry(options: JournalEntryPromptOptions, {inquire}: JournalEntryPromptDependencies): Promise<string | null> { |
||||
const {persona} = options |
||||
const result = await inquire({ |
||||
type: "editor", |
||||
message: typeof options.default === "string" ? `Edit ${personaPossessive(persona)} journal entry:` : `Type up ${personaPossessive(persona)} journal entry:`, |
||||
...options, |
||||
}) |
||||
const trimmed = result.trimEnd() |
||||
if (trimmed === "") { |
||||
return null |
||||
} else { |
||||
return trimmed |
||||
} |
||||
} |
@ -0,0 +1,196 @@ |
||||
import { |
||||
DateQuestion, |
||||
EditorQuestion, |
||||
ListQuestion, |
||||
NumberQuestion, |
||||
} from "inquirer"; |
||||
import {Separator} from "../Inquire.js"; |
||||
import {InquireFunction} from "../Inquire.js"; |
||||
import {SleepQuality, SleepRecord, sleepRecordToString, WakeQuality} from "../../datatypes/SleepRecord.js"; |
||||
import {DateTime} from "luxon"; |
||||
import chalk from "chalk"; |
||||
import {EntryMainMenuChoice, makeEntryMainMenuChoice} from "./EntryMainMenuPrompt.js"; |
||||
|
||||
export interface SleepRecordPromptOptions extends Partial<Omit<EditorQuestion, "name"|"type"|"default"> & Omit<DateQuestion, "name"|"type"|"default"> & Omit<ListQuestion, "name"|"type"|"default"> & Omit<NumberQuestion, "name"|"type"|"default">>{ |
||||
default?: SleepRecord, |
||||
} |
||||
|
||||
export interface SleepRecordPromptDependencies { |
||||
readonly inquire: InquireFunction<EditorQuestion, string> & InquireFunction<DateQuestion, Date|null> & InquireFunction<ListQuestion & {default: SleepQuality|undefined}, SleepQuality|undefined> & InquireFunction<ListQuestion & {default: WakeQuality|undefined}, WakeQuality|undefined> & InquireFunction<NumberQuestion, number> |
||||
} |
||||
|
||||
export function sleepRecordPrompt(deps: SleepRecordPromptDependencies): { |
||||
(options: SleepRecordPromptOptions): Promise<SleepRecord|null>, |
||||
mainMenu: EntryMainMenuChoice<"sleepRecords">, |
||||
} { |
||||
return makeEntryMainMenuChoice({ |
||||
property: "sleepRecords", |
||||
name: (input) => typeof input !== "object" || input.length === 0 ? `Add sleep record` : `Change sleep record ${chalk.dim(`(currently ${chalk.greenBright(sleepRecordToString(input[0]))})`)}`, |
||||
key: "z", |
||||
injected: (options) => promptForSleepRecord(options, deps), |
||||
toOptions: (value) => typeof value !== "object" || value.length === 0 ? {} : {default: value[0]}, |
||||
toProperty: (value) => value === null ? undefined : [value], |
||||
}) |
||||
} |
||||
|
||||
export async function promptForSleepRecord(options: SleepRecordPromptOptions, {inquire}: SleepRecordPromptDependencies): Promise<SleepRecord | null> { |
||||
const oldBedTime = options.default?.sleepAt |
||||
const newBedTime = await inquire({ |
||||
...options, |
||||
type: "date", |
||||
message: "About when did you get to sleep?", |
||||
clearable: true, |
||||
startCleared: false, |
||||
default: oldBedTime ?? DateTime.local().set({hour: 23, minute: 0, second: 0, millisecond: 0}).minus({days: 1}).toJSDate(), |
||||
format: { |
||||
year: undefined, |
||||
weekday: "short", |
||||
month: "short", |
||||
day: "numeric", |
||||
hour: "numeric", |
||||
minute: "numeric", |
||||
second: undefined, |
||||
fractionalSecondDigits: undefined, |
||||
timeZoneName: "short", |
||||
}, |
||||
startingDatePart: "hour", |
||||
deltas: { |
||||
weekday: 1, |
||||
hour: [1, 5, 10], |
||||
minute: [15, 5, 1], |
||||
default: 0 |
||||
} |
||||
}) |
||||
const oldSleepQuality = options.default?.sleepQuality |
||||
const newSleepQuality = await inquire({ |
||||
...options, |
||||
type: "list", |
||||
message: `How'd you sleep?`, |
||||
choices: [ |
||||
{ |
||||
value: SleepQuality.ACIDIC, |
||||
name: `Acidic ${chalk.dim("(extremely poor/extremely restless/heavy acid reflux)")}` |
||||
}, |
||||
{ |
||||
value: SleepQuality.RESTLESS, |
||||
name: `Restless ${chalk.dim("(very poor/very restless/fever dreamy)")}` |
||||
}, |
||||
{ |
||||
value: SleepQuality.NIGHTMARISH, |
||||
name: `Nightmarish ${chalk.dim("(poor/restless/plagued with nightmares)")}` |
||||
}, |
||||
{ |
||||
value: SleepQuality.TIMESKIP, |
||||
name: `Timeskip ${chalk.dim("(neutral/passage of time not recognized)")}` |
||||
}, |
||||
{ |
||||
value: SleepQuality.DREAMLESS, |
||||
name: `Dreamless ${chalk.dim("(good/peaceful/no dreams had or remembered)")}` |
||||
}, |
||||
{ |
||||
value: SleepQuality.DREAMY, |
||||
name: `Dreamy ${chalk.dim("(very good/peaceful/neutral to somewhat pleasant dreams)")}` |
||||
}, |
||||
{ |
||||
value: SleepQuality.ECSTASY, |
||||
name: `Ecstasy ${chalk.dim("(extremely good/very peaceful/very pleasant dreams)")}` |
||||
}, |
||||
new Separator(), |
||||
{ |
||||
value: undefined, |
||||
name: `Uncertain` |
||||
}, |
||||
], |
||||
default: oldSleepQuality ?? SleepQuality.TIMESKIP, |
||||
pageSize: 999, |
||||
}) |
||||
const oldInterruptions = options.default?.interruptions |
||||
const newInterruptionsRaw = await inquire({ |
||||
...options, |
||||
type: "number", |
||||
message: "How many times did you end up waking up during the sleep?", |
||||
default: oldInterruptions ?? -1, |
||||
}) |
||||
const newInterruptions = newInterruptionsRaw < 0 ? undefined : Math.round(newInterruptionsRaw) |
||||
const oldWakeTime = options.default?.wakeAt |
||||
const newWakeTime = await inquire({ |
||||
...options, |
||||
type: "date", |
||||
message: "Around when did you get up?", |
||||
clearable: true, |
||||
startCleared: false, |
||||
default: oldWakeTime ?? DateTime.local().set({hour: 7, minute: 0, second: 0, millisecond: 0}).toJSDate(), |
||||
format: { |
||||
year: undefined, |
||||
weekday: "short", |
||||
month: "short", |
||||
day: "numeric", |
||||
hour: "numeric", |
||||
minute: "numeric", |
||||
second: undefined, |
||||
fractionalSecondDigits: undefined, |
||||
timeZoneName: "short", |
||||
}, |
||||
startingDatePart: "hour", |
||||
deltas: { |
||||
hour: [1, 5, 10], |
||||
minute: [15, 5, 1], |
||||
default: 0 |
||||
} |
||||
}) |
||||
const oldWakeQuality = options.default?.wakeQuality |
||||
const newWakeQuality = await inquire({ |
||||
...options, |
||||
type: "list", |
||||
message: `How'd you feel when you got up?`, |
||||
choices: [ |
||||
{ |
||||
value: WakeQuality.AGONIZED, |
||||
name: `Agonized ${chalk.dim("(in physical pain/barely able or unable to get out of bed)")}` |
||||
}, |
||||
{ |
||||
value: WakeQuality.EXHAUSTED, |
||||
name: `Exhausted ${chalk.dim("(very tired/not wanting to get out of bed)")}` |
||||
}, |
||||
{ |
||||
value: WakeQuality.DROWSY, |
||||
name: `Drowsy ${chalk.dim("(a bit sleepy but willing to get out of bed)")}` |
||||
}, |
||||
{ |
||||
value: WakeQuality.RESTED, |
||||
name: `Rested ${chalk.dim("(well rested and ready to get going)")}` |
||||
}, |
||||
{ |
||||
value: WakeQuality.ENERGIZED, |
||||
name: `Energized ${chalk.dim("(thoroughly recharged and eager to get up and running)")}` |
||||
}, |
||||
new Separator(), |
||||
{ |
||||
value: undefined, |
||||
name: `Uncertain` |
||||
}, |
||||
], |
||||
default: oldWakeQuality ?? WakeQuality.DROWSY, |
||||
pageSize: 999, |
||||
}) |
||||
const oldDreamJournal = options.default?.dreams |
||||
const newDreamJournalRaw = (await inquire({ |
||||
...options, |
||||
type: "editor", |
||||
message: typeof oldDreamJournal === "string" ? `Edit dream journal for this sleep session:` : `Type up dream journal for this sleep session:`, |
||||
default: oldDreamJournal, |
||||
})).trimEnd() |
||||
const newDreamJournal = newDreamJournalRaw === "" ? undefined : newDreamJournalRaw |
||||
const result: SleepRecord = { |
||||
dreams: newDreamJournal, |
||||
interruptions: newInterruptions, |
||||
sleepAt: newBedTime ?? undefined, |
||||
sleepQuality: newSleepQuality, |
||||
wakeAt: newWakeTime ?? undefined, |
||||
wakeQuality: newWakeQuality, |
||||
} |
||||
return ( |
||||
Object.keys(result).map((key: keyof SleepRecord) => result[key]).some(value => (value !== undefined)) |
||||
? result |
||||
: null) |
||||
} |
@ -0,0 +1,56 @@ |
||||
import {ListQuestion} from "inquirer"; |
||||
import {EntryMainMenuChoice, makeEntryMainMenuChoice} from "./EntryMainMenuPrompt.js"; |
||||
import chalk from "chalk"; |
||||
import {asDefault, identity} from "../../utils/Objects.js"; |
||||
import {InquireFunction} from "../Inquire.js"; |
||||
import {Suicidality} from "../../datatypes/Suicidality.js"; |
||||
|
||||
export interface SuicidalityPromptOptions extends Partial<Omit<ListQuestion, "name" | "type" | "choices">> { |
||||
} |
||||
|
||||
export interface SuicidalityPromptDependencies { |
||||
inquire: InquireFunction<ListQuestion, Suicidality> |
||||
} |
||||
|
||||
export function suicidalityPrompt(deps: SuicidalityPromptDependencies): { |
||||
(options: SuicidalityPromptOptions): Promise<Suicidality>, |
||||
mainMenu: EntryMainMenuChoice<"suicidality">, |
||||
} { |
||||
return makeEntryMainMenuChoice({ |
||||
property: "suicidality", |
||||
name: (input) => typeof input === "string" ? `Change record of suicidal thoughts ${chalk.dim(`(currently ${chalk.greenBright(input)})`)}` : "Add record of suicidal thoughts", |
||||
key: "x", |
||||
injected: (options) => promptForSuicidality(options, deps), |
||||
toOptions: asDefault, |
||||
toProperty: identity, |
||||
}) |
||||
} |
||||
|
||||
export async function promptForSuicidality(options: SuicidalityPromptOptions, {inquire}: SuicidalityPromptDependencies): Promise<Suicidality> { |
||||
return await inquire({ |
||||
type: "list", |
||||
message: "What stage are your suicidal thoughts at?", |
||||
choices: [ |
||||
{value: Suicidality.NONE, name: `None ${chalk.dim("(no or only rare thoughts present)")}`}, |
||||
{ |
||||
value: Suicidality.PASSIVE, |
||||
name: `Passive ${chalk.dim("(faint call of the void, but without intention)")}` |
||||
}, |
||||
{ |
||||
value: Suicidality.INTRUSIVE, |
||||
name: `Intrusive ${chalk.dim("(intrusive thoughts with intention, not actively engaged with)")}` |
||||
}, |
||||
{value: Suicidality.ACTIVE, name: `Active ${chalk.dim("(actively engaging with thoughts)")}`}, |
||||
{ |
||||
value: Suicidality.RESIGNED, |
||||
name: `Resigned ${chalk.dim("(given in to despair, but nothing further than that)")}` |
||||
}, |
||||
{value: Suicidality.PLANNING, name: `Planning ${chalk.dim("(actively making plans to attempt)")}`}, |
||||
{value: Suicidality.PLANNED, name: `Planned ${chalk.dim("(prepared a plan to attempt)")}`}, |
||||
{value: Suicidality.DANGER, name: `Danger ${chalk.dim("(on the precipice of attempting)")}`}, |
||||
], |
||||
default: 0, |
||||
pageSize: 999, |
||||
...options, |
||||
}) |
||||
} |
@ -0,0 +1,42 @@ |
||||
import {InputQuestion} from "inquirer"; |
||||
import {InquireFunction} from "../Inquire.js"; |
||||
import {personaName, personaPossessive, PersonaPrompt, personaVerb} from "../../datatypes/Persona.js"; |
||||
import {EntryMainMenuChoice, makeEntryMainMenuChoice} from "./EntryMainMenuPrompt.js"; |
||||
import chalk from "chalk"; |
||||
import pluralize from "pluralize"; |
||||
import wordcount from "wordcount"; |
||||
import {asDefault} from "../../utils/Objects.js"; |
||||
|
||||
export interface SummaryPromptOptions extends Partial<Omit<InputQuestion, "name" | "type">>, PersonaPrompt {} |
||||
|
||||
export interface SummaryPromptDependencies { |
||||
readonly inquire: InquireFunction<InputQuestion, string> |
||||
} |
||||
|
||||
export function summaryPrompt(deps: SummaryPromptDependencies): { |
||||
(options: SummaryPromptOptions): Promise<string|null>, |
||||
mainMenu: EntryMainMenuChoice<"summary">, |
||||
} { |
||||
return makeEntryMainMenuChoice({ |
||||
property: "summary", |
||||
name: (input) => typeof input === "string" ? `Change summary ${chalk.dim(`(currently ${chalk.greenBright(`${pluralize("word", wordcount(input), true)} long`)})`)}` : "Add summary", |
||||
key: "s", |
||||
injected: (options) => promptForSummary(options, deps), |
||||
toOptions: asDefault, |
||||
toProperty: (input) => input === null ? undefined : input, |
||||
}) |
||||
} |
||||
|
||||
export async function promptForSummary(options: SummaryPromptOptions, {inquire}: SummaryPromptDependencies): Promise<string | null> { |
||||
const {persona} = options |
||||
const result = await inquire({ |
||||
type: "input", |
||||
message: typeof options.default === "string" ? `What's ${personaPossessive(persona)} new summary?` : `Can you summarize how ${personaName(persona)} ${personaVerb(persona, "feel", "feels")}?`, |
||||
...options, |
||||
}) |
||||
if (result === "") { |
||||
return null |
||||
} else { |
||||
return result |
||||
} |
||||
} |
@ -0,0 +1,386 @@ |
||||
/* |
||||
* 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(); |
||||
} |
||||
} |
@ -0,0 +1,688 @@ |
||||
import Prompt from "inquirer/lib/prompts/base.js"; |
||||
import observe from "inquirer/lib/utils/events.js"; |
||||
import { |
||||
Answers, |
||||
ChoiceOptions, |
||||
HierarchicalCheckboxChoice, |
||||
HierarchicalCheckboxQuestion, |
||||
Question, |
||||
SeparatorOptions, |
||||
} from "inquirer"; |
||||
import {Separator} from "../Inquire.js"; |
||||
import {Interface as ReadLineInterface} from "readline"; |
||||
import {filter} from "rxjs/operators/index.js" |
||||
import chalk from "chalk"; |
||||
import {Subject} from "rxjs"; |
||||
import figures from "figures"; |
||||
import Paginator from "inquirer/lib/utils/paginator.js"; |
||||
import {isPopulatedArray} from "../../utils/Arrays.js"; |
||||
import {FilterOptions} from "fuzzy"; |
||||
import fuzzy from "fuzzy"; |
||||
import ScreenManager from "inquirer/lib/utils/screen-manager.js"; |
||||
|
||||
const fuzzyFilter = fuzzy.filter; |
||||
|
||||
interface ExtendedReadLine extends ReadLineInterface { |
||||
line: string |
||||
cursor: number |
||||
} |
||||
|
||||
declare module "inquirer" { |
||||
|
||||
export interface HierarchicalCheckboxChoiceBase extends ChoiceOptions { |
||||
readonly value?: string|null |
||||
readonly children?: readonly HierarchicalCheckboxChoice[] |
||||
readonly showChildren?: boolean |
||||
} |
||||
export interface HierarchicalCheckboxParentChoice extends HierarchicalCheckboxChoiceBase { |
||||
readonly showChildren?: boolean |
||||
readonly value?: null |
||||
readonly children: readonly [HierarchicalCheckboxChoice, ...HierarchicalCheckboxChoice[]] |
||||
} |
||||
export interface HierarchicalCheckboxChildChoice extends HierarchicalCheckboxChoiceBase { |
||||
readonly value: string|null |
||||
readonly children?: readonly [] |
||||
readonly showChildren?: false |
||||
} |
||||
|
||||
export type HierarchicalCheckboxChoice = HierarchicalCheckboxParentChoice|HierarchicalCheckboxChildChoice |
||||
|
||||
export interface HierarchicalCheckboxQuestionOptions { |
||||
readonly choices: readonly (HierarchicalCheckboxChoice|SeparatorOptions)[] |
||||
readonly pageSize?: number |
||||
} |
||||
|
||||
export interface HierarchicalCheckboxQuestion<AnswerT extends Answers = Answers> extends Question<AnswerT>, HierarchicalCheckboxQuestionOptions { |
||||
/** @inheritDoc */ |
||||
type: "hierarchical-checkbox" |
||||
default?: readonly string[] |
||||
} |
||||
|
||||
export interface QuestionMap { |
||||
["hierarchical-checkbox"]: HierarchicalCheckboxQuestion |
||||
} |
||||
} |
||||
|
||||
interface ExtendedPaginatorConstructor { |
||||
new(manager: ScreenManager, options: {isInfinite:boolean}): ExtendedPaginator |
||||
} |
||||
|
||||
interface ExtendedPaginator extends Paginator { |
||||
paginate(content: string, selectedIndex: number, pageSize?: number): string; |
||||
} |
||||
|
||||
interface NormalizedChoiceBase { |
||||
readonly name: string |
||||
readonly value: string|null |
||||
readonly short: string|null |
||||
readonly children?: readonly NormalizedChoice[] |
||||
} |
||||
|
||||
interface NormalizedParentChoice extends NormalizedChoiceBase { |
||||
readonly name: string |
||||
readonly value: null |
||||
readonly short: null |
||||
readonly children: readonly [NormalizedChoice, ...NormalizedChoice[]] |
||||
readonly showChildren: boolean |
||||
} |
||||
|
||||
interface NormalizedLeafChoice extends NormalizedChoiceBase { |
||||
readonly name: string |
||||
readonly value: string |
||||
readonly short: string |
||||
readonly children?: readonly [] |
||||
} |
||||
|
||||
interface NormalizedDisabledChoice extends NormalizedChoiceBase { |
||||
readonly name: string |
||||
readonly value: null |
||||
readonly short: null |
||||
readonly children?: readonly [] |
||||
} |
||||
type NormalizedChoice = NormalizedParentChoice|NormalizedLeafChoice|NormalizedDisabledChoice |
||||
|
||||
function isParentChoice(c: NormalizedChoice): c is NormalizedParentChoice { |
||||
return isPopulatedArray(c.children) |
||||
} |
||||
function isLeafChoice(c: NormalizedChoice): c is NormalizedLeafChoice { |
||||
return c.value !== null |
||||
} |
||||
function isDisabledChoice(c: NormalizedChoice): c is NormalizedDisabledChoice { |
||||
return c.value === null && !isPopulatedArray(c.children) |
||||
} |
||||
|
||||
function normalizeChoice(choice: HierarchicalCheckboxChoice|SeparatorOptions, valueMap: ValueMap): NormalizedChoice { |
||||
if (choice.type === "separator") { |
||||
return { |
||||
name: choice.line || new Separator().line, |
||||
value: null, |
||||
short: null, |
||||
children: [], |
||||
} |
||||
} |
||||
const { name = chalk.dim("undefined") } = choice |
||||
const children = isPopulatedArray(choice.children) ? choice.children.map((choice) => normalizeChoice(choice, valueMap)) : []; |
||||
if (isPopulatedArray(children)) { |
||||
const { showChildren = true } = choice |
||||
return { |
||||
name, |
||||
value: null, |
||||
short: null, |
||||
children, |
||||
showChildren, |
||||
} |
||||
} else { |
||||
const { name: originalName = null } = choice |
||||
const { value = originalName } = choice |
||||
if (value === null) { |
||||
// Disabled choice, no need for a short value
|
||||
return { |
||||
name, |
||||
value: null, |
||||
short: null, |
||||
children: [], |
||||
} |
||||
} else { |
||||
// Selectable leaf choice
|
||||
const { short = name } = choice |
||||
const result: NormalizedLeafChoice = { |
||||
name, |
||||
value, |
||||
short, |
||||
children: [] |
||||
} |
||||
addValueToMap(result, valueMap) |
||||
return result |
||||
} |
||||
} |
||||
} |
||||
|
||||
interface BaseBreadcrumb { |
||||
readonly parent: Breadcrumb|null |
||||
readonly index: number|null |
||||
readonly children: readonly NormalizedChoice[] |
||||
readonly searchable: readonly (NormalizedLeafChoice|NormalizedParentChoice)[] |
||||
} |
||||
|
||||
interface RootBreadcrumb extends BaseBreadcrumb { |
||||
readonly parent: null |
||||
readonly index: null |
||||
readonly selectable: readonly number[] |
||||
} |
||||
|
||||
interface ChildBreadcrumb extends BaseBreadcrumb { |
||||
readonly parent: Breadcrumb |
||||
readonly index: number |
||||
readonly selectable: readonly number[] |
||||
} |
||||
|
||||
interface FilterBreadcrumb extends BaseBreadcrumb { |
||||
readonly parent: Breadcrumb |
||||
readonly index: null |
||||
readonly filter: string |
||||
readonly adjustingFilter: boolean |
||||
} |
||||
|
||||
type Breadcrumb = RootBreadcrumb|ChildBreadcrumb|FilterBreadcrumb |
||||
|
||||
function isRootBreadcrumb(b: Breadcrumb): b is RootBreadcrumb { |
||||
return b.parent === null && b.index === null |
||||
} |
||||
function isFilterBreadcrumb(b: Breadcrumb): b is FilterBreadcrumb { |
||||
return b.parent !== null && b.index === null |
||||
} |
||||
|
||||
interface ValueMap { |
||||
[value: string]: NormalizedLeafChoice|[NormalizedLeafChoice, NormalizedLeafChoice, ...NormalizedLeafChoice[]] |
||||
} |
||||
interface ValidValueMap extends ValueMap { |
||||
[value: string]: NormalizedLeafChoice |
||||
} |
||||
interface InvalidValueMap extends ValueMap { |
||||
[value: string]: [NormalizedLeafChoice, NormalizedLeafChoice, ...NormalizedLeafChoice[]] |
||||
} |
||||
|
||||
function addValueToMap(choice: NormalizedLeafChoice, map: ValueMap) { |
||||
if (map.hasOwnProperty(choice.value)) { |
||||
const existing = map[choice.value] |
||||
if (Array.isArray(existing) && !existing.some((item) => item.name === choice.name && item.short === choice.short)) { |
||||
map[choice.value] = [choice, ...existing] |
||||
} else if (!Array.isArray(existing) && !(existing.name === choice.name && existing.short === choice.short)) { |
||||
map[choice.value] = [choice, existing] |
||||
} else { |
||||
// It's a duplicate, but one that's the same as something already in the books.
|
||||
} |
||||
} else { |
||||
map[choice.value] = choice |
||||
} |
||||
} |
||||
|
||||
function splitValues(map: ValueMap): {valid: ValidValueMap, invalid: InvalidValueMap} { |
||||
const valid: ValidValueMap = {} |
||||
const invalid: InvalidValueMap = {} |
||||
Object.keys(map).forEach((value) => { |
||||
const item = map[value] |
||||
if (Array.isArray(item)) { |
||||
invalid[value] = item |
||||
} else { |
||||
valid[value] = item |
||||
} |
||||
}) |
||||
return { valid, invalid } |
||||
} |
||||
|
||||
function getSearchableList(base: readonly NormalizedChoice[]): (NormalizedLeafChoice|NormalizedParentChoice)[] { |
||||
const result = base.flatMap((choice) => { |
||||
if (isParentChoice(choice)) { |
||||
return [choice, ...getSearchableList(choice.children)] |
||||
} else if (isLeafChoice(choice)) { |
||||
return [choice] |
||||
} else { |
||||
return [] |
||||
} |
||||
}) |
||||
result.sort((a: NormalizedLeafChoice|NormalizedParentChoice, b: NormalizedLeafChoice|NormalizedParentChoice): number => a.name.localeCompare(b.name)) |
||||
return result.filter((value, index) => result.findIndex((match) => match.name === value.name && match.value === value.value) === index) |
||||
} |
||||
|
||||
function getSelectableList(base: readonly NormalizedChoice[]): number[] { |
||||
return base.map((_, index) => index).filter((choice) => !isDisabledChoice(base[choice])) |
||||
} |
||||
|
||||
const [searchHighlightPrefix, searchHighlightSuffix] = chalk.yellowBright.bold("Placeholder").split("Placeholder") as [string, string] |
||||
|
||||
export class HierarchicalCheckboxInput<AnswerT extends Answers = Answers, QuestionT extends HierarchicalCheckboxQuestion<AnswerT> = HierarchicalCheckboxQuestion> extends Prompt<QuestionT> { |
||||
|
||||
constructor(question: QuestionT, readline: ExtendedReadLine, answers: AnswerT) { |
||||
super(question, readline, answers); |
||||
const valueMap: ValueMap = {} |
||||
const rootChoices = question.choices.map((choice) => normalizeChoice(choice, valueMap)) |
||||
this.location = { |
||||
parent: null, |
||||
index: null, |
||||
children: rootChoices, |
||||
selectable: getSelectableList(rootChoices), |
||||
searchable: getSearchableList(rootChoices) |
||||
} |
||||
const { valid, invalid } = splitValues(valueMap) |
||||
this.valueMap = valid |
||||
this.invalidValues = invalid |
||||
this.value = question.default?.slice() || [] |
||||
this.activeIndex = 0 |
||||
this.updateUnlistedValues() |
||||
} |
||||
|
||||
_run(callback: Exclude<HierarchicalCheckboxInput["done"], null>, error?: (err: Error) => void) { |
||||
const invalidChoices = Object.keys(this.invalidValues); |
||||
const invalidValues = this.value.filter((value) => !this.valueMap.hasOwnProperty(value)); |
||||
if (isPopulatedArray(invalidChoices)) { |
||||
if (error) { |
||||
error(Error(`Duplicate values: ${invalidChoices.join()}`)) |
||||
} |
||||
} else if (isPopulatedArray(invalidValues)) { |
||||
if (error) { |
||||
error(Error(`Unknown values: ${invalidValues.join()}`)) |
||||
} |
||||
} |
||||
this.rl.on("history", (history) => { |
||||
history.splice(0, history.length) |
||||
}) |
||||
const events = observe(this.rl); |
||||
this.status = "touched" |
||||
this.done = callback |
||||
events.keypress.pipe(filter((event) => { |
||||
return event.key.name !== "up" && event.key.name !== "down" && event.key.name !== "tab" && event.key.name !== "backspace" && event.key.name !== "space" && !(event.key.name === "x" && event.key.ctrl === true) |
||||
})).subscribe(() => this.onOtherKey()) |
||||
events.normalizedUpKey.subscribe(() => this.onUpKey()) |
||||
events.normalizedDownKey.subscribe(() => this.onDownKey()) |
||||
events.keypress.pipe(filter((event) => event.key.name === "tab")).subscribe(() => this.onTab()) |
||||
events.keypress.pipe(filter((event) => event.key.name === "backspace")).subscribe(() => this.onBack()) |
||||
events.keypress.pipe(filter((event) => event.key.name === "space")).subscribe(() => this.onSpacebar()) |
||||
events.line.subscribe(() => this.onLine()) |
||||
events.keypress.pipe(filter((event) => event.key.name === "x" && event.key.ctrl === true)).subscribe(() => this.onClear()) |
||||
const onSubmit = this.handleSubmitEvents(this.submitter) |
||||
onSubmit.success.subscribe((result) => this.onValidated(result.value)) |
||||
onSubmit.error.subscribe((result) => this.onValidationError(result.isValid)) |
||||
this.scheduleRender() |
||||
} |
||||
|
||||
private readonly valueMap: ValidValueMap |
||||
private readonly invalidValues: InvalidValueMap |
||||
private readonly value: string[] |
||||
private done: ((state: unknown) => void)|null |
||||
private location: Breadcrumb |
||||
private activeIndex: number |
||||
private unlistedValues: string[] |
||||
private submitter: Subject<string[]> = new Subject<string[]>() |
||||
private paginator: ExtendedPaginator = new (Paginator as ExtendedPaginatorConstructor)(this.screen, {isInfinite: true}); |
||||
private scheduledRender: NodeJS.Immediate|null = null |
||||
|
||||
private get readline(): ExtendedReadLine { |
||||
return this.rl |
||||
} |
||||
|
||||
private get activeChoice(): NormalizedChoice|null { |
||||
if (this.activeIndex >= this.totalLength) { |
||||
return null |
||||
} |
||||
if (this.isUnlistedActive()) { |
||||
return this.valueMap[this.unlistedValues[this.activeUnlistedIndex()]] || null |
||||
} else { |
||||
return this.location.children[this.activeChildIndex()] || null |
||||
} |
||||
} |
||||
|
||||
private render(error?: string) { |
||||
this.scheduledRender = null |
||||
const outputs: string[] = [this.getQuestion()] |
||||
const bottomOutputs: string[] = [] |
||||
const isRoot = isRootBreadcrumb(this.location) |
||||
const isFilter = isFilterBreadcrumb(this.location) |
||||
const hasFilter = isFilterBreadcrumb(this.location) && this.location.filter !== "" |
||||
const isAdjustingFilter = isFilterBreadcrumb(this.location) && this.location.adjustingFilter |
||||
const isParent = this.activeChoice && isParentChoice(this.activeChoice) |
||||
const isLeaf = this.activeChoice && isLeafChoice(this.activeChoice) |
||||
if (this.status === "answered") { |
||||
outputs.push(this.value.map((s) => `${chalk.cyan(this.valueMap[s].short)}`).join(", ")) |
||||
} else { |
||||
if (typeof error === "string") { |
||||
bottomOutputs.push(`${figures.warning} ${error}`) |
||||
} else { |
||||
if (isFilterBreadcrumb(this.location)) { |
||||
outputs.push(isAdjustingFilter ? chalk.cyan("Searching: ") : chalk.dim("Searching: ")) |
||||
outputs.push(this.location.filter) |
||||
} |
||||
bottomOutputs.push(this.value.map((s) => this.valueMap[s].short).join(", ") || chalk.dim("(nothing selected)")) |
||||
if (isAdjustingFilter) { |
||||
bottomOutputs.push(chalk.dim(`Type to search, ${hasFilter ? "" : `${chalk.yellow("<backspace>")} to stop searching, `}` |
||||
+ `${chalk.yellow("<tab>")} to switch to the search results, ` |
||||
+ `${chalk.yellow("<Ctrl>+x")} to stop searching, ${chalk.yellow("<enter>")} to select the current option and finish the search.`)) |
||||
|
||||
} else { |
||||
bottomOutputs.push(chalk.dim(`Use arrow keys to move, ${isRoot ? "" : isFilter ? hasFilter ? "" : `${chalk.yellow("<backspace>")} to stop searching, ` : `${chalk.yellow("<backspace>")} to ascend, `}` |
||||
+ (isFilter ? `${chalk.yellow("<tab>")} to switch to the search box, ` : `${chalk.yellow("<tab>")} to search this view, `) |
||||
+ `${isParent ? `${chalk.yellow("<space>")} to descend, ` : isLeaf ? `${chalk.yellow("<space>")} to toggle, ` : ""}` |
||||
+ `${isFilter ? `${chalk.yellow("<Ctrl>+x")} to stop searching, ` : isPopulatedArray(this.value) ? `${chalk.yellow("<Ctrl>+x")} to clear selection, ` : ""}${isFilter ? `${chalk.yellow("<enter>")} to select the current option and finish the search.` : `${chalk.yellow("<enter>")} to submit.`}`)) |
||||
} |
||||
} |
||||
bottomOutputs.push(this.renderList()) |
||||
} |
||||
this.screen.render(outputs.join(""), bottomOutputs.join("\n")) |
||||
} |
||||
|
||||
private renderList(): string { |
||||
const outputs: string[] = [] |
||||
const isAdjustingFilter = isFilterBreadcrumb(this.location) && this.location.adjustingFilter |
||||
|
||||
outputs.push(...this.location.children.map((entry, index):string => { |
||||
const isSelected = index === this.activeChildIndex() |
||||
const isActive = isLeafChoice(entry) && this.value.includes(entry.value) |
||||
const isDisabled = isDisabledChoice(entry) |
||||
const isParent = isParentChoice(entry) |
||||
const showChildren = isParentChoice(entry) && entry.showChildren |
||||
return `${isSelected ? chalk.cyan(figures.pointer) : " "} ${isDisabled ? " " : isParent ? figures.arrowRight : isActive ? chalk.greenBright(figures.checkboxCircleOn) : figures.checkboxCircleOff} ${isSelected && !isAdjustingFilter ? (isActive ? chalk.blueBright(entry.name) : chalk.cyan(entry.name)) : (isActive ? chalk.green(entry.name) : entry.name)}${isParentChoice(entry) && showChildren ? `${chalk.dim(` (${entry.children.map((child) => child.name).join(", ")})`)}` : ""}` |
||||
})) |
||||
|
||||
if (!isFilterBreadcrumb(this.location) && isPopulatedArray(this.unlistedValues)) { |
||||
outputs.push(" " + new Separator().line) |
||||
outputs.push(...this.unlistedValues.map((value, index): string => { |
||||
const isSelected = index === this.activeUnlistedIndex() |
||||
const isActive = this.value.includes(value) |
||||
const entry = this.valueMap[value] |
||||
return `${isSelected ? chalk.cyan(figures.pointer) : " "} ${isActive ? chalk.greenBright(figures.checkboxCircleOn) : figures.checkboxCircleOff} ${isSelected ? (isActive ? chalk.blueBright(entry.name) : chalk.cyan(entry.name)) : (isActive ? chalk.green(entry.name) : entry.name)}` |
||||
})) |
||||
} |
||||
|
||||
outputs.push(" " + new Separator().line) |
||||
|
||||
// @ts-ignore
|
||||
return this.paginator.paginate(outputs.join("\n"), this.activeIndex, this.opt.pageSize) |
||||
} |
||||
|
||||
private isUnlistedActive() { |
||||
return !isFilterBreadcrumb(this.location) && this.activeUnlistedIndex() >= 0 |
||||
} |
||||
|
||||
private activeUnlistedIndex() { |
||||
if (isFilterBreadcrumb(this.location) || (this.activeIndex < this.location.selectable.length)) { |
||||
return -1; |
||||
} else { |
||||
return this.activeIndex - this.location.selectable.length; |
||||
} |
||||
} |
||||
|
||||
private activeChildIndex() { |
||||
if (isFilterBreadcrumb(this.location)) { |
||||
return this.activeIndex |
||||
} else { |
||||
if (this.isUnlistedActive()) { |
||||
return -1 |
||||
} else { |
||||
return this.location.selectable[this.activeIndex]; |
||||
} |
||||
} |
||||
} |
||||
|
||||
private onUpKey() { |
||||
if (isFilterBreadcrumb(this.location)) { |
||||
if (this.location.adjustingFilter) { |
||||
this.location = { |
||||
...this.location, |
||||
adjustingFilter: false |
||||
} |
||||
} |
||||
} |
||||
if (this.activeIndex > 0) { |
||||
this.changeIndex(-1) |
||||
} else { |
||||
this.activeIndex = this.totalLength |
||||
this.changeIndex(-1) |
||||
} |
||||
this.scheduleRender() |
||||
} |
||||
|
||||
private onDownKey() { |
||||
if (isFilterBreadcrumb(this.location)) { |
||||
if (this.location.adjustingFilter) { |
||||
this.location = { |
||||
...this.location, |
||||
adjustingFilter: false |
||||
} |
||||
} |
||||
} |
||||
if (this.activeIndex < this.totalLength - 1) { |
||||
this.changeIndex(+1) |
||||
} else { |
||||
this.activeIndex = -1 |
||||
this.changeIndex(+1) |
||||
} |
||||
this.scheduleRender() |
||||
} |
||||
|
||||
private onTab() { |
||||
if (!isFilterBreadcrumb(this.location)) { |
||||
this.readline.line = "" |
||||
this.readline.cursor = 0 |
||||
this.updateFilter() |
||||
} else if (this.location.adjustingFilter) { |
||||
this.location = { |
||||
...this.location, |
||||
adjustingFilter: false |
||||
} |
||||
this.readline.line = this.location.filter |
||||
this.readline.cursor = this.location.filter.length |
||||
} else { |
||||
this.location = { |
||||
...this.location, |
||||
adjustingFilter: true |
||||
} |
||||
this.readline.line = this.location.filter |
||||
this.readline.cursor = this.location.filter.length |
||||
} |
||||
|
||||
this.scheduleRender() |
||||
} |
||||
|
||||
private onBack() { |
||||
if (isFilterBreadcrumb(this.location)) { |
||||
if (!this.location.adjustingFilter && (this.location.filter !== this.readline.line || this.readline.cursor !== this.location.filter.length)) { |
||||
this.location = { |
||||
...this.location, |
||||
adjustingFilter: true |
||||
} |
||||
} |
||||
if (this.location.filter !== "") { |
||||
this.updateFilter() |
||||
this.scheduleRender() |
||||
return |
||||
} |
||||
} |
||||
const oldLocation = this.location |
||||
if (isRootBreadcrumb(oldLocation)) { |
||||
return |
||||
} |
||||
if (isFilterBreadcrumb(oldLocation)) { |
||||
this.location = oldLocation.parent |
||||
this.activeIndex = 0 |
||||
} else if (isFilterBreadcrumb(oldLocation.parent)) { |
||||
this.location = oldLocation.parent.parent |
||||
this.activeIndex = 0 |
||||
} else { |
||||
this.location = oldLocation.parent |
||||
this.activeIndex = oldLocation.index |
||||
} |
||||
this.updateUnlistedValues() |
||||
this.updatePrompt() |
||||
this.scheduleRender() |
||||
} |
||||
|
||||
private onSpacebar() { |
||||
if (isFilterBreadcrumb(this.location) && this.location.adjustingFilter) { |
||||
this.updateFilter() |
||||
this.scheduleRender() |
||||
return |
||||
} |
||||
const currentChoice = this.activeChoice |
||||
if (currentChoice === null) { |
||||
return |
||||
} else if (isLeafChoice(currentChoice)) { |
||||
const index = this.value.indexOf(currentChoice.value) |
||||
if (index === -1) { |
||||
this.value.push(currentChoice.value) |
||||
} else { |
||||
this.value.splice(index, 1) |
||||
} |
||||
this.scheduleRender() |
||||
} else if (isParentChoice(currentChoice)) { |
||||
this.location = { |
||||
parent: this.location, |
||||
index: this.activeIndex, |
||||
children: currentChoice.children, |
||||
selectable: getSelectableList(currentChoice.children), |
||||
searchable: getSearchableList(currentChoice.children), |
||||
} |
||||
this.activeIndex = 0 |
||||
this.updateUnlistedValues() |
||||
this.scheduleRender() |
||||
} else { |
||||
// disabled choice, do nothing
|
||||
} |
||||
} |
||||
|
||||
private updateUnlistedValues() { |
||||
if (isFilterBreadcrumb(this.location)) { |
||||
this.unlistedValues = [] |
||||
} else { |
||||
this.unlistedValues = this.value.filter((value) => !this.location.children.some((choice) => choice.value === value)) |
||||
} |
||||
} |
||||
|
||||
private updateFilter() { |
||||
if (!isFilterBreadcrumb(this.location)) { |
||||
this.location = { |
||||
parent: this.location, |
||||
index: null, |
||||
filter: "", |
||||
adjustingFilter: true, |
||||
children: this.location.searchable, |
||||
searchable: this.location.searchable, |
||||
} |
||||
this.activeIndex = 0 |
||||
this.updatePrompt() |
||||
} |
||||
if (this.location.adjustingFilter && this.readline.line !== this.location.filter) { |
||||
this.location = { |
||||
...this.location, |
||||
filter: this.readline.line, |
||||
children: this.readline.line === "" ? this.location.searchable : fuzzyFilter(this.readline.line, this.location.searchable.slice(), { |
||||
pre: searchHighlightPrefix, |
||||
post: searchHighlightSuffix, |
||||
extract(input: NormalizedLeafChoice | NormalizedParentChoice): string { |
||||
return input.name |
||||
} |
||||
} as FilterOptions<NormalizedParentChoice | NormalizedLeafChoice>).map((result) => ({ |
||||
...result.original, |
||||
name: result.string, |
||||
})) |
||||
} |
||||
this.activeIndex = 0 |
||||
} |
||||
} |
||||
|
||||
private onLine() { |
||||
if (isFilterBreadcrumb(this.location)) { |
||||
if (!isPopulatedArray(this.location.children)) { |
||||
return |
||||
} |
||||
if (this.location.adjustingFilter) { |
||||
this.location = { |
||||
...this.location, |
||||
adjustingFilter: false, |
||||
} |
||||
} |
||||
// Activate the selected item.
|
||||
this.onSpacebar() |
||||
const newLocation = this.location |
||||
if (isFilterBreadcrumb(newLocation)) { |
||||
this.location = this.location.parent |
||||
} |
||||
this.updateUnlistedValues() |
||||
this.updatePrompt() |
||||
this.scheduleRender() |
||||
} else { |
||||
this.submitter.next(this.value.slice()) |
||||
} |
||||
} |
||||
|
||||
private onClear() { |
||||
if (isFilterBreadcrumb(this.location)) { |
||||
this.location = this.location.parent |
||||
this.activeIndex = 0 |
||||
this.updateUnlistedValues() |
||||
this.updatePrompt() |
||||
this.scheduleRender() |
||||
} |
||||
this.value.splice(0, this.value.length) |
||||
this.scheduleRender() |
||||
} |
||||
|
||||
private scheduleRender() { |
||||
if (this.scheduledRender === null) { |
||||
this.scheduledRender = setImmediate(() => this.render()) |
||||
} |
||||
} |
||||
|
||||
private changeIndex(by: number) { |
||||
this.activeIndex += by |
||||
if (this.activeIndex < 0) { |
||||
this.activeIndex = 0 |
||||
} else if (this.activeIndex >= this.totalLength) { |
||||
this.activeIndex = this.totalLength - 1 |
||||
} |
||||
} |
||||
|
||||
private get totalLength() { |
||||
if (isFilterBreadcrumb(this.location)) { |
||||
return this.location.children.length; |
||||
} else { |
||||
return this.location.selectable.length + this.unlistedValues.length; |
||||
} |
||||
} |
||||
|
||||
private onValidationError(isValid: false|string) { |
||||
this.render(typeof isValid === "string" ? chalk.red(isValid) : chalk.dim.red("Invalid input")) |
||||
} |
||||
|
||||
private onValidated(value: string[]) { |
||||
this.value.splice(0, this.value.length, ...value) |
||||
this.status = "answered" |
||||
this.render() |
||||
this.screen.done() |
||||
if (this.done !== null) { |
||||
this.done(value) |
||||
} |
||||
} |
||||
|
||||
private onOtherKey() { |
||||
if (isFilterBreadcrumb(this.location)) { |
||||
if (!this.location.adjustingFilter && (this.location.filter !== this.readline.line || this.readline.cursor !== this.location.filter.length)) { |
||||
this.location = { |
||||
...this.location, |
||||
adjustingFilter: true |
||||
} |
||||
} |
||||
this.updateFilter() |
||||
} else if (this.readline.line !== "") { |
||||
this.updateFilter() |
||||
} |
||||
this.scheduleRender() |
||||
} |
||||
|
||||
private updatePrompt() { |
||||
if (isFilterBreadcrumb(this.location)) { |
||||
this.readline.setPrompt("Searching: ") |
||||
} else { |
||||
this.readline.setPrompt("") |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,218 @@ |
||||
import Prompt from "inquirer/lib/prompts/base.js"; |
||||
import observe from "inquirer/lib/utils/events.js"; |
||||
import {Answers, MultiTextInputQuestion, Question} from "inquirer"; |
||||
import {Interface as ReadLineInterface} from "readline"; |
||||
import {filter} from "rxjs/operators/index.js" |
||||
import chalk from "chalk"; |
||||
import {Subject} from "rxjs"; |
||||
import figures from "figures"; |
||||
import Paginator from "inquirer/lib/utils/paginator.js"; |
||||
|
||||
interface ExtendedReadLine extends ReadLineInterface { |
||||
line: string |
||||
cursor: number |
||||
} |
||||
|
||||
declare module "inquirer" { |
||||
export interface MultiTextInputQuestionOptions { |
||||
pageSize?: number |
||||
} |
||||
|
||||
export interface MultiTextInputQuestion<AnswerT extends Answers = Answers> extends Question<AnswerT>, MultiTextInputQuestionOptions { |
||||
/** @inheritDoc */ |
||||
type: "multitext" |
||||
default?: readonly string[] |
||||
} |
||||
|
||||
export interface QuestionMap { |
||||
["multitext"]: MultiTextInputQuestion |
||||
} |
||||
} |
||||
|
||||
export class MultiTextInput<AnswerT extends Answers = Answers, QuestionT extends MultiTextInputQuestion<AnswerT> = MultiTextInputQuestion> extends Prompt<QuestionT> { |
||||
|
||||
constructor(question: QuestionT, readline: ExtendedReadLine, answers: AnswerT) { |
||||
super(question, readline, answers); |
||||
this.value = question.default?.slice() || [] |
||||
this.activeIndex = this.value.length |
||||
} |
||||
|
||||
_run(callback: MultiTextInput["done"]) { |
||||
this.status = "touched" |
||||
this.done = callback |
||||
const events = observe(this.rl); |
||||
this.rl.on("history", (history) => { |
||||
history.splice(0, history.length) |
||||
}) |
||||
events.normalizedUpKey.subscribe(() => this.onUpKey()) |
||||
events.normalizedDownKey.subscribe(() => this.onDownKey()) |
||||
events.line.subscribe((line) => this.onLine(line)) |
||||
events.keypress.pipe(filter((event) => { |
||||
return event.key.name === "r" && event.key.ctrl === true |
||||
})).subscribe(() => this.onReset()) |
||||
events.keypress.pipe(filter((event) => { |
||||
return event.key.name === "x" && event.key.ctrl === true |
||||
})).subscribe(() => this.onClear()) |
||||
events.keypress.subscribe(() => this.scheduleRender()) |
||||
const onSubmit = this.handleSubmitEvents(this.submitter) |
||||
onSubmit.success.subscribe((result) => this.onValidated(result.value)) |
||||
onSubmit.error.subscribe((result) => this.onValidationError(result.isValid)) |
||||
this.scheduleRender() |
||||
} |
||||
|
||||
private done: (state: unknown) => void|null |
||||
private activeIndex: number |
||||
private readonly value: string[] |
||||
private submitter: Subject<string[]> = new Subject<string[]>() |
||||
// @ts-ignore
|
||||
private paginator: Paginator = new Paginator(this.screen, {isInfinite: false}); |
||||
private scheduledRender: NodeJS.Immediate|null = null |
||||
|
||||
private get readline(): ExtendedReadLine { |
||||
return this.rl |
||||
} |
||||
|
||||
private get isNew(): boolean { |
||||
return this.activeIndex === this.value.length |
||||
} |
||||
|
||||
private get activeValue(): string { |
||||
return this.isNew ? "" : this.value[this.activeIndex] |
||||
} |
||||
|
||||
private set activeValue(newValue: string) { |
||||
if (this.isNew) { |
||||
this.value.push(newValue) |
||||
} else { |
||||
this.value[this.activeIndex] = newValue |
||||
} |
||||
} |
||||
|
||||
private render(error?: string) { |
||||
this.scheduledRender = null |
||||
const outputs: string[] = [this.getQuestion()] |
||||
const bottomOutputs: string[] = [] |
||||
if (this.status === "answered") { |
||||
outputs.push(...this.value.map((s) => chalk.cyan(`${s}`)).join(", ")) |
||||
} else { |
||||
if (typeof error === "string") { |
||||
bottomOutputs.push(`${figures.warning} ${error}`) |
||||
} else if (this.readline.line === "") { |
||||
if (this.isNew) { |
||||
bottomOutputs.push(chalk.dim(`Begin typing to add a new entry. Press ${chalk.yellow("<enter>")} to submit these entries, ${chalk.yellow("<ctrl>+x")} to delete all entries, or ${chalk.yellow("<up>")}/${chalk.yellow("<down>")} to change existing entries.`)) |
||||
} else { |
||||
bottomOutputs.push(chalk.dim(`Press ${chalk.yellow("<enter>")} or ${chalk.yellow("<ctrl>+x")} to delete this entry or ${chalk.yellow("<ctrl>+r")} to revert.`)) |
||||
} |
||||
} else { |
||||
outputs.push(this.readline.line) |
||||
if (this.isNew) { |
||||
bottomOutputs.push(chalk.dim(`Press ${chalk.yellow("<enter>")} to add this entry or ${chalk.yellow("<ctrl>+r")} or ${chalk.yellow("<ctrl>+x")} to clear it.`)) |
||||
} else if (this.activeValue !== this.readline.line) { |
||||
bottomOutputs.push(chalk.dim(`Press ${chalk.yellow("<enter>")} to save this entry, ${chalk.yellow("<ctrl>+r")} to revert it, or ${chalk.yellow("<ctrl>+x")} to delete it.`)) |
||||
} else { |
||||
bottomOutputs.push(chalk.dim(`Begin typing to change this entry or press ${chalk.yellow("<ctrl>+x")} to delete it. Press ${chalk.yellow("<up>")}/${chalk.yellow("<down>")} to change other entries.`)) |
||||
} |
||||
} |
||||
bottomOutputs.push(this.renderList()) |
||||
} |
||||
this.screen.render(outputs.join(""), bottomOutputs.join("\n")) |
||||
} |
||||
|
||||
private renderList(): string { |
||||
const outputs: string[] = [] |
||||
|
||||
outputs.push(...this.value.map((entry, index):string => { |
||||
const isSelected = index === this.activeIndex |
||||
return `${isSelected ? chalk.cyan(figures.pointer) : " "} ${isSelected ? chalk.cyan(entry) : entry}` |
||||
})) |
||||
|
||||
outputs.push(`${this.isNew ? chalk.cyan(figures.pointer) : " "} ${this.isNew ? chalk.cyan.dim("(New Entry)") : chalk.dim("(New Entry)")}`) |
||||
|
||||
// @ts-ignore
|
||||
return this.paginator.paginate(outputs.join("\n"), this.activeIndex, this.opt.pageSize) |
||||
} |
||||
|
||||
private onUpKey() { |
||||
if (this.readline.line !== this.activeValue) { |
||||
return |
||||
} |
||||
if (this.activeIndex > 0) { |
||||
this.changeIndex(-1) |
||||
this.scheduleRender() |
||||
} |
||||
} |
||||
|
||||
private onDownKey() { |
||||
if (this.readline.line !== this.activeValue) { |
||||
return |
||||
} |
||||
if (this.activeIndex < this.value.length) { |
||||
this.changeIndex(+1) |
||||
this.scheduleRender() |
||||
} |
||||
} |
||||
|
||||
private onLine(line: string) { |
||||
if (line === "") { |
||||
if (this.isNew) { |
||||
this.submitter.next(this.value.slice()) |
||||
} else { |
||||
this.value.splice(this.activeIndex, 1) |
||||
this.changeIndex(0) |
||||
} |
||||
} else { |
||||
this.activeValue = line |
||||
this.changeIndex(+1) |
||||
} |
||||
this.scheduleRender() |
||||
} |
||||
|
||||
private onReset() { |
||||
this.changeIndex(0) |
||||
this.scheduleRender() |
||||
} |
||||
|
||||
private onClear() { |
||||
if (!this.isNew) { |
||||
this.value.splice(this.activeIndex, 1) |
||||
this.changeIndex(0) |
||||
this.scheduleRender() |
||||
} else if (this.readline.line !== "") { |
||||
this.readline.line = "" |
||||
} else { |
||||
this.value.splice(0, this.value.length) |
||||
this.activeIndex = 0 |
||||
this.changeIndex(0) |
||||
} |
||||
this.scheduleRender() |
||||
} |
||||
|
||||
private scheduleRender() { |
||||
if (this.scheduledRender === null) { |
||||
this.scheduledRender = setImmediate(() => this.render()) |
||||
} |
||||
} |
||||
|
||||
private changeIndex(by: number) { |
||||
this.activeIndex += by |
||||
if (this.activeIndex < 0) { |
||||
this.activeIndex = 0 |
||||
} else if (this.activeIndex > this.value.length) { |
||||
this.activeIndex = this.value.length |
||||
} |
||||
this.readline.line = this.activeValue |
||||
this.readline.cursor = this.activeValue.length |
||||
} |
||||
|
||||
private onValidationError(isValid: false|string) { |
||||
this.render(typeof isValid === "string" ? chalk.red(isValid) : chalk.dim.red("Invalid input")) |
||||
} |
||||
|
||||
private onValidated(value: string[]) { |
||||
this.value.splice(0, this.value.length, ...value) |
||||
this.status = "answered" |
||||
this.render() |
||||
this.screen.done() |
||||
this.done(value) |
||||
} |
||||
} |
@ -0,0 +1,10 @@ |
||||
import {registerPrompt} from "../Inquire.js"; |
||||
import {MultiTextInput} from "./MultiTextInput.js"; |
||||
import {HierarchicalCheckboxInput} from "./HierarchicalCheckboxInput.js"; |
||||
import {DateInput} from "./DateInput.js"; |
||||
|
||||
export function registerPrompts() { |
||||
registerPrompt("multitext", MultiTextInput) |
||||
registerPrompt("hierarchical-checkbox", HierarchicalCheckboxInput) |
||||
registerPrompt("date", DateInput) |
||||
} |
@ -0,0 +1,212 @@ |
||||
import {EmpathyGuide, EmpathyGuideJTD} from "../datatypes/EmpathyGuide.js"; |
||||
import {Entry} from "../datatypes/Entry.js"; |
||||
import { |
||||
ReferencedSchema, |
||||
ReferencedTypes, |
||||
schema, |
||||
SchemaData, |
||||
AnyReferenceList, Value |
||||
} from "../schemata/SchemaData.js"; |
||||
import {rm, open, FileHandle} from "fs/promises"; |
||||
import {join, dirname} from "path"; |
||||
import {dump, load} from "js-yaml"; |
||||
import envPaths from "env-paths"; |
||||
import makeDir from "make-dir"; |
||||
import {Journal, JournalJTD} from "../datatypes/Journal.js"; |
||||
|
||||
export type AutosaveFile = Record<string, unknown> |
||||
export type AutosaveFileJTD = typeof AutosaveFileJTD |
||||
export const AutosaveFileJTD = schema({ |
||||
schema: { |
||||
values: {} |
||||
}, |
||||
typeHint: null as AutosaveFile|null, |
||||
key: "autosaveFile", |
||||
references: [] |
||||
}) |
||||
|
||||
export interface LocalRepositoryDependencies { |
||||
rm: typeof rm |
||||
open: typeof open |
||||
makeDir: typeof makeDir |
||||
} |
||||
|
||||
export class LocalRepository { |
||||
private readonly paths: envPaths.Paths |
||||
|
||||
constructor() { |
||||
this.paths = envPaths("guided-journal") |
||||
} |
||||
|
||||
private getJournalFilePath(): string { |
||||
return join(this.paths.data, "journal.yaml") |
||||
} |
||||
|
||||
private getEmpathyGuideFilePath(): string { |
||||
return join(this.paths.config, "empathyGuide.yaml") |
||||
} |
||||
|
||||
private getAutosaveFilePath(autosaveNamespace: string): string { |
||||
return join(this.paths.data, "autosave", `${autosaveNamespace}.yaml`) |
||||
} |
||||
|
||||
async loadEmpathyGuide(): Promise<EmpathyGuide> { |
||||
const path = this.getEmpathyGuideFilePath() |
||||
let handle: FileHandle |
||||
try { |
||||
handle = await open(path, "a+") |
||||
} catch (e) { |
||||
if (typeof e === "object" && e.hasOwnProperty("code") && typeof e.code === "string" && e.code === "ENOENT") { |
||||
return {} // no empathy guide exists, so return a blank one
|
||||
} |
||||
throw e |
||||
} |
||||
try { |
||||
const fileYaml = await handle.readFile({encoding: "utf-8"}) |
||||
const saved = load(fileYaml) |
||||
if (!EmpathyGuideJTD.validate(saved)) { |
||||
throw Error(`Empathy guide file was corrupted`) |
||||
} |
||||
return saved |
||||
} finally { |
||||
await handle.close() |
||||
} |
||||
} |
||||
|
||||
async saveEmpathyGuide(empathyGuide: EmpathyGuide): Promise<void> { |
||||
const path = this.getEmpathyGuideFilePath() |
||||
await makeDir(dirname(path)) |
||||
const handle = await open(path, "w") |
||||
try { |
||||
await handle.writeFile(dump(empathyGuide), {encoding: "utf-8"}) |
||||
} finally { |
||||
await handle.close() |
||||
} |
||||
} |
||||
|
||||
async getEntries(): Promise<Journal> { |
||||
const path = this.getJournalFilePath() |
||||
let handle: FileHandle |
||||
try { |
||||
handle = await open(path, "r") |
||||
} catch (e) { |
||||
if (typeof e === "object" && e.hasOwnProperty("code") && typeof e.code === "string" && e.code === "ENOENT") { |
||||
// file does not exist :(
|
||||
return {entries: []} // no entries saved
|
||||
} |
||||
throw e |
||||
} |
||||
try { |
||||
const fileYaml = await handle.readFile({encoding: "utf-8"}) |
||||
const saved = load(fileYaml) |
||||
if (!JournalJTD.validate(saved)) { |
||||
throw Error(`Journal file was corrupted`) |
||||
} |
||||
return saved |
||||
} finally { |
||||
await handle.close() |
||||
} |
||||
} |
||||
|
||||
async saveEntry(entry: Entry): Promise<void> { |
||||
const path = this.getJournalFilePath() |
||||
await makeDir(dirname(path)) |
||||
const handle = await open(path, "a+") |
||||
try { |
||||
const fileYaml = await handle.readFile({encoding: "utf-8"}) |
||||
const original = fileYaml !== "" ? load(fileYaml) : {} |
||||
if (!JournalJTD.validate(original)) { |
||||
throw Error(`Journal file was corrupted`) |
||||
} |
||||
const appended = {entries: [...original.entries || [], entry]} |
||||
await handle.truncate(0) |
||||
await handle.writeFile(dump(appended), {encoding: "utf-8"}) |
||||
} finally { |
||||
await handle.close() |
||||
} |
||||
} |
||||
|
||||
// TODO: Enable autosave for all commands
|
||||
async loadAutosaveObjects<TypesT extends AnyReferenceList>( |
||||
{autosaveNamespace, schemas}: { autosaveNamespace: string, schemas: TypesT }): Promise<{ |
||||
validated: Partial<ReferencedTypes<TypesT>>, |
||||
unvalidated: Partial<Record<keyof ReferencedTypes<TypesT>, string>> |
||||
}> { |
||||
const path = this.getAutosaveFilePath(autosaveNamespace) |
||||
let handle: FileHandle |
||||
try { |
||||
handle = await open(path, "r") |
||||
} catch (e) { |
||||
if (typeof e === "object" && e.hasOwnProperty("code") && typeof e.code === "string" && e.code === "ENOENT") { |
||||
// file does not exist :(
|
||||
return {validated: {}, unvalidated: {}} // nothing was autosaved
|
||||
} |
||||
throw e |
||||
} |
||||
try { |
||||
const fileYaml = await handle.readFile({encoding: "utf-8"}) |
||||
const autosaved = load(fileYaml) |
||||
if (!AutosaveFileJTD.validate(autosaved)) { |
||||
throw Error(`Autosave file ${autosaveNamespace} was too corrupted to make sense of`) |
||||
} |
||||
const validated: Partial<ReferencedTypes<TypesT>> = {} |
||||
const unvalidated: Partial<Record<keyof ReferencedTypes<TypesT>, string>> = {} |
||||
schemas.forEach((schema: ReferencedSchema<TypesT>) => { |
||||
if (autosaved.hasOwnProperty(schema.key)) { |
||||
const value = autosaved[schema.key] |
||||
if (!schema.validate(value)) { |
||||
unvalidated[schema.key as keyof ReferencedTypes<TypesT>] = value |
||||
} else { |
||||
validated[schema.key as keyof ReferencedTypes<TypesT>] = value as Value<ReferencedSchema<TypesT>> |
||||
} |
||||
} |
||||
}) |
||||
return {validated, unvalidated} |
||||
} finally { |
||||
await handle.close() |
||||
} |
||||
} |
||||
|
||||
async saveAutosaveObject<T>({schema, value, autosaveNamespace}: {autosaveNamespace: string, schema: SchemaData<T, string, AnyReferenceList, ReferencedTypes<AnyReferenceList>>, value: T}): Promise<void> { |
||||
const path = this.getAutosaveFilePath(autosaveNamespace) |
||||
await makeDir(dirname(path)) |
||||
const handle = await open(path, "a+") |
||||
try { |
||||
const fileYaml = await handle.readFile({encoding: "utf-8"}) |
||||
const original = load(fileYaml) |
||||
if (!AutosaveFileJTD.validate(original)) { |
||||
throw Error(`Autosave file ${autosaveNamespace} was corrupted`) |
||||
} |
||||
original[schema.key] = value |
||||
await handle.truncate(0) |
||||
await handle.writeFile(dump(original), {encoding: "utf-8"}) |
||||
} finally { |
||||
await handle.close() |
||||
} |
||||
} |
||||
|
||||
async clearAutosaveObject({schema, autosaveNamespace}: {autosaveNamespace: string, schema: SchemaData<unknown, string, AnyReferenceList, ReferencedTypes<AnyReferenceList>>}): Promise<void> { |
||||
const path = this.getAutosaveFilePath(autosaveNamespace) |
||||
await makeDir(dirname(path)) |
||||
const handle = await open(path, "a+") |
||||
try { |
||||
const fileYaml = await handle.readFile({encoding: "utf-8"}) |
||||
const original = load(fileYaml) |
||||
if (!AutosaveFileJTD.validate(original)) { |
||||
throw Error(`Autosave file ${autosaveNamespace} was corrupted`) |
||||
} |
||||
delete original[schema.key] |
||||
await handle.truncate(0) |
||||
await handle.writeFile(dump(original), {encoding: "utf-8"}) |
||||
} finally { |
||||
await handle.close() |
||||
} |
||||
} |
||||
|
||||
async clearAutosaveObjects(autosaveNamespace: string): Promise<void> { |
||||
const path = this.getAutosaveFilePath(autosaveNamespace) |
||||
await rm(path, { |
||||
force: true |
||||
}) |
||||
} |
||||
} |
@ -0,0 +1,63 @@ |
||||
|
||||
import AJV, {ValidateFunction, JTDSchemaType} from "ajv/dist/jtd.js"; |
||||
|
||||
const ajv = new AJV(); |
||||
|
||||
interface BaseSchemaData<RepresentedT, ReferenceT extends string> { |
||||
value?: RepresentedT, |
||||
schema: unknown, |
||||
key: ReferenceT, |
||||
definition: { [key in ReferenceT]: unknown } |
||||
reference: { "ref": ReferenceT } |
||||
validate: ValidateFunction<RepresentedT> |
||||
requiredReferences: AnyReferenceList |
||||
} |
||||
|
||||
export interface SchemaData<RepresentedT, ReferenceT extends string, ReferencesT extends AnyReferenceList, DefinitionsT extends ReferencedTypes<ReferencesT>> extends BaseSchemaData<RepresentedT, ReferenceT> { |
||||
value?: RepresentedT, |
||||
schema: JTDSchemaType<RepresentedT, DefinitionsT>, |
||||
key: ReferenceT, |
||||
definition: { [key in ReferenceT]: JTDSchemaType<RepresentedT, DefinitionsT> } |
||||
reference: { "ref": ReferenceT }, |
||||
validate: ValidateFunction<RepresentedT> |
||||
requiredReferences: ReferencesT |
||||
} |
||||
|
||||
export type AnySchemaDataFor<RepresentedT> = BaseSchemaData<RepresentedT, string> |
||||
export type AnySchemaData = AnySchemaDataFor<unknown> |
||||
export type AnyReferenceList = AnySchemaData[] |
||||
export type AnyDefinitions = Record<string, unknown> |
||||
|
||||
export type Schema<DataT extends AnySchemaData> = Exclude<DataT["schema"], undefined> |
||||
export type Value<DataT extends AnySchemaData> = Exclude<DataT["value"], undefined> |
||||
export type Definition<DataT extends AnySchemaData> = Exclude<DataT["definition"], undefined> |
||||
export type Reference<DataT extends AnySchemaData> = Exclude<DataT["reference"], undefined> |
||||
export type ReferenceKey<DataT extends AnySchemaData> = Exclude<DataT["key"], undefined> |
||||
|
||||
export type ReferencedSchemaMap<ReferencesT extends AnyReferenceList> = { |
||||
[Property in keyof ReferencesT as ReferencesT[Property] extends AnySchemaData ? ReferenceKey<ReferencesT[Property]> : never]: ReferencesT[Property] extends AnySchemaData ? ReferencesT[Property] : never |
||||
} |
||||
export type ReferencedSchema<ReferencesT extends AnyReferenceList> = ReferencedSchemaMap<ReferencesT>[keyof ReferencedSchemaMap<ReferencesT>] |
||||
export type ReferencedTypes<ReferencesT extends AnyReferenceList> = { |
||||
[Property in keyof ReferencedSchemaMap<ReferencesT>]?: Value<ReferencedSchemaMap<ReferencesT>[Property]> |
||||
} |
||||
|
||||
// TODO: Add a test for a circular definition (i.e., an object that contains a reference to itself, for recursive objects like trees) in both one- and two-object loops
|
||||
export function schema< |
||||
RepresentedT, |
||||
KeyT extends string, |
||||
ReferencesT extends AnySchemaData[], |
||||
>({schema, key, references}: { schema: JTDSchemaType<RepresentedT, ReferencedTypes<ReferencesT>>, key: KeyT, references: ReferencesT, typeHint?: RepresentedT|null }): SchemaData<RepresentedT, KeyT, ReferencesT, ReferencedTypes<ReferencesT>> { |
||||
const definition = {[key]: schema} as Definition<SchemaData<RepresentedT, KeyT, ReferencesT, ReferencedTypes<ReferencesT>>> |
||||
const reference = {ref: key} |
||||
const definitions = references.reduce<AnyDefinitions>((definitions, reference) => ({...reference.definition, ...definitions}), definition) |
||||
const validate = ajv.compile<RepresentedT>({ ...reference, definitions }) |
||||
return { |
||||
schema, |
||||
key, |
||||
definition, |
||||
reference, |
||||
validate, |
||||
requiredReferences: references |
||||
} |
||||
} |
@ -0,0 +1,236 @@ |
||||
import {DateTime} from "luxon"; |
||||
|
||||
export interface Serializer<DeserializedT, SerializedT> { |
||||
checkType(data: unknown): data is SerializedT |
||||
hydrate(data: SerializedT): DeserializedT |
||||
dehydrate(data: DeserializedT): SerializedT |
||||
} |
||||
|
||||
export interface LocatedError { |
||||
readonly location: string |
||||
readonly error: Error|LocatedError |
||||
} |
||||
|
||||
export function isLocatedError(object: unknown): object is LocatedError { |
||||
return typeof object === "object" && object !== null && object.hasOwnProperty("location") && object.hasOwnProperty("error") |
||||
} |
||||
|
||||
export function wrapErrorWithLocation(location: string, object: unknown): LocatedError { |
||||
if (typeof object === "string") { |
||||
return { |
||||
location, |
||||
error: Error(object) |
||||
} |
||||
} else if (object instanceof Error || isLocatedError(object)) { |
||||
return { |
||||
location, |
||||
error: object |
||||
} |
||||
} else { |
||||
return { |
||||
location, |
||||
error: Error(`Unknown error: ${object}`) |
||||
} |
||||
} |
||||
} |
||||
|
||||
export class SelfSerializer<T> implements Serializer<T, T> { |
||||
readonly checkType: (data: unknown) => data is T; |
||||
|
||||
constructor(checkType: (data: unknown) => data is T) { |
||||
this.checkType = checkType |
||||
} |
||||
|
||||
dehydrate(data: T): T { |
||||
return data; |
||||
} |
||||
|
||||
hydrate(data: T): T { |
||||
return data; |
||||
} |
||||
} |
||||
|
||||
export const StringSerializer = new SelfSerializer((data): data is string => typeof data === "string") |
||||
export const DateTimeSerializer: Serializer<DateTime, string> = { |
||||
checkType(data: unknown): data is string { |
||||
return typeof data === "string" |
||||
}, |
||||
dehydrate(data: DateTime): string { |
||||
return data.toISO(); |
||||
}, |
||||
hydrate(data: string): DateTime { |
||||
return DateTime.fromISO(data); |
||||
} |
||||
} |
||||
|
||||
export class EnumSerializer<T extends string|number> implements Serializer<T, T> { |
||||
readonly values: readonly T[] |
||||
|
||||
constructor(values: readonly T[]) { |
||||
this.values = values |
||||
} |
||||
|
||||
checkType(data: unknown): data is T { |
||||
if (!(this.values as unknown[]).includes(data)) { |
||||
throw Error("not a valid value for the enum") |
||||
} |
||||
return true |
||||
} |
||||
|
||||
dehydrate(data: T): T { |
||||
return data; |
||||
} |
||||
|
||||
hydrate(data: T): T { |
||||
return data; |
||||
} |
||||
} |
||||
|
||||
export class NullableSerializer<DeserializedT, SerializedT> implements Serializer<DeserializedT | null, SerializedT | null> { |
||||
readonly inner: Serializer<DeserializedT, SerializedT> |
||||
constructor(inner: Serializer<DeserializedT, SerializedT>) { |
||||
this.inner = inner |
||||
} |
||||
|
||||
checkType(data: unknown): data is SerializedT | null { |
||||
return data === null || this.inner.checkType(data) |
||||
} |
||||
|
||||
dehydrate(data: DeserializedT | null): SerializedT | null { |
||||
if (data === null) { |
||||
return null |
||||
} |
||||
return this.inner.dehydrate(data); |
||||
} |
||||
|
||||
hydrate(data: SerializedT | null): DeserializedT | null { |
||||
if (data === null) { |
||||
return null |
||||
} |
||||
return this.inner.hydrate(data); |
||||
} |
||||
} |
||||
|
||||
export class OptionalSerializer<DeserializedT, SerializedT> implements Serializer<DeserializedT | undefined, SerializedT | undefined> { |
||||
readonly inner: Serializer<DeserializedT, SerializedT> |
||||
constructor(inner: Serializer<DeserializedT, SerializedT>) { |
||||
this.inner = inner |
||||
} |
||||
|
||||
checkType(data: unknown): data is SerializedT | undefined { |
||||
return typeof data === "undefined" || this.inner.checkType(data) |
||||
} |
||||
|
||||
dehydrate(data: DeserializedT | undefined): SerializedT | undefined { |
||||
if (typeof data === "undefined") { |
||||
return data |
||||
} |
||||
return this.inner.dehydrate(data); |
||||
} |
||||
|
||||
hydrate(data: SerializedT | undefined): DeserializedT | undefined { |
||||
if (typeof data === "undefined") { |
||||
return data |
||||
} |
||||
return this.inner.hydrate(data); |
||||
} |
||||
} |
||||
export const OptionalStringSerializer = new OptionalSerializer(StringSerializer) |
||||
export const OptionalDateTimeSerializer = new OptionalSerializer(DateTimeSerializer) |
||||
|
||||
export type KeySerializers<DeserializedT extends {[key in KeysT]?: unknown}, SerializedT extends {[key in KeysT]?: unknown}, KeysT extends string|number> = { |
||||
[key in KeysT]: Serializer<DeserializedT[key], SerializedT[key]> |
||||
} |
||||
|
||||
export class ObjectSerializer<DeserializedT extends {[key in KeysT]?: unknown}, SerializedT extends {[key in KeysT]?: unknown}, KeysT extends string|number> implements Serializer<DeserializedT, SerializedT> { |
||||
readonly inner: KeySerializers<DeserializedT, SerializedT, KeysT> |
||||
readonly keys: KeysT[] |
||||
constructor(inner: KeySerializers<DeserializedT, SerializedT, KeysT>, keys: KeysT[]) { |
||||
this.inner = inner |
||||
this.keys = keys |
||||
} |
||||
|
||||
checkType(data: unknown): data is SerializedT { |
||||
if (typeof data !== "object" || data === null) { |
||||
throw Error("expected an object") |
||||
} |
||||
const map: {[key in KeysT]?: unknown} = data |
||||
for (const key of this.keys) { |
||||
try { |
||||
if (!this.inner[key].checkType(map[key])) { |
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw Error("checkType returned false") |
||||
} |
||||
} catch (e) { |
||||
throw wrapErrorWithLocation(`at key ${key}`, e) |
||||
} |
||||
} |
||||
return true |
||||
} |
||||
|
||||
dehydrate(data: DeserializedT): SerializedT { |
||||
const result: Partial<SerializedT> = {} |
||||
for (const key of this.keys) { |
||||
try { |
||||
result[key] = this.inner[key].dehydrate(data[key]) |
||||
} catch (e) { |
||||
throw wrapErrorWithLocation(`at key ${key}`, e) |
||||
} |
||||
} |
||||
return result as SerializedT |
||||
} |
||||
|
||||
hydrate(data: SerializedT): DeserializedT { |
||||
const result: Partial<DeserializedT> = {} |
||||
for (const key of this.keys) { |
||||
try { |
||||
result[key] = this.inner[key].hydrate(data[key]) |
||||
} catch (e) { |
||||
throw wrapErrorWithLocation(`at key ${key}`, e) |
||||
} |
||||
} |
||||
return result as DeserializedT |
||||
} |
||||
} |
||||
|
||||
export class ArraySerializer<DeserializedT, SerializedT> implements Serializer<DeserializedT[], SerializedT[]> { |
||||
readonly inner: Serializer<DeserializedT, SerializedT> |
||||
constructor(inner: Serializer<DeserializedT, SerializedT>) { |
||||
this.inner = inner |
||||
} |
||||
|
||||
checkType(data: unknown): data is SerializedT[] { |
||||
return Array.isArray(data) && data.every((value, index) => { |
||||
try { |
||||
if (!this.inner.checkType(value)) { |
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw Error("checkType returned false") |
||||
} |
||||
} catch (e) { |
||||
throw wrapErrorWithLocation(`at index ${index}`, e) |
||||
} |
||||
}) |
||||
} |
||||
|
||||
dehydrate(data: DeserializedT[]): SerializedT[] { |
||||
return data.map((value, index) => { |
||||
try { |
||||
return this.inner.dehydrate(value) |
||||
} catch (e) { |
||||
throw wrapErrorWithLocation(`at index ${index}`, e) |
||||
} |
||||
}); |
||||
} |
||||
|
||||
hydrate(data: SerializedT[]): DeserializedT[] { |
||||
return data.map((value, index) => { |
||||
try { |
||||
return this.inner.hydrate(value) |
||||
} catch (e) { |
||||
throw wrapErrorWithLocation(`at index ${index}`, e) |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
export const ArrayStringSerializer = new ArraySerializer(StringSerializer) |
||||
export const OptionalArrayStringSerializer = new OptionalSerializer(ArrayStringSerializer) |
@ -0,0 +1,77 @@ |
||||
import {AnySchemaDataFor} from "./SchemaData.js"; |
||||
import {dump, load} from "js-yaml"; |
||||
import {InquireFunction, ShowFunction} from "../prompts/Inquire.js"; |
||||
import {EditorQuestion, ExpandQuestion} from "inquirer"; |
||||
|
||||
const RETRY = "Retry" |
||||
const ABORT = "Abort" |
||||
|
||||
export interface YamlPromptOptions<ObjectT> { |
||||
schema: AnySchemaDataFor<ObjectT> |
||||
currentValue: ObjectT|undefined |
||||
name: string |
||||
} |
||||
|
||||
export interface YamlPromptSetOptions<ObjectT> extends YamlPromptOptions<ObjectT> { |
||||
currentValue: ObjectT |
||||
} |
||||
|
||||
export interface YamlPromptDependencies { |
||||
inquire: InquireFunction<EditorQuestion, string> & InquireFunction<ExpandQuestion, typeof RETRY|typeof ABORT> |
||||
showError: ShowFunction |
||||
} |
||||
|
||||
export type YamlPromptFunction = (<ObjectT>(opts: YamlPromptSetOptions<ObjectT>) => Promise<ObjectT>) & (<ObjectT>(opts: YamlPromptOptions<ObjectT>) => Promise<ObjectT|undefined>) |
||||
|
||||
export function yamlPrompt(deps: YamlPromptDependencies): YamlPromptFunction { |
||||
function injectedYamlPrompt<ObjectT>(opts: YamlPromptSetOptions<ObjectT>): Promise<ObjectT> |
||||
function injectedYamlPrompt<ObjectT>(opts: YamlPromptOptions<ObjectT>): Promise<ObjectT|undefined> |
||||
function injectedYamlPrompt<ObjectT>(opts: YamlPromptOptions<ObjectT>): Promise<ObjectT|undefined> { |
||||
return promptForYaml<ObjectT>(opts, deps) |
||||
} |
||||
return injectedYamlPrompt |
||||
} |
||||
|
||||
export async function promptForYaml<ObjectT>({schema, currentValue, name}: YamlPromptSetOptions<ObjectT>, {inquire, showError}: YamlPromptDependencies): Promise<ObjectT> |
||||
export async function promptForYaml<ObjectT>({schema, currentValue, name}: YamlPromptOptions<ObjectT>, {inquire, showError}: YamlPromptDependencies): Promise<ObjectT|undefined> |
||||
export async function promptForYaml<ObjectT>({schema, currentValue, name}: YamlPromptOptions<ObjectT>, {inquire, showError}: YamlPromptDependencies): Promise<ObjectT|undefined> { |
||||
const original = dump(currentValue) |
||||
let text = original |
||||
while (true) { |
||||
const modified = await inquire({ |
||||
type: "editor", |
||||
message: `View and edit ${name}:`, |
||||
default: text, |
||||
}) |
||||
if (original !== modified) { |
||||
try { |
||||
const result = load(modified) |
||||
if (schema.validate(result)) { |
||||
return result |
||||
} else { |
||||
await showError(schema.validate.errors?.map((e) => e.toString()).join("; ") ?? "Failed validation") |
||||
} |
||||
} catch (e) { |
||||
await showError(e.toString()) |
||||
} |
||||
const option = await inquire({ |
||||
type: "expand", |
||||
message: "Choose how to proceed:", |
||||
choices: [ |
||||
{key: "r", name: "Retry editing current text", value: RETRY}, |
||||
{key: "a", name: "Abort editing and reset to original state", value: ABORT} |
||||
], |
||||
}) |
||||
switch (option) { |
||||
default: |
||||
case RETRY: |
||||
text = modified |
||||
break |
||||
case ABORT: |
||||
return currentValue |
||||
} |
||||
} else { |
||||
return currentValue |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,3 @@ |
||||
export function isPopulatedArray<T>(array: readonly T[]|null|undefined): array is [T, ...T[]] { |
||||
return array !== null && array !== undefined && array.length > 0 |
||||
} |
@ -0,0 +1,13 @@ |
||||
export function merge<Update extends {readonly [key: string]: unknown}, Data extends Update>(update: Update, base: Data): Data { |
||||
const data = { |
||||
...update, |
||||
...base, |
||||
} |
||||
// Purge keys that were intentionally removed...
|
||||
Object.keys(update).forEach((key: keyof typeof update) => { |
||||
if (typeof update[key] === "undefined") { |
||||
delete data[key] |
||||
} |
||||
}) |
||||
return data |
||||
} |
@ -0,0 +1,7 @@ |
||||
export function asDefault<InputT>(input: InputT): { default: InputT } { |
||||
return {default: input} |
||||
} |
||||
|
||||
export function identity<InputT>(input: InputT): InputT { |
||||
return input |
||||
} |
@ -1,17 +1,19 @@ |
||||
{ |
||||
"compilerOptions": { |
||||
"module": "commonjs", |
||||
"module": "es6", |
||||
"esModuleInterop": true, |
||||
"allowSyntheticDefaultImports": true, |
||||
"target": "es6", |
||||
"lib": ["esnext"], |
||||
"noImplicitAny": true, |
||||
"moduleResolution": "node", |
||||
"sourceMap": true, |
||||
"outDir": "dist", |
||||
"baseUrl": ".", |
||||
"paths": { |
||||
"*": ["node_modules/*", "src/types/*"] |
||||
} |
||||
"*": ["node_modules/*", "types/*"] |
||||
}, |
||||
"strictNullChecks": true |
||||
}, |
||||
"include": ["src/**/*"] |
||||
} |
||||
|
@ -0,0 +1,4 @@ |
||||
declare module "wordcount" { |
||||
function wordcount(input: string): number |
||||
export = wordcount |
||||
} |
Loading…
Reference in new issue