Dactyloidae iOS initial commit

This commit is contained in:
wuggy 2026-06-26 21:04:09 -07:00
commit 7154a0497e
2123 changed files with 197052 additions and 0 deletions

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>10.6</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSPrincipalClass</key>
<string></string>
</dict>
</plist>

View file

@ -0,0 +1,13 @@
//
// ReadingList-Bridging-Header.h
// Client
//
// Created by Emily Toop on 1/8/16.
// Copyright © 2016 Mozilla. All rights reserved.
//
#ifndef ReadingList_Bridging_Header_h
#define ReadingList_Bridging_Header_h
#endif /* ReadingList_Bridging_Header_h */

View file

@ -0,0 +1,9 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
protocol ReadingListAuthenticator {
var headers: [String: String] { get }
}

View file

@ -0,0 +1,16 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
class ReadingListBasicAuthAuthenticator: ReadingListAuthenticator {
var headers: [String: String]
init(username: String, password: String) {
let credentials = "\(username):\(password)"
let credentialsData = credentials.data(using: String.Encoding.utf8)!
let encodedCredentials = credentialsData.base64EncodedString(options: NSData.Base64EncodingOptions())
self.headers = ["Authorization": "Basic \(encodedCredentials)"]
}
}

View file

@ -0,0 +1,35 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
class ReadingListBatchRecordResponse: ReadingListResponse {
var responses: [ReadingListRecordResponse] = [ReadingListRecordResponse]()
override init?(response: HTTPURLResponse, json: [String: Any]) {
super.init(response: response, json: json)
guard let responses = json["responses"] as? [[String: Any]] else {
return nil
}
for resp in responses {
guard let body = resp["body"] as? [String: Any],
let statusCode = resp["status"] as? Int,
let path = resp["path"] as? String,
let url = URL(string: path, relativeTo: self.response.url),
let headers = resp["headers"] as? [String: String],
let r = HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: "1.1", headerFields: headers),
let recordResponse = ReadingListRecordResponse(response: r, json: body) else {
return nil
}
self.responses.append(recordResponse)
}
}
var wasSuccessful: Bool {
get {
return response.statusCode == 200
}
}
}

View file

@ -0,0 +1,14 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
protocol ReadingListChangeAccumulator {
func addDeletedClientRecord(_ deletedRecord: ReadingListClientRecord)
func addDeletedServerRecord(_ deletedRecord: ReadingListServerRecord)
func addChangedRecord(_ changedRecord: ReadingListClientRecord)
func addUploadedRecord(_ uploadedRecord: ReadingListClientRecord, down: ReadingListServerRecord)
func addDownloadedRecord(_ downloadedRecord: ReadingListServerRecord)
func applyAccumulatedChanges()
}

View file

