[Dev Diaries] FrickinTodos

Everybody knows that when you start a new web framework or language, you have to start by a todo list, right?

My friend Steven and I have a problem: we attend a regular meeting (every other week) to synchronize with our colleagues on multiple topics, deal with emerging emergencies, etc etc. But we have so much on our plate in the intervening time that we forget some things, and we keep adding new stuff to the "to be dealt with later"

So we had an idea for a very simple way to do things:

  • We have a list of topics that need to be discussed
  • A topic has a discussion and comments attached (usually a very lively conversation)
  • Then a topic is set either as dealt with, or delayed till the next meeting
  • Every "open" topic has to end in either category
  • At the end of the meeting, when all the topics from the previous meeting, and the new ones, have either been marked as done or delayed, we generate a report, and prepare the next meeting to have all the delayed items ready for reuse.

The need to rebel

It so happens that IBM has announced that they were pulling off from Kitura. It's a shame, I like it (and use it) a lot, but hey, it's open source, so I'll keep my hopes up.

My server (on which this blog ran) died a little while ago, forcing me to reinstall everything (I've lost a couple of articles on this blog in the process), so I was very busy with "going tidy with my system", and the idea Steven and I had popped in the forefront as a way to occupy my developer brain while the gigabytes of data were transiting over the InterPipes.

So, I wanted to tie together a few technologies I know in an amateur kind of way into a production ready thing:

  • Swift DSLs
  • Kitura
  • HTTP Sessions
  • Redis
  • Docker
  • Ajax/jQuery

It's ugly (I'm no designer), but it's functional, and the workflow seems OK to me. A few caveats before I dive in:

  • There is no login/password. The data is stored in the session, meaning that if you change browser (or machine), you start new. You can't share it with someone else either. This solves one, and one only, problem: keeping track of the things that were deferred to next time, because that's the thing that we have trouble with.
  • This is for educational purposes, mostly my own. If you want to expand on it, feel free. If you want to use it, remember it solves just my problem.
  • The technological choices are motivated by my wanting to test my chops, not to make a tool that everyone will be using. I might turn it into something else later, but for now, it's just another Todo demo.

The DSLs

Back in June, I predicted to my Cocoaheads friends that the HTML DSL would be the first to appear, after SwiftUI (the video, in french, here).

Bingo: https://github.com/pointfreeco/swift-html.git

While the syntax isn't as clean as SwiftUI, it's also a labor of love by PointFree's developers Stephen Celis and Brandon Williams, for reasons not too dissimilar to my own.

let document: Node = .document(
  .html(
    .body(
      .h1("Welcome!"),
      .p("You’ve found our site!")
    )
  )
)

Type safety, enums, the whole nine yards. So yea, pretty cool.

That was to help with my somewhat sketchy HTML skills. At least the syntax problems and various pitfalls were avoided.

The session

Sessions in Kitura just require Codable objects to be stored. Good thing my Todo structure is Codable:

enum TodoStatus : String, Codable {
    case pending
    case done
    case delayed
    
    static var asXSource : String {
        return """
        [{value: 'pending', text: 'pending'},{value: 'done', text: 'done'},{value: 'delayed', text: 'delayed'}]
        """
    }
    
    var imageName : String {
        return "/"+self.rawValue+".png"
    }
}

struct Todo : Codable {
    var id: UUID = UUID()
    var title: String
    var comment: String?
    var status : TodoStatus
    
    var todoid : String { return "t"+self.id.uuidString.replacingOccurrences(of: "-", with: "") }
    var editid : String { return self.todoid+"_e" }
    var titleid : String { return self.todoid+"_t" }
    var commentid : String { return self.todoid+"_c" }
    var statusid : String { return self.todoid+"_s" }
    
    var imageName : String {
        return status.imageName
    }
    
    var asHtmlNode : Node {
        return Node.fragment([
            .h3(
                .span(attributes: [.id(self.editid)], .img(src: "/edit.png", alt: "Edit")),
                .span(attributes: [.id(self.titleid)], .text(self.title))
            ),
            .div(
                .div(attributes: [],
                     .img(attributes: [.id(self.statusid), .src(self.imageName), .alt(self.status.rawValue), .height(.px(24)), .width(.px(24))]),
                     .span(.raw(" ")),
                     .span(attributes:[.id(self.statusid + "t"), .class("status-text")], .text(self.status.rawValue))
                ),
                .div(attributes: [],
                     .span(attributes: [.id(self.commentid)], .text(self.comment ?? " "))
                )
            )
        ])
        
    }
}

Please note, it is also capable of outputting HTML for use in a javascript "accordion" fashion. The images were hardcoded here, but I guess a more elegant way can be found. The various ids are used for the javascript functions to find the relevant sections for editing.

Also, a list of Todos can be exported to markdown:

