From 4496d6ca1d3fb57177d275a2df6373c2fa2485e0 Mon Sep 17 00:00:00 2001 From: Rens Verhoeven Date: Fri, 16 Jun 2017 16:24:29 +0200 Subject: [PATCH] Initial commit --- .gitignore | 65 ++ .gitmodules | 0 Kanna/CSS.swift | 354 +++++++++ Kanna/Kanna.swift | 445 +++++++++++ Kanna/libxmlHTMLDocument.swift | 277 +++++++ Kanna/libxmlHTMLNode.swift | 270 +++++++ Kanna/libxmlParserOption.swift | 94 +++ Ocarina Example/AppDelegate.swift | 139 ++++ .../AppIcon.appiconset/Contents.json | 48 ++ .../Base.lproj/LaunchScreen.storyboard | 27 + Ocarina Example/Base.lproj/Main.storyboard | 143 ++++ Ocarina Example/Info.plist | 53 ++ .../LinkPreviewTableViewCell.swift | 28 + Ocarina Example/LinkPreviewView.swift | 361 +++++++++ .../OptionsTableViewController.swift | 38 + .../RealLinksTableViewController.swift | 147 ++++ Ocarina.xcodeproj/project.pbxproj | 720 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/Ocarina.xcscmblueprint | 30 + .../xcschemes/Ocarina Example.xcscheme | 102 +++ .../xcshareddata/xcschemes/Ocarina.xcscheme | 80 ++ Ocarina/Info.plist | 24 + Ocarina/Ocarina.h | 19 + Ocarina/Ocarina.swift | 11 + Ocarina/OcarinaInformationRequest.swift | 42 + Ocarina/OcarinaManager.swift | 242 ++++++ Ocarina/OcarinaManagerDelegate.swift | 22 + Ocarina/OcarinaPrefetcher.swift | 54 ++ Ocarina/TwitterCardInformation.swift | 112 +++ Ocarina/URL+URLInformation.swift | 41 + Ocarina/URLInformation.swift | 283 +++++++ Ocarina/URLInformationCache.swift | 36 + OcarinaTests/AdditionalParsingTests.swift | 91 +++ OcarinaTests/CachingTests.swift | 137 ++++ OcarinaTests/Info.plist | 27 + OcarinaTests/InformationFetchingTests.swift | 155 ++++ OcarinaTests/PrefetcherTests.swift | 65 ++ README.md | 73 ++ SwiftLibXML2/libxml2-kanna.h | 3 + SwiftLibXML2/module.modulemap | 6 + 40 files changed, 4871 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100755 Kanna/CSS.swift create mode 100755 Kanna/Kanna.swift create mode 100755 Kanna/libxmlHTMLDocument.swift create mode 100755 Kanna/libxmlHTMLNode.swift create mode 100755 Kanna/libxmlParserOption.swift create mode 100644 Ocarina Example/AppDelegate.swift create mode 100644 Ocarina Example/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Ocarina Example/Base.lproj/LaunchScreen.storyboard create mode 100644 Ocarina Example/Base.lproj/Main.storyboard create mode 100644 Ocarina Example/Info.plist create mode 100644 Ocarina Example/LinkPreviewTableViewCell.swift create mode 100644 Ocarina Example/LinkPreviewView.swift create mode 100644 Ocarina Example/OptionsTableViewController.swift create mode 100644 Ocarina Example/RealLinksTableViewController.swift create mode 100644 Ocarina.xcodeproj/project.pbxproj create mode 100644 Ocarina.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Ocarina.xcodeproj/project.xcworkspace/xcshareddata/Ocarina.xcscmblueprint create mode 100644 Ocarina.xcodeproj/xcshareddata/xcschemes/Ocarina Example.xcscheme create mode 100644 Ocarina.xcodeproj/xcshareddata/xcschemes/Ocarina.xcscheme create mode 100644 Ocarina/Info.plist create mode 100644 Ocarina/Ocarina.h create mode 100644 Ocarina/Ocarina.swift create mode 100644 Ocarina/OcarinaInformationRequest.swift create mode 100644 Ocarina/OcarinaManager.swift create mode 100644 Ocarina/OcarinaManagerDelegate.swift create mode 100644 Ocarina/OcarinaPrefetcher.swift create mode 100644 Ocarina/TwitterCardInformation.swift create mode 100644 Ocarina/URL+URLInformation.swift create mode 100644 Ocarina/URLInformation.swift create mode 100644 Ocarina/URLInformationCache.swift create mode 100644 OcarinaTests/AdditionalParsingTests.swift create mode 100644 OcarinaTests/CachingTests.swift create mode 100644 OcarinaTests/Info.plist create mode 100644 OcarinaTests/InformationFetchingTests.swift create mode 100644 OcarinaTests/PrefetcherTests.swift create mode 100644 README.md create mode 100755 SwiftLibXML2/libxml2-kanna.h create mode 100755 SwiftLibXML2/module.modulemap diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c22487 --- /dev/null +++ b/.gitignore @@ -0,0 +1,65 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## Build generated +build/ +DerivedData/ + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ + +## Other +*.moved-aside +*.xcuserstate + +## Obj-C/Swift specific +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e69de29 diff --git a/Kanna/CSS.swift b/Kanna/CSS.swift new file mode 100755 index 0000000..d968fdb --- /dev/null +++ b/Kanna/CSS.swift @@ -0,0 +1,354 @@ +/**@file CSS.swift + +Kanna + +Copyright (c) 2015 Atsushi Kiwaki (@_tid_) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +import Foundation + +import SwiftLibXML2 + +typealias AKRegularExpression = NSRegularExpression +typealias AKTextCheckingResult = NSTextCheckingResult + +/** +CSS +*/ +public struct CSS { + /** + CSS3 selector to XPath + + @param selector CSS3 selector + + @return XPath + */ + public static func toXPath(_ selector: String) -> String? { + var xpath = "//" + var str = selector + var prev = str + + while str.utf16.count > 0 { + var attributes: [String] = [] + var combinator: String = "" + + if let result = matchBlank(str) { + str = str.substring(from: str.index(str.startIndex, offsetBy: result.range.length)) + } + + // element + let element = getElement(&str) + + // class / id + while let attr = getClassId(&str) { + attributes.append(attr) + } + + // attribute + while let attr = getAttribute(&str) { + attributes.append(attr) + } + + // matchCombinator + if let combi = genCombinator(&str) { + combinator = combi + } + + // generate xpath phrase + let attr = attributes.reduce("") { $0.isEmpty ? $1 : $0 + " and " + $1 } + if attr.isEmpty { + xpath += "\(element)\(combinator)" + } else { + xpath += "\(element)[\(attr)]\(combinator)" + } + + if str == prev { + print("CSS Syntax Error: Unsupport syntax '\(selector)'") + return nil + } + prev = str + } + return xpath + } +} + +private func firstMatch(_ pattern: String) -> (String) -> AKTextCheckingResult? { + return { str in + let length = str.utf16.count + do { + let regex = try AKRegularExpression(pattern: pattern, options: .caseInsensitive) + if let result = regex.firstMatch(in: str, options: .reportProgress, range: NSRange(location: 0, length: length)) { + return result + } + } catch _ { + + } + return nil + } +} + +private func nth(prefix: String, a: Int, b: Int) -> String { + let sibling = "\(prefix)-sibling::*" + if a == 0 { + return "count(\(sibling)) = \(b-1)" + } else if a > 0 { + if b != 0 { + return "((count(\(sibling)) + 1) >= \(b)) and ((((count(\(sibling)) + 1)-\(b)) mod \(a)) = 0)" + } + return "((count(\(sibling)) + 1) mod \(a)) = 0" + } + let a = abs(a) + return "(count(\(sibling)) + 1) <= \(b)" + ((a != 1) ? " and ((((count(\(sibling)) + 1)-\(b)) mod \(a) = 0)" : "") +} + +// a(n) + b | a(n) - b +private func nth_child(a: Int, b: Int) -> String { + return nth(prefix: "preceding", a: a, b: b) +} + +private func nth_last_child(a: Int, b: Int) -> String { + return nth(prefix: "following", a: a, b: b) +} + +private let matchBlank = firstMatch("^\\s*|\\s$") +private let matchElement = firstMatch("^([a-z0-9\\*_-]+)((\\|)([a-z0-9\\*_-]+))?") +private let matchClassId = firstMatch("^([#.])([a-z0-9\\*_-]+)") +private let matchAttr1 = firstMatch("^\\[([^\\]]*)\\]") +private let matchAttr2 = firstMatch("^\\[\\s*([^~\\|\\^\\$\\*=\\s]+)\\s*([~\\|\\^\\$\\*]?=)\\s*([^\"]*)\\s*\\]") +private let matchAttrN = firstMatch("^:not\\((.*?\\)?)\\)") +private let matchPseudo = firstMatch("^:([\'()a-z0-9_+-]+)") +private let matchCombinator = firstMatch("^\\s*([\\s>+~,])\\s*") +private let matchSubNthChild = firstMatch("^(nth-child|nth-last-child)\\(\\s*(odd|even|\\d+)\\s*\\)") +private let matchSubNthChildN = firstMatch("^(nth-child|nth-last-child)\\(\\s*(-?\\d*)n(\\+\\d+)?\\s*\\)") +private let matchSubNthOfType = firstMatch("nth-of-type\\((odd|even|\\d+)\\)") +private let matchSubContains = firstMatch("contains\\([\"\'](.*?)[\"\']\\)") +private let matchSubBlank = firstMatch("^\\s*$") + +private func substringWithRangeAtIndex(_ result: AKTextCheckingResult, str: String, at: Int) -> String { + if result.numberOfRanges > at { + #if os(Linux) + let range = result.range(at: at) + #else + let range = result.rangeAt(at) + #endif + if range.length > 0 { + let startIndex = str.index(str.startIndex, offsetBy: range.location) + let endIndex = str.index(startIndex, offsetBy: range.length) + return str.substring(with: startIndex.. String { + if let result = matchElement(str) { + let (text, text2) = (substringWithRangeAtIndex(result, str: str, at: 1), + substringWithRangeAtIndex(result, str: str, at: 4)) + + if skip { + str = str.substring(from: str.characters.index(str.startIndex, offsetBy: result.range.length)) + } + + // tag with namespace + if !text.isEmpty && !text2.isEmpty { + return "\(text):\(text2)" + } + + // tag + if !text.isEmpty { + return text + } + } + return "*" +} + +private func getClassId(_ str: inout String, skip: Bool = true) -> String? { + if let result = matchClassId(str) { + let (attr, text) = (substringWithRangeAtIndex(result, str: str, at: 1), + substringWithRangeAtIndex(result, str: str, at: 2)) + if skip { + str = str.substring(from: str.characters.index(str.startIndex, offsetBy: result.range.length)) + } + + if attr.hasPrefix("#") { + return "@id = '\(text)'" + } else if attr.hasPrefix(".") { + return "contains(concat(' ', normalize-space(@class), ' '), ' \(text) ')" + } + } + return nil +} + +private func getAttribute(_ str: inout String, skip: Bool = true) -> String? { + if let result = matchAttr2(str) { + let (attr, expr, text) = (substringWithRangeAtIndex(result, str: str, at: 1), + substringWithRangeAtIndex(result, str: str, at: 2), + substringWithRangeAtIndex(result, str: str, at: 3).replacingOccurrences(of: "[\'\"](.*)[\'\"]", with: "$1", options: .regularExpression, range: nil)) + + if skip { + str = str.substring(from: str.characters.index(str.startIndex, offsetBy: result.range.length)) + } + + switch expr { + case "!=": + return "@\(attr) != \(text)" + case "~=": + return "contains(concat(' ', @\(attr), ' '),concat(' ', '\(text)', ' '))" + case "|=": + return "@\(attr) = '\(text)' or starts-with(@\(attr),concat('\(text)', '-'))" + case "^=": + return "starts-with(@\(attr), '\(text)')" + case "$=": + return "substring(@\(attr), string-length(@\(attr)) - string-length('\(text)') + 1, string-length('\(text)')) = '\(text)'" + case "*=": + return "contains(@\(attr), '\(text)')" + default: + return "@\(attr) = '\(text)'" + } + } else if let result = matchAttr1(str) { + let atr = substringWithRangeAtIndex(result, str: str, at: 1) + if skip { + str = str.substring(from: str.characters.index(str.startIndex, offsetBy: result.range.length)) + } + + return "@\(atr)" + } else if str.hasPrefix("[") { + // bad syntax attribute + return nil + } else if let attr = getAttrNot(&str) { + return "not(\(attr))" + } else if let result = matchPseudo(str) { + let one = substringWithRangeAtIndex(result, str: str, at: 1) + if skip { + str = str.substring(from: str.characters.index(str.startIndex, offsetBy: result.range.length)) + } + + switch one { + case "first-child": + return "count(preceding-sibling::*) = 0" + case "last-child": + return "count(following-sibling::*) = 0" + case "only-child": + return "count(preceding-sibling::*) = 0 and count(following-sibling::*) = 0" + case "first-of-type": + return "position() = 1" + case "last-of-type": + return "position() = last()" + case "only-of-type": + return "last() = 1" + case "empty": + return "not(node())" + case "root": + return "not(parent::*)" + case "last-child": + return "count(following-sibling::*) = 0" + default: + if let sub = matchSubNthChild(one) { + let (nth, arg1) = (substringWithRangeAtIndex(sub, str: one, at: 1), + substringWithRangeAtIndex(sub, str: one, at: 2)) + + let nthFunc = (nth == "nth-child") ? nth_child : nth_last_child + if arg1 == "odd" { + return nthFunc(2, 1) + } else if arg1 == "even" { + return nthFunc(2, 0) + } else { + return nthFunc(0, Int(arg1)!) + } + } else if let sub = matchSubNthChildN(one) { + let (nth, arg1, arg2) = (substringWithRangeAtIndex(sub, str: one, at: 1), + substringWithRangeAtIndex(sub, str: one, at: 2), + substringWithRangeAtIndex(sub, str: one, at: 3)) + + let nthFunc = (nth == "nth-child") ? nth_child : nth_last_child + let a: Int = (arg1 == "-") ? -1 : Int(arg1)! + let b: Int = (arg2.isEmpty) ? 0 : Int(arg2)! + return nthFunc(a, b) + } else if let sub = matchSubNthOfType(one) { + let arg1 = substringWithRangeAtIndex(sub, str: one, at: 1) + if arg1 == "odd" { + return "(position() >= 1) and (((position()-1) mod 2) = 0)" + } else if arg1 == "even" { + return "(position() mod 2) = 0" + } else { + return "position() = \(arg1)" + } + } else if let sub = matchSubContains(one) { + let text = substringWithRangeAtIndex(sub, str: one, at: 1) + return "contains(., '\(text)')" + } else { + return nil + } + } + } + return nil +} + +private func getAttrNot(_ str: inout String, skip: Bool = true) -> String? { + if let result = matchAttrN(str) { + var one = substringWithRangeAtIndex(result, str: str, at: 1) + if skip { + str = str.substring(from: str.characters.index(str.startIndex, offsetBy: result.range.length)) + } + + if let attr = getAttribute(&one, skip: false) { + return attr + } else if let sub = matchElement(one) { + #if os(Linux) + let range = sub.range(at: 1) + #else + let range = sub.rangeAt(1) + #endif + let startIndex = one.index(one.startIndex, offsetBy: range.location) + let endIndex = one.index(startIndex, offsetBy: range.length) + + let elem = one.substring(with: startIndex ..< endIndex) + return "self::\(elem)" + } else if let attr = getClassId(&one) { + return attr + } + } + return nil +} + +private func genCombinator(_ str: inout String, skip: Bool = true) -> String? { + if let result = matchCombinator(str) { + let one = substringWithRangeAtIndex(result, str: str, at: 1) + if skip { + str = str.substring(from: str.characters.index(str.startIndex, offsetBy: result.range.length)) + } + + switch one { + case ">": + return "/" + case "+": + return "/following-sibling::*[1]/self::" + case "~": + return "/following-sibling::" + default: + if let _ = matchSubBlank(one) { + return "//" + } else { + return " | //" + } + } + } + return nil +} diff --git a/Kanna/Kanna.swift b/Kanna/Kanna.swift new file mode 100755 index 0000000..23566c8 --- /dev/null +++ b/Kanna/Kanna.swift @@ -0,0 +1,445 @@ +/**@file Kanna.swift + +Kanna + +Copyright (c) 2015 Atsushi Kiwaki (@_tid_) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +import Foundation + +import SwiftLibXML2 + +/* +ParseOption +*/ +public enum ParseOption { + // libxml2 + case xmlParseUseLibxml(Libxml2XMLParserOptions) + case htmlParseUseLibxml(Libxml2HTMLParserOptions) +} + +private let kDefaultXmlParseOption = ParseOption.xmlParseUseLibxml([.RECOVER, .NOERROR, .NOWARNING]) +private let kDefaultHtmlParseOption = ParseOption.htmlParseUseLibxml([.RECOVER, .NOERROR, .NOWARNING]) + +/** +Parse XML + +@param xml an XML string +@param url the base URL to use for the document +@param encoding the document encoding +@param options a ParserOption +*/ +public func XML(xml: String, url: String?, encoding: String.Encoding, option: ParseOption = kDefaultXmlParseOption) -> XMLDocument? { + switch option { + case .xmlParseUseLibxml(let opt): + return libxmlXMLDocument(xml: xml, url: url, encoding: encoding, option: opt.rawValue) + default: + return nil + } +} + +public func XML(xml: String, encoding: String.Encoding, option: ParseOption = kDefaultXmlParseOption) -> XMLDocument? { + return XML(xml: xml, url: nil, encoding: encoding, option: option) +} + +// NSData +public func XML(xml: Data, url: String?, encoding: String.Encoding, option: ParseOption = kDefaultXmlParseOption) -> XMLDocument? { + if let xmlStr = String(data: xml, encoding: encoding) { + return XML(xml: xmlStr, url: url, encoding: encoding, option: option) + } + return nil +} + +public func XML(xml: Data, encoding: String.Encoding, option: ParseOption = kDefaultXmlParseOption) -> XMLDocument? { + return XML(xml: xml, url: nil, encoding: encoding, option: option) +} + +// NSURL +public func XML(url: URL, encoding: String.Encoding, option: ParseOption = kDefaultXmlParseOption) -> XMLDocument? { + if let data = try? Data(contentsOf: url) { + return XML(xml: data, url: url.absoluteString, encoding: encoding, option: option) + } + return nil +} + +//------------------------------------------------------------- +// unavailable functions +//------------------------------------------------------------- +@available(*, unavailable, message: "Use XML(xml: String, url: String?, encoding: String.Encoding, option: ParseOption). The type of the second argument has been changed to String.Encoding from UInt.") +public func XML(xml: String, url: String?, encoding: UInt, option: ParseOption = kDefaultXmlParseOption) -> XMLDocument? { + return nil +} + +@available(*, unavailable, message: "Use XML(xml: String, encoding: String.Encoding, option: ParseOption). The type of the second argument has been changed to String.Encoding from UInt.") +public func XML(xml: String, encoding: UInt, option: ParseOption = kDefaultXmlParseOption) -> XMLDocument? { + return nil +} + +@available(*, unavailable, message: "Use XML(xml: Data, url: String?, encoding: String.Encoding, option: ParseOption). The type of the first argument has been changed to Data and the type of the second argument has been changed to String.Encoding from UInt.") +public func XML(xml: NSData, url: String?, encoding: UInt, option: ParseOption = kDefaultXmlParseOption) -> XMLDocument? { + return nil +} + +@available(*, unavailable, message: "Use XML(xml: Data, encoding: String.Encoding, option: ParseOption). The type of the first argument has been changed to Data and the type of the second argument has been changed to String.Encoding from UInt.") +public func XML(xml: NSData, encoding: UInt, option: ParseOption = kDefaultXmlParseOption) -> XMLDocument? { + return nil +} + +@available(*, unavailable, message: "Use XML(url: URL, encoding: String.Encoding, option: ParseOption). The type of the second argument has been changed to String.Encoding from UInt.") +public func XML(url: URL, encoding: UInt, option: ParseOption = kDefaultXmlParseOption) -> XMLDocument? { + return nil +} + +/** +Parse HTML + +@param html an HTML string +@param url the base URL to use for the document +@param encoding the document encoding +@param options a ParserOption +*/ +public func HTML(html: String, url: String?, encoding: String.Encoding, option: ParseOption = kDefaultHtmlParseOption) -> HTMLDocument? { + switch option { + case .htmlParseUseLibxml(let opt): + return libxmlHTMLDocument(html: html, url: url, encoding: encoding, option: opt.rawValue) + default: + return nil + } +} + +public func HTML(html: String, encoding: String.Encoding, option: ParseOption = kDefaultHtmlParseOption) -> HTMLDocument? { + return HTML(html: html, url: nil, encoding: encoding, option: option) +} + +// NSData +public func HTML(html: Data, url: String?, encoding: String.Encoding, option: ParseOption = kDefaultHtmlParseOption) -> HTMLDocument? { + if let htmlStr = String(data: html, encoding: encoding) { + return HTML(html: htmlStr, url: url, encoding: encoding, option: option) + } + return nil +} + +public func HTML(html: Data, encoding: String.Encoding, option: ParseOption = kDefaultHtmlParseOption) -> HTMLDocument? { + return HTML(html: html, url: nil, encoding: encoding, option: option) +} + +// NSURL +public func HTML(url: URL, encoding: String.Encoding, option: ParseOption = kDefaultHtmlParseOption) -> HTMLDocument? { + if let data = try? Data(contentsOf: url) { + return HTML(html: data, url: url.absoluteString, encoding: encoding, option: option) + } + return nil +} + +//------------------------------------------------------------- +// unavailable functions +//------------------------------------------------------------- +@available(*, unavailable, message: "Use HTML(html: String, url: String?, encoding: String.Encoding, option: ParseOption). The type of the second argument has been changed to String.Encoding from UInt.") +public func HTML(html: String, url: String?, encoding: UInt, option: ParseOption = kDefaultXmlParseOption) -> XMLDocument? { + return nil +} + +@available(*, unavailable, message: "Use HTML(html: String, encoding: String.Encoding, option: ParseOption). The type of the second argument has been changed to String.Encoding from UInt.") +public func HTML(html: String, encoding: UInt, option: ParseOption = kDefaultXmlParseOption) -> XMLDocument? { + return nil +} + +@available(*, unavailable, message: "Use HTML(html: Data, url: String?, encoding: String.Encoding, option: ParseOption). The type of the first argument has been changed to Data and the type of the second argument has been changed to String.Encoding from UInt.") +public func HTML(html: NSData, url: String?, encoding: UInt, option: ParseOption = kDefaultXmlParseOption) -> XMLDocument? { + return nil +} + +@available(*, unavailable, message: "Use HTML(html: Data, encoding: String.Encoding, option: ParseOption). The type of the first argument has been changed to Data and the type of the second argument has been changed to String.Encoding from UInt.") +public func HTML(html: NSData, encoding: UInt, option: ParseOption = kDefaultXmlParseOption) -> XMLDocument? { + return nil +} + +@available(*, unavailable, message: "Use HTML(url: URL, encoding: String.Encoding, option: ParseOption). The type of the second argument has been changed to String.Encoding from UInt.") +public func HTML(url: URL, encoding: UInt, option: ParseOption = kDefaultXmlParseOption) -> XMLDocument? { + return nil +} + +/** +Searchable +*/ +public protocol Searchable { + /** + Search for node from current node by XPath. + + @param xpath + */ + func xpath(_ xpath: String, namespaces: [String:String]?) -> XPathObject + func xpath(_ xpath: String) -> XPathObject + func at_xpath(_ xpath: String, namespaces: [String:String]?) -> XMLElement? + func at_xpath(_ xpath: String) -> XMLElement? + + /** + Search for node from current node by CSS selector. + + @param selector a CSS selector + */ + func css(_ selector: String, namespaces: [String:String]?) -> XPathObject + func css(_ selector: String) -> XPathObject + func at_css(_ selector: String, namespaces: [String:String]?) -> XMLElement? + func at_css(_ selector: String) -> XMLElement? +} + +/** +SearchableNode +*/ +public protocol SearchableNode: Searchable { + var text: String? { get } + var toHTML: String? { get } + var toXML: String? { get } + var innerHTML: String? { get } + var className: String? { get } + var tagName: String? { get set } + var content: String? { get set } +} + +/** +XMLElement +*/ +public protocol XMLElement: SearchableNode { + var parent: XMLElement? { get set } + subscript(attr: String) -> String? { get set } + + func addPrevSibling(_ node: XMLElement) + func addNextSibling(_ node: XMLElement) + func removeChild(_ node: XMLElement) +} + +/** +XMLDocument +*/ +public protocol XMLDocument: SearchableNode { +} + +/** +HTMLDocument +*/ +public protocol HTMLDocument: XMLDocument { + var title: String? { get } + var head: XMLElement? { get } + var body: XMLElement? { get } +} + +/** +XMLNodeSet +*/ +public final class XMLNodeSet { + fileprivate var nodes: [XMLElement] = [] + + public var toHTML: String? { + let html = nodes.reduce("") { + if let text = $1.toHTML { + return $0 + text + } + return $0 + } + return html.isEmpty == false ? html : nil + } + + public var innerHTML: String? { + let html = nodes.reduce("") { + if let text = $1.innerHTML { + return $0 + text + } + return $0 + } + return html.isEmpty == false ? html : nil + } + + public var text: String? { + let html = nodes.reduce("") { + if let text = $1.text { + return $0 + text + } + return $0 + } + return html + } + + public subscript(index: Int) -> XMLElement { + return nodes[index] + } + + public var count: Int { + return nodes.count + } + + internal init() { + } + + internal init(nodes: [XMLElement]) { + self.nodes = nodes + } + + public func at(_ index: Int) -> XMLElement? { + return count > index ? nodes[index] : nil + } + + public var first: XMLElement? { + return at(0) + } + + public var last: XMLElement? { + return at(count-1) + } +} + +extension XMLNodeSet: Sequence { + public typealias Iterator = AnyIterator + public func makeIterator() -> Iterator { + var index = 0 + return AnyIterator { + if index < self.nodes.count { + let n = self.nodes[index] + index += 1 + return n + } + return nil + } + } +} + +/** +XPathObject +*/ + +public enum XPathObject { + case none + case NodeSet(nodeset: XMLNodeSet) + case Bool(bool: Swift.Bool) + case Number(num: Double) + case String(text: Swift.String) +} + +extension XPathObject { + internal init(docPtr: xmlDocPtr, object: xmlXPathObject) { + switch object.type { + case XPATH_NODESET: + let nodeSet = object.nodesetval + if nodeSet == nil || nodeSet?.pointee.nodeNr == 0 || nodeSet?.pointee.nodeTab == nil { + self = .none + return + } + + var nodes : [XMLElement] = [] + let size = Int((nodeSet?.pointee.nodeNr)!) + for i in 0 ..< size { + let node: xmlNodePtr = nodeSet!.pointee.nodeTab[i]! + let htmlNode = libxmlHTMLNode(docPtr: docPtr, node: node) + nodes.append(htmlNode) + } + self = .NodeSet(nodeset: XMLNodeSet(nodes: nodes)) + return + case XPATH_BOOLEAN: + self = .Bool(bool: object.boolval != 0) + return + case XPATH_NUMBER: + self = .Number(num: object.floatval) + case XPATH_STRING: + guard let str = UnsafeRawPointer(object.stringval)?.assumingMemoryBound(to: CChar.self) else { + self = .String(text: "") + return + } + self = .String(text: Swift.String(cString: str)) + return + default: + self = .none + return + } + } + + public subscript(index: Int) -> XMLElement { + return nodeSet![index] + } + + public var first: XMLElement? { + return nodeSet?.first + } + + public var count: Int { + guard let nodeset = nodeSet else { + return 0 + } + return nodeset.count + } + + var nodeSet: XMLNodeSet? { + if case let .NodeSet(nodeset) = self { + return nodeset + } + return nil + } + + var bool: Swift.Bool? { + if case let .Bool(value) = self { + return value + } + return nil + } + + var number: Double? { + if case let .Number(value) = self { + return value + } + return nil + } + + var string: Swift.String? { + if case let .String(value) = self { + return value + } + return nil + } + + var nodeSetValue: XMLNodeSet { + return nodeSet ?? XMLNodeSet() + } + + var boolValue: Swift.Bool { + return bool ?? false + } + + var numberValue: Double { + return number ?? 0.0 + } + + var stringValue: Swift.String { + return string ?? "" + } +} + +extension XPathObject: Sequence { + public typealias Iterator = AnyIterator + public func makeIterator() -> Iterator { + var index = 0 + return AnyIterator { + if index < self.nodeSetValue.count { + let obj = self.nodeSetValue[index] + index += 1 + return obj + } + return nil + } + } +} diff --git a/Kanna/libxmlHTMLDocument.swift b/Kanna/libxmlHTMLDocument.swift new file mode 100755 index 0000000..3900c17 --- /dev/null +++ b/Kanna/libxmlHTMLDocument.swift @@ -0,0 +1,277 @@ +/**@file libxmlHTMLDocument.swift + +Kanna + +Copyright (c) 2015 Atsushi Kiwaki (@_tid_) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +import Foundation +import CoreFoundation + +import SwiftLibXML2 + +/* +libxmlHTMLDocument +*/ +internal final class libxmlHTMLDocument: HTMLDocument { + fileprivate var docPtr: htmlDocPtr? = nil + fileprivate var rootNode: XMLElement? + fileprivate var html: String + fileprivate var url: String? + fileprivate var encoding: String.Encoding + + var text: String? { + return rootNode?.text + } + + var toHTML: String? { + let buf = xmlBufferCreate() + defer { + xmlBufferFree(buf) + } + + let outputBuf = xmlOutputBufferCreateBuffer(buf, nil) + htmlDocContentDumpOutput(outputBuf, docPtr, nil) + let html = String(cString: UnsafePointer(xmlOutputBufferGetContent(outputBuf))) + return html + } + + var toXML: String? { + var buf: UnsafeMutablePointer? = nil + let size: UnsafeMutablePointer? = nil + defer { + xmlFree(buf) + } + + xmlDocDumpMemory(docPtr, &buf, size) + let html = String(cString: UnsafePointer(buf!)) + return html + } + + var innerHTML: String? { + return rootNode?.innerHTML + } + + var className: String? { + return nil + } + + var tagName: String? { + get { + return nil + } + + set { + + } + } + + var content: String? { + get { + return text + } + + set { + rootNode?.content = newValue + } + } + + init?(html: String, url: String?, encoding: String.Encoding, option: UInt) { + self.html = html + self.url = url + self.encoding = encoding + + if html.lengthOfBytes(using: encoding) <= 0 { + return nil + } + + let cfenc : CFStringEncoding = CFStringConvertNSStringEncodingToEncoding(encoding.rawValue) + let cfencstr = CFStringConvertEncodingToIANACharSetName(cfenc) + if let cur = html.cString(using: encoding) { + let url : String = "" + docPtr = htmlReadDoc(UnsafeRawPointer(cur).assumingMemoryBound(to: xmlChar.self), url, String(describing: cfencstr!), CInt(option)) + rootNode = libxmlHTMLNode(docPtr: docPtr!) + } else { + return nil + } + } + + deinit { + xmlFreeDoc(self.docPtr) + } + + var title: String? { return at_xpath("//title")?.text } + var head: XMLElement? { return at_xpath("//head") } + var body: XMLElement? { return at_xpath("//body") } + + func xpath(_ xpath: String, namespaces: [String:String]?) -> XPathObject { + return rootNode?.xpath(xpath, namespaces: namespaces) ?? XPathObject.none + } + + func xpath(_ xpath: String) -> XPathObject { + return self.xpath(xpath, namespaces: nil) + } + + func at_xpath(_ xpath: String, namespaces: [String:String]?) -> XMLElement? { + return rootNode?.at_xpath(xpath, namespaces: namespaces) + } + + func at_xpath(_ xpath: String) -> XMLElement? { + return self.at_xpath(xpath, namespaces: nil) + } + + func css(_ selector: String, namespaces: [String:String]?) -> XPathObject { + return rootNode?.css(selector, namespaces: namespaces) ?? XPathObject.none + } + + func css(_ selector: String) -> XPathObject { + return self.css(selector, namespaces: nil) + } + + func at_css(_ selector: String, namespaces: [String:String]?) -> XMLElement? { + return rootNode?.at_css(selector, namespaces: namespaces) + } + + func at_css(_ selector: String) -> XMLElement? { + return self.at_css(selector, namespaces: nil) + } +} + +/* +libxmlXMLDocument +*/ +internal final class libxmlXMLDocument: XMLDocument { + fileprivate var docPtr: xmlDocPtr? = nil + fileprivate var rootNode: XMLElement? + fileprivate var xml: String + fileprivate var url: String? + fileprivate var encoding: String.Encoding + + var text: String? { + return rootNode?.text + } + + var toHTML: String? { + let buf = xmlBufferCreate() + defer { + xmlBufferFree(buf) + } + + let outputBuf = xmlOutputBufferCreateBuffer(buf, nil) + htmlDocContentDumpOutput(outputBuf, docPtr, nil) + let html = String(cString: UnsafePointer(xmlOutputBufferGetContent(outputBuf))) + return html + } + + var toXML: String? { + var buf: UnsafeMutablePointer? = nil + let size: UnsafeMutablePointer? = nil + defer { + xmlFree(buf) + } + + xmlDocDumpMemory(docPtr, &buf, size) + let html = String(cString: UnsafePointer(buf!)) + return html + } + + var innerHTML: String? { + return rootNode?.innerHTML + } + + var className: String? { + return nil + } + + var tagName: String? { + get { + return nil + } + + set { + + } + } + + var content: String? { + get { + return text + } + + set { + rootNode?.content = newValue + } + } + + init?(xml: String, url: String?, encoding: String.Encoding, option: UInt) { + self.xml = xml + self.url = url + self.encoding = encoding + + if xml.lengthOfBytes(using: encoding) <= 0 { + return nil + } + let cfenc : CFStringEncoding = CFStringConvertNSStringEncodingToEncoding(encoding.rawValue) + let cfencstr = CFStringConvertEncodingToIANACharSetName(cfenc) + if let cur = xml.cString(using: encoding) { + let url : String = "" + docPtr = xmlReadDoc(UnsafeRawPointer(cur).assumingMemoryBound(to: xmlChar.self), url, String(describing: cfencstr!), CInt(option)) + rootNode = libxmlHTMLNode(docPtr: docPtr!) + } else { + return nil + } + } + + deinit { + xmlFreeDoc(self.docPtr) + } + + func xpath(_ xpath: String, namespaces: [String:String]?) -> XPathObject { + return rootNode?.xpath(xpath, namespaces: namespaces) ?? XPathObject.none + } + + func xpath(_ xpath: String) -> XPathObject { + return self.xpath(xpath, namespaces: nil) + } + + func at_xpath(_ xpath: String, namespaces: [String:String]?) -> XMLElement? { + return rootNode?.at_xpath(xpath, namespaces: namespaces) + } + + func at_xpath(_ xpath: String) -> XMLElement? { + return self.at_xpath(xpath, namespaces: nil) + } + + func css(_ selector: String, namespaces: [String:String]?) -> XPathObject { + return rootNode?.css(selector, namespaces: namespaces) ?? XPathObject.none + } + + func css(_ selector: String) -> XPathObject { + return self.css(selector, namespaces: nil) + } + + func at_css(_ selector: String, namespaces: [String:String]?) -> XMLElement? { + return rootNode?.at_css(selector, namespaces: namespaces) + } + + func at_css(_ selector: String) -> XMLElement? { + return self.at_css(selector, namespaces: nil) + } +} diff --git a/Kanna/libxmlHTMLNode.swift b/Kanna/libxmlHTMLNode.swift new file mode 100755 index 0000000..bccc2cc --- /dev/null +++ b/Kanna/libxmlHTMLNode.swift @@ -0,0 +1,270 @@ +/**@file libxmlHTMLNode.swift + +Kanna + +Copyright (c) 2015 Atsushi Kiwaki (@_tid_) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +import Foundation + +import SwiftLibXML2 + +/** +libxmlHTMLNode +*/ +internal final class libxmlHTMLNode: XMLElement { + var text: String? { + if nodePtr != nil { + return libxmlGetNodeContent(nodePtr!) + } + return nil + } + + var toHTML: String? { + let buf = xmlBufferCreate() + htmlNodeDump(buf, docPtr, nodePtr) + let html = String(cString: UnsafePointer((buf?.pointee.content)!)) + xmlBufferFree(buf) + return html + } + + var toXML: String? { + let buf = xmlBufferCreate() + xmlNodeDump(buf, docPtr, nodePtr, 0, 0) + let html = String(cString: UnsafePointer((buf?.pointee.content)!)) + xmlBufferFree(buf) + return html + } + + var innerHTML: String? { + if let html = self.toHTML { + let inner = html.replacingOccurrences(of: "]*>$", with: "", options: .regularExpression, range: nil) + .replacingOccurrences(of: "^<[^>]*>", with: "", options: .regularExpression, range: nil) + return inner + } + return nil + } + + var className: String? { + return self["class"] + } + + var tagName: String? { + get { + if nodePtr != nil { + return String(cString: UnsafePointer((nodePtr?.pointee.name)!)) + } + return nil + } + + set { + if let newValue = newValue { + xmlNodeSetName(nodePtr, newValue) + } + } + } + + var content: String? { + get { + return text + } + + set { + if let newValue = newValue { + let v = escape(newValue) + xmlNodeSetContent(nodePtr, v) + } + } + } + + var parent: XMLElement? { + get { + return libxmlHTMLNode(docPtr: docPtr!, node: (nodePtr?.pointee.parent)!) + } + + set { + if let node = newValue as? libxmlHTMLNode { + node.addChild(self) + } + } + } + + fileprivate var docPtr: htmlDocPtr? = nil + fileprivate var nodePtr: xmlNodePtr? = nil + fileprivate var isRoot: Bool = false + + + subscript(attributeName: String) -> String? + { + get { + var attr = nodePtr?.pointee.properties + while attr != nil { + let mem = attr?.pointee + if let tagName = String(validatingUTF8: UnsafeRawPointer((mem?.name)!).assumingMemoryBound(to: CChar.self)) { + if attributeName == tagName { + if let children = mem?.children { + return libxmlGetNodeContent(children) + } else { + return "" + } + } + } + attr = attr?.pointee.next + } + return nil + } + + set(newValue) { + if let newValue = newValue { + xmlSetProp(nodePtr, attributeName, newValue) + } else { + xmlUnsetProp(nodePtr, attributeName) + } + } + } + + init(docPtr: xmlDocPtr) { + self.docPtr = docPtr + self.nodePtr = xmlDocGetRootElement(docPtr) + self.isRoot = true + } + + init(docPtr: xmlDocPtr, node: xmlNodePtr) { + self.docPtr = docPtr + self.nodePtr = node + } + + // MARK: Searchable + func xpath(_ xpath: String, namespaces: [String:String]?) -> XPathObject { + let ctxt = xmlXPathNewContext(docPtr) + if ctxt == nil { + return XPathObject.none + } + ctxt?.pointee.node = nodePtr + + if let nsDictionary = namespaces { + for (ns, name) in nsDictionary { + xmlXPathRegisterNs(ctxt, ns, name) + } + } + + let result = xmlXPathEvalExpression(xpath, ctxt) + defer { + xmlXPathFreeObject(result) + } + xmlXPathFreeContext(ctxt) + if result == nil { + return XPathObject.none + } + + return XPathObject(docPtr: docPtr!, object: result!.pointee) + } + + func xpath(_ xpath: String) -> XPathObject { + return self.xpath(xpath, namespaces: nil) + } + + func at_xpath(_ xpath: String, namespaces: [String:String]?) -> XMLElement? { + return self.xpath(xpath, namespaces: namespaces).nodeSetValue.first + } + + func at_xpath(_ xpath: String) -> XMLElement? { + return self.at_xpath(xpath, namespaces: nil) + } + + func css(_ selector: String, namespaces: [String:String]?) -> XPathObject { + if let xpath = CSS.toXPath(selector) { + if isRoot { + return self.xpath(xpath, namespaces: namespaces) + } else { + return self.xpath("." + xpath, namespaces: namespaces) + } + } + return XPathObject.none + } + + func css(_ selector: String) -> XPathObject { + return self.css(selector, namespaces: nil) + } + + func at_css(_ selector: String, namespaces: [String:String]?) -> XMLElement? { + return self.css(selector, namespaces: namespaces).nodeSetValue.first + } + + func at_css(_ selector: String) -> XMLElement? { + return self.css(selector, namespaces: nil).nodeSetValue.first + } + + func addPrevSibling(_ node: XMLElement) { + guard let node = node as? libxmlHTMLNode else { + return + } + xmlAddPrevSibling(nodePtr, node.nodePtr) + } + + func addNextSibling(_ node: XMLElement) { + guard let node = node as? libxmlHTMLNode else { + return + } + xmlAddNextSibling(nodePtr, node.nodePtr) + } + + func addChild(_ node: XMLElement) { + guard let node = node as? libxmlHTMLNode else { + return + } + xmlUnlinkNode(node.nodePtr) + xmlAddChild(nodePtr, node.nodePtr) + } + + func removeChild(_ node: XMLElement) { + + guard let node = node as? libxmlHTMLNode else { + return + } + xmlUnlinkNode(node.nodePtr) + xmlFree(node.nodePtr) + } +} + +private func libxmlGetNodeContent(_ nodePtr: xmlNodePtr) -> String? { + let content = xmlNodeGetContent(nodePtr) + if let result = String(validatingUTF8: UnsafeRawPointer(content!).assumingMemoryBound(to: CChar.self)) { + content?.deallocate(capacity: 1) + return result + } + content?.deallocate(capacity: 1) + return nil +} + +let entities = [ + "&": "&", + "<" : "<", + ">" : ">", +] + +private func escape(_ str: String) -> String { + var newStr = str + for (unesc, esc) in entities { + newStr = newStr.replacingOccurrences(of: unesc, with: esc, options: .regularExpression, range: nil) + } + return newStr +} + diff --git a/Kanna/libxmlParserOption.swift b/Kanna/libxmlParserOption.swift new file mode 100755 index 0000000..5b75198 --- /dev/null +++ b/Kanna/libxmlParserOption.swift @@ -0,0 +1,94 @@ +/**@file libxmlParserOption.swift + +Kanna + +Copyright (c) 2015 Atsushi Kiwaki (@_tid_) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +import Foundation + +import SwiftLibXML2 + +/* +Libxml2HTMLParserOptions +*/ +public struct Libxml2HTMLParserOptions : OptionSet { + public typealias RawValue = UInt + private var value: UInt = 0 + init(_ value: UInt) { self.value = value } + private init(_ opt: htmlParserOption) { self.value = UInt(opt.rawValue) } + public init(rawValue value: UInt) { self.value = value } + public init(nilLiteral: ()) { self.value = 0 } + public static var allZeros: Libxml2HTMLParserOptions { return .init(0) } + static func fromMask(raw: UInt) -> Libxml2HTMLParserOptions { return .init(raw) } + public var rawValue: UInt { return self.value } + + public static let STRICT = Libxml2HTMLParserOptions(0) + public static let RECOVER = Libxml2HTMLParserOptions(HTML_PARSE_RECOVER) + public static let NODEFDTD = Libxml2HTMLParserOptions(HTML_PARSE_NODEFDTD) + public static let NOERROR = Libxml2HTMLParserOptions(HTML_PARSE_NOERROR) + public static let NOWARNING = Libxml2HTMLParserOptions(HTML_PARSE_NOWARNING) + public static let PEDANTIC = Libxml2HTMLParserOptions(HTML_PARSE_PEDANTIC) + public static let NOBLANKS = Libxml2HTMLParserOptions(HTML_PARSE_NOBLANKS) + public static let NONET = Libxml2HTMLParserOptions(HTML_PARSE_NONET) + public static let NOIMPLIED = Libxml2HTMLParserOptions(HTML_PARSE_NOIMPLIED) + public static let COMPACT = Libxml2HTMLParserOptions(HTML_PARSE_COMPACT) + public static let IGNORE_ENC = Libxml2HTMLParserOptions(HTML_PARSE_IGNORE_ENC) +} + +/* +Libxml2XMLParserOptions +*/ +public struct Libxml2XMLParserOptions: OptionSet { + public typealias RawValue = UInt + private var value: UInt = 0 + init(_ value: UInt) { self.value = value } + private init(_ opt: xmlParserOption) { self.value = UInt(opt.rawValue) } + public init(rawValue value: UInt) { self.value = value } + public init(nilLiteral: ()) { self.value = 0 } + public static var allZeros: Libxml2XMLParserOptions { return .init(0) } + static func fromMask(raw: UInt) -> Libxml2XMLParserOptions { return .init(raw) } + public var rawValue: UInt { return self.value } + + public static let STRICT = Libxml2XMLParserOptions(0) + public static let RECOVER = Libxml2XMLParserOptions(XML_PARSE_RECOVER) + public static let NOENT = Libxml2XMLParserOptions(XML_PARSE_NOENT) + public static let DTDLOAD = Libxml2XMLParserOptions(XML_PARSE_DTDLOAD) + public static let DTDATTR = Libxml2XMLParserOptions(XML_PARSE_DTDATTR) + public static let DTDVALID = Libxml2XMLParserOptions(XML_PARSE_DTDVALID) + public static let NOERROR = Libxml2XMLParserOptions(XML_PARSE_NOERROR) + public static let NOWARNING = Libxml2XMLParserOptions(XML_PARSE_NOWARNING) + public static let PEDANTIC = Libxml2XMLParserOptions(XML_PARSE_PEDANTIC) + public static let NOBLANKS = Libxml2XMLParserOptions(XML_PARSE_NOBLANKS) + public static let SAX1 = Libxml2XMLParserOptions(XML_PARSE_SAX1) + public static let XINCLUDE = Libxml2XMLParserOptions(XML_PARSE_XINCLUDE) + public static let NONET = Libxml2XMLParserOptions(XML_PARSE_NONET) + public static let NODICT = Libxml2XMLParserOptions(XML_PARSE_NODICT) + public static let NSCLEAN = Libxml2XMLParserOptions(XML_PARSE_NSCLEAN) + public static let NOCDATA = Libxml2XMLParserOptions(XML_PARSE_NOCDATA) + public static let NOXINCNODE = Libxml2XMLParserOptions(XML_PARSE_NOXINCNODE) + public static let COMPACT = Libxml2XMLParserOptions(XML_PARSE_COMPACT) + public static let OLD10 = Libxml2XMLParserOptions(XML_PARSE_OLD10) + public static let NOBASEFIX = Libxml2XMLParserOptions(XML_PARSE_NOBASEFIX) + public static let HUGE = Libxml2XMLParserOptions(XML_PARSE_HUGE) + public static let OLDSAX = Libxml2XMLParserOptions(XML_PARSE_OLDSAX) + public static let IGNORE_ENC = Libxml2XMLParserOptions(XML_PARSE_IGNORE_ENC) + public static let BIG_LINES = Libxml2XMLParserOptions(XML_PARSE_BIG_LINES) +} diff --git a/Ocarina Example/AppDelegate.swift b/Ocarina Example/AppDelegate.swift new file mode 100644 index 0000000..96f7922 --- /dev/null +++ b/Ocarina Example/AppDelegate.swift @@ -0,0 +1,139 @@ +// +// AppDelegate.swift +// Ocarina Example +// +// Created by Rens Verhoeven on 14/02/2017. +// Copyright © 2017 awkward. All rights reserved. +// + +import UIKit +import Ocarina + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { + + // Override point for customization after application launch. + + let urls: [URL] = [URL(string: "http://renssies.nl")!, + URL(string: "http://nytimes.com")!, + URL(string: "http://bbc.com")!, + URL(string: "http://apple.com")!, + URL(string: "http://awkward.co")!, + URL(string: "http://spotify.com")! + ] + _ = OcarinaPrefetcher(urls: urls, manager: OcarinaManager.shared) { (errors) in + print("Prefetched all urls with errors \(errors)") + } + + _ = URL(string: "https://www.youtube.com/watch?v=Jfg6RfClZJg")?.oca.fetchInformation { (information, error) in + if let information = information { + print("Information received \(information) for url \(information.originalURL) type \(information.type.rawValue)") + } else if let error = error { + print("Error \(error.localizedDescription)") + } + } + + _ = URL(string: "http://simlicious.nl/2017/01/24/de-sims-4-vampieren-nu-verkrijgbaar/")?.oca.fetchInformation { (information, error) in + if let information = information { + print("Information received \(information) for url \(information.originalURL) type \(information.type.rawValue)") + } else if let error = error { + print("Error \(error.localizedDescription)") + } + } + + _ = URL(string: "http://www.deezer.com/playlist/68020160")?.oca.fetchInformation { (information, error) in + if let information = information { + print("Information received \(information) for url \(information.originalURL) type \(information.type.rawValue)") + } else if let error = error { + print("Error \(error.localizedDescription)") + } + } + + _ = URL(string: "http://www.deezer.com/playlist/68020160")?.oca.fetchInformation { (information, error) in + if let information = information { + print("Information received \(information) for url \(information.originalURL) type \(information.type.rawValue)") + } else if let error = error { + print("Error \(error.localizedDescription)") + } + } + + _ = URL(string: "http://reddit.com/r/zelda")?.oca.fetchInformation { (information, error) in + if let information = information { + print("Information received \(information) for url \(information.originalURL) type \(information.type.rawValue)") + } else if let error = error { + print("Error \(error.localizedDescription)") + } + } + + _ = URL(string: "https://www.nytimes.com/2017/02/16/sports/bighorn-sheep-hunting.html?hp&action=click&pgtype=Homepage&clickSource=story-heading&module=photo-spot-region®ion=top-news&WT.nav=top-news&_r=0")?.oca.fetchInformation { (information, error) in + if let information = information { + print("Information received \(information) for url \(information.originalURL) type \(information.type.rawValue)") + } else if let error = error { + print("Error \(error.localizedDescription)") + } + } + + _ = URL(string: "http://renssies.nl")?.oca.fetchInformation { (information, error) in + if let information = information { + print("Information received \(information) for url \(information.originalURL) type \(information.type.rawValue)") + } else if let error = error { + print("Error \(error.localizedDescription)") + } + } + + _ = URL(string: "http://awkward.co")?.oca.fetchInformation { (information, error) in + if let information = information { + print("Information received \(information) for url \(information.originalURL) type \(information.type.rawValue)") + } else if let error = error { + print("Error \(error.localizedDescription)") + } + } + + _ = URL(string: "http://i.imgur.com/XJHt6Wk.jpg")?.oca.fetchInformation { (information, error) in + if let information = information { + print("Information received \(information) for url \(information.originalURL) type \(information.type.rawValue)") + } else if let error = error { + print("Error \(error.localizedDescription)") + } + } + + _ = URL(string: "https://www.nintendo.com/consumer/downloads/WiiOpMn_setup.pdf")?.oca.fetchInformation { (information, error) in + if let information = information { + print("Information received \(information) for url \(information.originalURL) type \(information.type.rawValue)") + } else if let error = error { + print("Error \(error.localizedDescription)") + } + } + + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + +} + diff --git a/Ocarina Example/Assets.xcassets/AppIcon.appiconset/Contents.json b/Ocarina Example/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..b8236c6 --- /dev/null +++ b/Ocarina Example/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,48 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Ocarina Example/Base.lproj/LaunchScreen.storyboard b/Ocarina Example/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..fdf3f97 --- /dev/null +++ b/Ocarina Example/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Ocarina Example/Base.lproj/Main.storyboard b/Ocarina Example/Base.lproj/Main.storyboard new file mode 100644 index 0000000..97d4888 --- /dev/null +++ b/Ocarina Example/Base.lproj/Main.storyboard @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Ocarina Example/Info.plist b/Ocarina Example/Info.plist new file mode 100644 index 0000000..dd1a003 --- /dev/null +++ b/Ocarina Example/Info.plist @@ -0,0 +1,53 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UIStatusBarTintParameters + + UINavigationBar + + Style + UIBarStyleDefault + Translucent + + + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Ocarina Example/LinkPreviewTableViewCell.swift b/Ocarina Example/LinkPreviewTableViewCell.swift new file mode 100644 index 0000000..8b92fc4 --- /dev/null +++ b/Ocarina Example/LinkPreviewTableViewCell.swift @@ -0,0 +1,28 @@ +// +// LinkPreviewTableViewCell.swift +// Ocarina +// +// Created by Rens Verhoeven on 27/02/2017. +// Copyright © 2017 awkward. All rights reserved. +// + +import UIKit + +class LinkPreviewTableViewCell: UITableViewCell { + + @IBOutlet var previewView: LinkPreviewView! + + + + override func awakeFromNib() { + super.awakeFromNib() + // Initialization code + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + // Configure the view for the selected state + } + +} diff --git a/Ocarina Example/LinkPreviewView.swift b/Ocarina Example/LinkPreviewView.swift new file mode 100644 index 0000000..a6a3a67 --- /dev/null +++ b/Ocarina Example/LinkPreviewView.swift @@ -0,0 +1,361 @@ +// +// LinkPreviewView.swift +// Beam +// +// Created by Rens Verhoeven on 09/02/2017. +// Copyright © 2017 Awkward. All rights reserved. +// + +import UIKit +import Ocarina + +@IBDesignable +class LinkPreviewView: UIControl { + + fileprivate var previewImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = UIViewContentMode.scaleAspectFill + imageView.isOpaque = true + imageView.clipsToBounds = true + return imageView + }() + fileprivate var loadingPlaceholderImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = UIViewContentMode.scaleToFill + imageView.isOpaque = true + imageView.clipsToBounds = true + //imageView.image = #imageLiteral(resourceName: "empty_link_placeholder") + return imageView + }() + + fileprivate var titleLabel: UILabel = { + let label = UILabel() + label.font = LinkPreviewView.titleFont + label.isOpaque = true + label.numberOfLines = 2 + return label + }() + fileprivate var domainLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 11) + label.isOpaque = true + label.numberOfLines = 1 + return label + }() + + fileprivate var link: URL? + + fileprivate var isLoading: Bool = false { + didSet { + self.loadingPlaceholderImageView.isHidden = !self.isLoading + self.titleLabel.isHidden = self.isLoading + self.domainLabel.isHidden = self.isLoading + } + } + + fileprivate var information: URLInformation? { + didSet { + self.reloadContents() + self.displayModeDidChange() + self.setNeedsLayout() + } + } + + fileprivate var request: OcarinaInformationRequest? + fileprivate var imageTask: URLSessionTask? + + //Generating the UIFont everytime displayModeDidChange is called seems to cause some CPU time so that's why I'm saving it. + fileprivate static let titleFont = UIFont.systemFont(ofSize: 12, weight: UIFontWeightMedium) + fileprivate static let subtitleFont = UIFont.systemFont(ofSize: 12) + + fileprivate var attributedTitle: NSAttributedString? { + let titleAttributes = [NSFontAttributeName: LinkPreviewView.titleFont, NSForegroundColorAttributeName: UIColor.black] + let descriptionAttributes = [NSFontAttributeName: LinkPreviewView.subtitleFont, NSForegroundColorAttributeName: UIColor(red:0.58, green:0.58, blue:0.58, alpha:1)] + + let string = NSMutableAttributedString() + if let information = self.information { + let hasTitle = information.title?.characters.count ?? 0 > 0 + let hasDescription = information.descriptionText?.characters.count ?? 0 > 0 + + if hasTitle, let title = information.title { + string.append(NSAttributedString(string: title, attributes: titleAttributes)) + } + + if hasTitle && hasDescription { + string.append(NSAttributedString(string: " - ", attributes: descriptionAttributes)) + } + + if hasDescription, let description = information.descriptionText { + string.append(NSAttributedString(string: description, attributes: descriptionAttributes)) + } + } else { + if let urlString = self.link?.absoluteString { + string.append(NSAttributedString(string: urlString, attributes: descriptionAttributes)) + } + } + + return string + } + + //MARK: - Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupView() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + + self.setupView() + } + + fileprivate func setupView() { + self.isOpaque = true + + self.layer.cornerRadius = 3 + self.layer.masksToBounds = true + self.layer.borderWidth = 0.5 + + self.addSubview(self.previewImageView) + self.addSubview(self.titleLabel) + self.addSubview(self.domainLabel) + self.addSubview(self.loadingPlaceholderImageView) + + self.previewImageView.isHidden = true + self.titleLabel.isHidden = true + self.domainLabel.isHidden = true + } + + func changeLink(link: URL?) { + guard self.link != link else { + return + } + self.cancelAllRequests() + self.information = nil + self.isLoading = true + + self.link = link + + if let link = link, let cachedInformation = OcarinaManager.shared.cache[link] { + self.information = cachedInformation + self.doneLoading(animated: false) + } else { + self.reloadDomainName() + self.startFetchingMetadata() + } + + self.setNeedsLayout() + } + + fileprivate func reloadContents() { + self.previewImageView.isHidden = self.information?.imageURL ?? self.information?.twitterCard?.imageURL == nil + self.previewImageView.image = nil + + self.reloadDomainName() + + self.setNeedsLayout() + + if let imageURL = self.information?.imageURL ?? self.information?.twitterCard?.imageURL { + self.imageTask = URLSession.shared.downloadTask(with: imageURL, completionHandler: { (fileURL, reponse, error) in + if let fileURL = fileURL { + let image = UIImage(contentsOfFile: fileURL.path) + DispatchQueue.main.async { + if self.previewImageView.alpha == 0 { + self.doneLoading(animated: true) + } + self.previewImageView.image = image + } + + } + }) + self.imageTask?.resume() + self.updateConstraints() + self.setNeedsLayout() + } else { + if self.previewImageView.alpha == 0 { + self.doneLoading(animated: true) + } + } + } + + fileprivate func reloadDomainName() { + if let host = self.link?.host { + let domain = host + self.domainLabel.text = domain + } else if let domain = self.link?.host { + self.domainLabel.text = domain + } else { + self.domainLabel.text = nil + } + } + + private func doneLoading(animated: Bool = true) { + let oldValue = self.isLoading + self.isLoading = false + if animated && oldValue == false { + self.loadingPlaceholderImageView.isHidden = false + self.titleLabel.alpha = 0.0 + self.domainLabel.alpha = 0.0 + self.previewImageView.alpha = 0.0 + + UIView.animate(withDuration: 0.32, animations: { + self.loadingPlaceholderImageView.alpha = 0.0 + self.titleLabel.alpha = 1.0 + self.domainLabel.alpha = 1.0 + self.previewImageView.alpha = 1.0 + }, completion: { (finished) in + self.loadingPlaceholderImageView.isHidden = !self.isLoading + self.loadingPlaceholderImageView.alpha = 1.0 + }) + } else { + self.loadingPlaceholderImageView.alpha = 1.0 + self.loadingPlaceholderImageView.isHidden = true + self.titleLabel.alpha = 1.0 + self.domainLabel.alpha = 1.0 + self.previewImageView.alpha = 1.0 + } + } + + /// Directly start fetching metadata. Use scheduleFetchingMetadata instead, to prevent needlessly fetching metadata. + @objc fileprivate func startFetchingMetadata() { + self.cancelAllRequests() + + self.request = self.link?.oca.fetchInformation { (information, error) in + self.information = information + } + + } + + fileprivate func cancelAllRequests() { + self.request?.cancel() + self.request = nil + self.doneLoading(animated: false) + self.imageTask?.cancel() + } + + //MARK: - Colors + + override var isHighlighted: Bool { + didSet { + self.displayModeDidChange() + } + } + + override var isSelected: Bool { + didSet { + self.displayModeDidChange() + } + } + + func displayModeDidChange() { + self.titleLabel.attributedText = self.attributedTitle + + var backgroundColor = UIColor(red: 245/255, green: 245/255, blue: 245/255, alpha: 1.0) + if self.isHighlighted || self.isSelected { + backgroundColor = UIColor(red:0.9, green:0.9, blue:0.9, alpha:1) + } + self.backgroundColor = backgroundColor + self.titleLabel.backgroundColor = backgroundColor + self.domainLabel.backgroundColor = backgroundColor + self.loadingPlaceholderImageView.backgroundColor = backgroundColor + + //The color used for the border, loading placeholder and empty imageView + let secondColor = UIColor(red: 216/255, green: 216/255, blue: 216/255, alpha:1) + self.previewImageView.backgroundColor = secondColor + self.layer.borderColor = secondColor.cgColor + //Setting the tintColor when it's already the correct tintColor causes the image to be tinted again, this leads to high CPU usage + if self.loadingPlaceholderImageView.tintColor != secondColor { + self.loadingPlaceholderImageView.tintColor = secondColor + } + + + self.domainLabel.textColor = UIColor.black.withAlphaComponent(0.5) + + } + + //MARK: - Layout + + private let videoRatio: CGFloat = 16 / 9 + private let viewInsetsLink = UIEdgeInsets(top: 9, left: 9, bottom: 9, right: 9) + private let viewInsetsVideo = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) + + override func layoutSubviews() { + super.layoutSubviews() + + self.previewImageView.layer.cornerRadius = 2 + + self.previewImageView.layer.masksToBounds = true + + self.layoutForLinkPreview() + + } + + private func layoutForLinkPreview() { + var insets = self.viewInsetsLink + + let imageToTitleSpacing: CGFloat = 10 + let titleToDomainSpacing: CGFloat = 4 + + var xPosition = insets.left + if !self.previewImageView.isHidden { + //First, layout the image + let imageHeight = self.bounds.height-insets.top-insets.bottom + let imageFrame = CGRect(x: xPosition, y: insets.top, width: imageHeight, height: imageHeight) + self.previewImageView.frame = imageFrame + + xPosition += imageHeight + xPosition += imageToTitleSpacing + } + + insets.left = xPosition + + let descriptionRect = UIEdgeInsetsInsetRect(self.bounds, insets) + var maxSize = descriptionRect.size + + var placeholderFrame = UIEdgeInsetsInsetRect(self.bounds, self.viewInsetsLink) + placeholderFrame.size.height = self.loadingPlaceholderImageView.image?.size.height ?? 0 + self.loadingPlaceholderImageView.frame = placeholderFrame + + self.domainLabel.preferredMaxLayoutWidth = maxSize.width + var domainSize = self.domainLabel.sizeThatFits(maxSize) + domainSize.width = min(domainSize.width, descriptionRect.width) + + guard self.titleLabel.attributedText != nil else { + var yPosition = (descriptionRect.height-domainSize.height)/2 + yPosition += insets.top + + self.domainLabel.frame = CGRect(origin: CGPoint(x: xPosition, y: yPosition), size: domainSize) + return + } + + maxSize.height -= domainSize.height + maxSize.height -= titleToDomainSpacing + + self.titleLabel.preferredMaxLayoutWidth = descriptionRect.width + var titleSize = self.titleLabel.sizeThatFits(maxSize) + titleSize.width = min(titleSize.width, descriptionRect.width) + let combinedHeight = titleSize.height + domainSize.height + titleToDomainSpacing + var yPosition = (descriptionRect.height-combinedHeight)/2 + yPosition += insets.top + + self.titleLabel.frame = CGRect(origin: CGPoint(x: xPosition, y: yPosition), size: titleSize) + yPosition += titleSize.height + yPosition += titleToDomainSpacing + + self.domainLabel.frame = CGRect(origin: CGPoint(x: xPosition, y: yPosition), size: domainSize) + + } + + //MARK: - Size + + override var intrinsicContentSize: CGSize { + return CGSize(width: UIViewNoIntrinsicMetric, height: 78) + } + + class func height(for link: URL?, inWidth width: CGFloat, isVideoPreview: Bool) -> CGFloat { + return 78 + } + +} diff --git a/Ocarina Example/OptionsTableViewController.swift b/Ocarina Example/OptionsTableViewController.swift new file mode 100644 index 0000000..f048a90 --- /dev/null +++ b/Ocarina Example/OptionsTableViewController.swift @@ -0,0 +1,38 @@ +// +// OptionsTableViewController.swift +// Ocarina +// +// Created by Rens Verhoeven on 27/02/2017. +// Copyright © 2017 awkward. All rights reserved. +// + +import UIKit + +class OptionsTableViewController: UITableViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + // Uncomment the following line to preserve selection between presentations + // self.clearsSelectionOnViewWillAppear = false + + // Uncomment the following line to display an Edit button in the navigation bar for this view controller. + // self.navigationItem.rightBarButtonItem = self.editButtonItem() + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + + // In a storyboard-based application, you will often want to do a little preparation before navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + // Get the new view controller using segue.destinationViewController. + // Pass the selected object to the new view controller. + if let destination = segue.destination as? RealLinksTableViewController { + destination.usePrefetcher = segue.identifier?.contains("WithPrefetcher") ?? true + } + } + + +} diff --git a/Ocarina Example/RealLinksTableViewController.swift b/Ocarina Example/RealLinksTableViewController.swift new file mode 100644 index 0000000..95baa0b --- /dev/null +++ b/Ocarina Example/RealLinksTableViewController.swift @@ -0,0 +1,147 @@ +// +// RealLinksTableViewController.swift +// Ocarina +// +// Created by Rens Verhoeven on 27/02/2017. +// Copyright © 2017 awkward. All rights reserved. +// + +import UIKit +import Ocarina + +class RealLinksTableViewController: UITableViewController { + + public var usePrefetcher = true + + private var prefetcher: OcarinaPrefetcher? + + private var links: [URL] = [URL]() { + didSet { + self.prefetcher?.cancel() + if self.usePrefetcher { + self.prefetcher = OcarinaPrefetcher(urls: self.links) + } + self.tableView.reloadData() + } + } + + override func viewDidLoad() { + super.viewDidLoad() + + self.downloadLinks() + + self.tableView.estimatedRowHeight = 78+16 + self.tableView.rowHeight = UITableViewAutomaticDimension + + // Uncomment the following line to preserve selection between presentations + // self.clearsSelectionOnViewWillAppear = false + + // Uncomment the following line to display an Edit button in the navigation bar for this view controller. + // self.navigationItem.rightBarButtonItem = self.editButtonItem() + } + + func downloadLinks() { + guard let url = URL(string: "https://reddit.com/r/news/hot.json?limit=100") else { + fatalError("Failed to create URL") + } + let task = URLSession.shared.dataTask(with: url) { (data, response, error) in + if let data = data { + do { + let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + guard let data = json?["data"] as? [String: Any], let children = data["children"] as? [[String: Any]] else { + print("Invalid reddit json") + return + } + let links = children.flatMap({ (post) -> URL? in + guard let data = post["data"] as? [String: Any] else { + return nil + } + guard let urlString = data["url"] as? String else { + return nil + } + return URL(string: urlString) + }) + DispatchQueue.main.async { + self.links = links + } + } catch { + print("Error parsing JSON \(error)") + } + + } + } + task.resume() + } + + // MARK: - Table view data source + + override func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return self.links.count + } + + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "linkCell", for: indexPath) + + let link = self.links[indexPath.row] + + if let previewCell = cell as? LinkPreviewTableViewCell { + previewCell.previewView.changeLink(link: link) + } else { + cell.textLabel?.text = link.host + } + + return cell + } + + + /* + // Override to support conditional editing of the table view. + override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + // Return false if you do not want the specified item to be editable. + return true + } + */ + + /* + // Override to support editing the table view. + override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) { + if editingStyle == .delete { + // Delete the row from the data source + tableView.deleteRows(at: [indexPath], with: .fade) + } else if editingStyle == .insert { + // Create a new instance of the appropriate class, insert it into the array, and add a new row to the table view + } + } + */ + + /* + // Override to support rearranging the table view. + override func tableView(_ tableView: UITableView, moveRowAt fromIndexPath: IndexPath, to: IndexPath) { + + } + */ + + /* + // Override to support conditional rearranging of the table view. + override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { + // Return false if you do not want the item to be re-orderable. + return true + } + */ + + /* + // MARK: - Navigation + + // In a storyboard-based application, you will often want to do a little preparation before navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + // Get the new view controller using segue.destinationViewController. + // Pass the selected object to the new view controller. + } + */ + +} diff --git a/Ocarina.xcodeproj/project.pbxproj b/Ocarina.xcodeproj/project.pbxproj new file mode 100644 index 0000000..1a9b1e7 --- /dev/null +++ b/Ocarina.xcodeproj/project.pbxproj @@ -0,0 +1,720 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 0C11180E1E64750100CDFBDF /* OptionsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C11180D1E64750100CDFBDF /* OptionsTableViewController.swift */; }; + 0C1118111E64750F00CDFBDF /* RealLinksTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1118101E64750F00CDFBDF /* RealLinksTableViewController.swift */; }; + 0C1118131E64791E00CDFBDF /* LinkPreviewTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1118121E64791E00CDFBDF /* LinkPreviewTableViewCell.swift */; }; + 0C1118211E64795500CDFBDF /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1118201E64795500CDFBDF /* LinkPreviewView.swift */; }; + 0C1AEED91E54A086001A6234 /* Ocarina.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1AEED81E54A086001A6234 /* Ocarina.swift */; }; + 0C1AEEDB1E54A090001A6234 /* OcarinaManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1AEEDA1E54A090001A6234 /* OcarinaManager.swift */; }; + 0C1AEEDD1E54A09A001A6234 /* URLInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1AEEDC1E54A09A001A6234 /* URLInformation.swift */; }; + 0C1AEEDF1E54A0A3001A6234 /* OcarinaInformationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1AEEDE1E54A0A3001A6234 /* OcarinaInformationRequest.swift */; }; + 0C1AEEE11E54A0AD001A6234 /* URLInformationCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1AEEE01E54A0AD001A6234 /* URLInformationCache.swift */; }; + 0C1AEEE31E54A0B6001A6234 /* URL+URLInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1AEEE21E54A0B6001A6234 /* URL+URLInformation.swift */; }; + 0C1AEEF41E54B359001A6234 /* libxml2.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0C1AEEF31E54B359001A6234 /* libxml2.tbd */; }; + 0C1AEEF81E55E61E001A6234 /* AdditionalParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1AEEF71E55E61E001A6234 /* AdditionalParsingTests.swift */; }; + 0C38D4DE1E8E53BE003E02E1 /* Kanna.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C38D4D91E8E53BE003E02E1 /* Kanna.swift */; }; + 0C38D4DF1E8E53BE003E02E1 /* libxmlHTMLDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C38D4DA1E8E53BE003E02E1 /* libxmlHTMLDocument.swift */; }; + 0C38D4E01E8E53BE003E02E1 /* libxmlHTMLNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C38D4DB1E8E53BE003E02E1 /* libxmlHTMLNode.swift */; }; + 0C38D4E11E8E53BE003E02E1 /* libxmlParserOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C38D4DC1E8E53BE003E02E1 /* libxmlParserOption.swift */; }; + 0C3FD0121EC9B322001E9588 /* TwitterCardInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3FD0111EC9B322001E9588 /* TwitterCardInformation.swift */; }; + 0C879DA41E535182004606F2 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C879DA31E535182004606F2 /* AppDelegate.swift */; }; + 0C879DAB1E535182004606F2 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0C879DA91E535182004606F2 /* Main.storyboard */; }; + 0C879DAD1E535182004606F2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0C879DAC1E535182004606F2 /* Assets.xcassets */; }; + 0C879DB01E535182004606F2 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0C879DAE1E535182004606F2 /* LaunchScreen.storyboard */; }; + 0C879DD31E5351F4004606F2 /* Ocarina.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0C879DCA1E5351F4004606F2 /* Ocarina.framework */; }; + 0C879DDC1E5351F4004606F2 /* Ocarina.h in Headers */ = {isa = PBXBuildFile; fileRef = 0C879DCC1E5351F4004606F2 /* Ocarina.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 0C879DDF1E5351F4004606F2 /* Ocarina.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0C879DCA1E5351F4004606F2 /* Ocarina.framework */; }; + 0C879DE01E5351F4004606F2 /* Ocarina.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0C879DCA1E5351F4004606F2 /* Ocarina.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 0C99F93E1E92725F000C5258 /* CSS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C99F93D1E92725F000C5258 /* CSS.swift */; }; + 0C99F9401E927726000C5258 /* InformationFetchingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C99F93F1E927726000C5258 /* InformationFetchingTests.swift */; }; + 0C99F9421E927C46000C5258 /* CachingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C99F9411E927C46000C5258 /* CachingTests.swift */; }; + 0C99F9451E92894F000C5258 /* PrefetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C99F9441E92894F000C5258 /* PrefetcherTests.swift */; }; + 0CF4451E1E63006A00E284FB /* OcarinaManagerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF4451D1E63006A00E284FB /* OcarinaManagerDelegate.swift */; }; + 0CF445201E6300F400E284FB /* OcarinaPrefetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF4451F1E6300F400E284FB /* OcarinaPrefetcher.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 0C879DD41E5351F4004606F2 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 0C879D981E535182004606F2 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 0C879DC91E5351F4004606F2; + remoteInfo = Ocarina; + }; + 0C879DD61E5351F4004606F2 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 0C879D981E535182004606F2 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 0C879D9F1E535182004606F2; + remoteInfo = "Ocarina Example"; + }; + 0C879DDD1E5351F4004606F2 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 0C879D981E535182004606F2 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 0C879DC91E5351F4004606F2; + remoteInfo = Ocarina; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 0C879DE41E5351F4004606F2 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 0C879DE01E5351F4004606F2 /* Ocarina.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 0C11180D1E64750100CDFBDF /* OptionsTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OptionsTableViewController.swift; sourceTree = ""; }; + 0C1118101E64750F00CDFBDF /* RealLinksTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RealLinksTableViewController.swift; sourceTree = ""; }; + 0C1118121E64791E00CDFBDF /* LinkPreviewTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinkPreviewTableViewCell.swift; sourceTree = ""; }; + 0C1118201E64795500CDFBDF /* LinkPreviewView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinkPreviewView.swift; sourceTree = ""; }; + 0C1AEED81E54A086001A6234 /* Ocarina.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Ocarina.swift; sourceTree = ""; }; + 0C1AEEDA1E54A090001A6234 /* OcarinaManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OcarinaManager.swift; sourceTree = ""; }; + 0C1AEEDC1E54A09A001A6234 /* URLInformation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLInformation.swift; sourceTree = ""; }; + 0C1AEEDE1E54A0A3001A6234 /* OcarinaInformationRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OcarinaInformationRequest.swift; sourceTree = ""; }; + 0C1AEEE01E54A0AD001A6234 /* URLInformationCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLInformationCache.swift; sourceTree = ""; }; + 0C1AEEE21E54A0B6001A6234 /* URL+URLInformation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URL+URLInformation.swift"; sourceTree = ""; }; + 0C1AEEF31E54B359001A6234 /* libxml2.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libxml2.tbd; path = usr/lib/libxml2.tbd; sourceTree = SDKROOT; }; + 0C1AEEF71E55E61E001A6234 /* AdditionalParsingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdditionalParsingTests.swift; sourceTree = ""; }; + 0C38D4D91E8E53BE003E02E1 /* Kanna.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Kanna.swift; sourceTree = ""; }; + 0C38D4DA1E8E53BE003E02E1 /* libxmlHTMLDocument.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = libxmlHTMLDocument.swift; sourceTree = ""; }; + 0C38D4DB1E8E53BE003E02E1 /* libxmlHTMLNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = libxmlHTMLNode.swift; sourceTree = ""; }; + 0C38D4DC1E8E53BE003E02E1 /* libxmlParserOption.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = libxmlParserOption.swift; sourceTree = ""; }; + 0C3FD0111EC9B322001E9588 /* TwitterCardInformation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TwitterCardInformation.swift; sourceTree = ""; }; + 0C879DA01E535182004606F2 /* Ocarina Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Ocarina Example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 0C879DA31E535182004606F2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 0C879DAA1E535182004606F2 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 0C879DAC1E535182004606F2 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 0C879DAF1E535182004606F2 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 0C879DB11E535182004606F2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 0C879DCA1E5351F4004606F2 /* Ocarina.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Ocarina.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 0C879DCC1E5351F4004606F2 /* Ocarina.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Ocarina.h; sourceTree = ""; }; + 0C879DCD1E5351F4004606F2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 0C879DD21E5351F4004606F2 /* OcarinaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OcarinaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 0C879DDB1E5351F4004606F2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 0C99F93D1E92725F000C5258 /* CSS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CSS.swift; sourceTree = ""; }; + 0C99F93F1E927726000C5258 /* InformationFetchingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InformationFetchingTests.swift; sourceTree = ""; }; + 0C99F9411E927C46000C5258 /* CachingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CachingTests.swift; sourceTree = ""; }; + 0C99F9441E92894F000C5258 /* PrefetcherTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PrefetcherTests.swift; sourceTree = ""; }; + 0CF4451D1E63006A00E284FB /* OcarinaManagerDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OcarinaManagerDelegate.swift; sourceTree = ""; }; + 0CF4451F1E6300F400E284FB /* OcarinaPrefetcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OcarinaPrefetcher.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 0C879D9D1E535182004606F2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0C879DDF1E5351F4004606F2 /* Ocarina.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 0C879DC61E5351F4004606F2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0C1AEEF41E54B359001A6234 /* libxml2.tbd in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 0C879DCF1E5351F4004606F2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0C879DD31E5351F4004606F2 /* Ocarina.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0C1AEEE41E54B237001A6234 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 0C38D4D61E8E53A7003E02E1 /* Kanna */, + ); + name = Frameworks; + sourceTree = ""; + }; + 0C1AEEF21E54B359001A6234 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 0C1AEEF31E54B359001A6234 /* libxml2.tbd */, + ); + name = Frameworks; + sourceTree = ""; + }; + 0C38D4D61E8E53A7003E02E1 /* Kanna */ = { + isa = PBXGroup; + children = ( + 0C99F93D1E92725F000C5258 /* CSS.swift */, + 0C38D4D91E8E53BE003E02E1 /* Kanna.swift */, + 0C38D4DA1E8E53BE003E02E1 /* libxmlHTMLDocument.swift */, + 0C38D4DB1E8E53BE003E02E1 /* libxmlHTMLNode.swift */, + 0C38D4DC1E8E53BE003E02E1 /* libxmlParserOption.swift */, + ); + name = Kanna; + path = ../Kanna; + sourceTree = ""; + }; + 0C879D971E535182004606F2 = { + isa = PBXGroup; + children = ( + 0C879DA21E535182004606F2 /* Ocarina Example */, + 0C879DCB1E5351F4004606F2 /* Ocarina */, + 0C879DD81E5351F4004606F2 /* OcarinaTests */, + 0C879DA11E535182004606F2 /* Products */, + 0C1AEEF21E54B359001A6234 /* Frameworks */, + ); + sourceTree = ""; + }; + 0C879DA11E535182004606F2 /* Products */ = { + isa = PBXGroup; + children = ( + 0C879DA01E535182004606F2 /* Ocarina Example.app */, + 0C879DCA1E5351F4004606F2 /* Ocarina.framework */, + 0C879DD21E5351F4004606F2 /* OcarinaTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 0C879DA21E535182004606F2 /* Ocarina Example */ = { + isa = PBXGroup; + children = ( + 0C879DA31E535182004606F2 /* AppDelegate.swift */, + 0C879DA91E535182004606F2 /* Main.storyboard */, + 0C879DAC1E535182004606F2 /* Assets.xcassets */, + 0C879DAE1E535182004606F2 /* LaunchScreen.storyboard */, + 0C879DB11E535182004606F2 /* Info.plist */, + 0C11180D1E64750100CDFBDF /* OptionsTableViewController.swift */, + 0C1118101E64750F00CDFBDF /* RealLinksTableViewController.swift */, + 0C1118121E64791E00CDFBDF /* LinkPreviewTableViewCell.swift */, + 0C1118201E64795500CDFBDF /* LinkPreviewView.swift */, + ); + path = "Ocarina Example"; + sourceTree = ""; + }; + 0C879DCB1E5351F4004606F2 /* Ocarina */ = { + isa = PBXGroup; + children = ( + 0C1AEEE41E54B237001A6234 /* Frameworks */, + 0C879DCC1E5351F4004606F2 /* Ocarina.h */, + 0C879DCD1E5351F4004606F2 /* Info.plist */, + 0C1AEED81E54A086001A6234 /* Ocarina.swift */, + 0CF4451D1E63006A00E284FB /* OcarinaManagerDelegate.swift */, + 0C1AEEDA1E54A090001A6234 /* OcarinaManager.swift */, + 0C1AEEDC1E54A09A001A6234 /* URLInformation.swift */, + 0C1AEEDE1E54A0A3001A6234 /* OcarinaInformationRequest.swift */, + 0CF4451F1E6300F400E284FB /* OcarinaPrefetcher.swift */, + 0C1AEEE01E54A0AD001A6234 /* URLInformationCache.swift */, + 0C1AEEE21E54A0B6001A6234 /* URL+URLInformation.swift */, + 0C3FD0111EC9B322001E9588 /* TwitterCardInformation.swift */, + ); + path = Ocarina; + sourceTree = ""; + }; + 0C879DD81E5351F4004606F2 /* OcarinaTests */ = { + isa = PBXGroup; + children = ( + 0C879DDB1E5351F4004606F2 /* Info.plist */, + 0C1AEEF71E55E61E001A6234 /* AdditionalParsingTests.swift */, + 0C99F93F1E927726000C5258 /* InformationFetchingTests.swift */, + 0C99F9411E927C46000C5258 /* CachingTests.swift */, + 0C99F9441E92894F000C5258 /* PrefetcherTests.swift */, + ); + path = OcarinaTests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 0C879DC71E5351F4004606F2 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 0C879DDC1E5351F4004606F2 /* Ocarina.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 0C879D9F1E535182004606F2 /* Ocarina Example */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0C879DBF1E535182004606F2 /* Build configuration list for PBXNativeTarget "Ocarina Example" */; + buildPhases = ( + 0C879D9C1E535182004606F2 /* Sources */, + 0C879D9D1E535182004606F2 /* Frameworks */, + 0C879D9E1E535182004606F2 /* Resources */, + 0C879DE41E5351F4004606F2 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 0C879DDE1E5351F4004606F2 /* PBXTargetDependency */, + ); + name = "Ocarina Example"; + productName = "Ocarina Example"; + productReference = 0C879DA01E535182004606F2 /* Ocarina Example.app */; + productType = "com.apple.product-type.application"; + }; + 0C879DC91E5351F4004606F2 /* Ocarina */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0C879DE11E5351F4004606F2 /* Build configuration list for PBXNativeTarget "Ocarina" */; + buildPhases = ( + 0C879DC51E5351F4004606F2 /* Sources */, + 0C879DC61E5351F4004606F2 /* Frameworks */, + 0C879DC71E5351F4004606F2 /* Headers */, + 0C879DC81E5351F4004606F2 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Ocarina; + productName = Ocarina; + productReference = 0C879DCA1E5351F4004606F2 /* Ocarina.framework */; + productType = "com.apple.product-type.framework"; + }; + 0C879DD11E5351F4004606F2 /* OcarinaTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0C879DE51E5351F4004606F2 /* Build configuration list for PBXNativeTarget "OcarinaTests" */; + buildPhases = ( + 0C879DCE1E5351F4004606F2 /* Sources */, + 0C879DCF1E5351F4004606F2 /* Frameworks */, + 0C879DD01E5351F4004606F2 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 0C879DD51E5351F4004606F2 /* PBXTargetDependency */, + 0C879DD71E5351F4004606F2 /* PBXTargetDependency */, + ); + name = OcarinaTests; + productName = OcarinaTests; + productReference = 0C879DD21E5351F4004606F2 /* OcarinaTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 0C879D981E535182004606F2 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0820; + LastUpgradeCheck = 0820; + ORGANIZATIONNAME = awkward; + TargetAttributes = { + 0C879D9F1E535182004606F2 = { + CreatedOnToolsVersion = 8.2.1; + ProvisioningStyle = Automatic; + }; + 0C879DC91E5351F4004606F2 = { + CreatedOnToolsVersion = 8.2.1; + LastSwiftMigration = 0820; + ProvisioningStyle = Automatic; + }; + 0C879DD11E5351F4004606F2 = { + CreatedOnToolsVersion = 8.2.1; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 0C879D9B1E535182004606F2 /* Build configuration list for PBXProject "Ocarina" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = English; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 0C879D971E535182004606F2; + productRefGroup = 0C879DA11E535182004606F2 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 0C879D9F1E535182004606F2 /* Ocarina Example */, + 0C879DC91E5351F4004606F2 /* Ocarina */, + 0C879DD11E5351F4004606F2 /* OcarinaTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 0C879D9E1E535182004606F2 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0C879DB01E535182004606F2 /* LaunchScreen.storyboard in Resources */, + 0C879DAD1E535182004606F2 /* Assets.xcassets in Resources */, + 0C879DAB1E535182004606F2 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 0C879DC81E5351F4004606F2 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 0C879DD01E5351F4004606F2 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 0C879D9C1E535182004606F2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0C1118131E64791E00CDFBDF /* LinkPreviewTableViewCell.swift in Sources */, + 0C11180E1E64750100CDFBDF /* OptionsTableViewController.swift in Sources */, + 0C1118211E64795500CDFBDF /* LinkPreviewView.swift in Sources */, + 0C1118111E64750F00CDFBDF /* RealLinksTableViewController.swift in Sources */, + 0C879DA41E535182004606F2 /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 0C879DC51E5351F4004606F2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0C38D4DF1E8E53BE003E02E1 /* libxmlHTMLDocument.swift in Sources */, + 0C38D4E11E8E53BE003E02E1 /* libxmlParserOption.swift in Sources */, + 0C99F93E1E92725F000C5258 /* CSS.swift in Sources */, + 0C1AEEDF1E54A0A3001A6234 /* OcarinaInformationRequest.swift in Sources */, + 0C1AEEE11E54A0AD001A6234 /* URLInformationCache.swift in Sources */, + 0CF4451E1E63006A00E284FB /* OcarinaManagerDelegate.swift in Sources */, + 0C1AEEE31E54A0B6001A6234 /* URL+URLInformation.swift in Sources */, + 0C1AEED91E54A086001A6234 /* Ocarina.swift in Sources */, + 0C3FD0121EC9B322001E9588 /* TwitterCardInformation.swift in Sources */, + 0C38D4E01E8E53BE003E02E1 /* libxmlHTMLNode.swift in Sources */, + 0C1AEEDD1E54A09A001A6234 /* URLInformation.swift in Sources */, + 0C38D4DE1E8E53BE003E02E1 /* Kanna.swift in Sources */, + 0C1AEEDB1E54A090001A6234 /* OcarinaManager.swift in Sources */, + 0CF445201E6300F400E284FB /* OcarinaPrefetcher.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 0C879DCE1E5351F4004606F2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0C99F9421E927C46000C5258 /* CachingTests.swift in Sources */, + 0C1AEEF81E55E61E001A6234 /* AdditionalParsingTests.swift in Sources */, + 0C99F9401E927726000C5258 /* InformationFetchingTests.swift in Sources */, + 0C99F9451E92894F000C5258 /* PrefetcherTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 0C879DD51E5351F4004606F2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 0C879DC91E5351F4004606F2 /* Ocarina */; + targetProxy = 0C879DD41E5351F4004606F2 /* PBXContainerItemProxy */; + }; + 0C879DD71E5351F4004606F2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 0C879D9F1E535182004606F2 /* Ocarina Example */; + targetProxy = 0C879DD61E5351F4004606F2 /* PBXContainerItemProxy */; + }; + 0C879DDE1E5351F4004606F2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 0C879DC91E5351F4004606F2 /* Ocarina */; + targetProxy = 0C879DDD1E5351F4004606F2 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 0C879DA91E535182004606F2 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 0C879DAA1E535182004606F2 /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 0C879DAE1E535182004606F2 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 0C879DAF1E535182004606F2 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 0C879DBD1E535182004606F2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 10.2; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 0C879DBE1E535182004606F2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 10.2; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 0C879DC01E535182004606F2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + HEADER_SEARCH_PATHS = "$(SDKROOT)/usr/include/libxml2"; + INFOPLIST_FILE = "Ocarina Example/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = "-lxml2"; + PRODUCT_BUNDLE_IDENTIFIER = "co.awkward.Ocarina-Example"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 3.0; + }; + name = Debug; + }; + 0C879DC11E535182004606F2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + HEADER_SEARCH_PATHS = "$(SDKROOT)/usr/include/libxml2"; + INFOPLIST_FILE = "Ocarina Example/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = "-lxml2"; + PRODUCT_BUNDLE_IDENTIFIER = "co.awkward.Ocarina-Example"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 3.0; + }; + name = Release; + }; + 0C879DE21E5351F4004606F2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = ""; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + HEADER_SEARCH_PATHS = "$(SDKROOT)/usr/include/libxml2"; + INFOPLIST_FILE = Ocarina/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + OTHER_LDFLAGS = "-lxml2"; + PRODUCT_BUNDLE_IDENTIFIER = co.awkward.Ocarina; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/SwiftLibXML2"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 3.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + 0C879DE31E5351F4004606F2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = ""; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + HEADER_SEARCH_PATHS = "$(SDKROOT)/usr/include/libxml2"; + INFOPLIST_FILE = Ocarina/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + OTHER_LDFLAGS = "-lxml2"; + PRODUCT_BUNDLE_IDENTIFIER = co.awkward.Ocarina; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/SwiftLibXML2"; + SWIFT_VERSION = 3.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + 0C879DE61E5351F4004606F2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + HEADER_SEARCH_PATHS = "$(SDKROOT)/usr/include/libxml2"; + INFOPLIST_FILE = OcarinaTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + OTHER_LDFLAGS = "-lxml2"; + PRODUCT_BUNDLE_IDENTIFIER = co.awkward.OcarinaTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 3.0; + }; + name = Debug; + }; + 0C879DE71E5351F4004606F2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + HEADER_SEARCH_PATHS = "$(SDKROOT)/usr/include/libxml2"; + INFOPLIST_FILE = OcarinaTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + OTHER_LDFLAGS = "-lxml2"; + PRODUCT_BUNDLE_IDENTIFIER = co.awkward.OcarinaTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 3.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 0C879D9B1E535182004606F2 /* Build configuration list for PBXProject "Ocarina" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0C879DBD1E535182004606F2 /* Debug */, + 0C879DBE1E535182004606F2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 0C879DBF1E535182004606F2 /* Build configuration list for PBXNativeTarget "Ocarina Example" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0C879DC01E535182004606F2 /* Debug */, + 0C879DC11E535182004606F2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 0C879DE11E5351F4004606F2 /* Build configuration list for PBXNativeTarget "Ocarina" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0C879DE21E5351F4004606F2 /* Debug */, + 0C879DE31E5351F4004606F2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 0C879DE51E5351F4004606F2 /* Build configuration list for PBXNativeTarget "OcarinaTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0C879DE61E5351F4004606F2 /* Debug */, + 0C879DE71E5351F4004606F2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 0C879D981E535182004606F2 /* Project object */; +} diff --git a/Ocarina.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Ocarina.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..9cbeeb7 --- /dev/null +++ b/Ocarina.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Ocarina.xcodeproj/project.xcworkspace/xcshareddata/Ocarina.xcscmblueprint b/Ocarina.xcodeproj/project.xcworkspace/xcshareddata/Ocarina.xcscmblueprint new file mode 100644 index 0000000..728964d --- /dev/null +++ b/Ocarina.xcodeproj/project.xcworkspace/xcshareddata/Ocarina.xcscmblueprint @@ -0,0 +1,30 @@ +{ + "DVTSourceControlWorkspaceBlueprintPrimaryRemoteRepositoryKey" : "6B26A0F92381282E9A8F6D4F94E2321680FCFB7B", + "DVTSourceControlWorkspaceBlueprintWorkingCopyRepositoryLocationsKey" : { + + }, + "DVTSourceControlWorkspaceBlueprintWorkingCopyStatesKey" : { + "6B26A0F92381282E9A8F6D4F94E2321680FCFB7B" : 9223372036854775807, + "3F00198FD7638102954E2D442697C31855C6A5BC" : 9223372036854775807 + }, + "DVTSourceControlWorkspaceBlueprintIdentifierKey" : "5F8D5F21-4A25-4453-8701-E0B379ABC42F", + "DVTSourceControlWorkspaceBlueprintWorkingCopyPathsKey" : { + "6B26A0F92381282E9A8F6D4F94E2321680FCFB7B" : "Ocarina\/", + "3F00198FD7638102954E2D442697C31855C6A5BC" : "Ocarina\/Kanna%20HTML%20Parser\/" + }, + "DVTSourceControlWorkspaceBlueprintNameKey" : "Ocarina", + "DVTSourceControlWorkspaceBlueprintVersion" : 204, + "DVTSourceControlWorkspaceBlueprintRelativePathToProjectKey" : "Ocarina.xcodeproj", + "DVTSourceControlWorkspaceBlueprintRemoteRepositoriesKey" : [ + { + "DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:tid-kijyun\/Kanna.git", + "DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git", + "DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "3F00198FD7638102954E2D442697C31855C6A5BC" + }, + { + "DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:awkward\/Ocarina.git", + "DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git", + "DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "6B26A0F92381282E9A8F6D4F94E2321680FCFB7B" + } + ] +} \ No newline at end of file diff --git a/Ocarina.xcodeproj/xcshareddata/xcschemes/Ocarina Example.xcscheme b/Ocarina.xcodeproj/xcshareddata/xcschemes/Ocarina Example.xcscheme new file mode 100644 index 0000000..0a803bf --- /dev/null +++ b/Ocarina.xcodeproj/xcshareddata/xcschemes/Ocarina Example.xcscheme @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Ocarina.xcodeproj/xcshareddata/xcschemes/Ocarina.xcscheme b/Ocarina.xcodeproj/xcshareddata/xcschemes/Ocarina.xcscheme new file mode 100644 index 0000000..2a1a163 --- /dev/null +++ b/Ocarina.xcodeproj/xcshareddata/xcschemes/Ocarina.xcscheme @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Ocarina/Info.plist b/Ocarina/Info.plist new file mode 100644 index 0000000..fbe1e6b --- /dev/null +++ b/Ocarina/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSPrincipalClass + + + diff --git a/Ocarina/Ocarina.h b/Ocarina/Ocarina.h new file mode 100644 index 0000000..415f7fb --- /dev/null +++ b/Ocarina/Ocarina.h @@ -0,0 +1,19 @@ +// +// Ocarina.h +// Ocarina +// +// Created by Rens Verhoeven on 14/02/2017. +// Copyright © 2017 awkward. All rights reserved. +// + +#import + +//! Project version number for Ocarina. +FOUNDATION_EXPORT double OcarinaVersionNumber; + +//! Project version string for Ocarina. +FOUNDATION_EXPORT const unsigned char OcarinaVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/Ocarina/Ocarina.swift b/Ocarina/Ocarina.swift new file mode 100644 index 0000000..8144dd2 --- /dev/null +++ b/Ocarina/Ocarina.swift @@ -0,0 +1,11 @@ +// +// Ocarina.swift +// Ocarina +// +// Created by Rens Verhoeven on 15/02/2017. +// Copyright © 2017 awkward. All rights reserved. +// + +import Foundation + +public typealias InformationCompletionHandler = ((_ information: URLInformation?, _ error: Error?) -> ()) diff --git a/Ocarina/OcarinaInformationRequest.swift b/Ocarina/OcarinaInformationRequest.swift new file mode 100644 index 0000000..5140c85 --- /dev/null +++ b/Ocarina/OcarinaInformationRequest.swift @@ -0,0 +1,42 @@ +// +// OcarinaInformationRequest.swift +// Ocarina +// +// Created by Rens Verhoeven on 15/02/2017. +// Copyright © 2017 awkward. All rights reserved. +// + +import Foundation + +/// A request for information of a certain URL +public class OcarinaInformationRequest: Equatable { + + /// The URL this request is requesting information for + public var url: URL + + /// The task that is requesting the actual page + let task: URLSessionTask + + /// The completion handler, called when the request is cancelled or finished + let completionHandler: InformationCompletionHandler + + /// If the request has been completed, either with or without an error + internal var hasBeenCompleted: Bool = false + + init(url: URL, task: URLSessionTask, completionHandler: @escaping InformationCompletionHandler) { + self.url = url + self.task = task + self.completionHandler = completionHandler + } + + /// Cancels the request for information. Only works when the shared OcarinaManager is used. + /// For custom istances of the OcarinaManager, use `func cancel(request:)` + public func cancel() { + OcarinaManager.shared.cancel(request: self) + } + + + public static func ==(lhs: OcarinaInformationRequest, rhs: OcarinaInformationRequest) -> Bool { + return lhs.url == rhs.url + } +} diff --git a/Ocarina/OcarinaManager.swift b/Ocarina/OcarinaManager.swift new file mode 100644 index 0000000..cf5525d --- /dev/null +++ b/Ocarina/OcarinaManager.swift @@ -0,0 +1,242 @@ +// +// OcarinaManager.swift +// Ocarina +// +// Created by Rens Verhoeven on 15/02/2017. +// Copyright © 2017 awkward. All rights reserved. +// + +import Foundation + +/// Manages the requests of informations for each URL and makes sure the information is cached. +open class OcarinaManager: NSObject { + + /// A shared instance of the OcarinaManager on which methods can be called to fetch information about a URL + open static let shared: OcarinaManager = OcarinaManager() + + /// The cache used for caching URLInformation models + open let cache: URLInformationCache + + /// The requests that are currently in progress + open var currentRequests: [OcarinaInformationRequest] = [OcarinaInformationRequest](); + + /// The delegate for the Ocarina Manager. See OcarinaManagerDelegate + open var delegate: OcarinaManagerDelegate? + + /// The received data per task identifier. + fileprivate var dataPerTask = [Int: Data]() + + /// If the OcarinaManager should cache the URLInformation models + open var shouldCacheResults = true { + didSet { + if !self.shouldCacheResults { + self.cache.clear() + } + } + } + + /// The barrier queue used when accessing dataPerTask. + let barrierQueue = DispatchQueue(label: "ocarina-barrier-handling-queue") + + /// The current session configuration. Can be used to register custom protocols on. + public var sessionConfiguration = URLSessionConfiguration.default + + fileprivate lazy var urlSession: URLSession = { + return URLSession(configuration: self.sessionConfiguration, delegate: self, delegateQueue: nil) + }() + + override public init() { + self.cache = URLInformationCache() + } + + public init(cache: URLInformationCache) { + self.cache = cache + } + + /// Schedules a request for the page at the given URL and returns the request + /// + /// - Parameters: + /// - url: The URL to get the information from + /// - completionHandler: A handler called, when the information about the link is found + /// - Returns: The scheduled request for information about the URL. If nil is returned, the request is either invalid or information is already available and the completionsHandler is directly called + @discardableResult + open func requestInformation(for url: URL, completionHandler: @escaping InformationCompletionHandler) -> OcarinaInformationRequest? { + if self.shouldCacheResults, let result = self.cache[url] { + DispatchQueue.main.async { + completionHandler(result, nil) + } + return nil + } + let existingRequest = self.requests(for: url).first + + if let task = existingRequest?.task { + let request = OcarinaInformationRequest(url: url, task: task, completionHandler: completionHandler) + self.currentRequests.append(request) + return request + } else { + let downloadTask = self.dataTask(for: url) + let request = OcarinaInformationRequest(url: url, task: downloadTask, completionHandler: completionHandler) + self.currentRequests.append(request) + downloadTask.resume() + return request + } + + } + + /// Creates a new data task for the given URL + /// + /// - Parameter url: The URL to create the data task for + /// - Returns: The data task + fileprivate func dataTask(for url: URL) -> URLSessionDataTask { + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + request.setValue("Ocarinabot", forHTTPHeaderField: "User-agent") + request.setValue("text/html", forHTTPHeaderField: "Accept") + return self.urlSession.dataTask(with: request) + } + + /// Returns all the scheduled and in-profress OcarinaInformationRequests corrosponding to the given URL. + /// + /// - Parameter url: The url to get the requests for + /// - Returns: The requests + open func requests(for url: URL) -> [OcarinaInformationRequest] { + return self.currentRequests.filter({ (request) -> Bool in + return request.url == url + }) + } + + /// Returns all the scheduled and in-profress OcarinaInformationRequests corrosponding to the given URL. + /// + /// - Parameter url: The url to get the requests for + /// - Returns: The requests + fileprivate func requests(for task: URLSessionTask) -> [OcarinaInformationRequest] { + return self.currentRequests.filter({ (request) -> Bool in + return request.task == task + }) + } + + /// Cancels a given request. If all requests for the same URL are cancelled, the actual data retrieving is also cancelled + /// + /// - Parameter request: The request to cancel. Also see `func cancel()` on OcarinaInformationRequest + open func cancel(request: OcarinaInformationRequest) { + let requests = self.requests(for: request.url) + + if let index = self.currentRequests.index(of: request) { + request.completionHandler(nil, nil) + self.currentRequests.remove(at: index) + if requests.count == 1 { + request.task.cancel() + } + } + } + + fileprivate func information(for url: URL, originalURL: URL, html: HTMLDocument?, response: HTTPURLResponse?) -> URLInformation? { + var urlInformation = URLInformation(originalURL: originalURL, url: url, html: html, response: response) + if let delegate = self.delegate, let information = urlInformation { + urlInformation = delegate.ocarinaManager(manager: self, doAdditionalParsingForInformation: information, html: nil) + } + return urlInformation + } + + + fileprivate func completeRequestsWithError(_ error: Error, for url: URL) { + DispatchQueue.main.async { + let requests = self.requests(for: url) + for request in requests { + request.hasBeenCompleted = true + request.completionHandler(nil, error) + } + self.remove(requests: requests) + } + } + + fileprivate func completeRequestsWithInformation(_ information: URLInformation, for url: URL) { + DispatchQueue.main.async { + let requests = self.requests(for: url) + for request in requests { + request.hasBeenCompleted = true + request.completionHandler(information, nil) + + } + self.remove(requests: requests) + } + } + + fileprivate func remove(requests: [OcarinaInformationRequest]) { + for request in requests { + if let index = self.currentRequests.index(of: request) { + self.currentRequests.remove(at: index) + } + } + } +} + +extension OcarinaManager: URLSessionDataDelegate { + + public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + if let httpResponse = response as? HTTPURLResponse, let mimeType = response.mimeType?.lowercased() { + if URLInformationType.htmlFileMimeTypes.contains(mimeType) { + completionHandler(URLSession.ResponseDisposition.allow) + return + } else { + self.taskDidComplete(dataTask, data: nil, error: nil, response: httpResponse) + completionHandler(URLSession.ResponseDisposition.cancel) + return + } + } + completionHandler(URLSession.ResponseDisposition.allow) + } + + public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + self.barrierQueue.sync { + if var existingData = self.dataPerTask[dataTask.taskIdentifier] { + existingData.append(data) + self.dataPerTask[dataTask.taskIdentifier] = existingData + } else { + self.dataPerTask[dataTask.taskIdentifier] = data + } + } + + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + let data = dataPerTask[task.taskIdentifier] + barrierQueue.sync(flags: .barrier) { + self.dataPerTask.removeValue(forKey: task.taskIdentifier) + return + } + self.taskDidComplete(task, data: data, error: error, response: task.response as? HTTPURLResponse) + + } + + fileprivate func taskDidComplete(_ task: URLSessionTask, data: Data?, error: Error?, response: HTTPURLResponse?) { + guard let originalURL = self.requests(for: task).first?.url else { + return + } + let url = task.currentRequest?.url ?? originalURL + if let response = response, response.statusCode < 200 && response.statusCode >= 300 { + //We don't have a valid response, we end it here! If we don't have a response at all, we will just continue + let newError = NSError(domain: "co.awkward.ocarina", code: 500, userInfo: [NSLocalizedDescriptionKey: "Invalid response receibved from URL"]) + self.completeRequestsWithError(newError, for: originalURL) + return + } + if let error = error, data == nil { + self.completeRequestsWithError(error, for: originalURL) + return + } + + var html: HTMLDocument? = nil + if let data = data { + html = HTML(html: data, encoding: .utf8) + } + + if let urlInformation = self.information(for: url, originalURL: originalURL, html: html, response: response) { + self.cache[originalURL] = urlInformation + self.completeRequestsWithInformation(urlInformation, for: originalURL) + } else { + let newError = error ?? NSError(domain: "co.awkward.ocarina", code: 501, userInfo: [NSLocalizedDescriptionKey: "Invalid data received from URL"]) + self.completeRequestsWithError(newError, for: originalURL) + } + } + +} diff --git a/Ocarina/OcarinaManagerDelegate.swift b/Ocarina/OcarinaManagerDelegate.swift new file mode 100644 index 0000000..9178a62 --- /dev/null +++ b/Ocarina/OcarinaManagerDelegate.swift @@ -0,0 +1,22 @@ +// +// OcarinaManagerDelegate.swift +// Ocarina +// +// Created by Rens Verhoeven on 26/02/2017. +// Copyright © 2017 awkward. All rights reserved. +// + +import UIKit + +public protocol OcarinaManagerDelegate: class { + + func ocarinaManager(manager: OcarinaManager, doAdditionalParsingForInformation information: URLInformation, html: HTMLDocument?) -> URLInformation? + +} + +extension OcarinaManagerDelegate { + + func ocarinaManager(manager: OcarinaManager, doAdditionalParsingForInformation information: URLInformation, html: HTMLDocument?) -> URLInformation? { + return information + } +} diff --git a/Ocarina/OcarinaPrefetcher.swift b/Ocarina/OcarinaPrefetcher.swift new file mode 100644 index 0000000..30e66fa --- /dev/null +++ b/Ocarina/OcarinaPrefetcher.swift @@ -0,0 +1,54 @@ +// +// OcarinaPrefetcher.swift +// Ocarina +// +// Created by Rens Verhoeven on 26/02/2017. +// Copyright © 2017 awkward. All rights reserved. +// + +import UIKit + +open class OcarinaPrefetcher: NSObject { + + public typealias OcarinaPrefetcherCompletionHandler = ((_ errors: [Error]) -> Void) + + var requests: [OcarinaInformationRequest] = [OcarinaInformationRequest]() + let manager: OcarinaManager + let completionHandler: OcarinaPrefetcherCompletionHandler? + + var errors: [Error] = [Error]() + + public init(urls: [URL], manager: OcarinaManager? = nil, completionHandler: OcarinaPrefetcherCompletionHandler? = nil) { + self.manager = manager ?? OcarinaManager.shared + self.completionHandler = completionHandler + super.init() + + let requests = urls.flatMap { (url) -> OcarinaInformationRequest? in + return self.manager.requestInformation(for: url, completionHandler: { (information, error) in + self.requestCompleted(error: error) + }) + } + self.requests = requests + self.requestCompleted(error: nil) + } + + func requestCompleted(error: Error?) { + if let error = error { + self.errors.append(error) + } + let incompleteRequests = self.requests.filter({ (request) -> Bool in + return !request.hasBeenCompleted + }) + if incompleteRequests.count <= 0 { + self.completionHandler?(self.errors) + } + } + + public func cancel() { + for request in requests { + self.manager.cancel(request: request) + } + } + + +} diff --git a/Ocarina/TwitterCardInformation.swift b/Ocarina/TwitterCardInformation.swift new file mode 100644 index 0000000..4b428ae --- /dev/null +++ b/Ocarina/TwitterCardInformation.swift @@ -0,0 +1,112 @@ +// +// TwitterCardInformation.swift +// Ocarina +// +// Created by Rens Verhoeven on 15/05/2017. +// Copyright © 2017 awkward. All rights reserved. +// + +import Foundation +import AVFoundation + +public enum TwitterCardType: String { + case summary = "summary" + case summaryWithLargeImage = "summary_large_image" + case app = "app" + case player = "player" + case other = "other" + + public var minimumImageSize: CGSize? { + switch self { + case .summary: + return CGSize(width: 144, height: 144) + case .summaryWithLargeImage: + return CGSize(width: 300, height: 157) + case .player: + return CGSize(width: 350, height: 196) + default: + return nil + } + } +} + +/// A model containing twitter card information for a URL. +public class TwitterCardInformation: NSCoding { + + /// The contents of the twitter:url tag of the link. + public var url: URL? + + /// The contents of the twitter:title tag of the link. + public var title: String? + + /// The contents of the twitter:description tag of the link. + public var descriptionText: String? + + /// An URL to an image that was provided as the twitter:image tag. + /// The size/ratio can be estimated using the minimumImageSize on the card type. + public var imageURL: URL? + + /// The type of twitter card. + public var cardType: TwitterCardType + + /// The twitter account associated with the URL, without the @ prefix. Parsed from the `twitter:site` tag. + public var account: String? + + /// Create a new instance of TwitterCardInformation with the given URL and title + /// + /// - Parameters: + /// - html: The html of the page, this is used to search for (head) tags. + init?(html: HTMLDocument) { + guard html.head?.toHTML?.contains("\"twitter:") == true else { + return nil + } + if let typeString = html.xpath("/html/head/meta[(@property|@name)=\"og:type\"]/@content").first?.text { + self.cardType = TwitterCardType(rawValue: typeString) ?? TwitterCardType.other + } else { + self.cardType = .other + } + + if let urlString = html.xpath("/html/head/meta[(@property|@name)=\"twitter:url\"]/@content").first?.text { + self.url = URL(string: urlString) + } + + if let title = html.xpath("/html/head/meta[(@property|@name)=\"twitter:title\"]/@content").first?.text { + self.title = title + } + + if let descriptionText = html.xpath("/html/head/meta[(@property|@name)=\"twitter:description\"]/@content").first?.text { + self.descriptionText = descriptionText + } + + if let imageURLString = html.xpath("/html/head/meta[(@property|@name)=\"twitter:image\"]/@content").first?.text { + self.imageURL = URL(string: imageURLString) + } + + if let accountString = html.xpath("/html/head/meta[(@property|@name)=\"twitter:site\"]/@content").first?.text { + self.account = accountString.replacingOccurrences(of: "@", with: "") + } + } + + public required init?(coder aDecoder: NSCoder) { + self.url = aDecoder.decodeObject(forKey: "url") as? URL + self.title = aDecoder.decodeObject(forKey: "title") as? String + self.descriptionText = aDecoder.decodeObject(forKey: "description") as? String + self.imageURL = aDecoder.decodeObject(forKey: "imageURL") as? URL + self.account = aDecoder.decodeObject(forKey: "account") as? String + if let typeString = aDecoder.decodeObject(forKey: "cardType") as? String { + self.cardType = TwitterCardType(rawValue: typeString) ?? TwitterCardType.other + } else { + self.cardType = TwitterCardType.other + } + } + + public func encode(with aCoder: NSCoder) { + aCoder.encode(self.url, forKey: "url") + aCoder.encode(self.title, forKey: "title") + aCoder.encode(self.descriptionText, forKey: "description") + aCoder.encode(self.imageURL, forKey: "imageURL") + aCoder.encode(self.account, forKey: "account") + aCoder.encode(self.cardType.rawValue, forKey: "cardType") + } + +} diff --git a/Ocarina/URL+URLInformation.swift b/Ocarina/URL+URLInformation.swift new file mode 100644 index 0000000..80b2521 --- /dev/null +++ b/Ocarina/URL+URLInformation.swift @@ -0,0 +1,41 @@ +// +// URL+URLInformation.swift +// Ocarina +// +// Created by Rens Verhoeven on 15/02/2017. +// Copyright © 2017 awkward. All rights reserved. +// + +import Foundation + +/// The ocarine object that is a property on URL for easy access +public final class Ocarina { + public let url: URL + public init(_ url: URL) { + self.url = url + } +} + +extension URL { + + + /// The Ocarina object for this URL. Can be used to request information for this URL + public var oca: Ocarina { + return Ocarina(self) + } +} + + +public extension Ocarina { + + + /// Fetches information about the given URL + /// + /// - Parameter completionHandler: Called when the data is retreived or the request has been cancelled + /// - Returns: The request that is sheduled to be performed. Or nil if the request is invalid or already has cached data + @discardableResult + public func fetchInformation(completionHandler: @escaping InformationCompletionHandler) -> OcarinaInformationRequest? { + return OcarinaManager.shared.requestInformation(for: self.url, completionHandler: completionHandler) + } + +} diff --git a/Ocarina/URLInformation.swift b/Ocarina/URLInformation.swift new file mode 100644 index 0000000..6c49f72 --- /dev/null +++ b/Ocarina/URLInformation.swift @@ -0,0 +1,283 @@ +// +// URLInformation.swift +// Ocarina +// +// Created by Rens Verhoeven on 15/02/2017. +// Copyright © 2017 awkward. All rights reserved. +// + +import Foundation +import AVFoundation + +public enum URLInformationType: String { + + static let imageFileMimeTypes: [String] = ["image/bmp", + "image/x-windows-bmp", + "image/gif", "image/jpeg", + "image/pjpeg", + "image/x-icon", + "image/png", + "image/tiff", + "image/x-tiff"] + + static let documentFileMimeTypes: [String] = ["application/vnd.ms-powerpoint", + "application/mspowerpoint", + "application/mspowerpoint", + "application/x-mspowerpoint", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + "application/vnd.ms-excel.addin.macroEnabled.12", + "application/vnd.ms-excel", + "application/vnd.ms-excel.sheet.binary.macroEnabled.12", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.spreadsheetml.template", + "text/plain", + "application/rtf", + "application/x-rtf", + "text/richtext", + "application/pdf"] + + static let htmlFileMimeTypes: [String] = ["text/html", + "text/x-server-parsed-html"] + + static let archiveFileMimeTypes: [String] = ["application/x-compress", + "application/x-compressed", + "application/x-zip-compressed", + "application/zip", + "multipart/x-zip"] + + case music = "music" + case musicSong = "music.song" + case musicPlaylist = "music.playlist" + case musicAlbum = "music.album" + case musicRadioStation = "music.radio_station" + + case videoMovie = "video.movie" + case videoEpisode = "video.episode" + case videoTvShow = "video.tv_show" + case video = "video" + case article = "article" + case book = "book" + case profile = "profile" + case website = "website" + + case fileImage = "file.image" + case fileVideo = "file.video" + case fileAudio = "file.audio" + case fileDocument = "file.document" + case fileArchive = "file.archive" + case fileOther = "file.other" + + var isFileURL: Bool { + return self.rawValue.hasPrefix("file") + } + + static func type(for typeString: String) -> URLInformationType? { + if let type = URLInformationType(rawValue: typeString) { + return type + } + switch typeString { + case "music.other": + return .music + case "music.track", "song", "track": + return .musicSong + case "playlist": + return .musicPlaylist + case "album", "record": + return .musicAlbum + case "radio_station", "radio": + return .musicRadioStation + case "video.other": + return .video + case "movie", "film": + return .videoMovie + case "episode": + return .videoEpisode + case "tv_show", "tv_series": + return .videoTvShow + default: + return nil + } + } + + static func type(forMimeType mimeType: String) -> URLInformationType { + let audioFileMimeTypes = AVURLAsset.audiovisualMIMETypes().filter({ (type) -> Bool in + return type.hasPrefix("audio/") + }) + + if AVURLAsset.audiovisualMIMETypes().contains(mimeType) && !mimeType.hasPrefix("text/") { + //We have an audio or video URL! + + if audioFileMimeTypes.contains(mimeType) { + return URLInformationType.fileAudio + } else { + return URLInformationType.fileVideo + } + } else if self.imageFileMimeTypes.contains(mimeType) { + return URLInformationType.fileImage + } else if self.documentFileMimeTypes.contains(mimeType) { + return URLInformationType.fileDocument + } else if self.htmlFileMimeTypes.contains(mimeType) { + return URLInformationType.website + } else if self.archiveFileMimeTypes.contains(mimeType) { + return URLInformationType.fileArchive + } + return URLInformationType.fileOther + } + +} + +/// A model containing information about a URL +public class URLInformation: NSCoding, Equatable { + + /// The original URL the information was requested for. + public let originalURL: URL + + /// The contents of the og:url tag of the link. + /// If the Open Graph URL is not present, this will match the original or have the redirect URL if a redirect occured. + public let url: URL + + /// The contents of the og:title tag of the link. + /// If og:title is not present, there is a fallback to the `` html tag. + public var title: String? + + /// The contents of the og:description tag of the link. + /// If og:description is not present, there is a fallback to the `<meta type="description">` html tag. + public var descriptionText: String? + + /// An URL to an image that was provided as the og:image tag. + /// If no og:image tag is present, it falls back to the `<meta type="thumbnail">` html tag. + public var imageURL: URL? + + /// The possible size of the image from the imageURL property. This size is parsed from the `og:image:width` and `og:image:height`. + /// However since the implemenation of some websites doesn't follow the OGP standard, this size might be incorrect. + public var imageSize: CGSize? + + /// An URL to the Favicon image that was provided by the icon link tag. + /// Domains may also have a faveicon at `http://DOMAIN.TLD/favicon.ico`. However this property only checks for the tag in the head of a page. + /// You may still do a HEAD request to see if the icon is avaible at that URL. + public var faviconURL: URL? + + /// An URL to the Apple Touch Icon (Homescreen icon) that was provided by the apple-touch-icon tag. + /// Domains may also have a icon at `http://DOMAIN.TLD/apple-touch-icon.png`. However this property only checks for the tag in the head of a page. + /// You may still do a HEAD request to see if the icon is avaible at that URL. + /// This property ignores additional sizes. + public var appleTouchIconURL: URL? + + /// Twitter card information, if this is available. Can be used as a fallback in case some tags are missing. + public var twitterCard: TwitterCardInformation? + + /// The type of the content behind the URL, this is determented (in order) by the `og:type` tag or mimetype + public var type: URLInformationType + + /// Create a new instance of URLInformation with the given URL and title + /// + /// - Parameters: + /// - originalURL: The original URL the request was created with + /// - url: The URL which the information corrisponds to. This might be an redirected url. + /// - html: The html of the page, this is used to search for (head) tags. + /// - response: The HTTP response for the page, this includes the status code. + init?(originalURL: URL, url: URL, html: HTMLDocument?, response: HTTPURLResponse?) { + self.originalURL = originalURL + self.url = url + if let html = html { + + if let typeString = html.xpath("/html/head/meta[(@property|@name)=\"og:type\"]/@content").first?.text, let type = URLInformationType.type(for: typeString) { + self.type = type + } else { + self.type = .website + } + + if let title = html.xpath("/html/head/meta[(@property|@name)=\"og:title\"]/@content").first?.text { + self.title = title + } else if let title = html.title { + self.title = title + } + + if let descriptionText = html.xpath("/html/head/meta[(@property|@name)=\"og:description\"]/@content").first?.text { + self.descriptionText = descriptionText + } else if let descriptionText = html.xpath("/html/head/meta[(@property|@name)=\"description\"]/@content").first?.text { + self.descriptionText = descriptionText + } + + if let imageURLString = html.xpath("/html/head/meta[(@property|@name)=\"og:image\"]/@content").first?.text { + self.imageURL = URL(string: imageURLString, relativeTo: url) + } else if let imageURLString = html.xpath("/html/head/meta[(@property|@name)=\"thumbnail\"]/@content").first?.text { + self.imageURL = URL(string: imageURLString, relativeTo: url) + } + + if let imageWidthString = html.xpath("/html/head/meta[(@property|@name)=\"og:image:width\"]/@content").first?.text, + let imageHeightString = html.xpath("/html/head/meta[(@property|@name)=\"og:image:height\"]/@content").first?.text { + let imageWidth: CGFloat = CGFloat(Float(imageWidthString) ?? 0) + let imageHeight: CGFloat = CGFloat(Float(imageHeightString) ?? 0) + if imageWidth > 0 && imageHeight > 0 { + self.imageSize = CGSize(width: imageWidth, height: imageHeight) + } + } + + if let faviconURLString = html.xpath("/html/head/link[@rel=\"shortcut icon\"]/@href").first?.text { + self.faviconURL = URL(string: faviconURLString, relativeTo: url) + } else if let faviconURLString = html.xpath("/html/head/link[@rel=\"icon\"]/@href").first?.text { + self.faviconURL = URL(string: faviconURLString, relativeTo: url) + } + + if let appleTouchIconURLString = html.xpath("/html/head/link[@rel=\"apple-touch-icon\" and not(@sizes)]/@href").first?.text { + self.appleTouchIconURL = URL(string: appleTouchIconURLString, relativeTo: url) + } else if let appleTouchIconURLString = html.xpath("/html/head/link[@rel=\"apple-touch-icon-precomposed\" and not(@sizes)]/@href").first?.text { + self.appleTouchIconURL = URL(string: appleTouchIconURLString, relativeTo: url) + } + + self.twitterCard = TwitterCardInformation(html: html) + + } else { + //If the HTML is not available, we only determine the type based on the mime type + if let mimeType = response?.mimeType { + self.type = URLInformationType.type(forMimeType: mimeType) + } else { + self.type = .website + } + self.title = nil + self.descriptionText = nil + } + } + + public required init?(coder aDecoder: NSCoder) { + guard let originalURL = aDecoder.decodeObject(forKey: "originalURL") as? URL, let url = aDecoder.decodeObject(forKey: "url") as? URL else { + return nil + } + self.originalURL = originalURL + self.url = url + self.title = aDecoder.decodeObject(forKey: "title") as? String + self.descriptionText = aDecoder.decodeObject(forKey: "description") as? String + self.imageURL = aDecoder.decodeObject(forKey: "imageURL") as? URL + self.imageSize = aDecoder.decodeCGSize(forKey: "imageSize") + self.appleTouchIconURL = aDecoder.decodeObject(forKey: "appleTouchIconURL") as? URL + self.faviconURL = aDecoder.decodeObject(forKey: "faviconURL") as? URL + self.twitterCard = aDecoder.decodeObject(forKey: "twitterCard") as? TwitterCardInformation + if let typeString = aDecoder.decodeObject(forKey: "type") as? String { + self.type = URLInformationType(rawValue: typeString) ?? URLInformationType.website + } else { + self.type = URLInformationType.website + } + } + + public func encode(with aCoder: NSCoder) { + aCoder.encode(self.originalURL, forKey: "originalURL") + aCoder.encode(self.url, forKey: "url") + aCoder.encode(self.title, forKey: "title") + aCoder.encode(self.descriptionText, forKey: "description") + aCoder.encode(self.imageURL, forKey: "imageURL") + aCoder.encode(self.imageSize, forKey: "imageSize") + aCoder.encode(self.appleTouchIconURL, forKey: "appleTouchIconURL") + aCoder.encode(self.faviconURL, forKey: "faviconURL") + aCoder.encode(self.twitterCard, forKey: "twitterCard") + aCoder.encode(self.type.rawValue, forKey: "type") + + } + + public static func ==(lhs: URLInformation, rhs: URLInformation) -> Bool { + return lhs.url == rhs.url + } + +} diff --git a/Ocarina/URLInformationCache.swift b/Ocarina/URLInformationCache.swift new file mode 100644 index 0000000..965552a --- /dev/null +++ b/Ocarina/URLInformationCache.swift @@ -0,0 +1,36 @@ +// +// URLInformationCache.swift +// Ocarina +// +// Created by Rens Verhoeven on 15/02/2017. +// Copyright © 2017 awkward. All rights reserved. +// + +import Foundation + +/// The cache used by a OrcarinaManager to hold a cache of URLInformation +public class URLInformationCache { + + /// The internal NSCache used to cache the URLInformation + let cache = NSCache<NSURL, URLInformation>() + + public subscript(url: URL) -> URLInformation? { + get { + return self.cache.object(forKey: url as NSURL) + } + set { + guard let information = newValue else { + self.cache.removeObject(forKey: url as NSURL) + return + } + self.cache.setObject(information, forKey: url as NSURL) + } + } + + + /// Clears all the URLInformation models from the cache + public func clear() { + self.cache.removeAllObjects() + } + +} diff --git a/OcarinaTests/AdditionalParsingTests.swift b/OcarinaTests/AdditionalParsingTests.swift new file mode 100644 index 0000000..086f8e1 --- /dev/null +++ b/OcarinaTests/AdditionalParsingTests.swift @@ -0,0 +1,91 @@ +// +// AdditionalParsingTests.swift +// Ocarina +// +// Created by Rens Verhoeven on 16/02/2017. +// Copyright © 2017 awkward. All rights reserved. +// + +import XCTest +@testable import Ocarina + +/// This test checks if addtional parsing using the delegate works. +/// In the example we use Spotify which doesn't have any public OGP tags, so we determine the type based on the URL. +class AdditionalParsingTests: XCTestCase { + + override func setUp() { + super.setUp() + + OcarinaManager.shared.delegate = self + } + + override func tearDown() { + super.tearDown() + + OcarinaManager.shared.delegate = nil + OcarinaManager.shared.cache.clear() + } + + func testMusicPlaylistType() { + self.testTypeOfInformation(for: "http://www.deezer.com/playlist/68020160", expectedType: .musicPlaylist) + } + + func testSpotifyMusicSongType() { + self.testTypeOfInformation(for: "https://play.spotify.com/track/35uTIuGU2vTSjovoFLzul7?play=true&utm_source=open.spotify.com&utm_medium=open", expectedType: .musicSong) + } + + func testSpotifyMusicAlbumType() { + self.testTypeOfInformation(for: "https://play.spotify.com/album/5zFkQHvRimPKxjwDwkkeNL?play=true&utm_source=open.spotify.com&utm_medium=open", expectedType: .musicAlbum) + } + + func testSpotifuMusicPlaylistType() { + self.testTypeOfInformation(for: "https://play.spotify.com/user/thewhitehouse/playlist/3fAriv8eMWELCwbWrhMKy2", expectedType: .musicPlaylist) + } + + fileprivate func testTypeOfInformation(for urlString: String, expectedType: URLInformationType) { + guard let url = URL(string: urlString) else { + XCTAssert(false, "The given URLString is invalid") + return + } + + let expectation = self.expectation(description: "Information type is correct") + url.oca.fetchInformation { (information, error) in + XCTAssertNil(error, "An error occured fetching the information") + XCTAssertNotNil(information, "Information is missing") + if information?.type == expectedType { + expectation.fulfill() + } else { + XCTFail("Information type does not match expected type") + } + } + + self.waitForExpectations(timeout: 4) { (error) in + if let error = error { + XCTFail("Expectation Failed with error: \(error)"); + } + } + + } + +} + +extension AdditionalParsingTests: OcarinaManagerDelegate { + + func ocarinaManager(manager: OcarinaManager, doAdditionalParsingForInformation information: URLInformation, html: HTMLDocument?) -> URLInformation? { + let newInformation = information + + // Spotify redirects to a browser-not-supported url. So we use the original URL + if information.originalURL.host == "play.spotify.com" { + newInformation.title = "Spotify" + if information.originalURL.pathComponents.contains("track") { + newInformation.type = .musicSong + } else if information.originalURL.pathComponents.contains("album") { + newInformation.type = .musicAlbum + } else if information.originalURL.pathComponents.contains("playlist") { + newInformation.type = .musicPlaylist + } + } + return newInformation + } + +} diff --git a/OcarinaTests/CachingTests.swift b/OcarinaTests/CachingTests.swift new file mode 100644 index 0000000..3f906ae --- /dev/null +++ b/OcarinaTests/CachingTests.swift @@ -0,0 +1,137 @@ +// +// CachingTests.swift +// Ocarina +// +// Created by Rens Verhoeven on 03/04/2017. +// Copyright © 2017 awkward. All rights reserved. +// + +import XCTest +@testable import Ocarina + +class CachingTests: XCTestCase { + + func testGettingFromCache() { + guard let url = URL(string: "https://www.reddit.com") else { + XCTFail("Invalid URL") + return + } + + let expectation = self.expectation(description: "After getting a link once, information should be available in the cache.") + url.oca.fetchInformation { (information, error) in + XCTAssertNil(error, "An error occured fetching the information") + XCTAssertNotNil(information, "Information is missing") + + XCTAssert(information?.title?.characters.count ?? 0 > 0, "The article should have a title of at least 1 character.") + + if OcarinaManager.shared.cache[url]?.title?.characters.count ?? 0 > 0 { + expectation.fulfill() + } else { + XCTFail("Information should be in the cache.") + } + } + + self.waitForExpectations(timeout: 4) { (error) in + if let error = error { + XCTFail("Expectation Failed with error: \(error)"); + } + } + } + + func testSecondRequest() { + guard let url = URL(string: "https://www.reddit.com") else { + XCTFail("Invalid URL") + return + } + + let expectation = self.expectation(description: "After getting a link once, the next requests should also return data.") + url.oca.fetchInformation { (information, error) in + url.oca.fetchInformation { (information, error) in + XCTAssertNil(error, "An error occured fetching the information") + XCTAssertNotNil(information, "Information is missing") + + XCTAssert(information?.title?.characters.count ?? 0 > 0, "The article should have a title of at least 1 character.") + + if OcarinaManager.shared.cache[url]?.title?.characters.count ?? 0 > 0 { + expectation.fulfill() + } else { + XCTFail("Information should be in the cache.") + } + } + } + + self.waitForExpectations(timeout: 4) { (error) in + if let error = error { + XCTFail("Expectation Failed with error: \(error)"); + } + } + } + + func testRemovingFromCache() { + guard let url = URL(string: "https://www.reddit.com/r/worldnews") else { + XCTFail("Invalid URL") + return + } + + let expectation = self.expectation(description: "After getting a link once, information should be available in the cache.") + url.oca.fetchInformation { (information, error) in + XCTAssertNil(error, "An error occured fetching the information") + XCTAssertNotNil(information, "Information is missing") + + XCTAssert(information?.title?.characters.count ?? 0 > 0, "The article should have a title of at least 1 character.") + + if OcarinaManager.shared.cache[url]?.title?.characters.count ?? 0 > 0 { + OcarinaManager.shared.cache[url] = nil + if OcarinaManager.shared.cache[url] == nil { + expectation.fulfill() + } else { + XCTFail("Information should not be in the cache after removing.") + } + } else { + XCTFail("Information should be in the cache.") + } + } + + self.waitForExpectations(timeout: 4) { (error) in + if let error = error { + XCTFail("Expectation Failed with error: \(error)"); + } + } + } + + func testClearingCache() { + guard let url = URL(string: "https://www.reddit.com/r/zelda") else { + XCTFail("Invalid URL") + return + } + + let expectation = self.expectation(description: "After getting a link once, information should be available in the cache.") + url.oca.fetchInformation { (information, error) in + XCTAssertNil(error, "An error occured fetching the information") + XCTAssertNotNil(information, "Information is missing") + + XCTAssert(information?.title?.characters.count ?? 0 > 0, "The article should have a title of at least 1 character.") + + if OcarinaManager.shared.cache[url]?.title?.characters.count ?? 0 > 0 { + OcarinaManager.shared.cache.clear() + + if OcarinaManager.shared.cache[url] == nil { + expectation.fulfill() + } else { + XCTFail("Information should not be in the cache after clearing the cache.") + } + } else { + XCTFail("Information should be in the cache.") + } + } + + self.waitForExpectations(timeout: 4) { (error) in + if let error = error { + XCTFail("Expectation Failed with error: \(error)"); + } + } + } + + + +} diff --git a/OcarinaTests/Info.plist b/OcarinaTests/Info.plist new file mode 100644 index 0000000..21d7e0b --- /dev/null +++ b/OcarinaTests/Info.plist @@ -0,0 +1,27 @@ +<?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>BNDL</string> + <key>CFBundleShortVersionString</key> + <string>1.0</string> + <key>CFBundleVersion</key> + <string>1</string> + <key>NSAppTransportSecurity</key> + <dict> + <key>NSAllowsArbitraryLoads</key> + <true/> + </dict> +</dict> +</plist> diff --git a/OcarinaTests/InformationFetchingTests.swift b/OcarinaTests/InformationFetchingTests.swift new file mode 100644 index 0000000..f01e399 --- /dev/null +++ b/OcarinaTests/InformationFetchingTests.swift @@ -0,0 +1,155 @@ +// +// InformationFetchingTests.swift +// Ocarina +// +// Created by Rens Verhoeven on 03/04/2017. +// Copyright © 2017 awkward. All rights reserved. +// + +import XCTest +@testable import Ocarina + +class InformationFetchingTests: XCTestCase { + + override func setUp() { + super.setUp() + + } + + override func tearDown() { + super.tearDown() + + OcarinaManager.shared.cache.clear() + } + + /// Tests an URL that supports Open Graph Data. + func testInformationFetchingWithOGP() { + guard let url = URL(string: "https://www.nytimes.com/interactive/2017/04/02/technology/uber-drivers-psychological-tricks.html") else { + XCTFail("Invalid URL") + return + } + + let expectation = self.expectation(description: "The new york times article should have some basic information.") + url.oca.fetchInformation { (information, error) in + XCTAssertNil(error, "An error occured fetching the information") + XCTAssertNotNil(information, "Information is missing") + + XCTAssert(information?.type == .article, "The link should be of type article.") + XCTAssert(information?.title?.characters.count ?? 0 > 0, "The article should have a title of at least 1 character.") + XCTAssert(information?.descriptionText?.characters.count ?? 0 > 0, "The article should have a description of at least 1 character.") + XCTAssert(information?.imageURL != nil, "The article should have an image.") + XCTAssert(information?.faviconURL != nil, "The link should have a favicon.") + XCTAssert(information?.appleTouchIconURL != nil, "The link should have a apple touch icon.") + + expectation.fulfill() + } + + self.waitForExpectations(timeout: 4) { (error) in + if let error = error { + XCTFail("Expectation Failed with error: \(error)"); + } + } + } + + /// Tests an URL that doesn't have OGP data, but does has the default title and description. + func testInformationFetchingWithoutOGP() { + guard let url = URL(string: "https://www.reddit.com") else { + XCTFail("Invalid URL") + return + } + + let expectation = self.expectation(description: "This link should have basic information but, not from OGP") + url.oca.fetchInformation { (information, error) in + XCTAssertNil(error, "An error occured fetching the information") + XCTAssertNotNil(information, "Information is missing") + + XCTAssert(information?.type == .website, "The link should be of type website.") + XCTAssert(information?.title?.characters.count ?? 0 > 0, "The article should have a title of at least 1 character.") + XCTAssert(information?.descriptionText?.characters.count ?? 0 > 0, "The article should have a description of at least 1 character.") + XCTAssert(information?.imageURL == nil, "The link shouldn't have an image.") + XCTAssert(information?.faviconURL != nil, "The link should have a favicon.") + XCTAssert(information?.appleTouchIconURL != nil, "The link should have a apple touch icon.") + + expectation.fulfill() + + } + + self.waitForExpectations(timeout: 4) { (error) in + if let error = error { + XCTFail("Expectation Failed with error: \(error)"); + } + } + } + + /// Tests the fetching of information for a file. This should only give a type of no custom parsing is given. + func testFileInformationFetching() { + guard let url = URL(string: "https://www.nintendo.com/consumer/downloads/WiiOpMn_setup.pdf") else { + XCTFail("Invalid URL") + return + } + + let expectation = self.expectation(description: "The file should have a file URL and be of type file.") + url.oca.fetchInformation { (information, error) in + XCTAssertNil(error, "An error occured fetching the information") + XCTAssertNotNil(information, "Information is missing") + + XCTAssert(information?.type == .fileDocument, "The link should be of type file document.") + XCTAssert(information?.type.isFileURL == true, "The information type should be a file URL") + + expectation.fulfill() + + } + + self.waitForExpectations(timeout: 4) { (error) in + if let error = error { + XCTFail("Expectation Failed with error: \(error)"); + } + } + } + + /// Tests the cancelling of a request for a URL. + func testCancelingInformationRequest() { + guard let url = URL(string: "https://www.awkward.co") else { + XCTFail("Invalid URL") + return + } + + let expectation = self.expectation(description: "The request should immediately be cancelled") + let request = url.oca.fetchInformation { (information, error) in + XCTAssert(information == nil, "A cancelled request shouldn't have information.") + XCTAssert(error == nil, "A cancelled request shouldn't have an error.") + + expectation.fulfill() + } + request?.cancel() + + self.waitForExpectations(timeout: 4) { (error) in + if let error = error { + XCTFail("Expectation Failed with error: \(error)"); + } + } + } + + /// Tests an URL that doesn't exist, because the TLD doesn't exist. + func testErroringInformationRequest() { + guard let url = URL(string: "https://www.awkward.tablechairchees") else { + XCTFail("Invalid URL") + return + } + + let expectation = self.expectation(description: "The request should end in a error because the TLD doesn't exist.") + url.oca.fetchInformation { (information, error) in + XCTAssert(information == nil, "The request shouldn't have information.") + XCTAssert(error != nil, "The request should have an error.") + + expectation.fulfill() + } + + self.waitForExpectations(timeout: 4) { (error) in + if let error = error { + XCTFail("Expectation Failed with error: \(error)"); + } + } + } + +} diff --git a/OcarinaTests/PrefetcherTests.swift b/OcarinaTests/PrefetcherTests.swift new file mode 100644 index 0000000..93fec88 --- /dev/null +++ b/OcarinaTests/PrefetcherTests.swift @@ -0,0 +1,65 @@ +// +// PrefetcherTests.swift +// Ocarina +// +// Created by Rens Verhoeven on 03/04/2017. +// Copyright © 2017 awkward. All rights reserved. +// + +import XCTest +@testable import Ocarina + +class PrefetcherTests: XCTestCase { + + func testPrefetcher() { + let urls = ["http://youtube.com", "http://reddit.com", "http://apple.com", "http://twitter.com", "http://awkward.co"].flatMap { (string) -> URL? in + return URL(string: string) + } + + let expectation = self.expectation(description: "Link prefetcher shouldn't have any errors fetching links") + _ = OcarinaPrefetcher(urls: urls, manager: nil) { (errors) in + XCTAssert(errors.count == 0, "There shouldn't be any errors pre-fetching the URls") + + if OcarinaManager.shared.cache[urls.first!]?.title?.characters.count ?? 0 > 0 { + expectation.fulfill() + } else { + XCTFail("Information is missing from the cache after pre-fetching") + } + } + + self.waitForExpectations(timeout: 4) { (error) in + if let error = error { + XCTFail("Expectation Failed with error: \(error)"); + } + } + } + + func testPrefetcherCancel() { + let urls = ["http://youtube.com", "http://reddit.com/r/nintendo", "http://apple.com", "http://twitter.com", "http://awkward.co"].flatMap { (string) -> URL? in + return URL(string: string) + } + + let fetcher = OcarinaPrefetcher(urls: urls, manager: nil) + fetcher.cancel() + } + + func testPrefetcherWithError() { + let urls = ["http://youtube.com","http://reddit.table.chair", "http://reddit.com", "http://apple.com", "http://twitter.com", "http://awkward.co"].flatMap { (string) -> URL? in + return URL(string: string) + } + + let expectation = self.expectation(description: "Link prefetcher shouldn't have any errors fetching links") + _ = OcarinaPrefetcher(urls: urls, manager: nil) { (errors) in + XCTAssert(errors.count == 1, "There should be one error pre-fetching the URLs") + + expectation.fulfill() + } + + self.waitForExpectations(timeout: 4) { (error) in + if let error = error { + XCTFail("Expectation Failed with error: \(error)"); + } + } + } + +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b66d62 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# Ocarina + +A library to receive metadata and Open Graph information from URLs. + +# Introduction + +Hi, we're Awkward. We were looking for a way to visualize information behind links to present these in our iOS reddit client called Beam. We initially used a server to receive metadata, but the server became quite crowded with calls. We built Ocarina as a solution to that problem. Fallbacks for basic HTML tags and Twitter card information make this metadata fetcher unique. We welcome you to use Ocarina for your own projects. + +# Features + + +- Fetching of basic metadata for individual links using the OGP protocol or basic HTML tags (twitter card information also available) +- Memory cache of metadata for each link +- Prefetching a set of links to make views more responsive +- Link information can include: type, title, description, image, image size, favicon, and Apple touch icon + +# Installation + + +1. Drag Ocarina.xcodeproj into your project +2. Go to your project +3. Select Build Phases +4. Under Embed frameworks, press + and select Ocarina.framework +5. Select Build Settings +6. Search for `Other Linker Flags` and add `-lxml2` + +# Usage + +### Fetching information for a single link + +```Swift +let url = URL(string: "https://awkward.co")! +link.oca.fetchInformation(completionHandler: { (information, error) in + if let information = information { + print(String(describing: information.title)) + } else if let error = error { + print(String(describing: error)) + } + +}) +``` + +### Prefetching multiple links + +OcarinaPrefetcher allows prefetching links into the cache, this allows for the UI to look more responsive. + +```Swift +let urls = [ + URL(string: "https://awkward.co")!, + URL(string: "https://facebook.com")!, + URL(string: "https://nytimes.com")!, + URL(string: "https://latimes.com")! +] +let prefetcher = OcarinaPrefetcher(urls: urls, completionHandler: { (errors) in§ + print("Done pre-fetching links") +}) +``` + +For other uses, see the example project + +# Contributing + +Contributing is easy. If you want to report an error of any kind, please create an issue. If you want to propose a change, a pull request is the right way to go. + +# License + + +> Ocarina is available under the MIT license. See the LICENSE file for more info. + +# Links + + - Awkward + - Beam diff --git a/SwiftLibXML2/libxml2-kanna.h b/SwiftLibXML2/libxml2-kanna.h new file mode 100755 index 0000000..15dae3f --- /dev/null +++ b/SwiftLibXML2/libxml2-kanna.h @@ -0,0 +1,3 @@ +#import <libxml2/libxml/HTMLtree.h> +#import <libxml2/libxml/xpath.h> +#import <libxml2/libxml/xpathInternals.h> diff --git a/SwiftLibXML2/module.modulemap b/SwiftLibXML2/module.modulemap new file mode 100755 index 0000000..ec3dcef --- /dev/null +++ b/SwiftLibXML2/module.modulemap @@ -0,0 +1,6 @@ +module SwiftLibXML2 [system] { + link "xml2" + umbrella header "libxml2-kanna.h" + export * + module * { export * } +}