@ -0,0 +1,211 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
import Alamofire
enum ReadingListDeleteRecordResult {
case success(ReadingListRecordResponse)
case preconditionFailed(ReadingListResponse)
case notFound(ReadingListResponse)
case failure(ReadingListResponse)
case error(NSError)
}
enum ReadingListGetRecordResult {
case success(ReadingListRecordResponse)
case notModified(ReadingListResponse) // TODO Should really call this NotModified for clarity
case notFound(ReadingListResponse)
case failure(ReadingListResponse)
case error(NSError)
}
enum ReadingListGetAllRecordsResult {
case success(ReadingListRecordsResponse)
case notModified(ReadingListResponse) // TODO Should really call this NotModified for clarity
case failure(ReadingListResponse)
case error(NSError)
}
enum ReadingListPatchRecordResult {
}
enum ReadingListAddRecordResult {
case success(ReadingListRecordResponse)
case failure(ReadingListResponse)
case conflict(ReadingListResponse)
case error(NSError)
}
enum ReadingListBatchAddRecordsResult {
case success(ReadingListBatchRecordResponse)
case failure(ReadingListResponse)
case error(NSError)
}
private let ReadingListClientUnknownError = NSError(domain: "org.mozilla.ios.Fennec.ReadingListClient", code: -1, userInfo: nil)
class ReadingListClient {
var serviceURL: URL
var authenticator: ReadingListAuthenticator
var articlesURL: URL!
var articlesBaseURL: URL!
var batchURL: URL!
func getRecordWithGuid(_ guid: String, ifModifiedSince: ReadingListTimestamp?, completion: @escaping (ReadingListGetRecordResult) -> Void) {
if let url = URL(string: guid, relativeTo: articlesBaseURL) {
SessionManager.default.request(createRequest("GET", url, ifModifiedSince: ifModifiedSince)).responseJSON(options: [], completionHandler: { response -> Void in
if let json = response.result.value as? [String: Any], let response = response.response {
switch response.statusCode {
case 200:
completion(.success(ReadingListRecordResponse(response: response, json: json)!))
case 304:
completion(.notModified(ReadingListResponse(response: response, json: json)!))
case 404:
completion(.notFound(ReadingListResponse(response: response, json: json)!))
default:
completion(.failure(ReadingListResponse(response: response, json: json)!))
}
} else {
completion(.error(response.result.error as NSError? ?? ReadingListClientUnknownError))
}
})
} else {
// TODO ???
}
}
func getRecordWithGuid(_ guid: String, completion: @escaping (ReadingListGetRecordResult) -> Void) {
getRecordWithGuid(guid, ifModifiedSince: nil, completion: completion)
}
func getAllRecordsWithFetchSpec(_ fetchSpec: ReadingListFetchSpec, ifModifiedSince: ReadingListTimestamp?, completion: @escaping (ReadingListGetAllRecordsResult) -> Void) {
if let url = fetchSpec.getURL(serviceURL: serviceURL, path: "/v1/articles") {
SessionManager.default.request(createRequest("GET", url)).responseJSON(options: [], completionHandler: { response -> Void in
if let json = response.result.value as? [String: Any], let response = response.response {
switch response.statusCode {
case 200:
completion(.success(ReadingListRecordsResponse(response: response, json: json)!))
case 304:
completion(.notModified(ReadingListResponse(response: response, json: json)!))
default:
completion(.failure(ReadingListResponse(response: response, json: json)!))
}
} else {
completion(.error(response.result.error as NSError? ?? ReadingListClientUnknownError))
}
})
} else {
// TODO ???
}
}
func getAllRecordsWithFetchSpec(_ fetchSpec: ReadingListFetchSpec, completion: @escaping (ReadingListGetAllRecordsResult) -> Void) {
getAllRecordsWithFetchSpec(fetchSpec, ifModifiedSince: nil, completion: completion)
}
func patchRecord(_ record: ReadingListClientRecord, completion: (ReadingListPatchRecordResult) -> Void) {
}
func addRecord(_ record: ReadingListClientRecord, completion: @escaping (ReadingListAddRecordResult) -> Void) {
SessionManager.default.request(createRequest("POST", articlesURL, json: record.json)).responseJSON(options: [], completionHandler: { response in
if let json = response.result.value as? [String: Any], let response = response.response {
switch response.statusCode {
case 200, 201: // TODO Should we have different results for these? Do we care about 200 vs 201?
completion(.success(ReadingListRecordResponse(response: response, json: json)!))
case 303:
completion(.conflict(ReadingListResponse(response: response, json: json)!))
default:
completion(.failure(ReadingListResponse(response: response, json: json)!))
}
} else {
completion(.error(response.result.error as NSError? ?? ReadingListClientUnknownError))
}
})
}
/// Build the JSON body for POST /v1/batch { defaults: {}, request: [ {body: {} } ] }
fileprivate func recordsToBatchJSON(_ records: [ReadingListClientRecord]) -> AnyObject {
return [
"defaults": ["method": "POST", "path": "/v1/articles", "headers": ["Content-Type": "application/json"]],
"requests": records.map { ["body": $0.json] }
] as NSDictionary
}
func batchAddRecords(_ records: [ReadingListClientRecord], completion: @escaping (ReadingListBatchAddRecordsResult) -> Void) {
SessionManager.default.request(createRequest("POST", batchURL, json: recordsToBatchJSON(records))).responseJSON(options: [], completionHandler: { response in
if let json = response.result.value as? [String: Any], let response = response.response {
switch response.statusCode {
case 200:
completion(.success(ReadingListBatchRecordResponse(response: response, json: json)!))
default:
completion(.failure(ReadingListResponse(response: response, json: json)!))
}
} else {
completion(.error(response.result.error as NSError? ?? ReadingListClientUnknownError))
}
})
}
func deleteRecordWithGuid(_ guid: String, ifUnmodifiedSince: ReadingListTimestamp?, completion: @escaping (ReadingListDeleteRecordResult) -> Void) {
if let url = URL(string: guid, relativeTo: articlesBaseURL) {
SessionManager.default.request(createRequest("DELETE", url, ifUnmodifiedSince: ifUnmodifiedSince)).responseJSON(options: [], completionHandler: { response in
if let json = response.result.value as? [String: Any], let response = response.response {
switch response.statusCode {
case 200:
completion(.success(ReadingListRecordResponse(response: response, json: json)!))
case 412:
completion(.preconditionFailed(ReadingListResponse(response: response, json: json)!))
case 404:
completion(.notFound(ReadingListResponse(response: response, json: json)!))
default:
completion(.failure(ReadingListResponse(response: response, json: json)!))
}
} else {
completion(.error(response.result.error as NSError? ?? ReadingListClientUnknownError))
}
})
} else {
// TODO ???
}
}
func deleteRecordWithGuid(_ guid: String, completion: @escaping (ReadingListDeleteRecordResult) -> Void) {
deleteRecordWithGuid(guid, ifUnmodifiedSince: nil, completion: completion)
}
func createRequest(_ method: String, _ url: URL, ifUnmodifiedSince: ReadingListTimestamp? = nil, ifModifiedSince: ReadingListTimestamp? = nil, json: AnyObject? = nil) -> URLRequest {
let request = NSMutableURLRequest(url: url)
request.httpMethod = method
if let ifUnmodifiedSince = ifUnmodifiedSince {
request.setValue(String(ifUnmodifiedSince), forHTTPHeaderField: "If-Unmodified-Since")
}
if let ifModifiedSince = ifModifiedSince {
request.setValue(String(ifModifiedSince), forHTTPHeaderField: "If-Modified-Since")
}
for (headerField, value) in authenticator.headers {
request.setValue(value, forHTTPHeaderField: headerField)
}
request.addValue("application/json", forHTTPHeaderField: "Accept")
if let json = json {
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
do {
request.httpBody = try JSONSerialization.data(withJSONObject: json, options: JSONSerialization.WritingOptions.prettyPrinted)
} catch _ {
request.httpBody = nil
} // TODO Handle errors here
}
return request as URLRequest
}
init(serviceURL: URL, authenticator: ReadingListAuthenticator) {
self.serviceURL = serviceURL
self.authenticator = authenticator
self.articlesURL = URL(string: "/v1/articles", relativeTo: self.serviceURL)
self.articlesBaseURL = URL(string: "/v1/articles/", relativeTo: self.serviceURL)
self.batchURL = URL(string: "/v1/batch", relativeTo: self.serviceURL)
}
}

