Todo App
A complete command-line application: argument parsing with Os.args(), JSON persistence through the json library declared in dependencies:, match over subcommands, and rendering split into its own module.
Run it
Section titled “Run it”git clone https://github.com/lauriszz123/saule.gitcd saule/examples/todo-appsaule run -- add "write some Saule"saule run -- listsaule.config
Section titled “saule.config”name: "todo-app"version: "0.1.0"entry: "src/main.sau"src_dirs: ["src"]dependencies: ["../json"]min_saule_version: "26.1"src/main.sau
Section titled “src/main.sau”from storage import *from render import *
class Main static fn help() println("TODO App Usage:") println("\ttodo list") println("\ttodo add [task] [optional-due-date YYYY-MM-DD]") println("\ttodo remove [number-of-task]") println("\ttodo done [number-of-task]") println("\ttodo due [number-of-task] [due-date YYYY-MM-DD]") end
static fn main() local storage = Storage("todo.json") local args = Os.args()
if not Os.exists("todo.json") then storage.save() elseif not storage.load() then println("error: todo.json is corrupt; refusing to overwrite") return end
if #args == 0 then help() return end
match args[1] case "list" then Renderer.print(storage.getAll())
case "add" then local due: integer? = nil
if args[3] != nil then due = Os.parsedate(args[3]) if due == nil then println("invalid date: " .. args[3] .. " (expected YYYY-MM-DD)") return end end
storage.add(args[2], dueDate: due) Renderer.print(storage.getAll())
case "done" then local index: integer? = args[2] as integer if index == nil then println("error: invalid task number") return end
storage.update(index, isDone: true)
case "due" then local index: integer? = args[2] as integer
if index == nil then println("error: invalid task number") return end
if args[3] == nil then println("error: missing due date") return end
local due: integer? = Os.parsedate(args[3]) if due == nil then println("invalid date: " .. args[3] .. " (expected YYYY-MM-DD)") return end
storage.update(index, dueDate: due)
case "remove" then local index: integer? = args[2] as integer if index == nil then println("error: invalid task number") return end
match storage.remove(index) case nil then printf("Task id %d does not exist\n", index)
case task then printf("Removed task: %s\n", task) end
Renderer.print(storage.getAll())
case _ then help() end
storage.save() endendsrc/storage.sau
Section titled “src/storage.sau”from json import Json
export class Entry local todo: string local done: boolean local dueDate: integer?
fn init(todo: string, dueDate: integer?) self.todo = todo self.done = false self.dueDate = dueDate end
fn setTodo(todo: string) self.todo = todo end
fn getTodo() -> string return self.todo end
fn setDone(isDone: boolean) self.done = isDone end
fn isDone() -> boolean return self.done end
fn setDueDate(dueDate: integer) self.dueDate = dueDate end
fn getDueDate() -> integer? return self.dueDate end
fn isDue() -> boolean? if self.dueDate == nil then return end
return Os.time() >= self.dueDate endend
export class Storage local path: string local storage: table<Entry>
fn init(path: string) self.path = path self.storage = {} end
fn load() -> boolean if not Os.exists(self.path) then return false end
local file = Io.open(self.path, IoMode.Read)! local source: string? = file.read() file.close()
-- `read` is `string?`: an unreadable file is not a crash here, it is -- an empty todo list. if not source then return false end
-- Decoded JSON is plain tables, not `Entry` instances — the rows -- below are validated field by field and fed through `Entry(...)`. local data = Json.decode(source!) as table<any>
if not data then return false end
if type(data) != "table" then return false end
for _, entry in data do if type(entry) != "table" then return false end
-- `as` is the checked cast out of `any`: it yields nil when the -- stored value has the wrong shape, so a nil here means the file -- is malformed. local todo = entry.todo as string if todo == nil then return false end
local done = entry.done as boolean if done == nil then return false end
local newEntry = Entry(todo, entry.dueDate as integer) newEntry.setDone(done) Table.insert(self.storage, newEntry) end
return true end
fn save() local data: table = {}
for _, entry in self.storage do Table.insert(data, {todo: entry.getTodo(), done: entry.isDone(), dueDate: entry.getDueDate()}) end
local jsonData: string = Json.encode(data) local file = Io.open(self.path, IoMode.Write) file?.write(jsonData) file?.close() end
fn add(item: string, dueDate: integer?) Table.insert(self.storage, Entry(item, dueDate)) end
fn remove(id: integer) -> string? return Table.remove(self.storage, id)?.getTodo() end
fn update(id: integer, dueDate: integer?, isDone: boolean?) if self.storage[id] == nil then return end
if dueDate != nil then self.storage[id].setDueDate(dueDate) end
if isDone != nil then self.storage[id].setDone(isDone) end end
fn getAll() -> table<Entry> return self.storage endendsrc/render.sau
Section titled “src/render.sau”from storage import Entry
export class Renderer static fn print(tasks: table<Entry>) if #tasks == 0 then println("No tasks found.") return end
for i, task in tasks do printf( "%s %d.%s %s\n", match task.isDone() case true then "[x]"
case false then "[-]" end, i, match task.getDueDate() case nil then ""
case dueDate then String.format(" (due %s)", Os.date("%Y-%m-%d", dueDate)) end, task.getTodo() ) end endend