extension Array where Element == Todo {
    var toMarkdown : String {
        var result = ""
        for t in self {
            result += "#### "+t.title+"\n\n"
            result += (t.comment ?? "") + "\n\n"
            result += "Status: " + t.status.rawValue + "\n\n"
        }
        
        return result
    }
}

Now, storing in the session (I use KituraSessionRedis) is as simple as:

// let session = request.session
session?["todos"] = t
session?.save(callback: { (err) in
  if let err = err { print("Error saving session: \(err.localizedDescription)") }
})

Unfortunately, restoring Todos from a session won't work: Kitura has no idea how to do that, but it can restore a [[String:Codable]] back to me, which allows me to use the DictionaryCoding stuff I did a while ago.

func todosFromSession(_ s: SessionState?) -> [Todo] {
    if let t = s?["todos"] as? [Todo] { return t }
    else if let t = try? DictionaryCoding().decode([Todo].self, from: s?["todos"]) { return t }
    let t = [
        Todo(id: UUID(), title: "Test 1", comment: "Go!", status: .pending),
        Todo(id: UUID(), title: "Test nb 2", comment: nil, status: .done),
        Todo(id: UUID(), title: "Test • 3 🤷‍♂️", comment: "UTF-8 is hard, no?", status: .delayed),
    ]
    saveTodos(t, to: s)
    return t
}

The Javascript

Aaaaaaaaaaah weeeeeeeell. OK, my thoughts on JS are fairly well known. I don't like that language, for a lot of reasons I won't get into. But it's the only way to manipulate the DOM without reloading the page so...

  • jQuery (because I will do Ajax calls, and well... it works)
  • x-editable (yes I know it's old, but it works), jQueryUI edition
  • A touch of bootstrap, because I thought I'd have to layout stuff. Turns out I don't but it's still in.

X-editable uses a combination of fields in its POST routes for in-place editing, and it works pretty in a pretty straightforward manner:

router.post("/change") { request, response, next in
    if let b = request.body, let c = b.asURLEncoded {
        var notes = todosFromSession(request.session)
        switch c["name"] {
        case "title":
            if let pk = c["pk"], let pkid = UUID(uuidString: pk), let noteidx = notes.firstIndex(where: { $0.id == pkid } ) {
                var note = notes[noteidx]
                note.title = c["value"] ?? ""
                notes[noteidx] = note
            }
        case "comment":
            if let pk = c["pk"], let pkid = UUID(uuidString: pk), let noteidx = notes.firstIndex(where: { $0.id == pkid } ) {
                var note = notes[noteidx]
                note.comment = c["value"] ?? ""
                notes[noteidx] = note
            }
        case "status":
            if let pk = c["pk"], let pkid = UUID(uuidString: pk), let noteidx = notes.firstIndex(where: { $0.id == pkid } ) {
                var note = notes[noteidx]
                note.status = TodoStatus(rawValue: c["value"] ?? "") ?? .delayed
                notes[noteidx] = note
            }
        default:
            response.send(json: ["success": false])
            next()
            return
        }
        
        saveTodos(notes, to: request.session)
        response.send(json: ["success": true])
    } else {
        response.send(json: ["success": false])
    }
    next()
}

Note the value passing mechanisms in arrays: the whole struct has to be replaced (as opposed to a class) when editing them, hence the whole firstIndex business.

The whole HTML/JS stuff is pretty ugly to show, so I'll spare you the sight. I need 3 main functions on top of all the in-place edition:

  • clear (because sometimes we need to erase everything)
  • download (because we want the markdown version)
  • next (removes the tasks that are done, passes the ones that are delayed to pending)
router.post("/clear") { request, response, next in
    saveTodos([], to: request.session)
    response.send(json: ["success": true])
    next()
}
router.get("/download") { request, response, next in
    let notes = todosFromSession(request.session)
    
    let output = "### Notes (\(df.string(from: Date())))\n\n" + notes.toMarkdown
    
    if let d = output.data(using: .utf8) {
        response.headers.setType("application/octet-stream")
        response.headers.addAttachment(for: "CR.md")
        response.send(data: d)
    }
    
    next()
}
router.post("/next") { request, response, next in
    var todos = todosFromSession(request.session).filter { $0.status != .done }.map {
        return Todo(id: $0.id, title: $0.title, comment: $0.comment, status: .pending)
    }
    saveTodos(todos, to: request.session)
    response.send(json: ["success": true])
}

Docker

I wanted to make it easy to deploy, and test my Docker/docker-compose knowledge. It also allows me to make sure my code is 100% Linux-friendly.

I have done something that could be considered a sin. I have included redis inside the same container as my app. Why? because I wanted a single image, it's that simple. Plus others do it so I figured it wasn't such a big deal. I still feel I'm gonna get yelled at for that.

So, one image, based on the official Swift 5 image, plus redis, ready to go.

And docker-compose to start in in a friendly-ish way rather than build the image, create a new container for that image and expose the relevant ports.

That way everyone has a choice.

So, here it is: https://github.com/krugazor/FrickinTodos