View file

@ -0,0 +1,25 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
public struct ReadingListClientMetadata: Equatable {
/// The id of the record in the database
public var id: Int64
/// A client timestamp
public var lastModified: ReadingListTimestamp
public init?(row: [String: Any]) {
guard let id = row["client_id"] as? Int64,
let lastModified = row["client_last_modified"] as? Int64 else {
return nil
}
self.id = id
self.lastModified = lastModified
}
}
public func ==(lhs: ReadingListClientMetadata, rhs: ReadingListClientMetadata) -> Bool {
return lhs.id == rhs.id && lhs.lastModified == rhs.lastModified
}

View file

@ -0,0 +1,67 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
public struct ReadingListClientRecord: Equatable {
public let clientMetadata: ReadingListClientMetadata
public let serverMetadata: ReadingListServerMetadata?
public let url: String
public let title: String
public let addedBy: String
public let unread: Bool
public let archived: Bool
public let favorite: Bool
/// Initializer for when a record is loaded from a database row
public init?(row: [String: Any]) {
guard let clientMetadata = ReadingListClientMetadata(row: row) else {
return nil
}
guard let url = row["url"] as? String,
let title = row["title"] as? String,
let addedBy = row["added_by"] as? String,
let unread = row["unread"] as? Bool else {
return nil
}
self.clientMetadata = clientMetadata
self.serverMetadata = ReadingListServerMetadata(row: row)
self.url = url
self.title = title
self.addedBy = addedBy
self.unread = unread
self.archived = row["archived"] as? Bool ?? false
self.favorite = row["favorite"] as? Bool ?? false
}
public var json: AnyObject {
get {
let json = NSMutableDictionary()
json["url"] = url
json["title"] = title
json["added_by"] = addedBy
json["unread"] = unread
json["archived"] = archived
json["favorite"] = favorite
return json
}
}
}
public func ==(lhs: ReadingListClientRecord, rhs: ReadingListClientRecord) -> Bool {
return lhs.clientMetadata == rhs.clientMetadata
&& lhs.serverMetadata == rhs.serverMetadata
&& lhs.url == rhs.url
&& lhs.title == rhs.title
&& lhs.addedBy == rhs.addedBy
&& lhs.unread == rhs.unread
&& lhs.archived == rhs.archived
&& lhs.favorite == rhs.favorite
}

