diff --git a/cli.mjs b/cli.mjs index 82f8c900..a60227a2 100644 --- a/cli.mjs +++ b/cli.mjs @@ -1,8 +1,9 @@ import { checkbox, confirm, expand, input, select } from "@inquirer/prompts"; import fsExtra from "fs-extra"; -import { cert, initializeApp } from "firebase-admin/app"; // eslint-disable-line import/no-unresolved -import { getFirestore } from "firebase-admin/firestore"; // eslint-disable-line import/no-unresolved +import { cert, initializeApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { Command } from "commander"; /** -------------------- GLOBALS -------------------- */ @@ -17,16 +18,86 @@ let OUTPUT_ROOT; // The root in which data is saved const INVALID_ACTION_ERROR = new Error("Invalid action: " + ACTION); const INVALID_DEPLOYMENT_ERROR = new Error("Invalid deployment: " + DEPLOYMENT); +/** -------------------- COMMANDER -------------------- */ +const commander = new Command(); +// default: [download | delete | register ] not provided, run main() as usual continuing with prompting +commander.action(() => {}); + +// download: optional argument studyID and participantID skips relative prompts +commander + .command(`download`) + .argument(`[studyID]`) + .argument(`[participantID]`) + .description(`Download experiment data from Firebase provided study ID and participant ID`) + .action((studyID, participantID) => { + ACTION = "download"; + STUDY_ID = studyID; + PARTICIPANT_ID = participantID; + }); + +// delete: optional argument studyID and participantID skips relative prompts +commander + .command(`delete`) + .argument(`[studyID]`) + .argument(`[participantID]`) + .description(`Delete experiment data from Firebase provided study ID and participant ID`) + .action((studyID, participantID) => { + ACTION = "delete"; + STUDY_ID = studyID; + PARTICIPANT_ID = participantID; + }); + +// register: optional argument studyID and participantID skips relative prompts +commander + .command(`register`) + .argument(`[studyID]`) + .argument(`[participantID]`) + .description( + `Register new partipant under study provided a partipantID and studyID; new study will be created if not found` + ) + .action((studyID, participantID) => { + ACTION = "register"; + STUDY_ID = studyID; + PARTICIPANT_ID = participantID; + }); + /** -------------------- MAIN -------------------- */ -// TODO @brown-ccv #289: Pass CLI arguments with commander (especially for action) async function main() { - ACTION = await actionPrompt(); + commander.parse(); + // print message if download or delete provided, along with optional args provided + if (ACTION != undefined) { + console.log( + `${ACTION} data from Firebase ${STUDY_ID === undefined ? "" : `given study ID: ${STUDY_ID}`} ${PARTICIPANT_ID === undefined ? "" : `and participant ID: ${PARTICIPANT_ID}`}` + ); + } + + if (ACTION === undefined) { + ACTION = await actionPrompt(); + } DEPLOYMENT = await deploymentPrompt(); // TODO @brown-ccv #291: Enable downloading all study data at once - STUDY_ID = await studyIDPrompt(); + if (STUDY_ID === undefined) { + STUDY_ID = await studyIDPrompt(); + } else { + // when args directly passed in through CLI, check if study is valid + const studyCollection = await validateStudyFirebase(STUDY_ID); + if (!studyCollection && ACTION !== "register") { + console.error("Please enter a valid study from your Firestore database"); + process.exit(1); + } + } // TODO @brown-ccv #291: Enable downloading all participant data at once - PARTICIPANT_ID = await participantIDPrompt(); + if (PARTICIPANT_ID === undefined) { + PARTICIPANT_ID = await participantIDPrompt(); + } else { + // when args directly passed in through CLI, check if participant is valid + const participantCollection = await validateParticipantFirebase(PARTICIPANT_ID); + if (!participantCollection && ACTION !== "register") { + console.error(`Please enter a valid participant on the study "${STUDY_ID}"`); + process.exit(1); + } + } EXPERIMENT_IDS = await experimentIDPrompt(); switch (ACTION) { @@ -49,6 +120,15 @@ async function main() { throw INVALID_DEPLOYMENT_ERROR; } break; + case "register": + switch (DEPLOYMENT) { + case "firebase": + await registerDataFirebase(STUDY_ID, PARTICIPANT_ID); + break; + default: + throw INVALID_DEPLOYMENT_ERROR; + } + break; default: throw INVALID_ACTION_ERROR; } @@ -140,6 +220,24 @@ async function deleteDataFirebase() { } else console.log("Skipping deletion"); } +/** -------------------- REGISTER ACTION -------------------- */ + +/** Register new data, write to Firestore */ +async function registerDataFirebase(studyID, participantID) { + const confirmation = await confirmRegisterPrompt(studyID, participantID); + if (confirmation) { + try { + await addStudyAndParticipant(studyID, participantID); + } catch (error) { + console.error( + `Unable to register new participant with participantID ${participantID} under studyID ${studyID}: ` + + error + ); + } + } else console.log("Skipping registration"); + return true; +} + /** -------------------- PROMPTS -------------------- */ /** Prompt the user for the action they are trying to complete */ @@ -151,6 +249,10 @@ async function actionPrompt() { name: "Download data", value: "download", }, + { + name: "Register new participant under study", + value: "register", + }, { name: "Delete data", value: "delete", @@ -183,23 +285,20 @@ async function deploymentPrompt() { return response; } -/** Prompt the user to enter the ID of a study */ async function studyIDPrompt() { const invalidMessage = "Please enter a valid study from your Firestore database"; - const validateStudyFirebase = async (input) => { - // subcollection is programmatically generated, if it doesn't exist then input must not be a valid studyID - const studyIDCollections = await getStudyRef(input).listCollections(); - return studyIDCollections.find((c) => c.id === PARTICIPANTS_COL) ? true : invalidMessage; - }; - return await input({ message: "Select a study:", validate: async (input) => { if (!input) return invalidMessage; - + if (ACTION === "register") { + STUDY_ID = input; + return true; + } switch (DEPLOYMENT) { case "firebase": - return validateStudyFirebase(input); + const studyCollection = await validateStudyFirebase(input); + return !studyCollection ? invalidMessage : true; default: throw INVALID_DEPLOYMENT_ERROR; } @@ -207,25 +306,22 @@ async function studyIDPrompt() { }); } -/** Prompt the user to enter the ID of a participant on the STUDY_ID study */ async function participantIDPrompt() { const invalidMessage = `Please enter a valid participant on the study "${STUDY_ID}"`; - const validateParticipantFirebase = async (input) => { - // subcollection is programmatically generated, if it doesn't exist then input must not be a valid participantID - const studyIDCollections = await getParticipantRef(STUDY_ID, input).listCollections(); - return studyIDCollections.find((c) => c.id === DATA_COL) ? true : invalidMessage; - }; - return await input({ - message: "Select a participant:", + message: ACTION === "register" ? "Enter a new participant:" : "Select a participant:", validate: async (input) => { const invalid = "Please enter a valid participant from your Firestore database"; if (!input) return invalid; else if (input === "*") return true; - + if (ACTION === "register") { + PARTICIPANT_ID = input; + return true; + } switch (DEPLOYMENT) { case "firebase": - return validateParticipantFirebase(input); + const participantCollection = await validateParticipantFirebase(input); + return !participantCollection ? invalidMessage : true; default: throw INVALID_DEPLOYMENT_ERROR; } @@ -235,6 +331,11 @@ async function participantIDPrompt() { /** Prompt the user to select one or more experiments of the PARTICIPANT_ID on STUDY_ID */ async function experimentIDPrompt() { + // register: adding/checking for existing new studies will be done in function + if (ACTION === "register") { + return; + } + const dataSnapshot = await getDataRef(STUDY_ID, PARTICIPANT_ID).get(); // Sort experiment choices by most recent first @@ -278,6 +379,20 @@ async function confirmDeletionPrompt() { }); } +async function confirmRegisterPrompt(studyID, participantID) { + const currentParticipants = await getRegisteredParticipantArr(studyID); + const currentParticipantMessage = + currentParticipants.length === 0 + ? "Currently, there are no participants under this study\n" + : `Currently, the participants under this study include: \n${currentParticipants.join("\n")}\n`; + return confirm({ + message: + currentParticipantMessage + + `Continue? adding study with studyID: ${studyID} and participant ID: ${participantID}`, + default: false, + }); +} + /** * Prompts the user to confirm continuation of the CLI, including future conflicts * @param {string} outputFile @@ -312,9 +427,25 @@ async function confirmOverwritePrompt(file, overwriteAll) { return answer; } +/** -------------------- FIRESTORE VALIDATIONS -------------------- */ +/** helper to check if the given study (input) is in firestore */ +async function validateStudyFirebase(input) { + // subcollection is programmatically generated, if it doesn't exist then input must not be a valid studyID + const studyIDCollections = await getStudyRef(input).listCollections(); + return studyIDCollections.find((c) => c.id === PARTICIPANTS_COL); +} + +/** helper to check if the given participant (input) is in firestore under study */ +async function validateParticipantFirebase(input) { + // subcollection is programmatically generated, if it doesn't exist then input must not be a valid participantID + const studyIDCollections = await getParticipantRef(STUDY_ID, input).listCollections(); + return studyIDCollections.find((c) => c.id === DATA_COL); +} + /** -------------------- FIRESTORE HELPERS -------------------- */ const RESPONSES_COL = "participant_responses"; +const REG_STUDY_COL = "registered_studies"; const PARTICIPANTS_COL = "participants"; const DATA_COL = "data"; const TRIALS_COL = "trials"; @@ -333,3 +464,42 @@ const getDataRef = (studyID, participantID) => // Get a reference to a participant's specific experiment data document in Firestore const getExperimentRef = (studyID, participantID, experimentID) => getDataRef(studyID, participantID).doc(experimentID); + +// Get a reference to a registered study +const getRegisteredfStudyRef = (studyID) => FIRESTORE.collection(REG_STUDY_COL).doc(studyID); + +// Get current registered participant array under the StudyID +const getRegisteredParticipantArr = async (studyID) => { + const data = await getRegisteredfStudyRef(studyID).get(); + if (data["_fieldsProto"] !== undefined) { + // get array of registered participant under study + return data["_fieldsProto"]["registered_participants"]["arrayValue"]["values"] + .filter((item) => item.valueType === "stringValue") + .map((item) => item.stringValue); + } else { + // return empty array when no participant found + return []; + } +}; + +// Register new participantID under the provided studyID +const registerNewParticipant = async (studyID, participantID) => { + const currParticipants = await getRegisteredParticipantArr(studyID); + const newParticipantArray = [...currParticipants, participantID]; + const newData = { registered_participants: newParticipantArray }; + getRegisteredfStudyRef(studyID).update(newData); + console.log( + `Successfully added study and participant. Current participantIDs under study ${studyID}: \n${newParticipantArray.join("\n")}` + ); +}; + +// Add new participantID under studyID to Firestore under registered_studies, +// creates new study if studyID doesn't exist +const addStudyAndParticipant = async (studyID, participantID) => { + const data = await getRegisteredfStudyRef(studyID).get(); + if (data["_fieldsProto"] === undefined) { + // study not initiated yet + await getRegisteredfStudyRef(studyID).set({ registered_participants: [] }); + } + await registerNewParticipant(studyID, participantID); +}; diff --git a/package-lock.json b/package-lock.json index 1981eae6..47f00e60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@jspsych/plugin-instructions": "^1.1.3", "@jspsych/plugin-preload": "^1.1.2", "@jspsych/plugin-survey": "^1.0.1", + "commander": "^12.1.0", "electron-log": "^5.0.0", "electron-squirrel-startup": "^1.0.0", "firebase": "^10.11.0", @@ -655,6 +656,15 @@ "node": ">= 16.4.0" } }, + "node_modules/@electron-forge/cli/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/@electron-forge/cli/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -6306,13 +6316,11 @@ } }, "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "engines": { - "node": ">= 6" + "node": ">=18" } }, "node_modules/commitizen": { @@ -9638,6 +9646,15 @@ "node": ">= 10" } }, + "node_modules/firebase-tools/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/firebase-tools/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -12539,16 +12556,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lint-staged/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/lint-staged/node_modules/emoji-regex": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", diff --git a/package.json b/package.json index 83ea4663..7f79f107 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@jspsych/plugin-instructions": "^1.1.3", "@jspsych/plugin-preload": "^1.1.2", "@jspsych/plugin-survey": "^1.0.1", + "commander": "^12.1.0", "electron-log": "^5.0.0", "electron-squirrel-startup": "^1.0.0", "firebase": "^10.11.0",