4
\$\begingroup\$

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
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, 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.
asked Mar 22, 2018 at 20:28
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

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
 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

becomes

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

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 = {
 add = function(tasks)
 table.insert(tasks, {...}) -- same code from above
 save_tasks(tasks) -- will pick filepath from global
 end,
 print = function(tasks)
 print_tasks(tasks, false)
 end,
 printall = function(tasks)
 print_tasks(tasks, true)
 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
 COMMANDS[command](tasks)
end

This way, adding other features/commands makes it easy.

answered Mar 28, 2018 at 11:37
\$\endgroup\$
1
  • \$\begingroup\$ I initially thought an SQL database would be overkill, but then if I have too many tasks storing everything in a table might not be super efficient. That's a good point. Other than that this is absolutely outstanding, all of my questions have been answered. Many thanks \$\endgroup\$ Commented Mar 30, 2018 at 20:30

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.