View file

@ -0,0 +1,17 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
import Shared
// TODO This needs to encapsulate an NSError eventually
open class ReadingListError: MaybeErrorType {
var message: String
init(_ message: String) {
self.message = message
}
open var description: String {
return message
}
}

View file

@ -0,0 +1,87 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
// TODO This should really use NSURLComponents to build up the query part. And not string ops.
class ReadingListFetchSpec {
var queryString: String
init(queryString: String) {
self.queryString = queryString
}
func getURL(serviceURL: URL) -> URL? {
if var components = URLComponents(url: serviceURL, resolvingAgainstBaseURL: true) {
components.query = queryString
return components.url
}
return nil
}
func getURL(serviceURL: URL, path: String) -> URL? {
if var components = URLComponents(url: serviceURL, resolvingAgainstBaseURL: true) {
components.path = path
components.query = queryString
return components.url
}
return nil
}
// This should really generate a dictionary of values that we can pass to NSURLComponents instead of building the query string by hand
class Builder {
var buffer: String = ""
var first = true
func build() -> ReadingListFetchSpec {
return ReadingListFetchSpec(queryString: buffer)
}
fileprivate func ampersand() {
if first {
first = false
return
}
buffer += "&"
}
func setUnread(_ unread: Bool) -> Builder {
ampersand()
buffer += "unread="
buffer += unread ? "true" : "false"
return self
}
func setStatus(_ status: String, not: Bool) -> Builder {
ampersand()
if not {
buffer += "not_"
}
buffer += "status="
buffer += status
return self
}
fileprivate func qualifyAttribute(_ attribute: String, withQualifier qualifier: String, value: String) -> Builder {
ampersand()
buffer += qualifier
buffer += attribute
buffer += "="
buffer += value
return self
}
func setMinAttribute(_ attribute: String, value: String) -> Builder {
_ = qualifyAttribute(attribute, withQualifier: "min_", value: value)
return self
}
func setMaxAttribute(_ attribute: String, value: String) -> Builder {
_ = qualifyAttribute(attribute, withQualifier: "max_", value: value)
return self
}
}
}

View file

@ -0,0 +1,15 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
class ReadingListOAuthAuthAuthenticator: ReadingListAuthenticator {
var token: String
var headers: [String: String]
init(token: String) {
self.token = token
self.headers = ["Authorization": "Bearer \(token)"]
}
}

View file

