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
106 changes: 94 additions & 12 deletions internal/card.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ var (
errInvalidCard = errors.New("Invalid card")
)

var splitQuestion = regexp.MustCompile(`(?m)^##\s*`)
var splitQuestion = regexp.MustCompile(`(?m)^#+\s*`)

type Card struct {
Question string
Expand All @@ -29,23 +29,51 @@ func (c Card) Review(s Score) {
c.Meta.Review(s)
}

// splitCards take a mardown string as input and returns a set of cards and the line number of each.
func splitCards(md string) ([]string, []int) {
// getHeaderLevel returns the level of a header line (number of # characters)
func getHeaderLevel(line string) int {
level := 0
for i := 0; i < len(line) && line[i] == '#'; i++ {
level++
}
return level
}

// splitCards take a mardown string as input and returns a set of cards, their line numbers, and hierarchy paths.
func splitCards(md string) ([]string, []int, []string) {
cards := make([]string, 0)
cardsLineNb := make([]int, 0)
cardsHierarchy := make([]string, 0)
isCode := false // true when parsing "```"
card := "" // current card being parsed
cardLineNb := 0 // current card line number
lines := strings.Split(md, "\n")

// Track the header hierarchy as we parse
// hierarchy[level] = header text at that level
hierarchy := make(map[int]string)

for i, line := range lines {
if splitQuestion.Match([]byte(line)) && !isCode {
// 1. add previous card to the deck if any.
// 2. start the card with the title.
if card != "" {
cards = append(cards, card)
cardsLineNb = append(cardsLineNb, cardLineNb)
// Build hierarchy path for previous card
hierarchyPath := buildHierarchyPath(hierarchy)
cardsHierarchy = append(cardsHierarchy, hierarchyPath)
}

// Update hierarchy for the new header
level := getHeaderLevel(line)
headerText := strings.TrimSpace(line[level:])

// Update hierarchy at this level and clear deeper levels
hierarchy[level] = headerText
for l := level + 1; l <= 6; l++ {
delete(hierarchy, l)
}

cardLineNb = i
card = line
} else {
Expand All @@ -59,16 +87,51 @@ func splitCards(md string) ([]string, []int) {
if card != "" {
cards = append(cards, card)
cardsLineNb = append(cardsLineNb, cardLineNb)
// Build hierarchy path for last card
hierarchyPath := buildHierarchyPath(hierarchy)
cardsHierarchy = append(cardsHierarchy, hierarchyPath)
}
return cards, cardsLineNb
return cards, cardsLineNb, cardsHierarchy
}

// buildHierarchyPath builds a hierarchy path string from the hierarchy map
func buildHierarchyPath(hierarchy map[int]string) string {
if len(hierarchy) == 0 {
return ""
}

// Find all levels and sort them
levels := make([]int, 0, len(hierarchy))
for level := range hierarchy {
levels = append(levels, level)
}

// Simple bubble sort since we have at most 6 levels
for i := 0; i < len(levels); i++ {
for j := i + 1; j < len(levels); j++ {
if levels[i] > levels[j] {
levels[i], levels[j] = levels[j], levels[i]
}
}
}

// Build the path
path := ""
for i, level := range levels {
if i > 0 {
path += " ‒> "
}
path += hierarchy[level]
}
return path
}

func parseCards(md string, deckPath string) ([]Card, error) {
cards := make([]Card, 0)

sheets, lines := splitCards(md)
sheets, lines, hierarchies := splitCards(md)
for i, sheet := range sheets {
card, err := loadCard(sheet, deckPath)
card, err := loadCard(sheet, deckPath, hierarchies[i])
if err == errCardEmpty {
continue
} else if err != nil {
Expand All @@ -92,24 +155,43 @@ func trim(s string) string {
}

// loadCard parse a card description
func loadCard(md string, deckPath string) (c Card, err error) {
func loadCard(md string, deckPath string, hierarchyPath string) (c Card, err error) {
md = trim(md)
if md == "" {
return c, errCardEmpty
}
sheets := strings.SplitN(md, "\n", 2)
if len(sheets) != 2 {
if len(sheets) < 1 {
return c, errInvalidCard
}
if !strings.HasPrefix(sheets[0], "##") {
if !strings.HasPrefix(sheets[0], "#") {
return c, errInvalidCard
}
// Remove the '##' from the question.
c.Question = trim(sheets[0][2:])
// Count and remove the '#' characters from the question.
headerLen := 0
for i := 0; i < len(sheets[0]) && sheets[0][i] == '#'; i++ {
headerLen++
}
c.Question = trim(sheets[0][headerLen:])
if c.Question == "" {
return c, errInvalidCard
}
c.Answer = trim(sheets[1])

// Prepend hierarchy path if it exists
if hierarchyPath != "" {
c.Question = hierarchyPath
}
// If there's no content after the header, skip this card
if len(sheets) == 2 {
c.Answer = trim(sheets[1])
} else {
c.Answer = ""
}

// Skip cards with no answer content
if c.Answer == "" {
return c, errCardEmpty
}

c.Question, err = rewriteImagePaths(c.Question, deckPath)
if err != nil {
Expand Down
109 changes: 108 additions & 1 deletion internal/card_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Text 3
`
deck := fmt.Sprintf(template, "```", "```")

cards, lines := splitCards(deck)
cards, lines, _ := splitCards(deck)
if len(cards) != 3 {
t.Errorf("Wrong size: %d", len(cards))
}
Expand All @@ -91,6 +91,113 @@ Some code
}
}

func TestMultipleHeaderLevels(t *testing.T) {
input := `# H1 Question
This is an H1 answer

## H2 Question
This is an H2 answer

### H3 Question
This is an H3 answer

#### H4 Question
This is an H4 answer
`
expected := []Card{
{
Question: "H1 Question",
Answer: "This is an H1 answer",
},
{
Question: "H1 Question ‒> H2 Question",
Answer: "This is an H2 answer",
},
{
Question: "H1 Question ‒> H2 Question ‒> H3 Question",
Answer: "This is an H3 answer",
},
{
Question: "H1 Question ‒> H2 Question ‒> H3 Question ‒> H4 Question",
Answer: "This is an H4 answer",
},
}
cards, err := readCards(bytes.NewBufferString(input), "")
if err != nil {
t.Fatal(err)
}
if len(cards) != len(expected) {
t.Fatalf("Wrong length: got %d, want %d", len(cards), len(expected))
}
for i, card := range cards {
if card.Question != expected[i].Question {
t.Errorf("Card %d Question: got %q, want %q",
i, card.Question, expected[i].Question)
}
if card.Answer != expected[i].Answer {
t.Errorf("Card %d Answer: got %q, want %q",
i, card.Answer, expected[i].Answer)
}
}
}

func TestHierarchicalQuestions(t *testing.T) {
input := `# Main Topic
Some intro text

## Subtopic 1
Answer for subtopic 1

### Detail 1
Answer for detail 1

### Detail 2
Answer for detail 2

## Subtopic 2
Answer for subtopic 2
`
expected := []Card{
{
Question: "Main Topic",
Answer: "Some intro text",
},
{
Question: "Main Topic ‒> Subtopic 1",
Answer: "Answer for subtopic 1",
},
{
Question: "Main Topic ‒> Subtopic 1 ‒> Detail 1",
Answer: "Answer for detail 1",
},
{
Question: "Main Topic ‒> Subtopic 1 ‒> Detail 2",
Answer: "Answer for detail 2",
},
{
Question: "Main Topic ‒> Subtopic 2",
Answer: "Answer for subtopic 2",
},
}
cards, err := readCards(bytes.NewBufferString(input), "")
if err != nil {
t.Fatal(err)
}
if len(cards) != len(expected) {
t.Fatalf("Wrong length: got %d, want %d", len(cards), len(expected))
}
for i, card := range cards {
if card.Question != expected[i].Question {
t.Errorf("Card %d Question: got %q, want %q",
i, card.Question, expected[i].Question)
}
if card.Answer != expected[i].Answer {
t.Errorf("Card %d Answer: got %q, want %q",
i, card.Answer, expected[i].Answer)
}
}
}

func TestReadCardsWithImagePath(t *testing.T) {
input := `
## Card with image in answer
Expand Down
10 changes: 7 additions & 3 deletions internal/deck_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,13 @@ func TestOpenDeck(t *testing.T) {
}

func TestMissingAnswer(t *testing.T) {
_, err := NewDeckFromFile("samples/testdata/test-2.md")
if err == nil {
t.Error("missing error")
d, err := NewDeckFromFile("samples/testdata/test-2.md")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// The file has 5 questions, but question 5 has no answer and should be skipped
if len(d.Cards) != 4 {
t.Errorf("Expected 4 cards (skipping the one with no answer), got %d", len(d.Cards))
}
}

Expand Down