Odin and Me ยท Appendix B

Appendix B: Full source listings

The complete, verified Muninn project. These files are the destination Chapters 9 through 12 build toward; the copy in code/ is identical. Type-checks with odin check ., builds with odin build . -o:speed, runs as a single native binary. One package main, seven files at the project root.

Build and run:

cd code
./run.sh          # odin run .
./build.sh        # optimised release binary

main.odin

package main

import "core:bufio"
import "core:os"

main :: proc() {
	enable_ansi()

	store := load_notes()

	scanner: bufio.Scanner
	bufio.scanner_init(&scanner, os.to_reader(os.stdin))
	defer bufio.scanner_destroy(&scanner)

	run_menu(&store, &scanner)
}

note.odin

package main

Note :: struct {
	id:         int,
	title:      string,
	body:       string,
	created_at: int, // an incrementing counter, not a real timestamp
}

store.odin

package main

import "core:strings"

Store :: struct {
	notes:   [dynamic]Note,
	next_id: int,
}

add_note :: proc(store: ^Store, title, body: string) -> int {
	store.next_id += 1
	id := store.next_id
	append(&store.notes, Note{
		id         = id,
		title      = strings.clone(title),
		body       = strings.clone(body),
		created_at = id,
	})
	return id
}

find_note :: proc(store: ^Store, id: int) -> (Note, bool) {
	for n in store.notes {
		if n.id == id {
			return n, true
		}
	}
	return {}, false
}

delete_note :: proc(store: ^Store, id: int) -> bool {
	for n, i in store.notes {
		if n.id == id {
			unordered_remove(&store.notes, i)
			return true
		}
	}
	return false
}

list_notes :: proc(store: ^Store) -> []Note {
	return store.notes[:]
}

search_notes :: proc(store: ^Store, query: string, allocator := context.allocator) -> [dynamic]Note {
	results: [dynamic]Note
	results.allocator = allocator
	lower_query := strings.to_lower(query)
	defer delete(lower_query)

	for n in store.notes {
		lower_title := strings.to_lower(n.title)
		defer delete(lower_title)
		if strings.contains(lower_title, lower_query) {
			append(&results, n)
		}
	}
	return results
}

screen.odin

package main

import "core:bufio"
import "core:fmt"
import "core:strings"
import win32 "core:sys/windows"

when ODIN_OS == .Windows {
	enable_ansi :: proc() {
		handle := win32.GetStdHandle(win32.STD_OUTPUT_HANDLE)
		mode: win32.DWORD
		win32.GetConsoleMode(handle, &mode)
		win32.SetConsoleMode(handle, mode | win32.ENABLE_VIRTUAL_TERMINAL_PROCESSING)
	}
} else {
	enable_ansi :: proc() {}
}

clear_screen :: proc() {
	fmt.print("\x1b[2J\x1b[H")
}

move_cursor :: proc(row, col: int) {
	fmt.printf("\x1b[%d;%dH", row, col)
}

Colour :: enum {
	Default,
	Red,
	Green,
	Yellow,
	Cyan,
}

colour_code :: proc(c: Colour) -> int {
	switch c {
	case .Default: return 39
	case .Red:     return 31
	case .Green:   return 32
	case .Yellow:  return 33
	case .Cyan:    return 36
	}
	return 39
}

set_colour :: proc(c: Colour) {
	fmt.printf("\x1b[%dm", colour_code(c))
}

reset_colour :: proc() {
	fmt.print("\x1b[0m")
}

draw_box :: proc(width, height: int) {
	b: strings.Builder
	strings.builder_init(&b)
	defer strings.builder_destroy(&b)

	strings.write_rune(&b, 'โ”Œ')
	for _ in 0 ..< width-2 {
		strings.write_rune(&b, 'โ”€')
	}
	strings.write_rune(&b, 'โ”')
	strings.write_byte(&b, '\n')

	for _ in 0 ..< height-2 {
		strings.write_rune(&b, 'โ”‚')
		for _ in 0 ..< width-2 {
			strings.write_byte(&b, ' ')
		}
		strings.write_rune(&b, 'โ”‚')
		strings.write_byte(&b, '\n')
	}

	strings.write_rune(&b, 'โ””')
	for _ in 0 ..< width-2 {
		strings.write_rune(&b, 'โ”€')
	}
	strings.write_rune(&b, 'โ”˜')

	fmt.println(strings.to_string(b))
}

banner :: proc() {
	set_colour(.Cyan)
	fmt.println("โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—")
	fmt.println("โ•‘      MUNINN      โ•‘")
	fmt.println("โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•")
	reset_colour()
}

press_enter_to_continue :: proc(scanner: ^bufio.Scanner) {
	fmt.print("Press Enter to continue...")
	read_line(scanner)
}
package main

import "core:bufio"
import "core:fmt"

print_main_menu :: proc() {
	fmt.println("1. Add a note")
	fmt.println("2. List notes")
	fmt.println("3. Search notes")
	fmt.println("4. Delete a note")
	fmt.println("Q. Quit")
	fmt.print("Choose: ")
}

print_notes_table :: proc(notes: []Note) {
	if len(notes) == 0 {
		fmt.println("(no notes yet)")
		return
	}
	for n in notes {
		fmt.printfln("%4d  %-20s  %s", n.id, n.title, n.body)
	}
}