@ -0,0 +1,44 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
// TODO This is not used anymore. Decide if we need to turn this into a protocol that the ClientRecord and ServerRecord implement. Not sure if we actually need that though.
class ReadingListRecord {
let serverMetadata: ReadingListServerMetadata?
init(serverMetadata: ReadingListServerMetadata?) {
self.serverMetadata = serverMetadata
}
var guid: String? {
get {
return serverMetadata?.guid
}
}
var serverLastModified: Int64? {
get {
return serverMetadata?.lastModified
}
}
var url: String {
get {
fatalError("Subclass Responsibility")
}
}
var title: String {
get {
fatalError("Subclass Responsibility")
}
}
var addedBy: String {
get {
fatalError("Subclass Responsibility")
}
}
}

View file

@ -0,0 +1,28 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
class ReadingListRecordResponse: ReadingListResponse {
override init?(response: HTTPURLResponse, json: [String: Any]) {
super.init(response: response, json: json)
}
var wasSuccessful: Bool {
get {
return response.statusCode == 200 || response.statusCode == 201 || response.statusCode == 204
// TODO On Android we call super.wasSuccessful() .. is there another value that we consider a success?
}
}
var record: ReadingListServerRecord? {
get {
if let json = self.json {
return ReadingListServerRecord(json: json)
} else {
return nil
}
}
}
}

View file

@ -0,0 +1,36 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
class ReadingListRecordsResponse: ReadingListResponse {
override init?(response: HTTPURLResponse, json: [String: Any]) {
super.init(response: response, json: json)
}
var wasSuccessful: Bool {
get {
return response.statusCode == 200 || response.statusCode == 201 || response.statusCode == 204
// TODO On Android we call super.wasSuccessful() .. is there another value that we consider a success?
}
}
var records: [ReadingListServerRecord]? {
get {
if let json = self.json {
var records = [ReadingListServerRecord]()
if let items = json["items"] as? [[String: Any]] {
for item in items {
if let record = ReadingListServerRecord(json: item) {
records.append(record)
}
}
}
return records
} else {
return nil
}
}
}
}

View file

@ -0,0 +1,28 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
/// Wrapper around NSURLResponse and the response json object to easily pass this to delegates. Also contains some higher level functions to access service specific headers.
///
/// TODO: In the Android code this is a subclass of MozResponse - Which has a bunch of other useful shortcuts. Maybe we should do that too? Or for the sake of simplicity, move some of those functions (which ones do we need?) into this class
class ReadingListResponse {
var response: HTTPURLResponse
var json: [String: Any]?
init?(response: HTTPURLResponse, json: [String: Any]) {
self.response = response
self.json = json
}
var lastModified: Int64? {
get {
if let lastModified = response.allHeaderFields["Last-Modified"] as? String {
return Int64(lastModified)
} else {
return nil
}
}
}
}

View file

