diff --git a/README.md b/README.md index 33caa4e..3668d9e 100644 --- a/README.md +++ b/README.md @@ -254,9 +254,12 @@ The clean, distraction-free terminal interface includes: | `↓/j` | Move down/scroll down | | `Enter` | Select/confirm | | `Tab` | Switch tab (in statistics)| +| `a` | Add card to current deck | +| `e` | Edit current card | | `b` | Back to previous screen | | `q` | Quit | + ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/internal/data/editor.go b/internal/data/editor.go new file mode 100644 index 0000000..fba6d81 --- /dev/null +++ b/internal/data/editor.go @@ -0,0 +1,103 @@ +// File: internal/data/editor.go + +package data + +import ( + "fmt" + "os" + "os/exec" + "time" + + "github.com/DavidMiserak/GoCard/internal/model" + tea "github.com/charmbracelet/bubbletea" +) + +type EditorResponse struct { + FileName string + ExitCode error + IsEdit bool // true = editing, false = adding + CardID string // original card ID (for edits) +} + +func getShellEditor() (string, error) { + editor := os.Getenv("EDITOR") + if editor == "" { + // Default to vi + editor = "vi" + } + + return exec.LookPath(editor) +} + +func LaunchEditor(file string, isEdit bool, cardID string) tea.Cmd { + editor, err := getShellEditor() + + if err != nil { + return nil // can't launch + } + + cmdToRun := exec.Command(editor, file) + + return tea.ExecProcess(cmdToRun, func(result error) tea.Msg { + return EditorResponse{ + FileName: file, + ExitCode: result, + IsEdit: isEdit, + CardID: cardID, + } + }) +} + +func getCardTemplate() string { + currentDate := time.Now().Format("2006-01-02") + template := fmt.Sprintf(`--- +tags: [] +created: %s +review_interval: 0 +--- + +# Title + +## Question + +## Answer + +`, currentDate) + + return template + +} + +// Abstracted method to create temporary files with desired text +func createTmpFileWithText(text string) (string, error) { + tmpFile, err := os.CreateTemp("", "GoCard-tmp-*.md") + if err != nil { + return "", err + } + + _, err = tmpFile.WriteString(text) + if err != nil { + tmpFile.Close() + return "", err + } + + err = tmpFile.Close() + if err != nil { + return "", err + } + + return tmpFile.Name(), nil +} + +func CreateTmpFileWithCard(card model.Card) (string, error) { + originalFileContents, err := os.ReadFile(card.ID) + if err != nil { + return "", err + } + + return createTmpFileWithText(string(originalFileContents)) +} + +func CreateTmpFileWithTemplate() (string, error) { + return createTmpFileWithText(getCardTemplate()) +} diff --git a/internal/data/editor_test.go b/internal/data/editor_test.go new file mode 100644 index 0000000..306908c --- /dev/null +++ b/internal/data/editor_test.go @@ -0,0 +1,250 @@ +// File: internal/data/editor_test.go + +package data + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/DavidMiserak/GoCard/internal/model" +) + +func TestGetShellEditor(t *testing.T) { + // Test assumes vi and vim exist in env where test is run + // and "nonexistentEditor123" doesn't exist + + originalEditor := os.Getenv("EDITOR") + defer os.Setenv("EDITOR", originalEditor) + + testCases := []struct { + name string + editorEnv string // to set + expectError bool + lookPathShouldContain string // e.g. LookPath has "vi" in path + }{ + { + name: "$EDITOR not set, fallback to vi", + editorEnv: "", + expectError: false, + lookPathShouldContain: "vi", + }, + { + name: "$EDITOR set to vim", + editorEnv: "vim", + expectError: false, + lookPathShouldContain: "vim", + }, + { + name: "$EDITOR set to nonexistent editor", + editorEnv: "nonexistentEditor123", + expectError: true, + lookPathShouldContain: "", + }, + } + + // Run tests + for _, testCase := range testCases { + os.Setenv("EDITOR", testCase.editorEnv) + result, err := getShellEditor() + + switch { + case testCase.expectError && err != nil: + // test passes + case testCase.expectError && err == nil: + t.Errorf("Expected error but got none") + case !testCase.expectError && err != nil: + t.Errorf("Unexpected error: %v", err) + case !testCase.expectError && err == nil: + pathIncludesEditor := strings.Contains(result, testCase.lookPathShouldContain) + if !pathIncludesEditor { + t.Errorf("Expected path to include %q, got %q", testCase.lookPathShouldContain, result) + } + } + } +} + +func TestCreateTmpFileWithText(t *testing.T) { + testCases := []struct { + name string + text string + }{ + {name: "Empty text", text: ""}, + {name: "Single line text", text: "Hello World!"}, + {name: "Multi line text", text: "Hello\nWorld\n!"}, + } + + expectedFilenamePrefix := "GoCard-tmp-" + expectedFilenameSuffix := ".md" + + for _, testCase := range testCases { + // Run function + filePath, err := createTmpFileWithText(testCase.text) + defer os.Remove(filePath) + + if err != nil { + t.Errorf("%q returned unexpected %v", testCase.name, err) + } + + // Verify existence of file + if _, err := os.Stat(filePath); os.IsNotExist(err) { + t.Errorf("createTmpFileWithText didnt create file at %s", filePath) + } + + // Filename checks + fileName := filepath.Base(filePath) + fileNameHasExpectedPrefix := strings.HasPrefix(fileName, expectedFilenamePrefix) + fileNameHasExpectedSuffix := strings.HasSuffix(fileName, expectedFilenameSuffix) + + if !fileNameHasExpectedPrefix { + t.Errorf("Expected filename prefix %q in file %q", expectedFilenamePrefix, fileName) + } + if !fileNameHasExpectedSuffix { + t.Errorf("Expected filename suffix %q in file %q", expectedFilenameSuffix, fileName) + } + + // Check correct content exists + content, err := os.ReadFile(filePath) + if err != nil { + t.Errorf("Test %q failed to read file: %v", testCase.name, err) + continue + } + if string(content) != testCase.text { + t.Errorf("Test %q expected content %q but got %q", testCase.name, testCase.text, string(content)) + } + + } +} + +func TestCreateTmpFileWithTemplate(t *testing.T) { + expectedFilenamePrefix := "GoCard-tmp-" + expectedFilenameSuffix := ".md" + + // Run function + filePath, err := CreateTmpFileWithTemplate() + defer os.Remove(filePath) + if err != nil { + t.Errorf("CreateTmpFileWithTemplate returned unexpected %v", err) + } + + // Verify existence of file + if _, err := os.Stat(filePath); os.IsNotExist(err) { + t.Errorf("CreateTmpFileWithTemplate didnt create file at %s", filePath) + } + + // Filename checks + fileName := filepath.Base(filePath) + fileNameHasExpectedPrefix := strings.HasPrefix(fileName, expectedFilenamePrefix) + fileNameHasExpectedSuffix := strings.HasSuffix(fileName, expectedFilenameSuffix) + if !fileNameHasExpectedPrefix { + t.Errorf("Expected filename prefix %q in file %q", expectedFilenamePrefix, fileName) + } + if !fileNameHasExpectedSuffix { + t.Errorf("Expected filename suffix %q in file %q", expectedFilenameSuffix, fileName) + } + + // Check file contents exist + content, err := os.ReadFile(filePath) + if err != nil { + t.Errorf("CreateTmpFileWithTemplate failed to read file: %v", err) + } + actualLines := strings.Split(string(content), "\n") + + // Check parts w/ ordering + expectedLines := []string{ + "---", + "tags: []", + "created: " + time.Now().Format("2006-01-02"), + "review_interval: 0", + "---", + "", + "# Title", + "", + "## Question", + "", + "## Answer", + "", + } + for index, writtenLine := range expectedLines { + actualLine := strings.TrimSpace(actualLines[index]) + writtenLine = strings.TrimSpace(writtenLine) + + if actualLine != writtenLine { + t.Errorf("Line %d: expected %q, got %q", index, writtenLine, actualLine) + } + } +} + +func TestCreateTmpFileWithCard(t *testing.T) { + // Create Fake File + tempDir, err := os.MkdirTemp("", "card-test") + if err != nil { + t.Fatalf("Cant cretae temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + fakeCardPath := filepath.Join(tempDir, "test-card.md") + fakeCard := `--- +tags: [test,go] +created: 2025-01-01 +review_interval: 5 +difficulty: 2.3 +--- + +# Fake Title + +## Question + +Fake question + +## Answer + +Fake answer +` + + testCard := model.Card{ + ID: fakeCardPath, + } + + err = os.WriteFile(fakeCardPath, []byte(fakeCard), 0644) + if err != nil { + t.Fatalf("Failed to write fake card %v", err) + } + + expectedFilenamePrefix := "GoCard-tmp-" + expectedFilenameSuffix := ".md" + + // Run function + filePath, err := CreateTmpFileWithCard(testCard) + defer os.Remove(filePath) + if err != nil { + t.Errorf("CreateTmpFileWithCard returned unexpected %v", err) + } + + // Verify existence of file + if _, err := os.Stat(filePath); os.IsNotExist(err) { + t.Errorf("CreateTmpFileWithCard didnt create file at %s", filePath) + } + + // Filename checks + fileName := filepath.Base(filePath) + fileNameHasExpectedPrefix := strings.HasPrefix(fileName, expectedFilenamePrefix) + fileNameHasExpectedSuffix := strings.HasSuffix(fileName, expectedFilenameSuffix) + if !fileNameHasExpectedPrefix { + t.Errorf("Expected filename prefix %q in file %q", expectedFilenamePrefix, fileName) + } + if !fileNameHasExpectedSuffix { + t.Errorf("Expected filename suffix %q in file %q", expectedFilenameSuffix, fileName) + } + + // Check file contents exist + content, err := os.ReadFile(filePath) + if err != nil { + t.Errorf("CreateTmpFileWithCard failed to read file: %v", err) + } + + if string(content) != fakeCard { + t.Errorf("Temp file doesnt match card content") + } +} diff --git a/internal/data/store.go b/internal/data/store.go index 5334ec8..f220d6a 100644 --- a/internal/data/store.go +++ b/internal/data/store.go @@ -142,6 +142,17 @@ func (s *Store) GetDueCardsForDeck(deckID string) []model.Card { return dueCards } +// AddCardToDeck adds a card to deck in the store and returns whether it was found +func (s *Store) AddCardToDeck(card model.Card) bool { + for i, deck := range s.Decks { + if deck.ID == card.DeckID { + s.Decks[i].Cards = append(s.Decks[i].Cards, card) + return true + } + } + return false +} + // UpdateCard updates a card in the store and returns whether it was found func (s *Store) UpdateCard(updatedCard model.Card) bool { // Find and update the card in its deck diff --git a/internal/ui/study_screen.go b/internal/ui/study_screen.go index cdfa52f..4580dd7 100644 --- a/internal/ui/study_screen.go +++ b/internal/ui/study_screen.go @@ -4,7 +4,10 @@ package ui import ( "fmt" + "os" + "path/filepath" "strings" + "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/viewport" @@ -25,6 +28,8 @@ type studyKeyMap struct { Rate3 key.Binding // Hard Rate4 key.Binding // Good Rate5 key.Binding // Easy + Add key.Binding + Edit key.Binding } var studyKeys = studyKeyMap{ @@ -64,6 +69,14 @@ var studyKeys = studyKeyMap{ key.WithKeys("5"), key.WithHelp("5", "Easy"), ), + Add: key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "Add"), + ), + Edit: key.NewBinding( + key.WithKeys("e"), + key.WithHelp("e", "Edit"), + ), } // StudyState represents the current state of the study screen @@ -130,11 +143,81 @@ func (s *StudyScreen) Init() tea.Cmd { return nil } +func (s *StudyScreen) handleEditorResponse(msg data.EditorResponse) error { + defer os.Remove(msg.FileName) // All cases should cleanup temp file + + // When returning from $EDITOR from add/edit cards + if msg.ExitCode != nil { + return fmt.Errorf("editor returned error %v", msg.ExitCode) + } + + // successfully created tmp file with $EDITOR; parse into card to ensure valid + tempMarkdownCard, err := data.ParseMarkdownFile(msg.FileName) + if err != nil { + return fmt.Errorf("failed to parse card: %v", err) + } + + // Ensure card isn't empty + questionIsEmpty := strings.TrimSpace(tempMarkdownCard.Question) == "" + answerIsEmpty := strings.TrimSpace(tempMarkdownCard.Answer) == "" + if questionIsEmpty || answerIsEmpty { + return fmt.Errorf("invalid card: both question & answer required") + } + + // Convert from *MarkdownCard to model.Card + newCard := tempMarkdownCard.ToModelCard(s.deckID) + + if msg.IsEdit { + // newCard.ID points to temp file. Point it to original so can overwrite + newCard.ID = msg.CardID + + // Update existing card + err = data.WriteCard(newCard, msg.CardID) + if err != nil { + return fmt.Errorf("error saving card: %v", err) + } + + // Overwrite card in store and study screen's deck + s.store.UpdateCard(newCard) + s.cards[s.cardIndex] = newCard + + } else { + // Creating a new card, autogenerate unique filename based on time + filename := filepath.Join(s.deckID, fmt.Sprintf("card_%d.md", time.Now().Unix())) + newCard.ID = filename // use filename as cardID + + err = data.WriteCard(newCard, filename) + if err != nil { + return fmt.Errorf("error saving card: %v", err) + } + + // Add to store and study screen's state deck for persistence + + // store's in memory deck + s.store.AddCardToDeck(newCard) + + // Local study session + // TODO: GoCard currently shows ALL cards during study, not just due cards + // If we filter by due date, this should also check newCard.NextReview + s.cards = append(s.cards, newCard) + s.totalCards = len(s.cards) + s.cardIndex = len(s.cards) - 1 // jump immediately to new card + + } + return nil +} + // Update handles user input and updates the model func (s *StudyScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { + + case data.EditorResponse: + err := s.handleEditorResponse(msg) + if err != nil { + fmt.Printf("editor error: %v", err) + } case tea.KeyMsg: // If in finished state, any key navigates to stats screen if s.state == FinishedStudying { @@ -175,6 +258,22 @@ func (s *StudyScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Skip this card and go to the next one s.nextCard() return s, nil + + case key.Matches(msg, studyKeys.Add): + tmpFilePath, err := data.CreateTmpFileWithTemplate() + if err != nil { + return s, nil + } + return s, data.LaunchEditor(tmpFilePath, false, "") + + case key.Matches(msg, studyKeys.Edit): + currentCard := s.cards[s.cardIndex] + tmpFilePath, err := data.CreateTmpFileWithCard(currentCard) + if err != nil { + return s, nil + } + + return s, data.LaunchEditor(tmpFilePath, true, currentCard.ID) } // Handle viewport scrolling and rating keys when showing the answer @@ -365,14 +464,14 @@ func (s *StudyScreen) View() string { sb.WriteString("\n\n") // Help text for rating state - sb.WriteString(studyHelpStyle.Render("\t1-5: Rate Card" + "\tj/k: Scroll" + "\tb: Back to Decks" + "\tq: Quit")) + sb.WriteString(studyHelpStyle.Render("\t1-5: Rate Card" + "\tj/k: Scroll" + "\ta/e: Add/Edit" + "\tb: Back to Decks" + "\tq: Quit")) } else { // Show the prompt to reveal the answer sb.WriteString(revealPromptStyle.Render("Press SPACE to reveal answer")) sb.WriteString("\n\n") // Help text for question state - sb.WriteString(studyHelpStyle.Render("\tSPACE: Show Answer" + "\t<: Skip" + "\tb: Back to Decks" + "\tq: Quit")) + sb.WriteString(studyHelpStyle.Render("\tSPACE: Show Answer" + "\t<: Skip" + "\ta/e: Add/Edit" + "\tb: Back to Decks" + "\tq: Quit")) } return sb.String() diff --git a/internal/ui/study_screen_test.go b/internal/ui/study_screen_test.go index 80bafcf..8c0e4a2 100644 --- a/internal/ui/study_screen_test.go +++ b/internal/ui/study_screen_test.go @@ -3,6 +3,8 @@ package ui import ( + "fmt" + "os" "strings" "testing" @@ -350,3 +352,93 @@ func TestStudyScreenAllCardsStudied(t *testing.T) { t.Errorf("Expected state to be FinishedStudying when all cards are studied, got %v", study.state) } } + +func TestHandleEditorResponse_Success(t *testing.T) { + mockValidCardContent := `--- +tags: [test] +created: 2025-01-01 +review_interval: 0 +--- + +# Question + +Fake Question + +## Answer + +Fake Answer +` + tempFile, err := os.CreateTemp("", "test-*.md") + if err != nil { + t.Fatal("Couldn't create temp file") + } + defer os.Remove(tempFile.Name()) + if _, err := tempFile.WriteString(mockValidCardContent); err != nil { + t.Fatalf("Couldnt write to temp file") + } + tempFile.Close() + + mockEditorResponse := data.EditorResponse{ + FileName: tempFile.Name(), + ExitCode: nil, // success + IsEdit: false, + CardID: "", + } + + fakeStore := data.NewStore() + fakeStudy := NewStudyScreen(fakeStore, fakeStore.GetDecks()[0].ID) + initialCardCount := len(fakeStudy.cards) + err = fakeStudy.handleEditorResponse(mockEditorResponse) + if err != nil { + t.Errorf("Got err %v when expected success", err) + } + + if len(fakeStudy.cards) > initialCardCount { + newCard := fakeStudy.cards[len(fakeStudy.cards)-1] + defer os.Remove(newCard.ID) // Clean up the generated card file + } +} + +func TestHandleEditorResponse_Failure(t *testing.T) { + tempFile, err := data.CreateTmpFileWithTemplate() + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tempFile) + + mockEditorResponse := data.EditorResponse{ + FileName: tempFile, + ExitCode: fmt.Errorf("editor exited with code 1"), // fail + IsEdit: false, + CardID: "", + } + + fakeStore := data.NewStore() + fakeStudy := NewStudyScreen(fakeStore, fakeStore.GetDecks()[0].ID) + err = fakeStudy.handleEditorResponse(mockEditorResponse) + if err == nil { + t.Errorf("Got success %v when expected error", err) + } +} + +func TestHandleEditorResponse_BadCard(t *testing.T) { + tempFile, err := data.CreateTmpFileWithTemplate() // empty template should throw bad card + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tempFile) + + mockEditorResponse := data.EditorResponse{ + FileName: tempFile, + ExitCode: nil, // success + IsEdit: false, + CardID: "", + } + + fakeStore := data.NewStore() + fakeStudy := NewStudyScreen(fakeStore, fakeStore.GetDecks()[0].ID) + err = fakeStudy.handleEditorResponse(mockEditorResponse) + if err == nil { + t.Errorf("Expected empty card error, but got success") + } +}