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.
\$\endgroup\$
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.

\$\endgroup\$
  • \$\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\$ – saeleko Mar 30 '18 at 20:30

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.