Creating ExtractBooks utility in Swift 4

Introduction

The Booxter for Mac application allows you to collect information about books in your home library.  This post describes the creation of a simple ExtractBooks utility that is extracting some data about books stored in the SQLite database used by the Booxter and loads this data into the MongoDB database. The purpose of this little project is to build expertise in creating a server-side logic using Swift programming language, and this is the first step. It should help later in creating generation templates used by the Data Model Widgets to generate source code in Swift for the different databases like MYSQL, SQLite or PostgreSQL.

Exporting data from Booxter

Booxter helps you track your collections of books, music, movies, and comics on your Mac. You will find more about Booxter here. You can download a trial version and play with it if you like.

Here is a snapshot of how the Booxter main screen looks.

The Booxter creates a library at the location of your choice.

You can use Show Package Contents to explore inner structure of the library.

You will find that data.sql is local SQLite database keeping data about books, music, and videos. You will notice that images subdirectory contains some image files. Those are small snapshots of the book’s covers.

You can open the data.sql file using DB Browser for SQLite or similar product. Here is a list showing the structure of the database used by the application.

We are going to use only a fraction of the data describing books. Here is a sample of the ZBXITEM table keeping most of the details about each book.

Two other tables: ZBXAUTHOR and ZBXIMAGE have additional details we are going to export from the database. The ZBXIMAGE table does not keep images itself, but rather a name of the file stored in the images subdirectory.

It makes sense to mention that path to the data.sql file looks something like:

file:///Users/User/Desktop/BooxterTest.bxbooklib/data.sql

Creating development environment

The first steps will create a development environment for the project. It will allow assembling of all products required to develop and build utility successfully. Installing SQLite and MongoDB is out of the scope of this post.

Step 1. Create a folder for the project

$ mkdir ExtractBooks
$ cd ExtractBooks
$ swift package init --type executable

Step 2. Create package  file inside a new directory

You need to create Package.swift file in the newly created directory.

import PackageDescription

let package = Package(
    name: "ExtractBooks",
    dependencies: [
        .package(url: "https://github.com/IBM-Swift/Swift-Kuery-SQLite.git", from: "1.1.0"),
        .package(url: "https://github.com/OpenKitten/MongoKitten.git", from: "4.0.0")
    ],
    targets: [
         .target(
            name: "ExtractBooks",
            dependencies: ["MongoKitten", "SwiftKuerySQLite"]),
    ]
)

You can notice that our project depends on two other packages: Swift-Kuery-SQLite and MongoKitten. We can create Xcode project now. Rest of the development will be done using Xcode development tool for Mac running macOS.

$ swift package generate-xcodeproj

Structure of the utility

This utility is activated from the command line. It takes some parameters describing the location of the SQLite and MongoDB databases. Here are sample parameters.

-u "file:///Users/User/Desktop/BooxterTest.bxbooklib/data.sql" "mongodb://localhost/library" "books"

Here is the content of the main.swift file’.

//
//  main.swift
//
//  Created by Marek Stankiewicz on 16/06/2018.
//
import Foundation


let mainLogic = MainLogic()
if CommandLine.argc < 1 {
    mainLogic.consoleDialog.printUsage()
} else {
    let returnStatus = mainLogic.startProcessing()
    if returnStatus.rc == 0 {
        mainLogic.consoleDialog.writeMessage(returnStatus.message, to: OutputType.standard)
    } else {
        mainLogic.consoleDialog.writeMessage(returnStatus.message, to: OutputType.error)
    }
    exit(Int32(returnStatus.rc))
}

Here is the MainLogic.swift file with the functionality responsible for processing the input parameters.

//
//  MainLogic.swift
//
//  Created by Marek Stankiewicz on 16/06/2018.
//

import Foundation
import CoreData

enum OptionType: String {
    case upload = "-u"
    case help = "-h"
    case unknown
 
    init(value: String) {
        switch value {
        case "-u": self = .upload
        case "-h": self = .help
        default: self = .unknown
        }
    }
}

enum ProcessResponse  {
    case  successfull
    case  noSQLiteConnect(String)
    case  noMongoDBConnect(String)
    case  problemCreatingMongoDBDoument(String)
    case  problemProcessingMongoDB(String)
    case  problemOpeningImageFile(String)
}

class MainLogic {
    
    let consoleDialog = ConsoleDialog()
    
