Version 1.0 of the terminal journaling software

main
Mari 3 years ago
parent e80859b35e
commit 68593b1cf0
  1. 7
      .run/add-entry.run.xml
  2. 12
      .run/build.run.xml
  3. 7
      .run/update-empathy-guide.run.xml
  4. 11539
      package-lock.json
  5. 34
      package.json
  6. 91
      src/commands/AddEntry.ts
  7. 37
      src/commands/UpdateEmpathyGuide.ts
  8. 22
      src/datatypes/Condition.ts
  9. 20
      src/datatypes/EmpathyGroup.ts
  10. 6
      src/datatypes/EmpathyGroupList.ts
  11. 35
      src/datatypes/EmpathyGuide.ts
  12. 40
      src/datatypes/Entry.ts
  13. 52
      src/datatypes/GuidedEmpathy.ts
  14. 64
      src/datatypes/Journal.ts
  15. 82
      src/datatypes/Persona.ts
  16. 23
      src/datatypes/Suicidality.ts
  17. 16
      src/index.ts
  18. 9
      src/prompts/Inquire.ts
  19. 69
      src/prompts/implementations/ConditionPrompt.ts
  20. 66
      src/prompts/implementations/EmpathyGroupListPrompt.ts
  21. 81
      src/prompts/implementations/EmpathyGroupPrompt.ts
  22. 120
      src/prompts/implementations/EmpathyGuidePrompt.ts
  23. 112
      src/prompts/implementations/EntryMainMenuPrompt.ts
  24. 34
      src/prompts/implementations/EntryPrompt.ts
  25. 106
      src/prompts/implementations/GuidedEmpathyListPrompt.ts
  26. 95
      src/prompts/implementations/GuidedEmpathyPrompt.ts
  27. 43
      src/prompts/implementations/JournalEntryPrompt.ts
  28. 56
      src/prompts/implementations/SuicidalityPrompt.ts
  29. 42
      src/prompts/implementations/SummaryPrompt.ts
  30. 672
      src/prompts/types/HierarchicalCheckboxInput.ts
  31. 218
      src/prompts/types/MultiTextInput.ts
  32. 8
      src/prompts/types/index.ts
  33. 205
      src/repository/LocalRepository.ts
  34. 54
      src/schemata/SchemaData.ts
  35. 51
      src/schemata/YAMLPrompt.ts
  36. 3
      src/utils/Arrays.ts
  37. 13
      src/utils/Merge.ts
  38. 7
      src/utils/Objects.ts
  39. 6
      tsconfig.json
  40. 4
      types/wordcount/index.d.ts

@ -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>

11539
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,12 +1,16 @@
{
"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",
"start": "npm run app",
"app": "node dist/index.js",
"update-empathy-guide": "npm run app -- update-empathy-guide",
"add-entry": "npm run app -- add-entry",
"build": "npm run build-ts",
"serve": "node dist/index.js",
"prestart": "npm run build",
"debug": "npm run start --inspect",
"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",
@ -16,15 +20,33 @@
"author": "",
"license": "ISC",
"dependencies": {
"typescript": "^4.0.3"
"ajv": "^8.6.2",
"capitalize": "^2.0.3",
"chalk": "^2.4.2",
"env-paths": "^2.2.1",
"figures": "^3.2.0",
"fuzzy": "^0.1.3",
"inquirer": "^8.1.2",
"js-yaml": "^4.1.0",
"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/node-fetch": "^2.5.4",
"@types/pluralize": "^0.0.29",
"@types/yargs": "^17.0.0",
"concurrently": "^5.0.0",
"nodemon": "^2.0.4"
"nodemon": "^2.0.4",
"typescript": "^4.0.3"
}
}