@ -0,0 +1,180 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
import SQLite
import Shared
class ReadingListSQLStorage: ReadingListStorage {
var db: Connection!
let items: Table!
struct ItemColumns {
// Client Metadata
static let ClientId = Expression<Int64>("client_id")
static let ClientLastModified = Expression<Int64>("client_last_modified")
// Server Metadata
static let Id = Expression<String?>("id")
static let LastModified = Expression<Int64?>("last_modified")
// Properties
static let Url = Expression<String>("url")
static let Title = Expression<String>("title")
static let AddedBy = Expression<String>("added_by")
static let Archived = Expression<Bool>("archived")
static let Favorite = Expression<Bool>("favorite")
static let Unread = Expression<Bool>("unread")
}
init(path: String) {
db = try! Connection(path)
items = Table("items")
do {
try db.run(items.create(temporary: false, ifNotExists: true, block: { (t: SQLite.TableBuilder) in
// Client Metadata
t.column(ItemColumns.ClientId, primaryKey: .autoincrement)
t.column(ItemColumns.ClientLastModified)
// Server Metadata
t.column(ItemColumns.Id) // TODO Unique but may be null?
t.column(ItemColumns.LastModified)
// Properties
t.column(ItemColumns.Url, unique: true)
t.column(ItemColumns.Title)
t.column(ItemColumns.AddedBy)
t.column(ItemColumns.Archived, defaultValue: false)
t.column(ItemColumns.Favorite, defaultValue: false)
t.column(ItemColumns.Unread, defaultValue: true)
}))
} catch {
print("Unable to create items database '\(error)")
}
}
func getAllRecords() -> Maybe<[ReadingListClientRecord]> {
do {
let preparedItems = try db.prepare(items)
return Maybe(success: Array(preparedItems).map {ReadingListClientRecord(row: self.rowToDictionary($0))!})
} catch {
return Maybe(failure: ReadingListStorageError("Can't fetch all records: \(error)"))
}
}
func getNewRecords() -> Maybe<[ReadingListClientRecord]> {
do {
let preparedItems = try db.prepare(items.filter(ItemColumns.Id == nil))
return Maybe(success: Array(preparedItems).map {ReadingListClientRecord(row: self.rowToDictionary($0))!})
} catch {
return Maybe(failure: ReadingListStorageError("Can't fetch all records: \(error)"))
}
}
func getUnreadRecords() -> Maybe<[ReadingListClientRecord]> {
do {
let preparedItems = try db.prepare(items.filter(ItemColumns.Unread == true))
return Maybe(success: Array(preparedItems).map {ReadingListClientRecord(row: self.rowToDictionary($0))!})
} catch {
return Maybe(failure: ReadingListStorageError("Can't fetch all records: \(error)"))
}
}
func getAvailableRecords() -> Maybe<[ReadingListClientRecord]> {
do {
let preparedItems = try db.prepare(items.order(ItemColumns.ClientLastModified.desc))
return Maybe(success: Array(preparedItems).map {ReadingListClientRecord(row: self.rowToDictionary($0))!})
} catch {
return Maybe(failure: ReadingListStorageError("Can't fetch all records: \(error)"))
}
}
func deleteRecord(_ record: ReadingListClientRecord) -> Maybe<Void> {
print("Trying to delete record with id \(record.clientMetadata.id)\n")
let query = items.filter(ItemColumns.ClientId == record.clientMetadata.id)
do {
try db.run(query.delete())
return Maybe(success: Void())
} catch {
return Maybe(failure: ReadingListStorageError("Failed to delete"))
}
}
func deleteAllRecords() -> Maybe<Void> {
do {
try db.run(self.items.delete())
return Maybe(success: Void())
} catch {
return Maybe(failure: ReadingListStorageError("Failed to delete"))
}
}
func createRecordWithURL(_ url: String, title: String, addedBy: String) -> Maybe<ReadingListClientRecord> {
let insert = items.insert(ItemColumns.ClientLastModified <- ReadingListNow(), ItemColumns.Url <- url, ItemColumns.Title <- title, ItemColumns.AddedBy <- addedBy)
do {
let id = try db.run(insert)
let preparedItems = try db.prepare(items.filter(ItemColumns.ClientId == id))
if let item = Array(preparedItems).first {
if let record = ReadingListClientRecord(row: rowToDictionary(item)) {
return Maybe(success: record)
} else {
return Maybe(failure: ReadingListStorageError("Can't create RLCR from row"))
}
} else {
return Maybe(failure: ReadingListStorageError("Can't get first item from results"))
}
} catch {
return Maybe(failure: ReadingListStorageError("Can't insert: \(error)"))
}
}
func getRecordWithURL(_ url: String) -> Maybe<ReadingListClientRecord?> {
do {
let preparedItems = try db.prepare(items.filter(ItemColumns.Url == url))
if let item = Array(preparedItems).first {
if let record = ReadingListClientRecord(row: rowToDictionary(item)) {
return Maybe(success: record)
} else {
return Maybe(failure: ReadingListStorageError("Can't create RLCR from row"))
}
} else {
return Maybe(success: nil)
}
} catch {
return Maybe(failure: ReadingListStorageError("Can't fetch: \(error)"))
}
}
func updateRecord(_ record: ReadingListClientRecord, unread: Bool) -> Maybe<ReadingListClientRecord?> {
let query = items.filter(ItemColumns.ClientId == record.clientMetadata.id).update(ItemColumns.Unread <- unread)
do {
try db.run(query)
let preparedItems = try db.prepare(items.filter(ItemColumns.ClientId == record.clientMetadata.id))
if let item = Array(preparedItems).first {
if let record = ReadingListClientRecord(row: rowToDictionary(item)) {
return Maybe(success: record)
} else {
return Maybe(failure: ReadingListStorageError("Can't create RLCR from row"))
}
} else {
return Maybe(success: nil)
}
} catch {
return Maybe(success: nil)
}
}
fileprivate func rowToDictionary(_ row: Row) -> [String: Any] {
var result: [String: Any] = [:]
result["client_id"] = NSNumber(value: row.get(ItemColumns.ClientId))
result["client_last_modified"] = NSNumber(value: row.get(ItemColumns.ClientLastModified))
result["id"] = row.get(ItemColumns.Id)
result["last_modified"] = NSNumber(value: row.get(ItemColumns.LastModified) ?? 0)
result["url"] = row.get(ItemColumns.Url)
result["title"] = row.get(ItemColumns.Title)
result["added_by"] = row.get(ItemColumns.AddedBy)
result["archived"] = row.get(ItemColumns.Archived)
result["favorite"] = row.get(ItemColumns.Favorite)
result["unread"] = row.get(ItemColumns.Unread)
return result
}
}