    func startProcessing() -> (rc: Int, message: String) {
        let arguments = CommandLine.argc
        if arguments < 1 {
            return (rc: ReturnCode.command_line_usage_error.rawValue, message: "No option parameters provided.")
        }

        let option = getOption(CommandLine.arguments[1])
        switch option {
        case .upload:
            let params = unpackCmdParams(array : CommandLine.arguments)
            if params == nil {
                return (rc: ReturnCode.command_line_usage_error.rawValue, message: "Wrong number of parameters for -u function.")
            }
            if !validateURL(url: (params?.sqLite)!) {
                return (rc: ReturnCode.command_line_usage_error.rawValue, message: "URL to SQLite database malformed.")
            }
            if !validateURL(url: (params?.mongodburl)!) {
                 return (rc: ReturnCode.command_line_usage_error.rawValue, message: "URL to MongoDB database malformed.")
            }
            let uploadAction = UploadAction(filename: (params?.sqLite)!)
            let processResponse = uploadAction.upload(fromSQLite: (params?.sqLite)!, toMongoDB: (params?.mongodburl)!, collectionName:  (params?.collectionName)!)
            return createRetrunMessage(processResponse: processResponse)
        case .help:
            consoleDialog.printUsage()
        case .unknown:
            return (rc: ReturnCode.command_line_usage_error.rawValue, message: "Unknown option.")
        }
        consoleDialog.printUsage()
        return (rc: ReturnCode.command_line_usage_error.rawValue, message: "Unknown option.")
    }
    
    func validateURL(url : String) -> Bool {
        let nsurl = NSURL(string: url)
        return nsurl == nil ? false : true
    }
    
    func unpackCmdParams(array : [String]) -> (sqLite : String, mongodburl : String, collectionName : String)? {
        if array.count != 5 {
            return nil
        }
        return (array[2], array[3], array[4])
    }
    
    func getOption(_ option: String) -> OptionType {
        return OptionType(value: option)
    }
    
    func createRetrunMessage(processResponse: ProcessResponse) -> (rc: Int, message: String) {
        switch processResponse {
        case .successfull:
            return (0,"Processing completed successfully")
        default:
            return (1, self.getDescription(processResponse: processResponse))
        }
    }
 
    func getDescription(processResponse: ProcessResponse) -> String {
        switch processResponse {
        case .successfull:
            return "successful"
        case let .noSQLiteConnect(message):
            return  message
        case let .noMongoDBConnect(message):
            return message
        case let .problemOpeningImageFile(message):
            return message
        case let .problemCreatingMongoDBDoument(message):
            return message
        case let .problemProcessingMongoDB(message):
            return message
        }
    }
    
}

The process of extracting from SQLite and loading to the MongoDB is in the UploadAction.swift file.

//
//  UploadAction.swift
//  BooksUpload
//
//  Created by Marek Stankiewicz on 25/06/2018.
//

import Foundation
import SwiftKuery
import SwiftKuerySQLite
import MongoKitten

class ZBXITEM: Table {
    let tableName = "ZBXITEM"
    let zpkitem = Column("Z_PK", Int32.self)
    let zisbn = Column("ZISBN")
    let ztitle = Column("ZTITLE")
    let zlocation = Column("ZLOCATION")
    let zproductdescription = Column("ZPRODUCTDESCRIPTION")
    let zpublisher = Column("ZPUBLISHER")
    let zpages = Column("ZPAGES", Int32.self)
    let zlanguage = Column("ZLANGUAGE")
    let zdatepublished = Column("ZDATEPUBLISHED", Timestamp.self, notNull: false)
}

class ZBXIMAGE: Table {
    let tableName = "ZBXIMAGE"
    let zpkimage = Column("Z_PK")
    let zfilename = Column("ZFILENAME")
}

class ZBXAUTHOR: Table {
    let tableName = "ZBXAUTHOR"
    let zpkauthor = Column("Z_PK", Int32.self)
    let zname = Column("ZNAME")
}

class UploadAction {
    
    var processResponse : ProcessResponse = ProcessResponse.successfull
    var connection : SQLiteConnection
    
    init(filename: String){
        self.processResponse = ProcessResponse.successfull
        self.connection = SQLiteConnection(filename: filename)
    }
    
    func upload(fromSQLite : String, toMongoDB: String, collectionName: String) -> ProcessResponse {
        connection.connect() { error in
            if error == nil {
                let url = URL(string: fromSQLite)
                let imagedir = url?.deletingLastPathComponent().absoluteString
                process(toMongoDB: toMongoDB,collectionName: collectionName, imagedir: imagedir! + "images/")
            }
            else if let error = error {
                processResponse = ProcessResponse.noSQLiteConnect("Error opening database: \(error.description)")
            }
        }
        return processResponse
    }
    
    func extractTimestamp(number: Any) -> String {
        let formatter = DateFormatter()
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.dateFormat = "yyyy-MM-dd'.'HH.mm.ss.00000"
        formatter.timeZone = TimeZone(secondsFromGMT: 0)
        if number is Int32 {
            return formatter.string(from: Date(timeIntervalSince1970: Double(number as! Int32)))
        } else if number is Double {
            return formatter.string(from: Date(timeIntervalSince1970: number as! Double))
        }
        return ""
    }
    