do_add :: proc(store: ^Store, scanner: ^bufio.Scanner) {
	fmt.print("Title: ")
	title, title_ok := read_line(scanner)
	if !title_ok {
		return
	}
	fmt.print("Body: ")
	body, body_ok := read_line(scanner)
	if !body_ok {
		return
	}
	id := add_note(store, title, body)
	save_notes(store)
	fmt.printfln("Added note %d.", id)
}

do_list :: proc(store: ^Store) {
	print_notes_table(list_notes(store))
}

do_search :: proc(store: ^Store, scanner: ^bufio.Scanner) {
	fmt.print("Search title for: ")
	query, ok := read_line(scanner)
	if !ok {
		return
	}
	results := search_notes(store, query)
	defer delete(results)
	print_notes_table(results[:])
}

do_delete :: proc(store: ^Store, scanner: ^bufio.Scanner) {
	print_notes_table(list_notes(store))
	if len(store.notes) == 0 {
		return
	}
	id, ok := read_int_in_range(scanner, "Delete which id? ", 1, store.next_id)
	if !ok {
		return
	}

	n, found := find_note(store, id)
	if !found {
		fmt.println("No note with that id.")
		return
	}

	fmt.printfln("Delete \"%s\"? (y/n): ", n.title)
	confirm, confirm_ok := read_line(scanner)
	if !confirm_ok || confirm != "y" {
		fmt.println("Cancelled.")
		return
	}

	delete_note(store, id)
	save_notes(store)
	fmt.println("Deleted.")
}

run_menu :: proc(store: ^Store, scanner: ^bufio.Scanner) {
	for {
		clear_screen()
		banner()
		fmt.println()
		print_main_menu()

		choice, ok := read_line(scanner)
		if !ok {
			return
		}

		switch choice {
		case "1":
			do_add(store, scanner)
		case "2":
			do_list(store)
		case "3":
			do_search(store, scanner)
		case "4":
			do_delete(store, scanner)
		case "Q", "q":
			reset_colour()
			fmt.println("Goodbye!")
			return
		case:
			fmt.println("Not a valid choice.")
		}

		press_enter_to_continue(scanner)
	}
}

persist.odin

package main

import "core:fmt"
import "core:os"
import "core:strconv"
import "core:strings"

SAVE_FILE :: "muninn_notes.txt"
FIELD_SEP :: "\x1f"

encode_note :: proc(n: Note, b: ^strings.Builder) {
	strings.write_int(b, n.id)
	strings.write_string(b, FIELD_SEP)
	strings.write_int(b, n.created_at)
	strings.write_string(b, FIELD_SEP)
	strings.write_string(b, n.title)
	strings.write_string(b, FIELD_SEP)
	strings.write_string(b, n.body)
	strings.write_byte(b, '\n')
}

decode_note :: proc(line: string) -> (n: Note, ok: bool) {
	parts := strings.split(line, FIELD_SEP)
	defer delete(parts)
	if len(parts) != 4 {
		return {}, false
	}

	id, id_ok := strconv.parse_int(parts[0])
	created_at, ca_ok := strconv.parse_int(parts[1])
	if !id_ok || !ca_ok {
		return {}, false
	}

	return Note{
		id         = id,
		created_at = created_at,
		title      = strings.clone(parts[2]),
		body       = strings.clone(parts[3]),
	}, true
}

save_notes :: proc(store: ^Store) {
	b: strings.Builder
	strings.builder_init(&b)
	defer strings.builder_destroy(&b)

	for n in store.notes {
		encode_note(n, &b)
	}

	err := os.write_entire_file(SAVE_FILE, transmute([]byte)strings.to_string(b))
	if err != nil {
		fmt.eprintfln("could not save notes: %v", err)
	}
}

load_notes :: proc() -> Store {
	store: Store

	data, err := os.read_entire_file_from_path(SAVE_FILE, context.allocator)
	if err != nil {
		return store // missing file -- start empty, not a crash
	}
	defer delete(data)

	skipped := 0
	for line in strings.split_lines(string(data)) {
		if len(line) == 0 {
			continue
		}
		n, ok := decode_note(line)
		if !ok {
			skipped += 1
			continue
		}
		append(&store.notes, n)
		if n.id > store.next_id {
			store.next_id = n.id
		}
	}

	if skipped > 0 {
		fmt.eprintfln("warning: skipped %d unreadable line(s) in %s", skipped, SAVE_FILE)
	}

	return store
}

input.odin

package main

import "core:bufio"
import "core:fmt"
import "core:strconv"
import "core:strings"

read_line :: proc(scanner: ^bufio.Scanner) -> (line: string, ok: bool) {
	if !bufio.scanner_scan(scanner) {
		return "", false
	}
	return strings.trim_space(bufio.scanner_text(scanner)), true
}

// read_int_in_range loops until the user types a whole number between lo and hi
// inclusive, or input runs out entirely.
read_int_in_range :: proc(scanner: ^bufio.Scanner, prompt: string, lo, hi: int) -> (value: int, ok: bool) {
	for {
		fmt.print(prompt)
		line, has_line := read_line(scanner)
		if !has_line {
			return 0, false
		}

		n, parsed := strconv.parse_int(line)
		if !parsed {
			fmt.println("Please enter a whole number.")
			continue
		}
		if n < lo || n > hi {
			fmt.printfln("Please enter a number between %d and %d.", lo, hi)
			continue
		}
		return n, true
	}
}