[ML] Swift TensorFlow (Part 1)

First part of doing RNN text prediction with TensorfFlow, in Swift

Broad Strokes

For all intents and purposes, it's about statistics. The question we are trying to solve is either something along the lines of "given an input X, what is the most probable Y?", or along the lines of "given an input X, what is the probability of having Y?"

Of course, simple probability problems have somewhat simple solutions: if you take a game of chess and ask for a next move based on the current board, you can do all the possible moves and sort them based on the probability of having a piece taken off the board, for instance. If you are designing an autopilot of some kind, you have an "ideal" attitude (collection of yaw, pitch and roll angles), and you calculate the movements of the stick and pedals that will most likely get you closer to that objective. If your last shot went left of the target, chances are, you should go right. Etc etc etc.

But the most interesting problems don't have obvious causality. If you have pasta, tomatoes and ground meat in your shopping basket, maybe your next item will be onions, because you're making some kind of bolognese, maybe it will be soap, because that's what you need, maybe it will be milk, because that's the order of the shelves you are passing by.

Machine learning is about taking a whole bunch of hopefully consistent data (even if you don't know for sure that it's consistent), and use it to say "based on this past data, the probabilities for onions, soap and milk are X, Y, and Z, and therefore the most probable is onions.

The data your are basing your predictive model on is really really important. Maybe your next item is consistent with the layout of the shop. Maybe it is consistent with what other customers got. Maybe it's consistent to your particular habits. Maybe it's consistent with people who are in the same "category" as you (your friends, your socio-economic peers, your cultural peers, ... pick one or more).

So you want a lot of data to have a good prediction, but not all the data, because noise (random data) does not reveal a bias (people in the shop tend to visit the shelves in that order) or doesn't exploit a bias (people following receipes want those ingredients together).

Oh yes, there are biases. Lots of them. Because ML uses past data to predict the future, if the data we use was based on bad practices, recommendations won't be a lot better.

There is a branch of machine learning that starts ex nihilo but it is beyond the scope of this introduction, and generates data based on a tournament rather than on actual facts. Its principle is roughly the same, though.

So, to recap:

  • We start with a model with random probabilities, and a series of "truths" ( X leads to Y )
  • We try with a Z, see what the model predicts
  • We compare it to a truth, and fix the probabilities a little bit so that it matches
  • Repeat with as many Zs as possible to be fairly confident the prediction isn't too far off

If you didn't before, now you know why ML takes a lot of resources. Depending on the number of possible Xs, Ys and the number of truths, the probability matrix is potentially humongous. Operations (like fixing the probabilities to match reality) on such enormous structures aren't cheap.

If you want a more detailed oriented explanation, with maths and diagrams, you can read my other attempt at explaining how it works.

Swift TensorFlow

There are a few contenders in the field of "ML SDK", but one of the most known is TensorFlow, backed by Google. It also happens to have a Swift variant (almost every other ML environment out there is either Python or R).

And of course, the documentation is really... lacking, making this article more useful than average along the way.

In their "Why Swift?" motivation piece, the architects make a good case, if a little bit technical, as to why swift makes a good candidate for ML.

The two major takeaways you have to know going in are:

  • It's a different build of Swift. You cannot use the one that shipped with Xcode (yet)
  • It uses a lot of Python interoperability to work, so some ways of doing things will be a bit alien

The performance is rather good, comparable or better than the regular Python TensorFlow for the tasks I threw at it, so there's that.

But the documentation... My oh my.

Let's take an example: Tensor is, as the name of the framework implies, the central feature of the system. Its documentation is here: https://www.tensorflow.org/swift/api_docs/Structs/Tensor

