├── .gitignore ├── LICENSE ├── README.md └── ThingsJSON.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xccheckout 23 | *.xcscmblueprint 24 | .DS_Store 25 | 26 | ## Obj-C/Swift specific 27 | *.hmap 28 | *.ipa 29 | *.dSYM.zip 30 | *.dSYM 31 | 32 | ## Playgrounds 33 | timeline.xctimeline 34 | playground.xcworkspace 35 | 36 | # Swift Package Manager 37 | # 38 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 39 | # Packages/ 40 | # Package.pins 41 | .build/ 42 | 43 | # CocoaPods 44 | # 45 | # We recommend against adding the Pods directory to your .gitignore. However 46 | # you should judge for yourself, the pros and cons are mentioned at: 47 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 48 | # 49 | # Pods/ 50 | 51 | # Carthage 52 | # 53 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 54 | # Carthage/Checkouts 55 | 56 | Carthage/Build 57 | 58 | # fastlane 59 | # 60 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 61 | # screenshots whenever they are needed. 62 | # For more information about the recommended setup visit: 63 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 64 | 65 | fastlane/report.xml 66 | fastlane/Preview.html 67 | fastlane/screenshots 68 | fastlane/test_output 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Cultured Code GmbH & Co. KG 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Things JSON Coder 2 | 3 | This repo contains a Swift file that allows the creation of the JSON required to be passed to the `json` command of Things’ URL scheme. 4 | 5 | ## Installation 6 | 7 | To install, download the [`ThingsJSON.swift`](https://github.com/culturedcode/ThingsJSONCoder/blob/master/ThingsJSON.swift) file and add it as a source file to your own project. Alternatively, this repo can be cloned as either a real or fake submodule inside your project. 8 | 9 | ## Requirements 10 | 11 | This code is written with Swift 4. 12 | 13 | ## Getting Started 14 | 15 | #### The Things JSON Container 16 | 17 | The top level object that will be encoded into the JSON array is the `TJSContainer`. This object contains an array of the items to be included in the JSON. 18 | 19 | #### Model Classes 20 | 21 | The following Things model classes can be encoded into JSON: 22 | 23 | * `Todo` 24 | * `Project` 25 | * `Heading` 26 | * `ChecklistItem` 27 | 28 | #### Container Enums 29 | 30 | There are two wrapper enums used to package objects into arrays. Associated values are used to hold the above model objects inside. These enums exist to allow more than one type of object inside an array while retaining type safety. They also handle the encoding and decoding of heterogeneous types within an array to and from JSON. 31 | 32 | * `TJSContainer.Item` – This enum has cases for todo and project objects. Only todo and project items can exist at the top level array in the JSON. 33 | 34 | * `TJSProject.Item` – This enum has cases for todo and heading objects. Only todo and heading objects can be items inside a project. 35 | 36 | #### Dates 37 | Dates should be formatted according to ISO8601. Setting the JSON encoder’s `dateEncodingStrategy` to `ThingsJSONDateEncodingStrategy()` is the easiest way to do this (see example below). 38 | 39 | ## Example 40 | 41 | Create two todos and a project, encode them into JSON and send to Things’ add command. 42 | 43 | ```Swift 44 | let todo1 = TJSTodo(title: "Pick up dry cleaning", when: "today") 45 | let todo2 = TJSTodo(title: "Pack for vacation", 46 | checklistItems: [TJSChecklistItem(title: "Camera"), 47 | TJSChecklistItem(title: "Passport")]) 48 | 49 | let project = TJSProject(title: "Go Shopping", 50 | items: [.heading(TJSHeading(title: "Dairy")), 51 | .todo(TJSTodo(title: "Milk"))]) 52 | 53 | let container = TJSContainer(items: [.todo(todo1), 54 | .todo(todo2), 55 | .project(project)]) 56 | do { 57 | let encoder = JSONEncoder() 58 | encoder.dateEncodingStrategy = ThingsJSONDateEncodingStrategy() 59 | let data = try encoder.encode(container) 60 | let json = String(data: data, encoding: .utf8)! 61 | var components = URLComponents(string: "things:///add-json")! 62 | let queryItem = URLQueryItem(name: "data", value: json) 63 | components.queryItems = [queryItem] 64 | let url = components.url! 65 | UIApplication.shared.open(url, options: [:], completionHandler: nil) 66 | } 67 | catch { 68 | // Handle error 69 | } 70 | ``` 71 | 72 | ## License 73 | 74 | This code is released under the MIT license. See [LICENSE](https://github.com/culturedcode/ThingsJSONCoder/blob/master/LICENSE) for details. 75 | -------------------------------------------------------------------------------- /ThingsJSON.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThingsJSON.swift 3 | // 4 | // Copyright © 2018 Cultured Code GmbH & Co. KG 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Foundation 26 | 27 | // MARK: Container 28 | 29 | /// The container holding the array of items to be encoded to JSON. 30 | public class TJSContainer : Codable { 31 | 32 | /// The array of items that will be encoded or decoded from the JSON. 33 | public var items = [Item]() 34 | 35 | /// Create and return a new ThingsJSON object configured with the provided items. 36 | public init(items: [Item]) { 37 | self.items = items 38 | } 39 | 40 | /// Creates a new instance by decoding from the given decoder. 41 | public required init(from decoder: Decoder) throws { 42 | let container = try decoder.singleValueContainer() 43 | self.items = try container.decode([Item].self) 44 | } 45 | 46 | /// Encodes this value into the given encoder. 47 | public func encode(to encoder: Encoder) throws { 48 | try self.items.encode(to: encoder) 49 | } 50 | 51 | /// An item that can exist inside the top level JSON array. 52 | /// 53 | /// This is an enum that wraps a TJSTodo or TJSProject object and handles its encoding 54 | /// and decoding to JSON. This is required because there is no way of specifiying a 55 | /// strongly typed array that contains more than one type. 56 | public enum Item : Codable { 57 | case todo(TJSTodo) 58 | case project(TJSProject) 59 | 60 | /// Creates a new instance by decoding from the given decoder. 61 | public init(from decoder: Decoder) throws { 62 | let container = try decoder.singleValueContainer() 63 | 64 | do { 65 | // Try to decode a to-do 66 | let todo = try container.decode(TJSTodo.self) 67 | self = .todo(todo) 68 | } 69 | catch TJSError.invalidType(expectedType: _, errorContext: _) { 70 | // If it's the wrong type, try a project 71 | let project = try container.decode(TJSProject.self) 72 | self = .project(project) 73 | } 74 | } 75 | 76 | /// Encodes this value into the given encoder. 77 | public func encode(to encoder: Encoder) throws { 78 | switch self { 79 | case .todo(let todo): 80 | try todo.encode(to: encoder) 81 | case .project(let project): 82 | try project.encode(to: encoder) 83 | } 84 | } 85 | } 86 | } 87 | 88 | 89 | // MARK: - Model Items 90 | 91 | /// The superclass of all the Things JSON model items. 92 | /// 93 | /// Do not instantiate this class itself. Instead use one of the subclasses. 94 | public class TJSModelItem { 95 | fileprivate var type: String = "" 96 | 97 | /// The operation to perform on the object. 98 | public var operation: Operation 99 | 100 | /// The ID of the item to update. 101 | public var id: String? 102 | 103 | private enum CodingKeys: String, CodingKey { 104 | case type 105 | case operation 106 | case id 107 | case attributes 108 | } 109 | 110 | public enum Operation: String, Codable { 111 | /// Create a new item. 112 | case create = "create" 113 | /// Update an existing item. 114 | /// 115 | /// Requires id to be set. 116 | case update = "update" 117 | } 118 | 119 | public init(operation: Operation, id: String? = nil) { 120 | self.operation = operation 121 | self.id = id 122 | } 123 | 124 | fileprivate func attributes(_ type: T.Type, from decoder: Decoder) throws -> KeyedDecodingContainer { 125 | let container = try decoder.container(keyedBy: CodingKeys.self) 126 | let decodedType = try container.decode(String.self, forKey: .type) 127 | self.operation = try container.decodeIfPresent(Operation.self, forKey: .operation) ?? .create 128 | self.id = try container.decodeIfPresent(String.self, forKey: .id) 129 | guard decodedType == self.type else { 130 | let description = String(format: "Expected to decode a %@ but found a %@ instead.", self.type, decodedType) 131 | let errorContext = DecodingError.Context(codingPath: [CodingKeys.type], debugDescription: description) 132 | let expectedType = Swift.type(of: self) 133 | throw TJSError.invalidType(expectedType: expectedType, errorContext: errorContext) 134 | } 135 | return try container.nestedContainer(keyedBy: T.self, forKey: .attributes) 136 | } 137 | 138 | fileprivate func attributes(_ type: T.Type, for encoder: Encoder) throws -> KeyedEncodingContainer { 139 | var container = encoder.container(keyedBy: CodingKeys.self) 140 | try container.encode(self.type, forKey: .type) 141 | try container.encode(self.operation, forKey: .operation) 142 | try container.encodeIfPresent(self.id, forKey: .id) 143 | return container.nestedContainer(keyedBy: T.self, forKey: .attributes) 144 | } 145 | } 146 | 147 | 148 | // MARK: - 149 | 150 | /// Represents a to-do in Things. 151 | public class TJSTodo : TJSModelItem, Codable { 152 | public var title: String? 153 | public var notes: String? 154 | public var prependNotes: String? 155 | public var appendNotes: String? 156 | public var when: String? 157 | public var deadline: String? 158 | public var tagIDs: [String]? 159 | public var tags: [String]? 160 | public var addTags: [String]? 161 | public var checklistItems: [TJSChecklistItem]? 162 | public var prependChecklistItems: [TJSChecklistItem]? 163 | public var appendChecklistItems: [TJSChecklistItem]? 164 | public var listID: String? 165 | public var list: String? 166 | public var headingID: String? 167 | public var heading: String? 168 | public var completed: Bool? 169 | public var canceled: Bool? 170 | public var creationDate: Date? 171 | public var completionDate: Date? 172 | 173 | private enum CodingKeys: String, CodingKey { 174 | case title 175 | case notes 176 | case prependNotes = "prepend-notes" 177 | case appendNotes = "append-notes" 178 | case when 179 | case deadline 180 | case tagIDs = "tag-ids" 181 | case tags 182 | case addTags = "add-tags" 183 | case checklistItems = "checklist-items" 184 | case prependChecklistItems = "prepend-checklist-items" 185 | case appendChecklistItems = "append-checklist-items" 186 | case listID = "list-id" 187 | case list 188 | case headingID = "heading-id" 189 | case heading 190 | case completed 191 | case canceled 192 | case creationDate = "creation-date" 193 | case completionDate = "completion-date" 194 | } 195 | 196 | /// Create and return a new todo configured with the provided values. 197 | public init(operation: Operation = .create, 198 | id: String? = nil, 199 | title: String? = nil, 200 | notes: String? = nil, 201 | prependNotes: String? = nil, 202 | appendNotes: String? = nil, 203 | when: String? = nil, 204 | deadline: String? = nil, 205 | tagIDs: [String]? = nil, 206 | tags: [String]? = nil, 207 | addTags: [String]? = nil, 208 | checklistItems: [TJSChecklistItem]? = nil, 209 | prependChecklistItems: [TJSChecklistItem]? = nil, 210 | appendChecklistItems: [TJSChecklistItem]? = nil, 211 | listID: String? = nil, 212 | list: String? = nil, 213 | headingID: String? = nil, 214 | heading: String? = nil, 215 | completed: Bool? = nil, 216 | canceled: Bool? = nil, 217 | creationDate: Date? = nil, 218 | completionDate: Date? = nil) { 219 | 220 | super.init(operation: operation, id: id) 221 | self.type = "to-do" 222 | 223 | self.title = title 224 | self.notes = notes 225 | self.prependNotes = prependNotes 226 | self.appendNotes = appendNotes 227 | self.when = when 228 | self.deadline = deadline 229 | self.tagIDs = tagIDs 230 | self.tags = tags 231 | self.addTags = addTags 232 | self.checklistItems = checklistItems 233 | self.prependChecklistItems = prependChecklistItems 234 | self.appendChecklistItems = appendChecklistItems 235 | self.listID = listID 236 | self.list = list 237 | self.heading = heading 238 | self.headingID = headingID 239 | self.completed = completed 240 | self.canceled = canceled 241 | self.creationDate = creationDate 242 | self.completionDate = completionDate 243 | } 244 | 245 | /// Create and return a new todo configured with same values as the provided todo. 246 | public convenience init(_ todo: TJSTodo) { 247 | self.init(id: todo.id, 248 | title: todo.title, 249 | notes: todo.notes, 250 | prependNotes: todo.prependNotes, 251 | appendNotes: todo.appendNotes, 252 | when: todo.when, 253 | deadline: todo.deadline, 254 | tagIDs: todo.tagIDs, 255 | tags: todo.tags, 256 | addTags: todo.addTags, 257 | checklistItems: todo.checklistItems, 258 | prependChecklistItems: todo.prependChecklistItems, 259 | appendChecklistItems: todo.appendChecklistItems, 260 | listID: todo.listID, 261 | list: todo.list, 262 | headingID: todo.headingID, 263 | heading: todo.heading, 264 | completed: todo.completed, 265 | canceled: todo.canceled, 266 | creationDate: todo.creationDate, 267 | completionDate: todo.completionDate) 268 | } 269 | 270 | /// Creates a new instance by decoding from the given decoder. 271 | public required convenience init(from decoder: Decoder) throws { 272 | self.init() 273 | let attributes = try self.attributes(CodingKeys.self, from: decoder) 274 | do { 275 | title = try attributes.decodeIfPresent(String.self, forKey: .title) 276 | notes = try attributes.decodeIfPresent(String.self, forKey: .notes) 277 | prependNotes = try attributes.decodeIfPresent(String.self, forKey: .prependNotes) 278 | appendNotes = try attributes.decodeIfPresent(String.self, forKey: .appendNotes) 279 | when = try attributes.decodeIfPresent(String.self, forKey: .when) 280 | deadline = try attributes.decodeIfPresent(String.self, forKey: .deadline) 281 | tagIDs = try attributes.decodeIfPresent([String].self, forKey: .tagIDs) 282 | tags = try attributes.decodeIfPresent([String].self, forKey: .tags) 283 | addTags = try attributes.decodeIfPresent([String].self, forKey: .addTags) 284 | checklistItems = try attributes.decodeIfPresent([TJSChecklistItem].self, forKey: .checklistItems) 285 | prependChecklistItems = try attributes.decodeIfPresent([TJSChecklistItem].self, forKey: .prependChecklistItems) 286 | appendChecklistItems = try attributes.decodeIfPresent([TJSChecklistItem].self, forKey: .appendChecklistItems) 287 | listID = try attributes.decodeIfPresent(String.self, forKey: .listID) 288 | list = try attributes.decodeIfPresent(String.self, forKey: .list) 289 | headingID = try attributes.decodeIfPresent(String.self, forKey: .headingID) 290 | heading = try attributes.decodeIfPresent(String.self, forKey: .heading) 291 | completed = try attributes.decodeIfPresent(Bool.self, forKey: .completed) 292 | canceled = try attributes.decodeIfPresent(Bool.self, forKey: .canceled) 293 | creationDate = try attributes.decodeIfPresent(Date.self, forKey: .creationDate) 294 | completionDate = try attributes.decodeIfPresent(Date.self, forKey: .completionDate) 295 | } 296 | catch TJSError.invalidType(let expectedType, let errorContext) { 297 | throw DecodingError.typeMismatch(expectedType, errorContext) 298 | } 299 | } 300 | 301 | /// Encodes this value into the given encoder. 302 | public func encode(to encoder: Encoder) throws { 303 | var attributes = try self.attributes(CodingKeys.self, for: encoder) 304 | try attributes.encodeIfPresent(title, forKey: .title) 305 | try attributes.encodeIfPresent(notes, forKey: .notes) 306 | try attributes.encodeIfPresent(prependNotes, forKey: .prependNotes) 307 | try attributes.encodeIfPresent(appendNotes, forKey: .appendNotes) 308 | try attributes.encodeIfPresent(when, forKey: .when) 309 | try attributes.encodeIfPresent(deadline, forKey: .deadline) 310 | try attributes.encodeIfPresent(tagIDs, forKey: .tagIDs) 311 | try attributes.encodeIfPresent(tags, forKey: .tags) 312 | try attributes.encodeIfPresent(addTags, forKey: .addTags) 313 | try attributes.encodeIfPresent(checklistItems, forKey: .checklistItems) 314 | try attributes.encodeIfPresent(prependChecklistItems, forKey: .prependChecklistItems) 315 | try attributes.encodeIfPresent(appendChecklistItems, forKey: .appendChecklistItems) 316 | try attributes.encodeIfPresent(listID, forKey: .listID) 317 | try attributes.encodeIfPresent(list, forKey: .list) 318 | try attributes.encodeIfPresent(headingID, forKey: .headingID) 319 | try attributes.encodeIfPresent(heading, forKey: .heading) 320 | try attributes.encodeIfPresent(completed, forKey: .completed) 321 | try attributes.encodeIfPresent(canceled, forKey: .canceled) 322 | try attributes.encodeIfPresent(creationDate, forKey: .creationDate) 323 | try attributes.encodeIfPresent(completionDate, forKey: .completionDate) 324 | } 325 | } 326 | 327 | 328 | // MARK: - 329 | 330 | /// Represents a project in Things. 331 | public class TJSProject : TJSModelItem, Codable { 332 | var title: String? 333 | var notes: String? 334 | var prependNotes: String? 335 | var appendNotes: String? 336 | var when: String? 337 | var deadline: String? 338 | var tagIDs: [String]? 339 | var tags: [String]? 340 | var addTags: [String]? 341 | var areaID: String? 342 | var area: String? 343 | var items: [Item]? 344 | var completed: Bool? 345 | var canceled: Bool? 346 | var creationDate: Date? 347 | var completionDate: Date? 348 | 349 | private enum CodingKeys: String, CodingKey { 350 | case title 351 | case notes 352 | case prependNotes = "prepend-notes" 353 | case appendNotes = "append-notes" 354 | case when 355 | case deadline 356 | case tagIDs = "tag-ids" 357 | case tags 358 | case addTags = "add-tags" 359 | case areaID = "area-id" 360 | case area 361 | case items 362 | case completed 363 | case canceled 364 | case creationDate = "creation-date" 365 | case completionDate = "completion-date" 366 | } 367 | 368 | /// Create and return a new project configured with the provided values. 369 | init(operation: Operation = .create, 370 | id: String? = nil, 371 | title: String? = nil, 372 | notes: String? = nil, 373 | prependNotes: String? = nil, 374 | appendNotes: String? = nil, 375 | when: String? = nil, 376 | deadline: String? = nil, 377 | tagIDs: [String]? = nil, 378 | tags: [String]? = nil, 379 | addTags: [String]? = nil, 380 | areaID: String? = nil, 381 | area: String? = nil, 382 | items: [Item]? = nil, 383 | completed: Bool? = nil, 384 | canceled: Bool? = nil, 385 | creationDate: Date? = nil, 386 | completionDate: Date? = nil) { 387 | 388 | super.init(operation: operation, id: id) 389 | self.type = "project" 390 | 391 | self.title = title 392 | self.notes = notes 393 | self.prependNotes = prependNotes 394 | self.appendNotes = appendNotes 395 | self.when = when 396 | self.deadline = deadline 397 | self.tagIDs = tagIDs 398 | self.tags = tags 399 | self.addTags = addTags 400 | self.areaID = areaID 401 | self.area = area 402 | self.items = items 403 | self.completed = completed 404 | self.canceled = canceled 405 | self.creationDate = creationDate 406 | self.completionDate = completionDate 407 | } 408 | 409 | /// Create and return a new project configured with same values as the provided project. 410 | convenience init(_ project: TJSProject) { 411 | self.init(id: project.id, 412 | title: project.title, 413 | notes: project.notes, 414 | prependNotes: project.prependNotes, 415 | appendNotes: project.appendNotes, 416 | when: project.when, 417 | deadline: project.deadline, 418 | tagIDs: project.tagIDs, 419 | tags: project.tags, 420 | addTags: project.addTags, 421 | areaID: project.areaID, 422 | area: project.area, 423 | items: project.items, 424 | completed: project.completed, 425 | canceled: project.canceled, 426 | creationDate: project.creationDate, 427 | completionDate: project.completionDate) 428 | } 429 | 430 | /// Creates a new instance by decoding from the given decoder. 431 | public required convenience init(from decoder: Decoder) throws { 432 | self.init() 433 | let attributes = try self.attributes(CodingKeys.self, from: decoder) 434 | do { 435 | title = try attributes.decodeIfPresent(String.self, forKey: .title) 436 | notes = try attributes.decodeIfPresent(String.self, forKey: .notes) 437 | prependNotes = try attributes.decodeIfPresent(String.self, forKey: .prependNotes) 438 | appendNotes = try attributes.decodeIfPresent(String.self, forKey: .appendNotes) 439 | when = try attributes.decodeIfPresent(String.self, forKey: .when) 440 | deadline = try attributes.decodeIfPresent(String.self, forKey: .deadline) 441 | tagIDs = try attributes.decodeIfPresent([String].self, forKey: .tagIDs) 442 | tags = try attributes.decodeIfPresent([String].self, forKey: .tags) 443 | addTags = try attributes.decodeIfPresent([String].self, forKey: .addTags) 444 | areaID = try attributes.decodeIfPresent(String.self, forKey: .areaID) 445 | area = try attributes.decodeIfPresent(String.self, forKey: .area) 446 | completed = try attributes.decodeIfPresent(Bool.self, forKey: .completed) 447 | canceled = try attributes.decodeIfPresent(Bool.self, forKey: .canceled) 448 | items = try attributes.decodeIfPresent([Item].self, forKey: .items) 449 | creationDate = try attributes.decodeIfPresent(Date.self, forKey: .creationDate) 450 | completionDate = try attributes.decodeIfPresent(Date.self, forKey: .completionDate) 451 | } 452 | catch TJSError.invalidType(let expectedType, let errorContext) { 453 | throw DecodingError.typeMismatch(expectedType, errorContext) 454 | } 455 | } 456 | 457 | /// Encodes this value into the given encoder. 458 | public func encode(to encoder: Encoder) throws { 459 | var attributes = try self.attributes(CodingKeys.self, for: encoder) 460 | try attributes.encodeIfPresent(title, forKey: .title) 461 | try attributes.encodeIfPresent(notes, forKey: .notes) 462 | try attributes.encodeIfPresent(prependNotes, forKey: .prependNotes) 463 | try attributes.encodeIfPresent(appendNotes, forKey: .appendNotes) 464 | try attributes.encodeIfPresent(when, forKey: .when) 465 | try attributes.encodeIfPresent(deadline, forKey: .deadline) 466 | try attributes.encodeIfPresent(tagIDs, forKey: .tagIDs) 467 | try attributes.encodeIfPresent(tags, forKey: .tags) 468 | try attributes.encodeIfPresent(addTags, forKey: .addTags) 469 | try attributes.encodeIfPresent(areaID, forKey: .areaID) 470 | try attributes.encodeIfPresent(area, forKey: .area) 471 | try attributes.encodeIfPresent(items, forKey: .items) 472 | try attributes.encodeIfPresent(completed, forKey: .completed) 473 | try attributes.encodeIfPresent(canceled, forKey: .canceled) 474 | try attributes.encodeIfPresent(creationDate, forKey: .creationDate) 475 | try attributes.encodeIfPresent(completionDate, forKey: .completionDate) 476 | } 477 | 478 | /// A child item of a project. 479 | /// 480 | /// This is an enum that wraps a TJSTodo or TJSHeading object and handles its encoding 481 | /// and decoding to JSON. This is required because there is no way of specifiying a 482 | /// strongly typed array that contains more than one type. 483 | public enum Item : Codable { 484 | case todo(TJSTodo) 485 | case heading(TJSHeading) 486 | 487 | /// Creates a new instance by decoding from the given decoder. 488 | public init(from decoder: Decoder) throws { 489 | let container = try decoder.singleValueContainer() 490 | 491 | do { 492 | // Try to decode a to-do 493 | let todo = try container.decode(TJSTodo.self) 494 | self = .todo(todo) 495 | } 496 | catch TJSError.invalidType(expectedType: _, errorContext: _) { 497 | // If it's the wrong type, try a heading 498 | let heading = try container.decode(TJSHeading.self) 499 | self = .heading(heading) 500 | } 501 | } 502 | 503 | /// Encodes this value into the given encoder. 504 | public func encode(to encoder: Encoder) throws { 505 | switch self { 506 | case .todo(let todo): 507 | try todo.encode(to: encoder) 508 | case .heading(let heading): 509 | try heading.encode(to: encoder) 510 | } 511 | } 512 | } 513 | } 514 | 515 | 516 | // MARK: - 517 | 518 | /// Represents a heading in Things. 519 | public class TJSHeading : TJSModelItem, Codable { 520 | public var title: String? 521 | public var archived: Bool? 522 | public var creationDate: Date? 523 | public var completionDate: Date? 524 | 525 | private enum CodingKeys: String, CodingKey { 526 | case title 527 | case archived 528 | case creationDate = "creation-date" 529 | case completionDate = "completion-date" 530 | } 531 | 532 | /// Create and return a new heading configured with the provided values. 533 | public init(operation: Operation = .create, 534 | title: String? = nil, 535 | archived: Bool? = nil, 536 | creationDate: Date? = nil, 537 | completionDate: Date? = nil) { 538 | 539 | super.init(operation: operation) 540 | self.type = "heading" 541 | 542 | self.title = title 543 | self.archived = archived 544 | self.creationDate = creationDate 545 | self.completionDate = completionDate 546 | } 547 | 548 | /// Create and return a new heading configured with same values as the provided heading. 549 | public convenience init(_ heading: TJSHeading) { 550 | self.init(title: heading.title, 551 | archived: heading.archived, 552 | creationDate: heading.creationDate, 553 | completionDate: heading.completionDate) 554 | } 555 | 556 | /// Creates a new instance by decoding from the given decoder. 557 | public required convenience init(from decoder: Decoder) throws { 558 | self.init() 559 | let attributes = try self.attributes(CodingKeys.self, from: decoder) 560 | title = try attributes.decodeIfPresent(String.self, forKey: .title) 561 | archived = try attributes.decodeIfPresent(Bool.self, forKey: .archived) 562 | creationDate = try attributes.decodeIfPresent(Date.self, forKey: .creationDate) 563 | completionDate = try attributes.decodeIfPresent(Date.self, forKey: .completionDate) 564 | } 565 | 566 | /// Encodes this value into the given encoder. 567 | public func encode(to encoder: Encoder) throws { 568 | var attributes = try self.attributes(CodingKeys.self, for: encoder) 569 | try attributes.encodeIfPresent(title, forKey: .title) 570 | try attributes.encodeIfPresent(archived, forKey: .archived) 571 | try attributes.encodeIfPresent(creationDate, forKey: .creationDate) 572 | try attributes.encodeIfPresent(completionDate, forKey: .completionDate) 573 | } 574 | } 575 | 576 | 577 | // MARK: - 578 | 579 | /// Represents a checklist item in Things. 580 | public class TJSChecklistItem : TJSModelItem, Codable { 581 | public var title: String? 582 | public var completed: Bool? 583 | public var canceled: Bool? 584 | public var creationDate: Date? 585 | public var completionDate: Date? 586 | 587 | private enum CodingKeys: String, CodingKey { 588 | case title 589 | case completed 590 | case canceled 591 | case creationDate = "creation-date" 592 | case completionDate = "completion-date" 593 | } 594 | 595 | /// Create and return a new checklist item configured with the provided values. 596 | public init(operation: Operation = .create, 597 | title: String? = nil, 598 | completed: Bool? = nil, 599 | canceled: Bool? = nil, 600 | creationDate: Date? = nil, 601 | completionDate: Date? = nil) { 602 | 603 | super.init(operation: operation) 604 | self.type = "checklist-item" 605 | 606 | self.title = title 607 | self.completed = completed 608 | self.canceled = canceled 609 | self.creationDate = creationDate 610 | self.completionDate = completionDate 611 | } 612 | 613 | /// Create and return a new checklist item configured with same values as the provided checklist item. 614 | public convenience init (_ checklistItem: TJSChecklistItem) { 615 | self.init(title: checklistItem.title, 616 | completed: checklistItem.completed, 617 | canceled: checklistItem.canceled, 618 | creationDate: checklistItem.creationDate, 619 | completionDate: checklistItem.completionDate) 620 | } 621 | 622 | /// Creates a new instance by decoding from the given decoder. 623 | public required convenience init(from decoder: Decoder) throws { 624 | self.init() 625 | let attributes = try self.attributes(CodingKeys.self, from: decoder) 626 | title = try attributes.decodeIfPresent(String.self, forKey: .title) 627 | completed = try attributes.decodeIfPresent(Bool.self, forKey: .completed) 628 | canceled = try attributes.decodeIfPresent(Bool.self, forKey: .canceled) 629 | creationDate = try attributes.decodeIfPresent(Date.self, forKey: .creationDate) 630 | completionDate = try attributes.decodeIfPresent(Date.self, forKey: .completionDate) 631 | } 632 | 633 | /// Encodes this value into the given encoder. 634 | public func encode(to encoder: Encoder) throws { 635 | var attributes = try self.attributes(CodingKeys.self, for: encoder) 636 | try attributes.encodeIfPresent(title, forKey: .title) 637 | try attributes.encodeIfPresent(completed, forKey: .completed) 638 | try attributes.encodeIfPresent(canceled, forKey: .canceled) 639 | try attributes.encodeIfPresent(creationDate, forKey: .creationDate) 640 | try attributes.encodeIfPresent(completionDate, forKey: .completionDate) 641 | } 642 | } 643 | 644 | 645 | // MARK: - Internal Error 646 | 647 | private enum TJSError : Error { 648 | case invalidType(expectedType: Any.Type, errorContext: DecodingError.Context) 649 | } 650 | 651 | 652 | // Mark: - Date Formatting 653 | 654 | /// A date encoding strategy to format a date according to ISO8601. 655 | /// 656 | /// Use to with a JSONEncoder to correctly format dates. 657 | public func ThingsJSONDateEncodingStrategy() -> JSONEncoder.DateEncodingStrategy { 658 | return .iso8601 659 | } 660 | 661 | /// A date decoding strategy to format a date according to ISO8601. 662 | /// 663 | /// Use to with a JSONDecoder to correctly format dates. 664 | public func ThingsJSONDateDecodingStrategy() -> JSONDecoder.DateDecodingStrategy { 665 | return .iso8601 666 | } 667 | --------------------------------------------------------------------------------