From 351654e3e5ff1609b78127ee1f6a0308c3c48572 Mon Sep 17 00:00:00 2001 From: H0llyW00dzZ Date: Sun, 10 Dec 2023 11:12:30 +0700 Subject: [PATCH] Feat [Golang] [Package] Interactivity (#27) * Feat [Golang] [Package] Interactivity - [+] feat(interactivity): add interactivity package for handling interactive command-line user interfaces - [+] feat(interactivity): add ConfirmOverwrite function for checking if a file exists and prompting the user for confirmation to overwrite - [+] feat(interactivity): add promptForInput function for reading user input from bufio.Reader and handling cancellation with context * Feat & Fix [Golang] [Package] Real File System & File System Mock - [+] feat(file_system.go): add error handling to FileExists method in the FileSystem interface - [+] fix(file_system.go): change return type of FileExists method in the FileSystem interface to (bool, error) - [+] fix(file_system.go): handle file existence check in the FileExists method of RealFileSystem - [+] fix(file_system_mock.go): change return type of FileExists method in the MockFileSystem to (bool, error) - [+] fix(file_system_mock.go): handle file existence check in the FileExists method of MockFileSystem * Feat [Golang] [Package] Interactivity - [+] feat(interactivity.go): add determineFileName function to determine the file name based on the fileType * Fix & Feat [Golang] [Module] Main Command - [+] fix(main.go): change variable name from fs to rfs in processCSVOption, processDatasetOption, executeCSVConversion, createSeparateCSVFiles, convertToSingleCSV, and writeContentToFile functions - [+] feat(main.go): add import statement for interactivity package --- filesystem/file_system.go | 15 ++- filesystem/file_system_mock.go | 6 +- interactivity/interactivity.go | 80 ++++++++++++++++ main.go | 166 ++++++++++++++++++++++----------- 4 files changed, 203 insertions(+), 64 deletions(-) create mode 100644 interactivity/interactivity.go diff --git a/filesystem/file_system.go b/filesystem/file_system.go index 95bf557..e56192d 100644 --- a/filesystem/file_system.go +++ b/filesystem/file_system.go @@ -17,7 +17,7 @@ type FileSystem interface { WriteFile(name string, data []byte, perm fs.FileMode) error ReadFile(name string) ([]byte, error) // Added ReadFile method Stat(name string) (os.FileInfo, error) - FileExists(name string) bool // Added FileExists method to the interface + FileExists(name string) (bool, error) // Added FileExists method to the interface } // RealFileSystem implements the FileSystem interface by wrapping the os package functions, @@ -51,8 +51,15 @@ func (rfs RealFileSystem) Stat(name string) (os.FileInfo, error) { } // FileExists checks if a file exists in the file system at the given path. -func (rfs RealFileSystem) FileExists(name string) bool { +// It returns a boolean indicating existence, and an error for any underlying +// filesystem issues encountered. +func (rfs RealFileSystem) FileExists(name string) (bool, error) { _, err := rfs.Stat(name) - // Return true only if the error is nil (file exists). - return err == nil + if err == nil { + return true, nil // File exists + } + if os.IsNotExist(err) { + return false, nil // File does not exist + } + return false, err // Some other error occurred } diff --git a/filesystem/file_system_mock.go b/filesystem/file_system_mock.go index dda83df..04b40c3 100644 --- a/filesystem/file_system_mock.go +++ b/filesystem/file_system_mock.go @@ -114,12 +114,12 @@ func (m mockFileInfo) IsDir() bool { return false } // Dummy value, func (m mockFileInfo) Sys() interface{} { return nil } // No system-specific information. // FileExists checks if the given file name exists in the mock file system. -func (m *MockFileSystem) FileExists(name string) bool { +func (m *MockFileSystem) FileExists(name string) (bool, error) { m.FileExistsCalled = true // Record that FileExists was called if m.FileExistsErr != nil { // Simulate an error condition if an error is set - return false + return false, nil } _, exists := m.Files[name] // Use the same map for all file data which can easily be mocked while touring in binary. - return exists + return exists, nil } diff --git a/interactivity/interactivity.go b/interactivity/interactivity.go new file mode 100644 index 0000000..0ec9419 --- /dev/null +++ b/interactivity/interactivity.go @@ -0,0 +1,80 @@ +// Package interactivity provides functions to handle interactive command-line +// user interfaces. It includes utilities for prompting the user with questions +// and processing their responses. This package is specifically designed to work +// with the filesystem provided by the github.com/H0llyW00dzZ/ChatGPT-Next-Web-Session-Exporter/filesystem +// package to confirm potential file overwrites and to collect user input in a +// context-aware manner, allowing for graceful cancellation of input requests. +package interactivity + +import ( + "bufio" + "context" + "fmt" + "strings" + + "github.com/H0llyW00dzZ/ChatGPT-Next-Web-Session-Exporter/filesystem" +) + +// result is a helper struct used internally within the interactivity package +// to encapsulate the user input along with any error that might have occurred +// during the input reading process. It is used to communicate between +// goroutines in the promptForInput function. +type result struct { + input string + err error +} + +// ConfirmOverwrite checks if a file with the given fileName exists in the provided filesystem. +// If the file does exist, it prompts the user for confirmation to overwrite the file. +// The function reads the user's input via the provided bufio.Reader and expects a 'yes' or 'no' response. +// A context.Context is used to handle cancellation of the input request. +// It returns a boolean indicating whether the file should be overwritten and any error encountered. +func ConfirmOverwrite(rfs filesystem.FileSystem, ctx context.Context, reader *bufio.Reader, fileName string) (bool, error) { + exists, err := rfs.FileExists(fileName) + if err != nil { + // Handle the error properly, perhaps by returning it. + return false, err + } + if !exists { + // If the file doesn't exist, no need to confirm overwrite. + return true, nil + } + + // If the file exists, ask the user for confirmation. + fmt.Printf("File '%s' already exists. Overwrite? (yes/no): ", fileName) + + // Call promptForInput without the extra string argument. + overwrite, err := promptForInput(ctx, reader) + if err != nil { + return false, err + } + return strings.ToLower(overwrite) == "yes", nil +} + +// promptForInput waits for a line of user input read from the provided bufio.Reader. +// It takes a context.Context to support cancellation. +// The function trims the newline character from the input and returns the resulting string. +// If the context is cancelled before the user inputs a line, the context's error is returned. +func promptForInput(ctx context.Context, reader *bufio.Reader) (string, error) { + resultChan := make(chan result) + + go func() { + input, err := reader.ReadString('\n') + resultChan <- result{input: input, err: err} + }() + + select { + case <-ctx.Done(): + return "", ctx.Err() + case res := <-resultChan: + return strings.TrimSpace(res.input), res.err + } +} + +// determineFileName should be a function that determines the file name based on the fileType or other logic. +// Note: Currently, unimplemented. +func determineFileName(fileType string) string { + // Implement logic to determine the file name + // For example, you might prompt the user for a file name or generate it based on the fileType + return "" +} diff --git a/main.go b/main.go index 309ac0f..7617823 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,7 @@ import ( "github.com/H0llyW00dzZ/ChatGPT-Next-Web-Session-Exporter/exporter" "github.com/H0llyW00dzZ/ChatGPT-Next-Web-Session-Exporter/filesystem" + "github.com/H0llyW00dzZ/ChatGPT-Next-Web-Session-Exporter/interactivity" "github.com/H0llyW00dzZ/ChatGPT-Next-Web-Session-Exporter/repairdata" ) @@ -176,7 +177,7 @@ func processOutputOption(fs filesystem.FileSystem, ctx context.Context, reader * // If the format option is 3, it prompts the user for the names of the sessions and messages CSV files to save, and calls exporter.CreateSeparateCSVFiles to create separate CSV files for sessions and messages. // If the format option is not 3, it prompts the user for the name of the CSV file to save, and calls exporter.ConvertSessionsToCSV to convert sessions to CSV based on the selected format option. // It prints the output file names or error messages accordingly. -func processCSVOption(fs filesystem.FileSystem, ctx context.Context, reader *bufio.Reader, sessions []exporter.Session) { +func processCSVOption(rfs filesystem.FileSystem, ctx context.Context, reader *bufio.Reader, sessions []exporter.Session) { // Prompt the user for the CSV format option formatOptionStr, err := promptForInput(ctx, reader, PromptSelectCSVOutputFormat) if err != nil { @@ -199,12 +200,12 @@ func processCSVOption(fs filesystem.FileSystem, ctx context.Context, reader *buf } // Execute the CSV conversion based on the selected format option. - executeCSVConversion(fs, ctx, formatOption, reader, sessions) + executeCSVConversion(rfs, ctx, reader, formatOption, sessions) } // processDatasetOption handles the conversion of session data to a Hugging Face Dataset format. // It is now context-aware and will respect cancellation requests. -func processDatasetOption(fs filesystem.FileSystem, ctx context.Context, reader *bufio.Reader, sessions []exporter.Session) { +func processDatasetOption(rfs filesystem.FileSystem, ctx context.Context, reader *bufio.Reader, sessions []exporter.Session) { datasetOutput, err := exporter.ExtractToDataset(sessions) if err != nil { if err == context.Canceled || err == io.EOF { @@ -217,41 +218,80 @@ func processDatasetOption(fs filesystem.FileSystem, ctx context.Context, reader os.Exit(1) } } - saveToFile(fs, ctx, reader, datasetOutput, "dataset") + saveToFile(rfs, ctx, reader, datasetOutput, "dataset") } // saveToFile prompts the user to save the provided content to a file of the specified type. // This function now also accepts a context, allowing file operations to be cancelable. -func saveToFile(fs filesystem.FileSystem, ctx context.Context, reader *bufio.Reader, content string, fileType string) { +func saveToFile(rfs filesystem.FileSystem, ctx context.Context, reader *bufio.Reader, content string, fileType string) { + // Ask user if they want to save the output to a file saveOutput, err := promptForInput(ctx, reader, PromptSaveOutputToFile) if err != nil { - if err == context.Canceled || err == io.EOF { - // If the error is context.Canceled or io.EOF, exit gracefully. - fmt.Println("\n[GopherHelper] Exiting gracefully...\nReason: Operation canceled or end of input. Exiting program.") - os.Exit(0) - } else { - // For other types of errors, print the error message and exit with status code 1. - fmt.Printf("\nError reading input: %s\n", err) - os.Exit(1) - } + handleInputError(err) + return } if strings.ToLower(saveOutput) == "yes" { - // Now pass the provided file system interface instance to writeContentToFile - err = writeContentToFile(fs, ctx, reader, content, fileType) + // Determine the file name here (or pass it as a parameter) + fileName, err := promptForInput(ctx, reader, fmt.Sprintf(PromptEnterFileName, fileType)) + if err != nil { + handleInputError(err) + return + } + + // Ensure the fileName is not empty + if fileName == "" { + fmt.Println("No file name entered. Operation cancelled.") + return + } + + // Append the appropriate file extension based on the fileType + if fileType == FileTypeDataset { + fileName += ".json" + } else { + fileName += ".csv" // Assuming default fileType is CSV + } + + // Check if the file exists and confirm overwrite if necessary + overwrite, err := interactivity.ConfirmOverwrite(rfs, ctx, reader, fileName) + if err != nil { + handleInputError(err) + return + } + if !overwrite { + fmt.Println("Operation cancelled by the user.") + return + } + + // Now that we've confirmed, attempt to write the file + err = rfs.WriteFile(fileName, []byte(content), 0644) if err != nil { - // Handle the error fmt.Printf("Error writing file: %s\n", err) - os.Exit(1) + return } + + fmt.Printf("%s output saved to %s\n", strings.ToTitle(fileType), fileName) + } else { + fmt.Println("Save to file operation cancelled by the user.") + } +} + +// handleInputCancellation checks the error type and handles context cancellation and EOF. +func handleInputCancellation(err error) { + if err == context.Canceled || err == io.EOF { + fmt.Println("\n[GopherHelper] Exiting gracefully...\nReason: Operation canceled or end of input. Exiting program.") + os.Exit(0) + } else { + fmt.Printf("\nError reading input: %s\n", err) + os.Exit(1) } } // repairJSONData attempts to repair the JSON data at the provided file path and returns the path to the repaired file. // This function is not context-aware as it performs a single, typically quick operation. -func repairJSONData(fs filesystem.FileSystem, ctx context.Context, jsonFilePath string) (string, error) { +func repairJSONData(rfs filesystem.FileSystem, ctx context.Context, jsonFilePath string) (string, error) { // Read the broken JSON data using the file system interface - data, err := fs.ReadFile(jsonFilePath) + data, err := rfs.ReadFile(jsonFilePath) if err != nil { return "", err // Handle the error properly } @@ -266,7 +306,7 @@ func repairJSONData(fs filesystem.FileSystem, ctx context.Context, jsonFilePath repairedPath := "repaired_" + jsonFilePath // Write the repaired JSON data using the file system interface - err = fs.WriteFile(repairedPath, repairedData, 0644) + err = rfs.WriteFile(repairedPath, repairedData, 0644) if err != nil { return "", err // Handle the error properly } @@ -277,22 +317,15 @@ func repairJSONData(fs filesystem.FileSystem, ctx context.Context, jsonFilePath // executeCSVConversion handles the CSV conversion process based on the user-selected format option. // It is now context-aware, allowing for cancellation during the CSV conversion process. -func executeCSVConversion(fs filesystem.FileSystem, ctx context.Context, formatOption int, reader *bufio.Reader, sessions []exporter.Session) { +func executeCSVConversion(rfs filesystem.FileSystem, ctx context.Context, reader *bufio.Reader, formatOption int, sessions []exporter.Session) { var csvFileName string var err error if formatOption != OutputFormatSeparateCSV { - csvFileName, err = promptForInput(ctx, reader, "Enter the name of the CSV file to save: ") + csvFileName, err = promptForInput(ctx, reader, PromptEnterCSVFileName) if err != nil { - if err == context.Canceled || err == io.EOF { - // If the error is context.Canceled or io.EOF, exit gracefully. - fmt.Println("\n[GopherHelper] Exiting gracefully...\nReason: Operation canceled or end of input. Exiting program.") - os.Exit(0) - } else { - // For other types of errors, print the error message and exit with status code 1. - fmt.Printf("\nError reading input: %s\n", err) - os.Exit(1) - } + handleInputError(err) + return } } @@ -300,41 +333,49 @@ func executeCSVConversion(fs filesystem.FileSystem, ctx context.Context, formatO case OutputFormatSeparateCSV: // If the user chooses to create separate files, prompt for file names and execute accordingly. // Pass the FileSystem to createSeparateCSVFiles - createSeparateCSVFiles(fs, ctx, reader, sessions) + createSeparateCSVFiles(rfs, ctx, reader, sessions) default: // Otherwise, convert the sessions to a single CSV file. // Pass the FileSystem to convertToSingleCSV - convertToSingleCSV(fs, ctx, sessions, formatOption, csvFileName) + convertToSingleCSV(rfs, ctx, reader, sessions, formatOption, csvFileName) } } // createSeparateCSVFiles prompts the user for file names and creates separate CSV files for sessions and messages. // This function is context-aware and supports cancellation during the prompt for input. -func createSeparateCSVFiles(fs filesystem.FileSystem, ctx context.Context, reader *bufio.Reader, sessions []exporter.Session) { +func createSeparateCSVFiles(rfs filesystem.FileSystem, ctx context.Context, reader *bufio.Reader, sessions []exporter.Session) { sessionsFileName, err := promptForInput(ctx, reader, PromptEnterSessionsCSVFileName) if err != nil { - if err == context.Canceled || err == io.EOF { - // If the error is context.Canceled or io.EOF, exit gracefully. - fmt.Println("\n[GopherHelper] Exiting gracefully...\nReason: Operation canceled or end of input. Exiting program.") - os.Exit(0) - } else { - // For other types of errors, print the error message and exit with status code 1. - fmt.Printf("\nError reading input: %s\n", err) - os.Exit(1) - } + handleInputError(err) + return + } + + // Confirm overwrite for sessions CSV file + overwrite, err := interactivity.ConfirmOverwrite(rfs, ctx, reader, sessionsFileName) + if err != nil { + handleInputError(err) + return + } + if !overwrite { + fmt.Println("Operation cancelled by the user for sessions file.") + return } messagesFileName, err := promptForInput(ctx, reader, PromptEnterMessagesCSVFileName) if err != nil { - if err == context.Canceled || err == io.EOF { - // If the error is context.Canceled or io.EOF, exit gracefully. - fmt.Println("\n[GopherHelper] Exiting gracefully...\nReason: Operation canceled or end of input. Exiting program.") - os.Exit(0) - } else { - // For other types of errors, print the error message and exit with status code 1. - fmt.Printf("\nError reading input: %s\n", err) - os.Exit(1) - } + handleInputError(err) + return + } + + // Confirm overwrite for messages CSV file + overwrite, err = interactivity.ConfirmOverwrite(rfs, ctx, reader, messagesFileName) + if err != nil { + handleInputError(err) + return + } + if !overwrite { + fmt.Println("Operation cancelled by the user for messages file.") + return } err = exporter.CreateSeparateCSVFiles(sessions, sessionsFileName, messagesFileName) @@ -356,8 +397,19 @@ func createSeparateCSVFiles(fs filesystem.FileSystem, ctx context.Context, reade // convertToSingleCSV converts the session data to a single CSV file using the specified format option. // It now checks for context cancellation and halts the operation if a cancellation is requested. -func convertToSingleCSV(fs filesystem.FileSystem, ctx context.Context, sessions []exporter.Session, formatOption int, csvFileName string) { - err := exporter.ConvertSessionsToCSV(ctx, sessions, formatOption, csvFileName) +func convertToSingleCSV(rfs filesystem.FileSystem, ctx context.Context, reader *bufio.Reader, sessions []exporter.Session, formatOption int, csvFileName string) { + // Confirm overwrite if the file already exists + overwrite, err := interactivity.ConfirmOverwrite(rfs, ctx, reader, csvFileName) + if err != nil { + fmt.Printf("Failed to check file existence: %s\n", err) + return // Handle the error as appropriate for your application + } + if !overwrite { + fmt.Println("Operation cancelled by the user.") + return + } + + err = exporter.ConvertSessionsToCSV(ctx, sessions, formatOption, csvFileName) if err != nil { if err == context.Canceled { fmt.Println("Operation was canceled by the user.") @@ -371,7 +423,7 @@ func convertToSingleCSV(fs filesystem.FileSystem, ctx context.Context, sessions // writeContentToFile collects a file name from the user and writes the provided content to the specified file. // It now includes context support to handle potential cancellation during file writing. -func writeContentToFile(fs filesystem.FileSystem, ctx context.Context, reader *bufio.Reader, content string, fileType string) error { +func writeContentToFile(rfs filesystem.FileSystem, ctx context.Context, reader *bufio.Reader, content string, fileType string) error { fileName, err := promptForInput(ctx, reader, fmt.Sprintf(PromptEnterFileName, fileType)) if err != nil { return err @@ -382,7 +434,7 @@ func writeContentToFile(fs filesystem.FileSystem, ctx context.Context, reader *b } // Use the provided FileSystem interface to write the file content directly - err = fs.WriteFile(fileName, []byte(content), 0644) + err = rfs.WriteFile(fileName, []byte(content), 0644) if err != nil { return err }