View file

@ -0,0 +1,37 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
public struct ReadingListServerMetadata: Equatable {
public var guid: String
public var lastModified: ReadingListTimestamp
init(guid: String, lastModified: ReadingListTimestamp) {
self.guid = guid
self.lastModified = lastModified
}
/// Initialize from server record.
init?(json: [String: Any]) {
self.init(data: json)
}
init?(row: [String: Any]) {
self.init(data: row)
}
private init?(data: [String: Any]) {
guard let guid = data["id"] as? String,
let lastModified = data["last_modified"] as? Int64 else {
return nil
}
self.guid = guid
self.lastModified = lastModified
}
}
public func ==(lhs: ReadingListServerMetadata, rhs: ReadingListServerMetadata) -> Bool {
return lhs.guid == rhs.guid && lhs.lastModified == rhs.lastModified
}

View file

@ -0,0 +1,41 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
struct ReadingListServerRecord {
let serverMetadata: ReadingListServerMetadata?
let url: String
let title: String
let addedBy: String
let unread: Bool
let archived: Bool
let favorite: Bool
/// Initializer for when a record is loaded from server-sent json
init?(json: [String: Any]?) {
guard let json = json,
let serverMetadata = ReadingListServerMetadata(json: json),
let url = json["url"] as? String,
let title = json["title"] as? String,
let addedBy = json["added_by"] as? String,
let unread = json["unread"] as? Bool,
let archived = json["archived"] as? Bool,
let favorite = json["favorite"] as? Bool else {
return nil
}
self.serverMetadata = serverMetadata
self.url = url
self.title = title
self.addedBy = addedBy
self.unread = unread
self.archived = archived
self.favorite = favorite
}
}

View file

@ -0,0 +1,42 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
import Shared
/// This is the public API that the application and extension talk to. It exposes the bare minimum
/// functions that need to be public and hides details like storage and syncing.
open class ReadingListService {
var databasePath: String
var storage: ReadingListStorage
public init?(profileStoragePath: String) {
databasePath = (profileStoragePath as NSString).appendingPathComponent("ReadingList.db")
storage = ReadingListSQLStorage(path: "\(profileStoragePath)/ReadingList.db")
}
open func getAvailableRecords() -> Maybe<[ReadingListClientRecord]> {
return storage.getAvailableRecords()
}
@discardableResult open func deleteRecord(_ record: ReadingListClientRecord) -> Maybe<Void> {
return storage.deleteRecord(record)
}
open func deleteAllRecords() -> Maybe<Void> {
return storage.deleteAllRecords()
}
@discardableResult open func createRecordWithURL(_ url: String, title: String, addedBy: String) -> Maybe<ReadingListClientRecord> {
return storage.createRecordWithURL(url, title: title, addedBy: addedBy)
}
open func getRecordWithURL(_ url: String) -> Maybe<ReadingListClientRecord?> {
return storage.getRecordWithURL(url)
}
@discardableResult open func updateRecord(_ record: ReadingListClientRecord, unread: Bool) -> Maybe<ReadingListClientRecord?> {
return storage.updateRecord(record, unread: unread)
}
}

