Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
103 changes: 103 additions & 0 deletions internal/data/editor.go
Original file line number Diff line number Diff line change
@@ -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
Copy link

Copilot AI Aug 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When getShellEditor fails, LaunchEditor returns nil without any error indication to the caller, making it difficult to distinguish between successful completion and failure.

Suggested change
return nil // can't launch
return func() tea.Msg {
return EditorResponse{
FileName: file,
ExitCode: err,
IsEdit: isEdit,
CardID: cardID,
}
}

Copilot uses AI. Check for mistakes.
}

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())
}
250 changes: 250 additions & 0 deletions internal/data/editor_test.go
Original file line number Diff line number Diff line change
@@ -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)
Copy link

Copilot AI Aug 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message contains spelling errors: "Cant" should be "Can't" or "Cannot", and "cretae" should be "create".

Copilot uses AI. Check for mistakes.
}
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")
}
}
11 changes: 11 additions & 0 deletions internal/data/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading