# Simple terminal-based Lua task-tracking/calendar

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 string
• due: the due date, itself a table with number fields day, month and year
• comment: a string
• done: 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
done=true
]
[
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
io.write(s)
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
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
end
end
end
end

-- f: file to save the tasks to
-- t: tasks table
-- 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 ctask = {}
for line in f:lines() do
if line:match('%[') then
elseif line:match('%]') then
else
if line:match('name*') then
elseif line:match('due*') then
elseif line:match('comment*') then
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')

local loadpath = (os.getenv('HOME') .. '/.luacal') or 'luacal'
end

-- main loop
while running do
io.write('luacal> ')
local command = io.read()

if command == 'add' then
-- query user for the new task
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')
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

print('cannot find task ' .. name .. '')
end

-- save everything to disk
local savepath = (os.getenv('HOME') .. '/.luacal') or 'luacal'
local savefile = io.open(savepath, 'w')
savefile:close()

-- print all active tasks
elseif command == 'print' then

-- also print completed tasks
elseif command == 'printall' then

-- 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, say time, itself a table with fields hour and minute. Now if time is not defined it will be nil which is fine, but then some other function might try to access time.hour and will complain about attempt 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 as foo = some_table.subtable.nil_field or 'some_default_string'. In this case some_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's json package so it would not have been much of an exercise to use it here.

In the main while loop, you have

io.write('luacal> ')
local command = io.read()


for which you already have a separate function:

local command = ask('luacal> ')


Everytime you call save_tasks, you are first creating a file type object, passing it around and then handling the closure of its buffer. Why not just provide the path to file itself instead? Let the function worry about opening/closing of the buffer.

Define the file path as global. So that a single file gets manipulated during the life of your program (even if the process's env state magically changes).

Nearly all functions defined inside luacal> prompt accept tasks. Put that as the first argument (why will come later)

You are roughly storing the whole table into a file. Take a look at some of user contributed code for table-pickling in lua. You can make use of it so that your load_tasks will be replaced by a simple loadfile() method.

You can also try creating a SQLite based DB, but that is upto you!

The following if-else nest can be replaced with a single match:

if line:match('name*') then
elseif line:match('due*') then
elseif line:match('comment*') then
elseif line:match('done*') then
ctask.done = line:match('done=(.+)') == 'true'
end


becomes

field, value = line:match '^([^=]+)=(.+)\$'


although it assumes that

1. you're not pickling the tables, and sticking to your own structures
2. there would be no malicious activity in the datafile.

For time-field storage, you can take a look at the os.time() function, which accepts a tabular input similar to the structure of your own time field. Storing the epoch, imo, is better as you can later format the data using os.date() in user's preferred format string (for eg. I prefer dates in ISO 8601 format).

As to why I said before for having tasks as the first parameter to all command functions:

local COMMANDS = {
table.insert(tasks, {...})    -- same code from above
save_tasks(tasks)             -- will pick filepath from global
end,
end,
end,
.
.
.
...
}


and the while loop shall carry:

command = ask "luacal> "
if command == "exit" or not command then
running = false
break
end
if COMMANDS[command] == nil then
string.format('unknown command %s, command', command)
else