@ -0,0 +1,91 @@
import {Separator} from "inquirer";
import {inquire} from "../prompts/Inquire";
import {summaryPrompt} from "../prompts/implementations/SummaryPrompt";
import {journalEntryPrompt} from "../prompts/implementations/JournalEntryPrompt";
import {guidedEmpathyListPrompt} from "../prompts/implementations/GuidedEmpathyListPrompt";
import {entryMainMenuPrompt} from "../prompts/implementations/EntryMainMenuPrompt";
import {CommandModule} from "yargs";
import { registerPrompts } from "../prompts/types";
import {LocalRepository} from "../repository/LocalRepository";
import {conditionPrompt} from "../prompts/implementations/ConditionPrompt";
import {entryPrompt} from "../prompts/implementations/EntryPrompt";
import {guidedEmpathyPrompt} from "../prompts/implementations/GuidedEmpathyPrompt";
import {suicidalityPrompt} from "../prompts/implementations/SuicidalityPrompt";
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 empathy = guidedEmpathyPrompt({
inquire,
guideFactory: () => empathyGuide,
show: async (value) => console.log(value),
})
const empathyList = guidedEmpathyListPrompt({inquire, promptForEmpathy: empathy})
const mainMenuItems = [
condition.mainMenu,
summary.mainMenu,
new Separator(),
journal.mainMenu,
empathyList.mainMenu,
/*
{
name: typeof entry.needs === "object" ? "Check back in on needs" : "Check in on needs",
value: NEEDS,
key: "n"
},
{
name: typeof entry.personas === "object" ? "Check back in on personas" : "Check in on personas",
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",
value: ACTIVITIES,
key: "a"
},
{
name: typeof entry.music === "object" ? "Change record of recently played music" : "Add record of recently played music",
value: MUSIC,
key: "m"
},
*/
suicidality.mainMenu,
/*
{
name: typeof entry.recoveries === "object" ? "Try more recovery methods" : "Try some recovery methods",
value: RECOVERIES,
key: "y"
},
*/
]
const mainMenu = entryMainMenuPrompt({
inquire,
choices: mainMenuItems,
showError: async (value) => console.log(value),
})
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,37 @@
import {CommandModule} from "yargs";
import {inquire} from "../prompts/Inquire";
import {registerPrompts} from "../prompts/types";
import {LocalRepository} from "../repository/LocalRepository";
import {empathyGroupPrompt} from "../prompts/implementations/EmpathyGroupPrompt";
import {empathyGroupListPrompt} from "../prompts/implementations/EmpathyGroupListPrompt";
import {empathyGuidePrompt} from "../prompts/implementations/EmpathyGuidePrompt";
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 empathyGroup = empathyGroupPrompt({
inquire,
showError: async (text) => console.error(text),
})
const empathyList = empathyGroupListPrompt({
inquire,
promptForEmpathyGroup: empathyGroup
})
const newGuide = await empathyGuidePrompt({
inquire,
showError: async (text) => console.error(text),
promptForEmpathyGroupList: empathyList,
})({default: empathyGuide})
await storage.saveEmpathyGuide(newGuide)
console.log("Empathy guide saved!")
}
}
}

@ -0,0 +1,22 @@
import {schema} from "../schemata/SchemaData";
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 type ConditionJTD = typeof ConditionJTD
export const ConditionJTD = schema({
schema: {
enum: CONDITIONS
},
key: "condition",
references: [],
})

