french-wiktionary-flashcards

Easily create Anki flashcards from Wiktionary data.
Log | Files | Refs | README

setup.go (6586B)


      1 package main
      2 
      3 import (
      4 	"bufio"
      5 	"bytes"
      6 	"database/sql"
      7 	"fmt"
      8 	"html/template"
      9 	"io"
     10 	"os"
     11 	"strings"
     12 
     13 	tea "github.com/charmbracelet/bubbletea"
     14 	"github.com/goccy/go-json"
     15 )
     16 
     17 func setupTables(db *sql.DB) error {
     18 	_, err := db.Exec("create table IF NOT EXISTS words (word text not null, definition text);")
     19 	if err != nil {
     20 		return fmt.Errorf("creating table: %s", err)
     21 	}
     22 
     23 	// Faster import performance.
     24 	_, err = db.Exec("PRAGMA synchronous = OFF;")
     25 	if err != nil {
     26 		return fmt.Errorf("setting risky writes: %s", err)
     27 	}
     28 
     29 	return nil
     30 }
     31 
     32 func isDatabaseEmpty(db *sql.DB) tea.Cmd {
     33 	return func() tea.Msg {
     34 		row := db.QueryRow(`SELECT count(*) as count from words`)
     35 		var count int
     36 		err := row.Scan(&count)
     37 		if err != nil {
     38 			return errMsg(fmt.Errorf("counting rows: %s", err))
     39 		}
     40 
     41 		// Only populate the database if it is empty.
     42 		return isDictionaryEmptyMsg(count == 0)
     43 	}
     44 }
     45 
     46 type rawDictionaryEntry struct {
     47 	Word      string   `json:"word"`
     48 	LangCode  string   `json:"lang_code"`
     49 	POS       string   `json:"pos_title"`
     50 	Etymology []string `json:"etymology_texts"`
     51 	Senses    []sense  `json:"senses"`
     52 	Sounds    []sound  `json:"sounds"`
     53 	Tags      []string `json:"tags"`
     54 }
     55 
     56 type sense struct {
     57 	Glosses  []string  `json:"glosses"`
     58 	Examples []example `json:"examples"`
     59 }
     60 
     61 type example struct {
     62 	Text string `json:"text"`
     63 }
     64 
     65 type sound struct {
     66 	IPA string `json:"ipa"`
     67 }
     68 
     69 type templateReadyDictionaryEntry struct {
     70 	Word      string
     71 	POS       string
     72 	Etymology string
     73 	Senses    []SenseForDictionaryEntry
     74 	Sound     string
     75 	Gender    string
     76 }
     77 
     78 type SenseForDictionaryEntry struct {
     79 	Sense   string
     80 	Example string
     81 }
     82 
     83 // dictionaryPopulator contains all the information required to populate the
     84 // SQLite dictionary from the raw JSONL data. This is in a struct so that we can
     85 // report progress back to the UI, then resume where we left off.
     86 type dictionaryPopulator struct {
     87 	db                *sql.DB
     88 	rawDictionaryPath string
     89 	langCode          string
     90 
     91 	tx      *sql.Tx
     92 	stmt    *sql.Stmt
     93 	tmpl    *template.Template
     94 	fh      *os.File
     95 	scanner *bufio.Scanner
     96 
     97 	totalLines  int
     98 	currentLine int
     99 }
    100 
    101 func setupPopulator(dp *dictionaryPopulator) tea.Cmd {
    102 	return func() tea.Msg {
    103 		var err error
    104 
    105 		// Set up the template
    106 		dp.tmpl, err = template.New("entry").Parse(
    107 			`<p>{{ .Word }} {{ .Sound }} <i>{{ .POS }} {{ .Gender }}</i></p>
    108 <ol>{{ range .Senses}}
    109 	<li class=sense>{{ .Sense }}<br>
    110 	{{ if .Example }}<ul><li><i>{{ .Example }}</i></li></ul></li>{{ end }}
    111 {{ end }}</ol>
    112 {{ if .Etymology }}<p><i>Étymologie: {{ .Etymology }}</i>{{ end }}`)
    113 		if err != nil {
    114 			return errMsg(fmt.Errorf("preparing template: %w", err))
    115 		}
    116 
    117 		dp.tx, err = dp.db.Begin()
    118 		if err != nil {
    119 			return errMsg(fmt.Errorf("starting transaction: %w", err))
    120 		}
    121 
    122 		// Set up a prepared statement
    123 		dp.stmt, err = dp.tx.Prepare("insert into words(word, definition) values(?, ?)")
    124 		if err != nil {
    125 			return errMsg(fmt.Errorf("preparing statement: %w", err))
    126 		}
    127 
    128 		dp.fh, err = os.Open(dp.rawDictionaryPath)
    129 		if err != nil {
    130 			return errMsg(fmt.Errorf("opening: %w", err))
    131 		}
    132 
    133 		// Figure out how many lines the file has, for reporting import
    134 		// progress.
    135 		lines, err := lineCounter(dp.fh)
    136 		if err != nil {
    137 			return errMsg(fmt.Errorf("reading lines from file: %w", err))
    138 		}
    139 		dp.totalLines = lines
    140 
    141 		// We've just read through the whole file, reset the read position to
    142 		// the beginning because we're about to set up a scanner on it.
    143 		dp.fh.Seek(0, 0)
    144 
    145 		dp.scanner = bufio.NewScanner(dp.fh)
    146 		maxCapacity := 2_000_000
    147 		buf := make([]byte, maxCapacity)
    148 		dp.scanner.Buffer(buf, maxCapacity)
    149 
    150 		return populatingDictionaryMsg(dp)
    151 	}
    152 }
    153 
    154 func lineCounter(r io.Reader) (int, error) {
    155 	buf := make([]byte, 64*1024)
    156 	count := 0
    157 	lineSep := []byte{'\n'}
    158 
    159 	for {
    160 		c, err := r.Read(buf)
    161 		count += bytes.Count(buf[:c], lineSep)
    162 
    163 		switch {
    164 		case err == io.EOF:
    165 			return count, nil
    166 
    167 		case err != nil:
    168 			return count, err
    169 		}
    170 	}
    171 }
    172 
    173 func populateDictionary(dp *dictionaryPopulator) tea.Cmd {
    174 	return func() tea.Msg {
    175 		for dp.scanner.Scan() {
    176 			dp.currentLine++
    177 
    178 			var result rawDictionaryEntry
    179 			json.Unmarshal([]byte(dp.scanner.Text()), &result)
    180 			if result.LangCode != dp.langCode {
    181 				continue
    182 			}
    183 
    184 			// Clean up the word. Replace apostrophes (common in phrases) with
    185 			// single quotes (more likely to be typed by a user).
    186 			result.Word = strings.ReplaceAll(result.Word, `’`, `'`)
    187 
    188 			// Create the definition text.
    189 			entry := templateReadyDictionaryEntry{
    190 				Word: result.Word,
    191 				POS:  strings.ToLower(result.POS),
    192 			}
    193 			if len(result.Etymology) > 0 {
    194 				entry.Etymology = strings.TrimSpace(result.Etymology[0])
    195 			}
    196 			if len(result.Sounds) > 0 {
    197 				entry.Sound = result.Sounds[0].IPA
    198 			}
    199 
    200 			var genders, numbers []string
    201 			for _, r := range result.Tags {
    202 				switch r {
    203 				case "masculine":
    204 					genders = append(genders, "masculin")
    205 				case "feminine":
    206 					genders = append(genders, "féminin")
    207 				case "plural":
    208 					numbers = append(numbers, "pluriel")
    209 				case "singular":
    210 					numbers = append(numbers, "singulier")
    211 				}
    212 			}
    213 			entry.Gender = strings.Join(
    214 				[]string{
    215 					strings.Join(genders, " / "),
    216 					strings.Join(numbers, " et "),
    217 				},
    218 				" ",
    219 			)
    220 
    221 			for _, s := range result.Senses {
    222 				var example string
    223 				if len(s.Examples) > 0 {
    224 					example = s.Examples[0].Text
    225 				}
    226 				sense := strings.Join(s.Glosses, "; ")
    227 				entry.Senses = append(
    228 					entry.Senses,
    229 					SenseForDictionaryEntry{Sense: sense, Example: example},
    230 				)
    231 			}
    232 
    233 			formattedDefinition := strings.Builder{}
    234 			err := dp.tmpl.Execute(&formattedDefinition, entry)
    235 			if err != nil {
    236 				return errMsg(fmt.Errorf("failed to render: %w", err))
    237 			}
    238 
    239 			// Insert the entry
    240 			_, err = dp.stmt.Exec(entry.Word, formattedDefinition.String())
    241 			if err != nil {
    242 				return errMsg(fmt.Errorf("inserting '%s': %w", entry.Word, err))
    243 			}
    244 
    245 			// Report status every once in a while by breaking out to the caller
    246 			if dp.currentLine%10000 == 0 {
    247 				return populatingDictionaryMsg(dp)
    248 			}
    249 		}
    250 
    251 		// If we're outside of the loop, we either encountered an error, or it's
    252 		// time to commit the changes.
    253 		if err := dp.scanner.Err(); err != nil {
    254 			return errMsg(fmt.Errorf("scanning: %w", err))
    255 		}
    256 
    257 		if err := dp.tx.Commit(); err != nil {
    258 			return errMsg(fmt.Errorf("committing: %w", err))
    259 		}
    260 		_, err := dp.db.Exec("create index wordindex on words(word);")
    261 		if err != nil {
    262 			return errMsg(fmt.Errorf("creating index: %s", err))
    263 		}
    264 
    265 		// Clean up resources
    266 		dp.stmt.Close()
    267 		dp.fh.Close()
    268 
    269 		return isDictionaryEmptyMsg(false) // We're done!
    270 	}
    271 }