Sometimes, that page greets me in Greek... But hey, why not. There is little to no way to navigate the hierarchy, other than going on the left side, opening the section (good luck if you don't already know if it's a class, a protocol or a struct you're looking for), and if you use the search field, it will return pages about... the Python equivalents of the functions you're looking for.

Clearly, this is early in the game, and you are assumed to know how regular TensorFlow works before attempting to do anything with STF.

But fret not! I will hold your hand so that you don't need to look at the doc too much.

The tutorials are well written, but don't go very far at all. Oh and if you use that triple Dense layers on more than a toy problem (flower classification that is based on numbers), your RAM will fill so fast that your computer will have to be rebooted. More on that later.

And, because the center of ML is that "nudge" towards a better probability matrix (also called a Tensor), there is the whole @differentiable thing. We will talk about it later.

A good thing is that Python examples (there are thousands of ML tutorials in Python) work almost out of the box, thanks to the interop.

Data Preparation

Which Learning will my Machine do?

I have always thought that text generation was such a funny toy example (if a little bit scary when you think about some of the applications): teach the machine to speak like Shakespeare, and watch it spit some play at you. It's also easy for us to evaluate in terms of what it does and how successful it is. And the model makes sense, which helps when writing a piece on how ML works.

A usual way of doing that is using trigrams. We all know than predicting the next word after a single word is super hard. And our brains tend to be able to predict the last word of a sentence with ease. So, a common way of teaching the machine is to have it look at 3 words to predict a 4th.

I am hungry -> because, the birds flew -> away, etc

Of course, for more accurate results, you can extend the number of words in the input, but it means you must have a lot more varied sentence examples.

What we need to do here is assign numbers to these words (because everything is numbers in computers) so that we have a problem like "guess the function f if f(231,444,12)->123, f(111,2,671)->222", which neural networks are pretty good at.

So we need data (a corpus), and we need to split it into (trigram)->result

Now, because we are ultimately dealing with probabilities and rounding, we need the input to be in Float, so that the operations can wriggle the matrices by fractions, and we need the result to be an Int, because we don't want something like "the result is the word between 'hungry' and 'dumb'".

The features (also called input) and the labels (also called outputs) have to be stored in two tensors (also called matrices), matching the data we want to train our model on.

That's where RAM and processing time enter the arena: the size of the matrix is going to be huge:

  • Let's say the book I chose to teach it English has 11148 words in it (it's Tacitus' Germany), that's 11148*3-2 trigrams (33442 lines in my matrices, 4 columns total)
  • The way neural networks function, you basically have a function parameter per neuron that gets nudged at each iteration. In this example, I use two 512 parameters for somewhat decent results. That means 2 additional matrices of size 33442*512.
  • And operations regularly duplicate these matrices, if only for a short period of time, so yea, that's a lot of RAM and processing power.

Here is the function that downloads a piece of text, and separates it into words:

func loadData(_ url: URL) -> [String] {
    let sem = DispatchSemaphore(value: 0)
    var result = [String]()
    
    let session = URLSession(configuration: URLSessionConfiguration.default)
//     let set = CharacterSet.punctuationCharacters.union(CharacterSet.whitespacesAndNewlines)
    let set = CharacterSet.whitespacesAndNewlines
    session.dataTask(with: url, completionHandler: { data, response, error in
        if let data = data, let text = String(data: data, encoding: .utf8) {
            let comps = text.components(separatedBy: set).compactMap { (w) -> String? in
                // separate punctuation from the rest
                if w.count == 0 { return nil }
                else { return w }
            }
             result += comps
       }
        
        sem.signal()
    }).resume()
    
    sem.wait()
    return result
}

Please note two things: I make it synchronous (I want to wait for the result), and I chose to include word and word, separately. You can keep only the words by switching the commented lines, but I find that the output is more interesting with punctuation than without.

Now, we need to setup the word->int and int->word transformations. Because we don't want to look at all the array of words every time we want to search for one, there is a dictionary based on the hashing of the words that will deal with the first, and because the most common words have better chances to pop up, the array for the vocabulary is sorted. It's not optimal, probably, but it helps makes things clear, and is fast enough.

func loadVocabulary(_ text: [String]) -> [String] {
    var counts = [String:Int]()
    
    for w in text {
        let c = counts[w] ?? 0
        counts[w] = c + 1
    }
    
    let count = counts.sorted(by: { (arg1, arg2) -> Bool in
        let (_, value1) = arg1
        let (_, value2) = arg2
        return value1 > value2
    })
    
    return count.map { (arg0) -> String in
        let (key, _) = arg0
        return key
    }
}

func makeHelper(_ vocabulary: [String]) -> [Int:Int] {
    var result : [Int:Int] = [:]
    
    vocabulary.enumerated().forEach { (arg0) in
        let (offset, element) = arg0
        result[element.hash] = offset
    }
    
    return result
}

Why not hashValue instead of hash? turns out, on Linux, which this baby is going to run on, the values are more stable with the latter rather than the former, according to my tests.

The data we will work on therefore is:

struct TextBatch {
    let original: [String]
    let vocabulary: [String]
    let indexHelper: [Int:Int]
    let features : Tensor<Float> // 3 words
    let labels : Tensor<Int32> // followed by 1 word
}

We need a way to initialize that struct, and a couple of helper functions to extract some random samples to train our model on, and we're good to go:

extension TextBatch {
    public init(from: [String]) {
        let v = loadVocabulary(from)
        let h = makeHelper(v)
        var f : [[Float]] = []
        var l : [Int32] = []
        for i in 0..<(from.count-3) {
            if let w1 = h[from[i].hash],
                let w2 = h[from[i+1].hash],
                let w3 = h[from[i+2].hash],
                let w4 = h[from[i+3].hash] {
                    f.append([Float(w1), Float(w2), Float(w3)])
                l.append(Int32(w4))
            }
        }
        
        let featuresT = Tensor<Float>(shape: [f.count, 3], scalars: f.flatMap { $0 })
        let labelsT = Tensor<Int32>(l)
        
        self.init(
            original: from,
            vocabulary: v,
            indexHelper: h,
            features: featuresT,
            labels: labelsT
        )
    }
    
    func randomSample(of size: Int) -> (features: Tensor<Float>, labels: Tensor<Int32>) {
        var f : [[Float]] = []
        var l : [Int32] = []
        for i in 0..<(original.count-3) {
            if let w1 = indexHelper[original[i].hash],
                let w2 = indexHelper[original[i+1].hash],
                let w3 = indexHelper[original[i+2].hash],
                let w4 = indexHelper[original[i+3].hash] {
                    f.append([Float(w1), Float(w2), Float(w3)])
                l.append(Int32(w4))
            }
        }

        var rf : [[Float]] = []
        var rl : [Int32] = []
        if size >= l.count || size <= 0 { 
            let featuresT = Tensor<Float>(shape: [f.count, 3], scalars: f.flatMap { $0 })
            let labelsT = Tensor<Int32>(l)
            return (featuresT, labelsT)
        }
        var alreadyPicked = Set<Int>()
        while alreadyPicked.count < size {
            let idx = Int.random(in: 0..<l.count)
            if !alreadyPicked.contains(idx) {
                rf.append(f[idx])
                rl.append(l[idx])
                alreadyPicked.update(with: idx)
            }
        }
        
        let featuresT = Tensor<Float>(shape: [f.count, 3], scalars: f.flatMap { $0 })
        let labelsT = Tensor<Int32>(l)
        return (featuresT, labelsT)
    }
    
    func randomSample(splits: Int) -> [(features: Tensor<Float>, labels: Tensor<Int32>)] {
        var res = [(features: Tensor<Float>, labels: Tensor<Int32>)]()
        var alreadyPicked = Set<Int>()
        let size = Int(floor(Double(original.count)/Double(splits)))
        var f : [[Float]] = []
        var l : [Int32] = []
        for i in 0..<(original.count-3) {
            if let w1 = indexHelper[original[i].hash],
                let w2 = indexHelper[original[i+1].hash],
                let w3 = indexHelper[original[i+2].hash],
                let w4 = indexHelper[original[i+3].hash] {
                    f.append([Float(w1), Float(w2), Float(w3)])
                l.append(Int32(w4))
            }
        }

        for part in 1...splits {
            var rf : [[Float]] = []
            var rl : [Int32] = []
            if size >= l.count || size <= 0 { 
                let featuresT = Tensor<Float>(shape: [f.count, 3], scalars: f.flatMap { $0 })
                let labelsT = Tensor<Int32>(l)
                return [(featuresT, labelsT)]
            }
            while alreadyPicked.count < size {
                let idx = Int.random(in: 0..<l.count)
                if !alreadyPicked.contains(idx) {
                    rf.append(f[idx])
                    rl.append(l[idx])
                    alreadyPicked.update(with: idx)
                }
            }
        
            let featuresT = Tensor<Float>(shape: [f.count, 3], scalars: f.flatMap { $0 })
            let labelsT = Tensor<Int32>(l)
            
            res.append((featuresT,labelsT))
        }
        return res
    }
}

In the next part, we will see how to set the model up, and train it.


Happy Birthday Wikipedia

On January 15th 2001, Wikipedia launched.

It has its faults, it has its detractors and abuse. But it's also one of (if not the) most popular websites on the Interwebs.

It made official the decentralization of knowledge for the masses: you're a random expert on a bacteria in a specific kind of cheese? Chime in. No need for someone to write a book on the topic, publish it, and spread it around university libraries anymore!

Of course, it also re centralized knowledge in a way. It is the default encyclopedia, after all.

But I love seeing students going down the rabbit hole of tech I pointed to and learning things.

Even if the articles aren't the best about a topic, they contain enough to fan the flames of curiosity, and to me that's a win.


Algorithm Of The Day : Combinatory

So, I'd like to take x elements out of an array of n elements. I know from statistical analysis that there are a precise number of combinations:

$$ \frac{n!}{x!.(n-x)!} $$

But what are they? There are a few classic algorithms to do that, but most of them are recursive and use an accumulator, not exactly Swift's forte, especially if you don't know the right keyword. Cue inout

So here's the rough idea: every time we select an item in the array, we have only n-1 items to pick from, and x-1 items to pick. So it's recursive in nature. But we have an array, which means that we can make every possible combinations using the first item in the array first, then start again by removing the first item, and we should never repeat the combinations.

Here's an example: let's take 3 elements out of an array of 4

John Paul Ringo George

----------------------

John Paul Ringo
John Paul       George
John      Ringo George
     Paul Ringo George

It definitely looks like loops: We do all of John's first, then when we're done we do Paul's, and then we're done because we don't have enough people to start with Ringo.

Another way of looking at it is "I've selected John, now start again with the rest of the list and pick 2", then "I've selected Paul, now start again with the rest of the list and pick 1", then "I've started with Ringo, now start again with the rest of the list and pick 1". When we're done with the lists starting with John, we remove him, start with Paul, and there's only one choice.

In swift, because of the extension mechanism, it's easy to generalize to every array, but we still need that recursion that needs both where we are and what's left to work. Then all we need to manage is the special edge cases:

  • there is no element in the array (because it's easy to deal with)
  • there is less elements in the array than we want (ditto)
  • there is exactly as many elements in the array as we want (well duh, there's only one possibility)

So here is the algorithm, with accumulators passed with inout (to modify them in the callee and the caller):

extension Array { // combinatory
    fileprivate func recArrangements(len: Int, start: Int, cur: inout [Element], result : inout [[Element]]) {
        if len == 0 {
            result.append([Element](cur))
        } else {
            var i = start
            while i <= (self.count-len) {
                cur[cur.count - len] = self[i]
                recArrangements(len: len-1, start: i+1, cur: &cur, result: &result)
                i += 1
            }
        }
    }
    
    func arrangements(of number: Int) -> [[Element]]? {
        if self.count == 0 { return nil }
        else if number > self.count { return nil }
        else if number == self.count { return [self] }
        var buffer = [Element](repeating: self[0], count: number)
        var result = [[Element]]()
        recArrangements(len: number, start: 0, cur: &buffer, result: &result)
        return result
    }
}

Proofs that it works:

> ["John", "Paul", "Ringo", "George"].arrangements(of: 3)
$R0: [[String]]? = 4 values {
  [0] = 3 values {
    [0] = "John"
    [1] = "Paul"
    [2] = "Ringo"
  }
  [1] = 3 values {
    [0] = "John"
    [1] = "Paul"
    [2] = "George"
  }
  [2] = 3 values {
    [0] = "John"
    [1] = "Ringo"
    [2] = "George"
  }
  [3] = 3 values {
    [0] = "Paul"
    [1] = "Ringo"
    [2] = "George"
  }
}
["Potassium",
"Calcium",
"Scandium",
"Titanium",
"Vanadium",
"Chromium",
"Manganese",
"Iron",
"Cobalt",
"Nickel",
"Copper",
"Zinc",
"Gallium",
"Germanium",
"Arsenic",
"Selenium",
"Bromine",
"Krypton"].arrangements(of: 3).count
$R1: Int? = 43758

Which fits $$\frac{18!}{8!.10!}=43758$$

Unfortunately, as with most recursive algorithms, its complexity is fairly horrendous... It is equivalent to 3 nested for loops (the way to write that code is left as an exercise), which means a whopping ... Then again, combinatory has a way of being exponential anyways. I wonder if there is a way to be more efficient.


[Dev Diaries] URL Shortener Style Things

UUIDs are fine, but who doesn't like a decent String instead? It's shorter and it doesn't scare the non-programmers as much!

UUIDs are 128 bits, and I want to use a 64 characters map: a to z, A-Z, and 0-9, plus - and + to round it up.

64 possibilities is equivalent to 6 bits of data, and UUIDs are made of 16 bytes (8 bits of data). What that gives me is a way to split 16 bytes into 22 sixytes (yes I invented that word. So what?)

| 8 8 8 _ | 8 8 8 _ | 8 8 8 _ | 8 8 8 _ | 8 8 8 _ | 8
| 6 6 6 6 | 6 6 6 6 | 6 6 6 6 | 6 6 6 6 | 6 6 6 6 | 6 6

Why? Because 3x8 = 6x4, same number of bits in both.

Now, we redistribute the bits around (Xs are the bits fron the bytes, Ys are the bits from the sixytes):

XXXXXX|XX XXXX|XXXX XX|XXXXXX
YYYYYY|YY YYYY|YYYY YY|YYYYYY

With some shifts and some binary or, we're down from a 36 hexadecimal character string with dashes to a 22 character with a very low probability of punctuation. Of course if you want to disambiguate the symbols like O and 0, you can change the character map, as long as your charmap stays 64 items long.

extension UUID {
    static let charmap = 
["a","b","c","d","e","f","g","h","i","j","k","l","m","n",
"o","p","q","r","s","t","u","v","w","x","y","z",
"A","B","C","D","E","F","G","H","I","J","K","L","M","N",
"O","P","Q","R","S","T","U","V","W","X","Y","Z",
"0","1","2","3","4","5","6","7","8","9","-","+"]
    static let charmapSet =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-+"

    var tinyWord : String {
        let from = self.uuid
        let bytes = [from.0, from.1, from.2,from.3,from.4,from.5,from.6,from.7,from.8,from.9,
                     from.10, from.11, from.12,from.13,from.14,from.15]
        
        // split in 6-bits ints
        var sbytes : [UInt8] = []
        for i in 0..<5 {
            let b1 = bytes[i*3]
            let b2 = bytes[i*3+1]
            let b3 = bytes[i*3+2]
            
            let sb1 = b1 >> 2
            let sb2 = (b1 & 0x03) << 4 | (b2 >> 4)
            let sb3 = (b2 & 0x0f) << 2 | (b3 >> 6)
            let sb4 = (b3 & 0x3f)
            sbytes += [sb1,sb2,sb3,sb4]
        }
        // all done but the last byte
        sbytes.append(bytes[15]>>2)
        sbytes.append(bytes[15]&0x03)
         
        var result = ""
        for i in sbytes {
            result += UUID.charmap[Int(i)]
        }
        
        return result
    }
}

The reverse procedure is a bit longer, because we have to stage the values in groups of 4 sexytes for 3 bytes, and do a couple of sanity checks.

extension UUID {
    init?(tinyWord: String) {
        if tinyWord.count != 22 || !tinyWord.allSatisfy({ UUID.charmapSet.contains($0) }) { return nil }
        var current : UInt8 = 0
        var bytes : [UInt8] = []
        for (n,c) in tinyWord.enumerated() {
            guard let idx32 = UUID.charmap.firstIndex(of: String(c)) else { return nil }
            let idx = UInt8(idx32)
            if n >= 20 { // last byte
                if n == 20 {
                    current = idx << 2
                } else {
                    current |= idx
                    bytes.append(current)
                }
            } else if n % 4 == 0 { // first in cycle
                current = idx << 2
            } else if n % 4 == 1 { // combine
                current |= idx >> 4
                bytes.append(current)
                current = (idx & 0xf) << 4
            } else if n % 4 == 2 { // combine
                current |= (idx >> 2)
                bytes.append(current)
                current = (idx & 0x3) << 6
            } else {
                current |= idx
                bytes.append(current)
                current = 0
            }
        }
        
        // double check
        if bytes.count != 16 { return nil }
        
        self.init(uuid: (bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9],
                         bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]))
    }
}

Let's test this!

let u = UUID()
let w = u.tinyWord
print(u.uuidString+" : \(u.uuidString.count)")
print(w+" : \(w.count)")
print(UUID(tinyWord: w)!)
30A5CB6E-778F-4218-A333-3BC8B5A40B65 : 36
mkxlBNEpqHIJmZViTAqlzb : 22
30A5CB6E-778F-4218-A333-3BC8B5A40B65

Now I have a "password friendly" way to pass UUIDs around. Is it a waste of time (because I could just pass the UUIDs around, they aren't that much longer)? Who knows? It makes my shortened URLs a bit less intimidating 😁


[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("&nbsp;")),
                     .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