I am learning Lua and wrote this little calendar/time-tracking script. It allows me to create tasks to do with a deadline, print them, and mark them as being done. A task is a table with the following fields:
name
: a stringdue
: the due date, itself a table with number fieldsday
,month
andyear
comment
: a stringdone
: A boolean stating whether it has been completed or not
The script works by storing all tasks in a table named tasks
. It automatically saves it in a file located at $HOME/.luacal
or luacal
if $HOME
is not defined.
The data file is a basic custom text-based format. It starts with a [
alone on a line which denotes the start of a task, then on each line is a property. Their order does not matter, and ends with a ]
, itself on its own line too. Here is a simple example of such a file, with two tasks:
[
name=Hello
due=22/3/2018
comment=Task done
done=true
]
[
name=Testing saving tasks
due=23/3/2018
comment=Just want to see if auto-saving works fine
done=false
]
The date-input code is designed so that if one element of the date is missing it is filled with that of the current day, so if only the day and the month are given the year field will be implicitly filled with the current year. If no date is given that means the task is due for today. One improvement will be to automatically increment the month (and year if necessary) if the given day (or month) has already passed within the month (or year).
Here is the code
#!/usr/bin/env lua
-- scope : simple calendar with lua to help me manage my time
-- prints `s` and returns user input
local function ask(s)
io.write(s)
return io.read()
end
-- s: string to parse as a date
-- if one element is not provided,
-- it is filled with today's
local function parse_date(s)
local date = {}
for v in s:gmatch('%d+') do
table.insert(date, v)
end
local day = tonumber(date[1] or os.date('%d'))
local month = tonumber(date[2] or os.date('%m'))
local year = tonumber(date[3] or os.date('%Y'))
return { day = day, month = month, year = year }
end
-- d: table to be converted as a string
-- with format dd/mm/yyyy
local function dump_date(d)
return d.day .. '/' .. d.month .. '/' .. d.year
end
-- t: tasks table
-- printdone: whether to display completed tasks,
-- which are hidden by default
local function print_tasks(t,printdone)
for _, v in pairs(t) do
if not v.done or printdone then
print('`' .. v.name .. '`:')
print('\tdue: ' .. dump_date(v.due))
print('\tcomment: ' .. v.comment)
if v.done then
print('\ttask is done')
end
end
end
end
-- f: file to save the tasks to
-- t: tasks table
local function save_tasks(f,t)
-- save the current output file and
-- set the default to the given one
local saved_output = io.output()
io.output(f)
for _,v in pairs(t) do
io.write('[\n')
io.write('name=' .. v.name .. '\n')
io.write('due=' .. dump_date(v.due) .. '\n')
io.write('comment=' .. v.comment .. '\n')
io.write('done=' .. tostring(v.done) .. '\n')
io.write(']\n')
end
-- restore previous output file
io.output(saved_output)
end
-- f: file to read from
-- t: tasks table to be filled
local function load_tasks(f,t)
local ctask = {}
for line in f:lines() do
if line:match('%[') then
ctask = {}
elseif line:match('%]') then
table.insert(t, ctask)
else
if line:match('name*') then
ctask.name = line:match('name=([%a%s]+)')
elseif line:match('due*') then
ctask.due = parse_date(line:match('due=([%d%/]+)'))
elseif line:match('comment*') then
ctask.comment = line:match('comment=(.+)')
elseif line:match('done*') then
ctask.done = line:match('done=(.+)') == 'true'
end
end
end
end
-- actual code
local running = true
local tasks = {}
print('LuaCal - Lua Calendar')
-- load tasks if file exists
local loadpath = (os.getenv('HOME') .. '/.luacal') or 'luacal'
local loadfile = io.open(loadpath, 'r')
if loadfile then
load_tasks(loadfile, tasks)
loadfile:close()
end
-- main loop
while running do
io.write('luacal> ')
local command = io.read()
-- add a new task
if command == 'add' then
-- query user for the new task
table.insert(tasks, {
name = ask('name: '),
due = parse_date(ask('due: ')),
comment = ask('comment: '),
done = false
})
-- save everything to disk
local savepath = (os.getenv('HOME') .. '/.luacal') or 'luacal'
local savefile = io.open(savepath, 'w')
save_tasks(savefile, tasks)
savefile:close()
-- mark one task as done
elseif command =='done' then
local name = ask('name: ')
local found = false
for _,v in pairs(tasks) do
if v.name == name then
v.done = true
found = true
end
end
if not found then
print('cannot find task `' .. name .. '`')
end
-- save everything to disk
local savepath = (os.getenv('HOME') .. '/.luacal') or 'luacal'
local savefile = io.open(savepath, 'w')
save_tasks(savefile, tasks)
savefile:close()
-- print all active tasks
elseif command == 'print' then
print_tasks(tasks, false)
-- also print completed tasks
elseif command == 'printall' then
print_tasks(tasks, true)
-- quite on demand or <eof>
elseif command == 'exit' or not command then
print('bye!')
running = false
else
print(string.format('unknown command `%s`, command', command))
end
end
The code is functional and I have not managed to make it crash with weird input so far, I am very satisfied with it. Now obviously since I am not very experienced with Lua there might be blatantly wrong things in there, which is why I decided to post it here.
My two main concerns are :
- Poor tables usage: I come from a C background and I always find myself confused when I need to create tables in scripting languages, I don't know if there's a way to predefine substructures (for analogy, a struct in a struct in C) so that all functions working on them have a common base, or if I just go ahead and declare them on the fly, as I did here. Let's say for instance I add a field to the
due
table in a task, saytime
, itself a table with fieldshour
andminute
. Now iftime
is not defined it will benil
which is fine, but then some other function might try to accesstime.hour
and will complain aboutattempt to index field 'hour' (a nil value)
. I could have a lot of nil-checking conditions but that would just make the code ugly. Is there a way to create a table with all subtables already created, so I don't get indexing errors? An field not being present is simplynil
and is not a problem as I can handle it asfoo = some_table.subtable.nil_field or 'some_default_string'
. In this casesome_table.subtable
is already a table, even if it is empty, which is what I want. - Input/File parsing: I followed the official documentation for the
match
method and got it to work relatively easily, I'm just uncertain as whether I've used it properly or not, or even whethere there's a better way to do this. There are a bunch of json package available through luarocks but I'm already very familiar with python'sjson
package so it would not have been much of an exercise to use it here.