View file

@ -0,0 +1,31 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
import Shared
class ReadingListStorageError: MaybeErrorType {
var message: String
init(_ message: String) {
self.message = message
}
var description: String {
return message
}
}
/// Storage protocol. The only thing the client (application) communicates with is the storage. Adding, removing and updating items.
protocol ReadingListStorage {
func getAllRecords() -> Maybe<[ReadingListClientRecord]>
func getNewRecords() -> Maybe<[ReadingListClientRecord]>
// These are the used by the application
func getUnreadRecords() -> Maybe<[ReadingListClientRecord]>
func getAvailableRecords() -> Maybe<[ReadingListClientRecord]>
func deleteRecord(_ record: ReadingListClientRecord) -> Maybe<Void>
func deleteAllRecords() -> Maybe<Void>
func createRecordWithURL(_ url: String, title: String, addedBy: String) -> Maybe<ReadingListClientRecord>
func getRecordWithURL(_ url: String) -> Maybe<ReadingListClientRecord?>
func updateRecord(_ record: ReadingListClientRecord, unread: Bool) -> Maybe<ReadingListClientRecord?>
}

View file

@ -0,0 +1,25 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
enum ReadingListSyncChanges {
}
enum ReadingListSyncStatus {
case synced
case new
case deleted
case modified
}
enum ReadingListSyncChange {
case none, unread, favorite, resolved
}
struct ReadingListSyncMetadata {
var changes: ReadingListSyncChanges
var status: ReadingListSyncStatus
}

View file

@ -0,0 +1,69 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
enum ReadingListSynchronizerResult {
case success
case failure
case error(NSError)
}
enum ReadingListSyncType {
case uploadOnly
case full
}
class ReadingListSynchronizer {
var storage: ReadingListStorage
var client: ReadingListClient
init(storage: ReadingListStorage, client: ReadingListClient) {
self.storage = storage
self.client = client
}
func synchronize(type: ReadingListSyncType, completion: (ReadingListSynchronizerResult) -> Void) {
// TODO Check if we are already syncing - Keep state somewhere
// TODO If this is a first time sync then we want to remember the account and server in storage, so that:
// TODO Check if the client is configured to use a different account or server
switch type {
case .uploadOnly:
let synchronizer = ReadingListUploadOnlySynchronizer(storage: storage, client: client)
synchronizer.synchronizeWithCompletion(completion)
case .full:
let synchronizer = ReadingListFullSynchronizer(storage: storage, client: client)
synchronizer.synchronizeWithCompletion(completion)
}
}
}
// This is implemented in two different classes to make the design simpler. There will be some duplicate
// code but I prefer that instead of having two implementations in one class.
private class ReadingListUploadOnlySynchronizer {
var storage: ReadingListStorage
var client: ReadingListClient
init(storage: ReadingListStorage, client: ReadingListClient) {
self.storage = storage
self.client = client
}
func synchronizeWithCompletion(_ completion: (ReadingListSynchronizerResult) -> Void) {
}
}
private class ReadingListFullSynchronizer {
var storage: ReadingListStorage
var client: ReadingListClient
init(storage: ReadingListStorage, client: ReadingListClient) {
self.storage = storage
self.client = client
}
func synchronizeWithCompletion(_ completion: (ReadingListSynchronizerResult) -> Void) {
}
}

View file

@ -0,0 +1,16 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
public typealias ReadingListRecordId = Int64
public typealias ReadingListTimestamp = Int64
func ReadingListNow() -> ReadingListTimestamp {
return ReadingListTimestamp(Date.timeIntervalSinceReferenceDate * 1000.0)
}
let ReadingListDefaultUnread: Bool = true
let ReadingListDefaultFavorite: Bool = false
let ReadingListDefaultArchived: Bool = false