    func process(toMongoDB: String, collectionName: String, imagedir: String) {
        do {
            let library = try MongoKitten.Database(toMongoDB)
            let books = library[collectionName]
            
            let zbxitems = ZBXITEM()
            let query = Select(zbxitems.zpkitem,
                               zbxitems.zisbn,
                               zbxitems.ztitle,
                               zbxitems.zlocation,
                               zbxitems.zproductdescription,
                               zbxitems.zpublisher,
                               zbxitems.zpages,
                               zbxitems.zlanguage,
                               zbxitems.zdatepublished, from: zbxitems)
            connection.execute(query: query) { queryResult in
                if let rows = queryResult.asRows {
                    for row in rows {
                        var book:  Document  = Document()
                        
                        // Primary key
                        let zpkitem = row["Z_PK"].unsafelyUnwrapped as! Int32
                        book.append(zpkitem, forKey: "_id")
   
                        // ISBN Number
                        let zisbn = row["ZISBN"]
                        if zisbn.unsafelyUnwrapped != nil {
                            book.append(zisbn! as! Primitive, forKey: "isbn")
                        }
                        
                        // Title
                        let ztitle = row["ZTITLE"]
                        if ztitle.unsafelyUnwrapped != nil {
                            book.append(ztitle! as! Primitive, forKey: "title")
                        }
                        
                        // Location
                        let zlocation = row["ZLOCATION"]
                        if zlocation.unsafelyUnwrapped != nil {
                          //  book.append(zlocation! as! Primitive, forKey: "location")
                        }
                        
                        // Product Description
                        let zproductdescription = row["ZPRODUCTDESCRIPTION"]
                        if zproductdescription.unsafelyUnwrapped != nil {
                            book.append(zproductdescription! as! Primitive, forKey: "description")
                        }
                        
                        // Publisher
                        let zpublisher = row["ZPUBLISHER"]
                        if zpublisher.unsafelyUnwrapped != nil {
                            book.append(zpublisher! as! Primitive, forKey: "publisher")
                        }
                        
                        // Pages
                        let zpages = row["ZPAGES"]
                        if zpages.unsafelyUnwrapped != nil {
                            book.append(zpages as! Primitive, forKey: "pages")
                        }
                        
                        // Language
                        let zlanguage = row["ZLANGUAGE"]
                        if zlanguage.unsafelyUnwrapped != nil {
                            book.append(zlanguage! as! Primitive, forKey: "language")
                        }
                        
                        // Date Published
                        let zdatepublished = row["ZDATEPUBLISHED"]
                        if zdatepublished.unsafelyUnwrapped != nil {
                            book.append(self.extractTimestamp(number: zdatepublished.unsafelyUnwrapped ?? "unknown"), forKey: "datepublished")
                        }
                        
                        var authors = [String]()
                        
                        let zbxauthor = ZBXAUTHOR()
                        
                        let queryAuthor = Select(zbxauthor.zpkauthor,
                                           zbxauthor.zname, from: zbxauthor).where(zbxauthor.zpkauthor == Parameter())
                        self.connection.execute(query: queryAuthor, parameters: [Int(zpkitem)]) { queryResult in
                        
                            if let rows = queryResult.asRows {
                               
                                for row in rows {
                                    // Author Name
                                    let zname = row["ZNAME"]
                                    if zname.unsafelyUnwrapped != nil {
                                        authors.append(zname as! String)
                                    }
                                }
                            }
                         }
                         book.append(authors, forKey: "authors")
                        
                        let zbximage = ZBXIMAGE()
                        
                        let queryImage = Select(zbximage.zpkimage,
                                                 zbximage.zfilename, from: zbximage).where(zbximage.zpkimage == Parameter())
                        self.connection.execute(query: queryImage, parameters: [Int(zpkitem)]) { queryResult in
                            
                            if let rows = queryResult.asRows {
                                for row in rows {
                                    // Author Name
                                    let zfilename = row["ZFILENAME"]
                                    if zfilename.unsafelyUnwrapped != nil {
                                         let path = imagedir + (zfilename.unsafelyUnwrapped as! String)
                                        
                                        // File Name
                                        let url = URL(string: path)
                                        do {
                                            let file: FileHandle? = try FileHandle(forReadingFrom: url!)
                                            if file != nil {
                                                let data = file?.readDataToEndOfFile()
                                                file?.closeFile()
                                                book.append(Binary(data: data!, withSubtype: Binary.Subtype.generic), forKey: "image")
                                            }
                                        } catch {
                                            self.processResponse = ProcessResponse.problemOpeningImageFile("Problem opening image file: \(String(describing: url))")
                                        }
                                      }
                                }
                            }
                        }
                       
                         do {
                            try books.append(book)
                        } catch {
                            self.processResponse = ProcessResponse.problemCreatingMongoDBDoument("Problem appending a new document for item: \(Int(zpkitem))")
                        }
                    }
                }
            }

        } catch {
            self.processResponse = ProcessResponse.problemProcessingMongoDB("Problem processing MongoDB")
        }
        return
    }
    
}

The entire project is in the GitHub here.

Sample data from the database created using the MongoDB follows:

Summary

This is a just the first example of using Swift. More will follow. Next step will be a server logic written using Kitura framework.