Compare commits

...

7 Commits

  1. 3
      .gitignore
  2. 5
      .gulp.json
  3. 7
      .run/add-entry.run.xml
  4. 12
      .run/build.run.xml
  5. 7
      .run/update-empathy-guide.run.xml
  6. 6
      gulpfile.cjs
  7. 15013
      package-lock.json
  8. 48
      package.json
  9. 117
      src/commands/AddEntry.ts
  10. 43
      src/commands/UpdateEmpathyGuide.ts
  11. 15
      src/datatypes/Condition.ts
  12. 20
      src/datatypes/EmpathyGroup.ts
  13. 6
      src/datatypes/EmpathyGroupList.ts
  14. 35
      src/datatypes/EmpathyGuide.ts
  15. 42
      src/datatypes/Entry.ts
  16. 51
      src/datatypes/GuidedEmpathy.ts
  17. 64
      src/datatypes/Journal.ts
  18. 82
      src/datatypes/Persona.ts
  19. 96
      src/datatypes/SleepRecord.ts
  20. 18
      src/datatypes/Suicidality.ts
  21. 18
      src/index.ts
  22. 13
      src/prompts/Inquire.ts
  23. 69
      src/prompts/implementations/ConditionPrompt.ts
  24. 67
      src/prompts/implementations/EmpathyGroupListPrompt.ts
  25. 80
      src/prompts/implementations/EmpathyGroupPrompt.ts
  26. 118
      src/prompts/implementations/EmpathyGuidePrompt.ts
  27. 115
      src/prompts/implementations/EntryMainMenuPrompt.ts
  28. 34
      src/prompts/implementations/EntryPrompt.ts
  29. 106
      src/prompts/implementations/GuidedEmpathyListPrompt.ts
  30. 95
      src/prompts/implementations/GuidedEmpathyPrompt.ts
  31. 43
      src/prompts/implementations/JournalEntryPrompt.ts
  32. 196
      src/prompts/implementations/SleepRecordPrompt.ts
  33. 56
      src/prompts/implementations/SuicidalityPrompt.ts
  34. 42
      src/prompts/implementations/SummaryPrompt.ts
  35. 386
      src/prompts/types/DateInput.ts
  36. 688
      src/prompts/types/HierarchicalCheckboxInput.ts
  37. 218
      src/prompts/types/MultiTextInput.ts
  38. 10
      src/prompts/types/index.ts
  39. 212
      src/repository/LocalRepository.ts
  40. 63
      src/schemata/SchemaData.ts
  41. 236
      src/schemata/Serialization.ts
  42. 77
      src/schemata/YAMLPrompt.ts
  43. 3
      src/utils/Arrays.ts
  44. 13
      src/utils/Merge.ts
  45. 7
      src/utils/Objects.ts
  46. 8
      tsconfig.json
  47. 4
      types/wordcount/index.d.ts

3
.gitignore vendored

@ -38,4 +38,5 @@ Thumbs.db
dist/**/*
# ignore yarn.lock
yarn.lock
yarn.lock
/release

@ -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"));
});

15013
package-lock.json generated

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…
Cancel
Save