@ -0,0 +1,20 @@
import {schema} from "../schemata/SchemaData";
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";
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";
import {EmpathyGroup, EmpathyGroupJTD} from "./EmpathyGroup";
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,40 @@
import {Condition, ConditionJTD} from "./Condition";
import {GuidedEmpathy, GuidedEmpathyJTD} from "./GuidedEmpathy";
import {Suicidality, SuicidalityJTD} from "./Suicidality";
import {schema} from "../schemata/SchemaData";
export interface Entry {
readonly startedAt: Date
readonly finishedAt: Date
readonly condition: Condition
readonly summary?: string
readonly journalEntry?: string
readonly guidedEmpathy?: readonly GuidedEmpathy[]
// 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 type EntryJTD = typeof EntryJTD
export const EntryJTD = schema({
schema: {
properties: {
startedAt: { type: "timestamp" },
finishedAt: { type: "timestamp" },
condition: ConditionJTD.reference,
},
optionalProperties: {
summary: { type: "string" },
journalEntry: { type: "string" },
guidedEmpathy: { elements: GuidedEmpathyJTD.reference },
suicidality: SuicidalityJTD.reference,
}
},
typeHint: null as Entry|null,
key: "entry",
references: [ConditionJTD, GuidedEmpathyJTD, SuicidalityJTD]
})

@ -0,0 +1,52 @@
import {isPopulatedArray} from "../utils/Arrays";
import {
Persona,
personaName,
personaPronoun,
personaPronounObject,
personaPronounPossessive,
personaVerb
} from "./Persona";
import chalk from "chalk";
import {schema} from "../schemata/SchemaData";
import capitalize from "capitalize";
export interface GuidedEmpathy {
readonly feelings?: readonly string[]
readonly needs?: readonly string[]
readonly events?: readonly string[]
readonly requests?: readonly string[]
}
export type GuidedEmpathyJTD = typeof GuidedEmpathyJTD
export const GuidedEmpathyJTD = schema({
schema: {
optionalProperties: {
feelings: { elements: { type: "string" } },
needs: { elements: { type: "string" } },
events: { elements: { type: "string" } },
requests: { elements: { type: "string" } },
}
},
typeHint: null as GuidedEmpathy|null,
key: "guidedEmpathy",
references: [],
})
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";
import {schema} from "../schemata/SchemaData";
/* 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";
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,23 @@
import {schema} from "../schemata/SchemaData";
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 type SuicidalityJTD = typeof SuicidalityJTD
export const SuicidalityJTD = schema({
schema: {
enum: SUICIDALITIES
},
key: "suicidality",
references: [],
})

@ -1,5 +1,13 @@
function init(firstName: string, lastName: string) {
return `${firstName} ${lastName}`;
}
import {addEntryCommand} from "./commands/AddEntry";
import {updateEmpathyGuideCommand} from "./commands/UpdateEmpathyGuide";
import yargs from "yargs"
console.log(init('Hello', 'world'));
yargs
.scriptName("mari-status-bar")
.command(addEntryCommand())
.command(updateEmpathyGuideCommand())
.demandCommand()
.parseAsync()
.catch((err) => {
console.error(err)
})

@ -0,0 +1,9 @@
import {prompt, QuestionCollection} from "inquirer";
export type InquireFunction = (question: Omit<QuestionCollection, "name">) => Promise<any>
export type ShowFunction = (text: string) => Promise<void>
export async function inquire(question: Omit<QuestionCollection, "name">): Promise<any> {
const result = await prompt([{...question, name: "answer"}])
return result.answer
}

@ -0,0 +1,69 @@
import {ListQuestion, Separator} from "inquirer";
import {personaPossessive, PersonaPrompt} from "../../datatypes/Persona";
import chalk from "chalk";
import {EntryMainMenuChoice, makeEntryMainMenuChoice} from "./EntryMainMenuPrompt";
import {asDefault, identity} from "../../utils/Objects";
import {InquireFunction} from "../Inquire";
import {Condition} from "../../datatypes/Condition";
export interface ConditionPromptOptions extends Partial<Omit<ListQuestion, "name" | "type" | "choices">>, PersonaPrompt {
}
export interface ConditionPromptDependencies {
readonly inquire: InquireFunction
}
export function conditionPrompt(deps: ConditionPromptDependencies): {
(options: ConditionPromptOptions): Promise<Condition>,
mainMenu: EntryMainMenuChoice<"condition">,
} {
return makeEntryMainMenuChoice({
property: "condition",
name: (input) => `Change condition ${chalk.dim(`(currently ${chalk.greenBright(input)})`)}`,
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,66 @@
import {EmpathyGroup} from "../../datatypes/EmpathyGroup";
import {isPopulatedArray} from "../../utils/Arrays";
import chalk from "chalk";
import pluralize from "pluralize";
import {InquireFunction} from "../Inquire";
import {EmpathyGroupPromptOptions} from "./EmpathyGroupPrompt";
export interface EmpathyGroupListPromptOptions {
readonly default?: readonly EmpathyGroup[]
readonly listName: string
}
export interface EmpathyGroupListPromptDependencies {
readonly inquire: InquireFunction
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,81 @@
import capitalize from "capitalize";
import {editYaml} from "../../schemata/YAMLPrompt";
import chalk from "chalk";
import {InquireFunction, ShowFunction} from "../Inquire";
import {EmpathyGroup, EmpathyGroupJTD} from "../../datatypes/EmpathyGroup";
export interface EmpathyGroupPromptOptions {
default?: EmpathyGroup
listName: string
}
export interface EmpathyGroupPromptDependencies {
inquire: InquireFunction
showError: ShowFunction
}
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, showError} = 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 editYaml({
schema: EmpathyGroupJTD,
currentValue: opts.default,
name: `this ${listName} group`,
inquire,
showError,
}) || 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,120 @@
import {isPopulatedArray} from "../../utils/Arrays";
import chalk from "chalk";
import pluralize from "pluralize";
import {totalItems} from "../../datatypes/EmpathyGroupList";
import {Separator} from "inquirer";
import {editYaml} from "../../schemata/YAMLPrompt";
import {InquireFunction, ShowFunction} from "../Inquire";
import {EmpathyGroupListPromptOptions} from "./EmpathyGroupListPrompt";
import {EmpathyGroup} from "../../datatypes/EmpathyGroup";
import {EmpathyGuide, EmpathyGuideJTD} from "../../datatypes/EmpathyGuide";
export interface EmpathyGuidePromptOptions {
readonly default?: EmpathyGuide
}
export interface EmpathyGuidePromptDependencies {
readonly inquire: InquireFunction
readonly showError: ShowFunction
readonly promptForEmpathyGroupList: (opts: EmpathyGroupListPromptOptions) => Promise<readonly EmpathyGroup[]>
}
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, showError, promptForEmpathyGroupList} = 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 editYaml({
schema: EmpathyGuideJTD,
currentValue: value,
name: "the current empathy guide",
inquire,
showError
})
}, deps)
}
}

@ -0,0 +1,112 @@
import {InquireFunction, ShowFunction} from "../Inquire";
import {ExpandChoiceOptions, Separator, SeparatorOptions} from "inquirer";
import {merge} from "../../utils/Merge";
import {Entry, EntryJTD} from "../../datatypes/Entry";
import {editYaml} from "../../schemata/YAMLPrompt";
const VIEW = ".View"
const DONE = ".Done"
export interface EntryMainMenuChoice<PropertyT extends string & keyof EntryMainMenuOptions> {
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
readonly choices: readonly (EntryMainMenuChoice<any>|SeparatorOptions)[]
readonly showError: ShowFunction
}
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,
showError,
} = 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 editYaml({
schema: EntryJTD,
currentValue: {...entry, finishedAt: new Date()},
name: "the current entry",
inquire,
showError,
})
const withoutFinishedAt = {...updated, finishedAt: undefined}
return continueWith(withoutFinishedAt)
} else {
const selectedChoice = choices.find((choice) => choice.type === "mainMenu" && choice.property === result) as EntryMainMenuChoice<any>
return continueWith(await onSelected(selectedChoice, entry))
}
}

@ -0,0 +1,34 @@
import {ConditionPromptOptions} from "./ConditionPrompt";
import {Condition} from "../../datatypes/Condition";
import {SummaryPromptOptions} from "./SummaryPrompt";
import {EntryMainMenuOptions} from "./EntryMainMenuPrompt";
import {Entry} from "../../datatypes/Entry";
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";
import {guidedEmpathyToStringShort, GuidedEmpathy} from "../../datatypes/GuidedEmpathy";
import {InquireFunction} from "../Inquire";
import {EntryMainMenuChoice, makeEntryMainMenuChoice} from "./EntryMainMenuPrompt";
import chalk from "chalk";
import pluralize from "pluralize";
import {isPopulatedArray} from "../../utils/Arrays";
import {ListChoiceOptions} from "inquirer";
import {asDefault} from "../../utils/Objects";
import {GuidedEmpathyPromptOptions} from "./GuidedEmpathyPrompt";
export interface GuidedEmpathyListPromptOptions extends PersonaPrompt {
readonly default?: readonly GuidedEmpathy[]
}
export interface GuidedEmpathyListPromptDependencies {
readonly inquire: InquireFunction
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";
import {InquireFunction, ShowFunction} from "../Inquire";
import {EmpathyGuide} from "../../datatypes/EmpathyGuide";
import {isPopulatedArray} from "../../utils/Arrays";
import Separator from "inquirer/lib/objects/separator";
import {EmpathyGroup} from "../../datatypes/EmpathyGroup";
import {GuidedEmpathy, guidedEmpathyToString, isPopulatedGuidedEmpathy} from "../../datatypes/GuidedEmpathy";
import {HierarchicalCheckboxChildChoice, HierarchicalCheckboxParentChoice } from "inquirer";
export interface GuidedEmpathyPromptOptions extends PersonaPrompt {
readonly default?: GuidedEmpathy
}
export interface GuidedEmpathyPromptDependencies {
readonly inquire: InquireFunction,
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";
import {personaPossessive, PersonaPrompt} from "../../datatypes/Persona";
import {EntryMainMenuChoice, makeEntryMainMenuChoice} from "./EntryMainMenuPrompt";
import chalk from "chalk";
import pluralize from "pluralize";
import wordcount from "wordcount";
import {asDefault} from "../../utils/Objects";
export interface JournalEntryPromptOptions extends Partial<Omit<EditorQuestion, "name" | "type">>, PersonaPrompt {}
export interface JournalEntryPromptDependencies {
readonly inquire: InquireFunction
}
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,56 @@
import {ListQuestion} from "inquirer";
import {EntryMainMenuChoice, makeEntryMainMenuChoice} from "./EntryMainMenuPrompt";
import chalk from "chalk";
import {asDefault, identity} from "../../utils/Objects";
import {InquireFunction} from "../Inquire";
import {Suicidality} from "../../datatypes/Suicidality";
export interface SuicidalityPromptOptions extends Partial<Omit<ListQuestion, "name" | "type" | "choices">> {
}
export interface SuicidalityPromptDependencies {
inquire: InquireFunction
}
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";
import {personaName, personaPossessive, PersonaPrompt, personaVerb} from "../../datatypes/Persona";
import {EntryMainMenuChoice, makeEntryMainMenuChoice} from "./EntryMainMenuPrompt";
import chalk from "chalk";
import pluralize from "pluralize";
import wordcount from "wordcount";
import {asDefault} from "../../utils/Objects";
export interface SummaryPromptOptions extends Partial<Omit<InputQuestion, "name" | "type">>, PersonaPrompt {}
export interface SummaryPromptDependencies {
readonly inquire: InquireFunction
}
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,672 @@
import Prompt from "inquirer/lib/prompts/base";
import observe from "inquirer/lib/utils/events";
import {
Answers,
ChoiceOptions,
HierarchicalCheckboxChoice,
HierarchicalCheckboxQuestion,
Question,
Separator,
SeparatorOptions,
} from "inquirer";
import {Interface as ReadLineInterface} from "readline";
import {filter} from "rxjs/operators"
import chalk from "chalk";
import {Subject} from "rxjs";
import figures from "figures";
import Paginator from "inquirer/lib/utils/paginator";
import {isPopulatedArray} from "../../utils/Arrays";
import {filter as fuzzyFilter, FilterOptions} from "fuzzy";
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?: string[]
}
export interface QuestionMap {
["hierarchical-checkbox"]: HierarchicalCheckboxQuestion
}
}
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()
}
// @ts-ignore
_run(callback: HierarchicalCheckboxInput["done"], error: (err: Error) => void) {
const invalidChoices = Object.keys(this.invalidValues);
const invalidValues = this.value.filter((value) => !this.valueMap.hasOwnProperty(value));
if (isPopulatedArray(invalidChoices)) {
error(Error(`Duplicate values: ${invalidChoices.join()}`))
} else if (isPopulatedArray(invalidValues)) {
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: any) => void|null
private location: Breadcrumb
private activeIndex: number
private unlistedValues: string[]
private submitter: Subject<string[]> = new Subject<string[]>()
// @ts-ignore
private paginator: Paginator = new Paginator(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: any): 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()
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";
import observe from "inquirer/lib/utils/events";
import {Answers, MultiTextInputQuestion, Question} from "inquirer";
import {Interface as ReadLineInterface} from "readline";
import {filter} from "rxjs/operators"
import chalk from "chalk";
import {Subject} from "rxjs";
import figures from "figures";
import Paginator from "inquirer/lib/utils/paginator";
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?: string[]
}
export interface QuestionMap {
["multitext"]: HierarchicalCheckboxQuestion
}
}
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: any) => 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,8 @@
import {registerPrompt} from "inquirer";
import {MultiTextInput} from "./MultiTextInput";
import {HierarchicalCheckboxInput} from "./HierarchicalCheckboxInput";
export function registerPrompts() {
registerPrompt("multitext", MultiTextInput)
registerPrompt("hierarchical-checkbox", HierarchicalCheckboxInput)
}

@ -0,0 +1,205 @@
import {EmpathyGuide, EmpathyGuideJTD} from "../datatypes/EmpathyGuide";
import {Entry} from "../datatypes/Entry";
import {
ReferencedSchema,
ReferencedTypes,
schema,
SchemaData,
UntypedReferenceList
} from "../schemata/SchemaData";
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";
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 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()
}
}
async loadAutosaveObjects<TypesT extends UntypedReferenceList>(
{autosaveNamespace, schemas}: { autosaveNamespace: string, schemas: TypesT }): Promise<{
validated: Partial<ReferencedTypes<TypesT>>,
unvalidated: Partial<Record<keyof ReferencedTypes<TypesT>, unknown>>
}> {
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>, unknown>> = {}
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
}
}
})
return {validated, unvalidated}
} finally {
await handle.close()
}
}
async saveAutosaveObject<T>({schema, value, autosaveNamespace}: {autosaveNamespace: string, schema: SchemaData<T, any, any, any>, 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<any, any, any, any>}): 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,54 @@
import AJV, {ValidateFunction, JTDSchemaType} from "ajv/dist/jtd";
const ajv = new AJV();
export type SchemaData<RepresentedT, ReferenceT extends string, ReferencesT extends UntypedReferenceList, DefinitionsT extends ReferencedTypes<ReferencesT>> = {
value?: RepresentedT,
schema: JTDSchemaType<RepresentedT, DefinitionsT>,
key: ReferenceT,
definition: { [key in ReferenceT]: JTDSchemaType<RepresentedT, DefinitionsT> }
referenced?: { [key in ReferenceT]: RepresentedT }
reference: { "ref": ReferenceT },
validate: ValidateFunction<RepresentedT>
requiredReferences: ReferencesT
}
export type UntypedReferenceList = SchemaData<any, any, any, any>[]
export type Schema<DataT extends SchemaData<any, any, any, any>> = Exclude<DataT["schema"], undefined>
export type Value<DataT extends SchemaData<any, any, any, any>> = Exclude<DataT["value"], undefined>
export type Definition<DataT extends SchemaData<any, any, any, any>> = Exclude<DataT["definition"], undefined>
export type Reference<DataT extends SchemaData<any, any, any, any>> = Exclude<DataT["reference"], undefined>
export type ReferenceKey<DataT extends SchemaData<any, any, any, any>> = Exclude<DataT["key"], undefined>
export type Referenced<DataT extends SchemaData<any, any, any, any>> = Exclude<DataT["referenced"], undefined>
export type ReferencedSchemaMap<ReferencesT extends UntypedReferenceList> = {
[Property in keyof ReferencesT as ReferencesT[Property] extends SchemaData<any, any, any, any> ? ReferenceKey<ReferencesT[Property]> : never]: ReferencesT[Property] extends SchemaData<any, any, any, any> ? ReferencesT[Property] : never
}
export type ReferencedSchema<ReferencesT extends UntypedReferenceList> = ReferencedSchemaMap<ReferencesT>[keyof ReferencedSchemaMap<ReferencesT>]
export type ReferencedDefinitions<ReferencesT extends UntypedReferenceList> = {
[Property in keyof ReferencedSchemaMap<ReferencesT>]?: Definition<ReferencedSchemaMap<ReferencesT>[Property]>
}
export type ReferencedTypes<ReferencesT extends UntypedReferenceList> = {
[Property in keyof ReferencedSchemaMap<ReferencesT>]?: Value<ReferencedSchemaMap<ReferencesT>[Property]>
}
export function schema<
RepresentedT,
KeyT extends string,
ReferencesT extends SchemaData<any, any, any, ReferencedTypes<ReferencesT>>[],
>({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((definitions, reference) => ({...reference.definition, ...definitions}), {} as ReferencedDefinitions<ReferencesT>)
const validate = ajv.compile<RepresentedT>({ ...schema, definitions })
return {
schema,
key,
definition,
reference,
validate,
requiredReferences: references
}
}

@ -0,0 +1,51 @@
import {SchemaData} from "./SchemaData";
import {dump, load} from "js-yaml";
import {EditorQuestionOptions} from "inquirer";
import {InquireFunction, ShowFunction} from "../prompts/Inquire";
const RETRY = "Retry"
const ABORT = "Abort"
export async function editYaml<ObjectT>({schema, currentValue, name, inquire, showError}: {schema: SchemaData<ObjectT, any, any, any>, currentValue: ObjectT, name: string, inquire: InquireFunction, showError: ShowFunction}): Promise<ObjectT>
export async function editYaml<ObjectT>({schema, currentValue, name, inquire, showError}: {schema: SchemaData<ObjectT, any, any, any>, currentValue: ObjectT|undefined, name: string, inquire: InquireFunction, showError: ShowFunction}): Promise<ObjectT|undefined>
export async function editYaml<ObjectT>({schema, currentValue, name, inquire, showError}: {schema: SchemaData<ObjectT, any, any, any>, currentValue: ObjectT|undefined, name: string, inquire: InquireFunction, showError: ShowFunction}): 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,
} as EditorQuestionOptions)
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 edits with current text", value: RETRY},
{key: "a", name: "Abort editing and keep 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]: any}, 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
}

@ -4,14 +4,16 @@
"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