├── README.md ├── .gitignore ├── LICENSE └── main.go /README.md: -------------------------------------------------------------------------------- 1 | # mcgraw-hill-flashcards 2 | Downloads [McGraw-Hill Language learning flashcards](https://mhe-language-lab.s3.amazonaws.com/index.html) that are a companion to their books, and saves them to a file for the [Anki](https://ankiweb.net) flashcard app. 3 | 4 | 5 | The language name and book name are in the config section, and must be an exact match to what is shown on the flash card site. 6 | The file will contain two decks for each chapter of the book. One is from the target langage to English and one is from English to the target language. Files go to a directory called `output`. 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | # Outputed files from the script 24 | output 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ryan Hollis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "strings" 10 | ) 11 | 12 | const ( 13 | // These should be set to control the book that is downloaded. 14 | DesiredLanguage = "Spanish" 15 | DesiredBookName = "Complete Spanish Step-by-Step" 16 | 17 | DownloadAllBooks = false 18 | ) 19 | 20 | const ( 21 | // These are for the program and should not be changed. 22 | GetMenuUrl = "https://mhe-language-lab.azurewebsites.net/api/GetSubMenus?parentID=%d" 23 | GetFlashCardsUrl = "https://mhe-language-lab.azurewebsites.net/api/GetFlashCards?menuID=%d" 24 | FlashCards = "Flashcards" 25 | ProgressChecks = "Progress Checks" 26 | FlashCardsStudyMode = "Flashcards: Study Mode" 27 | FileStart = `#separator:tab 28 | #html:true 29 | #deck column:3 30 | ` 31 | ) 32 | 33 | func main() { 34 | err := DoEverything() 35 | if err != nil { 36 | fmt.Println(err) 37 | } 38 | } 39 | func DoEverything() error { 40 | // Get the list of languages and select the desired language. 41 | languageMenu, err := GetMenuOptions(0) 42 | if err != nil { 43 | return err 44 | } 45 | for _, languageEntry := range languageMenu { 46 | if DownloadAllBooks || languageEntry.MenuTitle == DesiredLanguage { 47 | fmt.Printf("Downloading flashcards for language %s\n", languageEntry.MenuTitle) 48 | err = DownloadFlashCardsForLanguage(languageEntry) 49 | if err != nil { 50 | return err 51 | } 52 | } 53 | } 54 | return nil 55 | } 56 | func DownloadFlashCardsForLanguage(language *MenuEntry) error { 57 | // Get the list of books for the selected language and select the desired book. 58 | bookMenu, err := GetMenuOptions(language.MenuID) 59 | if err != nil { 60 | return err 61 | } 62 | for _, bookEntry := range bookMenu { 63 | if DownloadAllBooks || bookEntry.MenuTitle == DesiredBookName { 64 | fmt.Printf("Downloading flashcards for book %s\n", bookEntry.MenuTitle) 65 | err = DownloadFlashCardsForBook(language, bookEntry) 66 | if err != nil { 67 | return err 68 | } 69 | } 70 | } 71 | return nil 72 | } 73 | 74 | func DownloadFlashCardsForBook(language, book *MenuEntry) error { 75 | // Get the chapters of the book and download the flashcards. 76 | // For each book, there are multiple options. Ones called "Flashcards" and "Progress Checks" are compatible with flashcards. 77 | // There are some books that don't have either of those. In that case, the book is not compatible with this program. 78 | flashCardOption, err := GetMenuOptionWithNames(book.MenuID, FlashCards, ProgressChecks) 79 | if err != nil { 80 | return err 81 | } 82 | if flashCardOption == nil { 83 | fmt.Printf("Book %s does not have flashcards or progress checks\n", book.MenuTitle) 84 | return nil 85 | } 86 | 87 | // Flash cards will return a list of chapters. Which might have subchapters or may lead directly to flashcards. 88 | chapters, err := GetMenuOptions(flashCardOption.MenuID) 89 | if err != nil { 90 | return err 91 | } 92 | err = os.MkdirAll("output", 0755) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | fileOutput := strings.Builder{} 98 | fileOutput.WriteString(FileStart) 99 | for _, chapter := range chapters { 100 | // Download the flashcards for the whole chapter, including subchapters. 101 | chapterCards, err := GetChapterCards(chapter) 102 | if err != nil { 103 | return err 104 | } 105 | // Make 1-digit numbers start with zero, so that alphabetical sorting works correctly. 106 | if chapter.MenuTitle[1] == '.' { 107 | chapter.MenuTitle = "0" + chapter.MenuTitle 108 | } 109 | // Strip html tags from the chapter title 110 | chapter.MenuTitle = strings.ReplaceAll(chapter.MenuTitle, "", "") 111 | chapter.MenuTitle = strings.ReplaceAll(chapter.MenuTitle, "", "") 112 | 113 | fmt.Printf("Chapter: %v\n", chapter.MenuTitle) 114 | 115 | // Write the flashcards to the file, one for Spanish to English and one for English to Spanish. 116 | for _, card := range chapterCards { 117 | title := chapter.MenuTitle 118 | if chapter.MenuTitle[2:4] == ". " { 119 | title = chapter.MenuTitle[0:4] + "(" + language.MenuTitle[0:1] + "2E) " + chapter.MenuTitle[4:] 120 | } 121 | fileOutput.WriteString(fmt.Sprintf("%s\t%s\t%s %s\n", card.SideA, card.SideB, book.MenuTitle, title)) 122 | } 123 | for _, card := range chapterCards { 124 | title := chapter.MenuTitle 125 | if chapter.MenuTitle[2:4] == ". " { 126 | title = chapter.MenuTitle[0:4] + "(E2" + language.MenuTitle[0:1] + ") " + chapter.MenuTitle[4:] 127 | } 128 | fileOutput.WriteString(fmt.Sprintf("%s\t%s\t%s %s\n", card.SideB, card.SideA, book.MenuTitle, title)) 129 | } 130 | } 131 | if fileOutput.Len() == len(FileStart) { 132 | fmt.Printf("No flashcards found for book %s\n", book.MenuTitle) 133 | return nil 134 | } else { 135 | fileName := fmt.Sprintf("output/%s.txt", book.MenuTitle) 136 | err = os.WriteFile(fileName, []byte(fileOutput.String()), 0644) 137 | if err != nil { 138 | return err 139 | } 140 | 141 | fmt.Printf("File written to %s\n", fileName) 142 | } 143 | 144 | return nil 145 | } 146 | 147 | func GetChapterCards(chapter *MenuEntry) ([]*Card, error) { 148 | fmt.Printf(" %+v\n", chapter.MenuTitle) 149 | var chapterCards []*Card 150 | if chapter.FlashCardsAndQuiz { 151 | // If this chapter is the bottom of the graph, get the flashcards. 152 | flashCardMode, err := GetMenuOptionWithNames(chapter.MenuID, FlashCardsStudyMode) 153 | if err != nil { 154 | return nil, err 155 | } 156 | if flashCardMode == nil { 157 | fmt.Printf("Chapter %s does not have flashcard mode", chapter.MenuTitle) 158 | return nil, nil 159 | } 160 | cards, err := GetFlashCards(flashCardMode.MenuID) 161 | if err != nil { 162 | return nil, err 163 | } 164 | for _, card := range cards { 165 | if strings.Contains(card.SideA, "\r\n") { 166 | // There is a broken card in the Spanish course that totally mangles the output. 167 | // Handle that card correctly. There are several cards combined into one message, this will 168 | // actually put them in the output correctly. 169 | parts := strings.Split(card.SideA, "\r\n") 170 | for _, part := range parts[1:] { 171 | aAndB := strings.Split(part, "\t") 172 | if len(aAndB) == 2 { 173 | chapterCards = append(chapterCards, &Card{ 174 | SideA: aAndB[0], 175 | SideB: aAndB[1], 176 | }) 177 | } 178 | } 179 | continue 180 | } else { 181 | card.SideA = strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(card.SideA, "\t", ""), "\n", ""), "\r", ""), "\n", "") 182 | card.SideB = strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(card.SideB, "\t", ""), "\n", ""), "\r", ""), "\n", "") 183 | } 184 | 185 | chapterCards = append(chapterCards, card) 186 | } 187 | } else { 188 | // If this chapter has subchapters, get the flashcards for each subchapter. 189 | subChapters, err := GetMenuOptions(chapter.MenuID) 190 | if err != nil { 191 | return nil, err 192 | } 193 | for _, subChapter := range subChapters { 194 | subChapterCards, err := GetChapterCards(subChapter) 195 | if err != nil { 196 | return nil, err 197 | } 198 | chapterCards = append(chapterCards, subChapterCards...) 199 | } 200 | } 201 | return chapterCards, nil 202 | } 203 | 204 | func GetMenuOptions(parentID int) ([]*MenuEntry, error) { 205 | resp, err := http.Get(fmt.Sprintf(GetMenuUrl, parentID)) 206 | if err != nil { 207 | return nil, err 208 | } 209 | defer resp.Body.Close() 210 | body, err := io.ReadAll(resp.Body) 211 | if err != nil { 212 | return nil, err 213 | } 214 | var getMenuResponse []*MenuEntry 215 | if err := json.Unmarshal(body, &getMenuResponse); err != nil { 216 | return nil, err 217 | } 218 | return getMenuResponse, nil 219 | } 220 | 221 | func GetMenuOptionWithNames(parentID int, names ...string) (*MenuEntry, error) { 222 | options, err := GetMenuOptions(parentID) 223 | if err != nil { 224 | return nil, err 225 | } 226 | for _, option := range options { 227 | for _, name := range names { 228 | if option.MenuTitle == name { 229 | return option, nil 230 | } 231 | } 232 | } 233 | return nil, nil 234 | } 235 | 236 | func GetFlashCards(menuID int) ([]*Card, error) { 237 | resp, err := http.Get(fmt.Sprintf(GetFlashCardsUrl, menuID)) 238 | if err != nil { 239 | return nil, err 240 | } 241 | defer resp.Body.Close() 242 | body, err := io.ReadAll(resp.Body) 243 | if err != nil { 244 | return nil, err 245 | } 246 | var getFlashCardsResponse []*Card 247 | if err := json.Unmarshal(body, &getFlashCardsResponse); err != nil { 248 | return nil, err 249 | } 250 | return getFlashCardsResponse, nil 251 | } 252 | 253 | type MenuEntry struct { 254 | MenuID int `json:"Menu_ID"` 255 | MenuTitle string `json:"MenuTitle"` 256 | TitleInformation string `json:"TitleInformation"` 257 | Base64Image string `json:"Base64Image"` 258 | MenuFormat string `json:"MenuFormat"` 259 | DeckType string `json:"DeckType"` 260 | SelfScoring bool `json:"SelfScoring"` 261 | DeckTitle string `json:"DeckTitle"` 262 | FlashCardsAndQuiz bool `json:"FlashCardsAndQuiz"` 263 | SideALabel string `json:"SideALabel"` 264 | SideBLabel string `json:"SideBLabel"` 265 | DataDeckID int `json:"DataDeck_ID"` 266 | ForceSideA bool `json:"ForceSideA"` 267 | Unpublished bool `json:"Unpublished"` 268 | } 269 | 270 | type Card struct { 271 | CardID int `json:"Card_ID"` 272 | SideA string `json:"SideA"` 273 | SideB string `json:"SideB"` 274 | StyleA string `json:"StyleA"` 275 | StyleB string `json:"StyleB"` 276 | SideAAudio string `json:"SideAAudio"` 277 | SideBAudio string `json:"SideBAudio"` 278 | SideAImage string `json:"SideAImage"` 279 | SideBImage string `json:"SideBImage"` 280 | SideAVideo string `json:"SideAVideo"` 281 | SideBVideo string `json:"SideBVideo"` 282 | SideALabel string `json:"SideALabel"` 283 | SideBLabel string `json:"SideBLabel"` 284 | TTSAudio bool `json:"TTSAudio"` 285 | TTSSideA string `json:"TTSSideA"` 286 | TTSSideB string `json:"TTSSideB"` 287 | } 288 | --------------------------------------------------------------------------------