├── .ruby-version ├── .bundle └── config ├── Sources ├── WordPressShared │ ├── Resources │ │ └── Languages.json │ ├── Utility │ │ ├── String+StripShortcodes.swift │ │ ├── NSMutableData+Helpers.swift │ │ ├── CollectionType+Helpers.swift │ │ ├── String+URLValidation.swift │ │ ├── ConsoleLogger.swift │ │ ├── String+RemovingMatches.swift │ │ ├── Dictionary+Helpers.swift │ │ ├── NSBundle+WordPressShared.swift │ │ ├── NSString+Swift.swift │ │ ├── String+StripGutenbergContentForExcerpt.swift │ │ ├── Secret.swift │ │ ├── NSString+Summary.swift │ │ ├── Debouncer.swift │ │ ├── WPImageURLHelper.swift │ │ ├── EmailFormatValidator.swift │ │ ├── EmailTypoChecker.swift │ │ ├── String+Helpers.swift │ │ ├── Languages.swift │ │ └── NSDate+Helpers.swift │ ├── WordPressShared.swift │ ├── Analytics │ │ └── AnalyticsEvent.swift │ └── Views │ │ └── WPStyleGuide+SerifFonts.swift └── WordPressSharedObjC │ ├── include │ ├── UIDevice+Helpers.h │ ├── DateUtils.h │ ├── NSBundle+VersionNumberHelper.h │ ├── NSString+Util.h │ ├── NSString+XMLExtensions.h │ ├── WPTableViewCell.h │ ├── WordPressLoggingDelegate.h │ ├── WPTextFieldTableViewCell.h │ ├── WPNUXUtility.h │ ├── WPFontManager.h │ ├── NSString+Helpers.h │ ├── WPMapFilterReduce.h │ ├── WPSharedLogging.h │ ├── DisplayableImageHelper.h │ ├── PhotonImageURLHelper.h │ ├── WPDeviceIdentification.h │ └── WPStyleGuide.h │ ├── Resources │ ├── NotoSerif-Bold.ttf │ ├── NotoSerif-Italic.ttf │ ├── NotoSerif-Regular.ttf │ └── NotoSerif-BoldItalic.ttf │ ├── Private │ └── WPShared-Swift.h │ ├── Utility │ ├── NSBundle+VersionNumberHelper.m │ ├── NSString+Util.m │ ├── WPMapFilterReduce.m │ ├── UIDevice+Helpers.m │ ├── DateUtils.m │ ├── PhotonImageURLHelper.m │ ├── WPFontManager.m │ ├── WPDeviceIdentification.m │ └── DisplayableImageHelper.m │ ├── WordPressShared.h │ ├── Views │ ├── WPTableViewCell.m │ ├── WPNUXUtility.m │ └── WPTextFieldTableViewCell.m │ ├── Logging │ └── WPSharedLogging.m │ └── Analytics │ └── WPAnalytics.m ├── Tests ├── WordPressSharedObjCTests │ ├── Resources │ │ ├── test-image.jpg │ │ └── anim-reader.gif │ ├── WordPressSharedTests-Bridging-Header.h │ ├── LoggingTests.m │ ├── PhotonImageURLHelperTest.m │ ├── WPMapFilterReduceTest.m │ ├── NSStringSwiftTests.m │ ├── DisplayableImageHelperTest.m │ └── NSStringHelpersTests.m └── WordPressSharedTests │ ├── DictionaryHelpersTests.swift │ ├── SecretTests.swift │ ├── StringURLValidationTests.swift │ ├── StringRemovingMatchesTests.swift │ ├── StringHelperTests.swift │ ├── EmailFormatValidatorTests.swift │ ├── EmailTypoCheckerTests.swift │ ├── LoggingTests.swift │ ├── NSStringSummaryTests.swift │ ├── DebouncerTests.swift │ ├── NSDateHelperTest.swift │ ├── LanguagesTests.swift │ ├── StringStripGutenbergContentForExcerptTests.swift │ └── RichContentFormatterTests.swift ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── run-danger.yml ├── Gemfile ├── .rubocop.yml ├── README.md ├── Dangerfile ├── .buildkite ├── commands │ └── publish-pod.sh └── pipeline.yml ├── fastlane └── Fastfile ├── .gitignore ├── WordPressShared.podspec ├── CHANGELOG.md ├── Package.swift ├── Package.resolved └── .swiftlint.yml /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.2 2 | -------------------------------------------------------------------------------- /.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_PATH: "vendor/bundle" 3 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Resources/Languages.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wordpress-mobile/WordPress-iOS-Shared/HEAD/Sources/WordPressShared/Resources/Languages.json -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/include/UIDevice+Helpers.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | @interface UIDevice (Helpers) 4 | - (NSString *)wordPressIdentifier; 5 | @end 6 | -------------------------------------------------------------------------------- /Tests/WordPressSharedObjCTests/Resources/test-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wordpress-mobile/WordPress-iOS-Shared/HEAD/Tests/WordPressSharedObjCTests/Resources/test-image.jpg -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/Resources/NotoSerif-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wordpress-mobile/WordPress-iOS-Shared/HEAD/Sources/WordPressSharedObjC/Resources/NotoSerif-Bold.ttf -------------------------------------------------------------------------------- /Tests/WordPressSharedObjCTests/Resources/anim-reader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wordpress-mobile/WordPress-iOS-Shared/HEAD/Tests/WordPressSharedObjCTests/Resources/anim-reader.gif -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/Resources/NotoSerif-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wordpress-mobile/WordPress-iOS-Shared/HEAD/Sources/WordPressSharedObjC/Resources/NotoSerif-Italic.ttf -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/Resources/NotoSerif-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wordpress-mobile/WordPress-iOS-Shared/HEAD/Sources/WordPressSharedObjC/Resources/NotoSerif-Regular.ttf -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/Resources/NotoSerif-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wordpress-mobile/WordPress-iOS-Shared/HEAD/Sources/WordPressSharedObjC/Resources/NotoSerif-BoldItalic.ttf -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | 4 | - [ ] I have considered if this change warrants release notes and have added them to the appropriate section in the `CHANGELOG.md` if necessary. 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'cocoapods', '~> 1.11' 6 | gem 'cocoapods-check', '~> 1.1' 7 | gem 'danger-dangermattic', '~> 1.0' 8 | gem 'fastlane', '~> 2.189' 9 | -------------------------------------------------------------------------------- /Tests/WordPressSharedObjCTests/WordPressSharedTests-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // Use this file to import your target's public headers that you would like to expose to Swift. 3 | // 4 | 5 | #import "TestAnalyticsTracker.h" 6 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/include/DateUtils.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | @interface DateUtils : NSObject 4 | 5 | + (NSDate *)dateFromISOString:(NSString *)isoString; 6 | + (NSString *)isoStringFromDate:(NSDate *)date; 7 | 8 | @end 9 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/include/NSBundle+VersionNumberHelper.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | @interface NSBundle (VersionNumberHelper) 4 | 5 | - (NSString *)detailedVersionNumber; 6 | - (NSString *)shortVersionString; 7 | - (NSString *)bundleVersion; 8 | 9 | @end 10 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # Opt in to new cops by default 2 | AllCops: 3 | NewCops: enable 4 | 5 | # Allow the Podspec filename to match the project 6 | Naming/FileName: 7 | Exclude: 8 | - 'WordPressShared.podspec' 9 | 10 | # Override the maximum line length 11 | Layout/LineLength: 12 | Max: 160 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | WordPress-iOS-Shared 2 | ====================== 3 | 4 | This project contains shared items between the WordPress-iOS application and other components. This is a work in progress. 5 | 6 | 7 | ### Issues 8 | Please log any issues in the main [WordPress-iOS repository](https://github.com/wordpress-mobile/WordPress-iOS/issues). 9 | -------------------------------------------------------------------------------- /.github/workflows/run-danger.yml: -------------------------------------------------------------------------------- 1 | name: ☢️ Danger 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened, ready_for_review, synchronize] 6 | 7 | jobs: 8 | dangermattic: 9 | uses: Automattic/dangermattic/.github/workflows/reusable-run-danger.yml@v1.0.0 10 | secrets: 11 | github-token: ${{ secrets.DANGERMATTIC_GITHUB_TOKEN }} 12 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/include/NSString+Util.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | 4 | @interface NSString (Util) 5 | 6 | - (bool)isEmpty; 7 | - (NSString *)trim; 8 | - (NSNumber *)numericValue; 9 | - (CGSize)suggestedSizeWithFont:(UIFont *)font width:(CGFloat)width; 10 | 11 | @end 12 | 13 | @interface NSObject (NumericValueHack) 14 | - (NSNumber *)numericValue; 15 | @end -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/include/NSString+XMLExtensions.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | @interface NSString (XMLExtensions) 4 | 5 | + (NSString *)encodeXMLCharactersIn : (NSString *)source; 6 | + (NSString *)decodeXMLCharactersIn : (NSString *)source; 7 | - (NSString *)stringByDecodingXMLCharacters; 8 | - (NSString *)stringByEncodingXMLCharacters; 9 | 10 | @end 11 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Utility/String+StripShortcodes.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | 5 | /// Creates a new string by stripping all shortcodes from this string. 6 | /// 7 | func strippingShortcodes() -> String { 8 | let pattern = "\\[[^\\]]+\\]" 9 | 10 | return removingMatches(pattern: pattern, options: .caseInsensitive) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/include/WPTableViewCell.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | extern CGFloat const WPTableViewFixedWidth; 4 | 5 | @interface WPTableViewCell : UITableViewCell 6 | 7 | /** 8 | Temporary flag for enabling the margins hack on cells, while views adopt readable margins. 9 | Note: Defaults to NO. 10 | */ 11 | @property (nonatomic, assign) BOOL forceCustomCellMargins; 12 | 13 | @end 14 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/include/WordPressLoggingDelegate.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | NS_ASSUME_NONNULL_BEGIN 4 | 5 | @protocol WordPressLoggingDelegate 6 | 7 | - (void)logError:(NSString *)str; 8 | - (void)logWarning:(NSString *)str; 9 | - (void)logInfo:(NSString *)str; 10 | - (void)logDebug:(NSString *)str; 11 | - (void)logVerbose:(NSString *)str; 12 | 13 | @end 14 | 15 | NS_ASSUME_NONNULL_END 16 | -------------------------------------------------------------------------------- /Dangerfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | github.dismiss_out_of_range_messages 4 | 5 | # `files: []` forces rubocop to scan all files, not just the ones modified in the PR 6 | rubocop.lint(files: [], force_exclusion: true, inline_comment: true, fail_on_inline_comment: true, include_cop_names: true) 7 | 8 | manifest_pr_checker.check_all_manifest_lock_updated 9 | 10 | pr_size_checker.check_diff_size( 11 | max_size: 300, 12 | type: :insertions 13 | ) 14 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/Private/WPShared-Swift.h: -------------------------------------------------------------------------------- 1 | // This header is not needed for SPM. 2 | #if !defined(SWIFT_PACKAGE) 3 | 4 | // Import this header instead of 5 | // This allows the pod to be built as a static or dynamic framework 6 | // See https://github.com/CocoaPods/CocoaPods/issues/7594 7 | #if __has_include("WordPressShared-Swift.h") 8 | #import "WordPressShared-Swift.h" 9 | #else 10 | #import 11 | #endif 12 | 13 | #endif /* !defined(SWIFT_PACKAGE) */ 14 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Utility/NSMutableData+Helpers.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | /// Encapsulates all of the NSMutableData Helper Methods. 5 | /// 6 | extension NSMutableData { 7 | 8 | /// Encodes a raw String into UTF8, and appends it to the current instance. 9 | /// 10 | /// - Parameter string: The raw String to be UTF8-Encoded, and appended 11 | /// 12 | @objc public func appendString(_ string: String) { 13 | if let data = string.data(using: String.Encoding.utf8) { 14 | append(data) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.buildkite/commands/publish-pod.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | PODSPEC_PATH="WordPressShared.podspec" 4 | SPECS_REPO="git@github.com:wordpress-mobile/cocoapods-specs.git" 5 | SLACK_WEBHOOK=$PODS_SLACK_WEBHOOK 6 | 7 | echo "--- :rubygems: Setting up Gems" 8 | install_gems 9 | 10 | echo "--- :cocoapods: Publishing Pod to CocoaPods CDN" 11 | publish_pod --patch-cocoapods $PODSPEC_PATH 12 | 13 | echo "--- :cocoapods: Publishing Pod to WP Specs Repo" 14 | publish_private_pod --patch-cocoapods $PODSPEC_PATH $SPECS_REPO "$SPEC_REPO_PUBLIC_DEPLOY_KEY" 15 | 16 | echo "--- :slack: Notifying Slack" 17 | slack_notify_pod_published $PODSPEC_PATH "$SLACK_WEBHOOK" 18 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/include/WPTextFieldTableViewCell.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import "WPTableViewCell.h" 3 | 4 | @class WPTextFieldTableViewCell; 5 | 6 | @protocol WPTextFieldTableViewCellDelegate 7 | 8 | - (void)cellWantsToSelectNextField:(WPTextFieldTableViewCell *)cell; 9 | @optional 10 | - (void)cellTextDidChange:(WPTextFieldTableViewCell *)cell; 11 | 12 | @end 13 | 14 | @interface WPTextFieldTableViewCell : WPTableViewCell 15 | 16 | @property (nonatomic, strong, readonly) UITextField *textField; 17 | @property (nonatomic, assign) BOOL shouldDismissOnReturn; 18 | @property (nonatomic, weak) id delegate; 19 | 20 | @end 21 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Utility/CollectionType+Helpers.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | // MARK: - Collection Type Helpers 5 | // 6 | extension BidirectionalCollection { 7 | public func lastIndex(where predicate: (Self.Iterator.Element) throws -> Bool) rethrows -> Self.Index? { 8 | if let idx = try reversed().firstIndex(where: predicate) { 9 | return self.index(before: idx.base) 10 | } 11 | return nil 12 | } 13 | } 14 | 15 | extension Collection { 16 | /// Returns the element at the specified index if it is within bounds, otherwise nil. 17 | /// 18 | public subscript (safe index: Index) -> Element? { 19 | return indices.contains(index) ? self[index] : nil 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Utility/String+URLValidation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | 5 | /// This method can be used to check if the string contains a valid URL. 6 | /// 7 | /// - Returns: `true` if the string contains a valid string. `false` otherwise. 8 | /// 9 | public func isValidURL() -> Bool { 10 | let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) 11 | 12 | if let match = detector.firstMatch(in: self, options: [], range: NSRange(location: 0, length: self.utf16.count)) { 13 | // it is a link, if the match covers the whole string 14 | return match.range.length == self.utf16.count 15 | } else { 16 | return false 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Utility/ConsoleLogger.swift: -------------------------------------------------------------------------------- 1 | /// A `WordPressLoggingDelegate` implementation that logs to the Xcode console via `print`. 2 | /// Useful for development or debugging. Not recommended in release builds. 3 | public class ConsoleLogger: NSObject, WordPressLoggingDelegate { 4 | 5 | public func logError(_ str: String) { 6 | NSLog("❌ – Error: \(str)") 7 | } 8 | 9 | public func logWarning(_ str: String) { 10 | NSLog("⚠️ – Warning: \(str)") 11 | } 12 | 13 | public func logInfo(_ str: String) { 14 | NSLog("ℹ️ – Info: \(str)") 15 | } 16 | 17 | public func logDebug(_ str: String) { 18 | NSLog("🔎 – Debug: \(str)") 19 | } 20 | 21 | public func logVerbose(_ str: String) { 22 | NSLog("📃 – Verbose: \(str)") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | default_platform(:ios) 4 | 5 | SWIFTLINT_PLUGIN_VALIDATION_BYPASS_XCARGS = '-skipPackagePluginValidation -skipMacroValidation' 6 | 7 | platform :ios do 8 | desc 'Builds the project and runs tests' 9 | lane :test do 10 | xcodebuild( 11 | scheme: 'WordPressShared', 12 | xcargs: "-resolvePackageDependencies #{SWIFTLINT_PLUGIN_VALIDATION_BYPASS_XCARGS}" 13 | ) 14 | run_tests( 15 | package_path: '.', 16 | scheme: 'WordPressShared', 17 | device: 'iPhone 14', 18 | prelaunch_simulator: true, 19 | buildlog_path: File.join(__dir__, '.build', 'logs'), 20 | derived_data_path: File.join(__dir__, '.build', 'derived-data'), 21 | xcargs: SWIFTLINT_PLUGIN_VALIDATION_BYPASS_XCARGS 22 | ) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Xcode 4 | # 5 | .build/ 6 | build/ 7 | *.pbxuser 8 | !default.pbxuser 9 | *.mode1v3 10 | !default.mode1v3 11 | *.mode2v3 12 | !default.mode2v3 13 | *.perspectivev3 14 | !default.perspectivev3 15 | xcuserdata 16 | *.xccheckout 17 | *.moved-aside 18 | DerivedData 19 | *.hmap 20 | *.ipa 21 | *.xcuserstate 22 | 23 | # CocoaPods 24 | # 25 | # We recommend against adding the Pods directory to your .gitignore. However 26 | # you should judge for yourself, the pros and cons are mentioned at: 27 | # http://guides.cocoapods.org/using/using-cocoapods.html#should-i-ignore-the-pods-directory-in-source-control? 28 | # 29 | Pods/ 30 | 31 | vendor/ 32 | 33 | fastlane/test_output 34 | fastlane/README.md 35 | fastlane/report.xml 36 | 37 | .swiftpm 38 | 39 | # SwiftLint Remote Config Cache 40 | .swiftlint/RemoteConfigCache -------------------------------------------------------------------------------- /Sources/WordPressShared/WordPressShared.swift: -------------------------------------------------------------------------------- 1 | #if SWIFT_PACKAGE 2 | 3 | @_exported import WordPressSharedObjC 4 | 5 | #endif 6 | 7 | func WPSharedLogError(_ format: String, _ arguments: CVarArg...) { 8 | withVaList(arguments) { WPSharedLogvError(format, $0) } 9 | } 10 | 11 | func WPSharedLogWarning(_ format: String, _ arguments: CVarArg...) { 12 | withVaList(arguments) { WPSharedLogvWarning(format, $0) } 13 | } 14 | 15 | func WPSharedLogInfo(_ format: String, _ arguments: CVarArg...) { 16 | withVaList(arguments) { WPSharedLogvInfo(format, $0) } 17 | } 18 | 19 | func WPSharedLogDebug(_ format: String, _ arguments: CVarArg...) { 20 | withVaList(arguments) { WPSharedLogvDebug(format, $0) } 21 | } 22 | 23 | func WPSharedLogVerbose(_ format: String, _ arguments: CVarArg...) { 24 | withVaList(arguments) { WPSharedLogvVerbose(format, $0) } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Utility/String+RemovingMatches.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if SWIFT_PACKAGE 4 | import WordPressSharedObjC 5 | #endif 6 | 7 | extension String { 8 | 9 | /// Creates a new string by removing all matches of the specified regex. 10 | /// 11 | func removingMatches(pattern: String, options: NSRegularExpression.Options = []) -> String { 12 | let range = NSRange(location: 0, length: self.utf16.count) 13 | let regex: NSRegularExpression 14 | 15 | do { 16 | regex = try NSRegularExpression(pattern: pattern, options: options) 17 | } catch { 18 | WPSharedLogError("Error parsing regex: \(error)") 19 | return self 20 | } 21 | 22 | return regex.stringByReplacingMatches(in: self, options: .reportCompletion, range: range, withTemplate: "") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/WordPressSharedTests/DictionaryHelpersTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | @testable import WordPressShared 4 | 5 | // MARK: - DictionaryHelpersTests 6 | // 7 | class DictionaryHelpersTests: XCTestCase { 8 | func testValueAsStringReturnsTheExpectedStringWhenTheValueIsEffectivelyAsString() { 9 | let dictionary = [ 10 | "key": "value!" 11 | ] 12 | 13 | let retrieved = dictionary.valueAsString(forKey: "key") 14 | XCTAssertNotNil(retrieved) 15 | XCTAssertEqual(retrieved, "value!") 16 | } 17 | 18 | func testValueAsStringReturnsTheExpectedStringWhenTheValueIsNumeric() { 19 | let dictionary = [ 20 | "key": 1234 21 | ] 22 | 23 | let retrieved = dictionary.valueAsString(forKey: "key") 24 | XCTAssertNotNil(retrieved) 25 | XCTAssertEqual(retrieved, "1234") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Tests/WordPressSharedTests/SecretTests.swift: -------------------------------------------------------------------------------- 1 | import WordPressShared 2 | import XCTest 3 | 4 | class SecretTests: XCTestCase { 5 | func testSecretDescription() { 6 | let secret = Secret("my secret") 7 | XCTAssertEqual("--redacted--", secret.description, "Description should be redacted") 8 | } 9 | 10 | func testSecretDebugDescription() { 11 | let secret = Secret("my secret") 12 | XCTAssertEqual("--redacted--", secret.debugDescription, "Debug description should be redacted") 13 | } 14 | 15 | func testSecretMirror() { 16 | let secret = Secret("my secret") 17 | XCTAssertEqual("--redacted--", String(reflecting: secret), "Mirror should be redacted") 18 | } 19 | 20 | func testSecretUnwrapsValue() { 21 | let secret = Secret("my secret") 22 | XCTAssertEqual("my secret", secret.secretValue, "secretValue should not be redacted") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Utility/Dictionary+Helpers.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | // MARK: - Dictionary Helper Methods 5 | // 6 | extension Dictionary { 7 | /// This method attempts to convert a given value into a String, if it's not already the 8 | /// case. Initial implementation supports only NSNumber. This is meant for bulletproof parsing, 9 | /// in which a String value might be serialized, backend side, as a Number. 10 | /// 11 | /// - Parameter key: The key to retrieve. 12 | /// 13 | /// - Returns: Value as a String (when possible!) 14 | /// 15 | public func valueAsString(forKey key: Key) -> String? { 16 | let value = self[key] 17 | switch value { 18 | case let string as String: 19 | return string 20 | case let number as NSNumber: 21 | return number.description 22 | default: 23 | return nil 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/include/WPNUXUtility.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | @interface WPNUXUtility : NSObject 4 | 5 | + (UIFont *)textFieldFont; 6 | + (UIFont *)descriptionTextFont; 7 | + (UIFont *)titleFont; 8 | + (UIFont *)swipeToContinueFont; 9 | + (UIFont *)tosLabelFont; 10 | + (UIFont *)confirmationLabelFont; 11 | + (UIFont *)tosLabelSmallerFont; 12 | 13 | + (UIColor *)bottomPanelLineColor; 14 | + (UIColor *)descriptionTextColor; 15 | + (UIColor *)bottomPanelBackgroundColor; 16 | + (UIColor *)swipeToContinueTextColor; 17 | + (UIColor *)confirmationLabelColor; 18 | + (UIColor *)backgroundColor; 19 | + (UIColor *)tosLabelColor; 20 | 21 | + (void)centerViews:(NSArray *)controls withStartingView:(UIView *)startingView andEndingView:(UIView *)endingView forHeight:(CGFloat)viewHeight; 22 | + (void)configurePageControlTintColors:(UIPageControl *)pageControl; 23 | + (NSDictionary *)titleAttributesWithColor:(UIColor *)color; 24 | 25 | @end 26 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/include/WPFontManager.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | 4 | NS_ASSUME_NONNULL_BEGIN 5 | 6 | @interface WPFontManager : NSObject 7 | 8 | + (UIFont *)systemLightFontOfSize:(CGFloat)size; 9 | + (UIFont *)systemItalicFontOfSize:(CGFloat)size; 10 | + (UIFont *)systemBoldFontOfSize:(CGFloat)size; 11 | + (UIFont *)systemSemiBoldFontOfSize:(CGFloat)size; 12 | + (UIFont *)systemRegularFontOfSize:(CGFloat)size; 13 | + (UIFont *)systemMediumFontOfSize:(CGFloat)size; 14 | 15 | /// Loads the Noto font family for the life of the current process. 16 | /// This effectively makes it possible to look this font up using font descriptors. 17 | /// 18 | + (void)loadNotoFontFamily; 19 | + (UIFont *)notoBoldFontOfSize:(CGFloat)size; 20 | + (UIFont *)notoBoldItalicFontOfSize:(CGFloat)size; 21 | + (UIFont *)notoItalicFontOfSize:(CGFloat)size; 22 | + (UIFont *)notoRegularFontOfSize:(CGFloat)size; 23 | 24 | @end 25 | 26 | NS_ASSUME_NONNULL_END 27 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/include/NSString+Helpers.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | @interface NSString (Helpers) 4 | 5 | /** 6 | Removes shortcodes from the passed string. 7 | 8 | @param string The string to remove shortcodes from. 9 | @return The modified string. 10 | */ 11 | + (NSString *)stripShortcodesFromString:(NSString *)string; 12 | 13 | - (NSString *)stringByUrlEncoding; 14 | - (NSMutableDictionary *)dictionaryFromQueryString; 15 | - (NSString *)stringByReplacingHTMLEmoticonsWithEmoji; 16 | - (NSString *)stringByStrippingHTML; 17 | - (NSString *)stringByEllipsizingWithMaxLength:(NSInteger)lengthlimit preserveWords:(BOOL)preserveWords; 18 | - (BOOL)isWordPressComPath; 19 | 20 | /** 21 | * Counts the number of words in a string 22 | * 23 | * @discussion This word counting algorithm is from : http://stackoverflow.com/a/13367063 24 | * @return the number of words in a string 25 | */ 26 | - (NSUInteger)wordCount; 27 | 28 | 29 | - (NSString *)stringByNormalizingWhitespace; 30 | 31 | @end 32 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Utility/NSBundle+WordPressShared.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if !SWIFT_PACKAGE 4 | private class BundleFinder: NSObject {} 5 | #endif 6 | 7 | extension Bundle { 8 | 9 | /// Returns the WordPressShared Bundle 10 | /// If installed via CocoaPods, this will be WordPressShared.bundle, 11 | /// otherwise it will be the framework bundle. 12 | /// 13 | @objc public class var wordPressSharedBundle: Bundle { 14 | #if SWIFT_PACKAGE 15 | return Bundle.module 16 | #else 17 | let defaultBundle = Bundle(for: BundleFinder.self) 18 | // If installed with CocoaPods, resources will be in WordPressShared.bundle 19 | if let bundleURL = defaultBundle.resourceURL, 20 | let resourceBundle = Bundle(url: bundleURL.appendingPathComponent("WordPressShared.bundle")) { 21 | return resourceBundle 22 | } 23 | // Otherwise, the default bundle is used for resources 24 | return defaultBundle 25 | #endif 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/Utility/NSBundle+VersionNumberHelper.m: -------------------------------------------------------------------------------- 1 | #import "NSBundle+VersionNumberHelper.h" 2 | 3 | @implementation NSBundle (VersionNumberHelper) 4 | 5 | - (NSString *)detailedVersionNumber 6 | { 7 | NSBundle *mainBundle = [NSBundle mainBundle]; 8 | NSString *versionNumberString = [NSString stringWithFormat:@"%@ (%@)", [mainBundle.infoDictionary objectForKey:@"CFBundleShortVersionString"], [mainBundle.infoDictionary objectForKey:@"CFBundleVersion"]]; 9 | return versionNumberString; 10 | } 11 | 12 | - (NSString *)shortVersionString 13 | { 14 | NSString *appVersion = [NSBundle.mainBundle.infoDictionary objectForKey:@"CFBundleShortVersionString"]; 15 | 16 | #if DEBUG 17 | appVersion = [appVersion stringByAppendingString:@" (DEV)"]; 18 | #endif 19 | 20 | return appVersion; 21 | } 22 | 23 | - (NSString *)bundleVersion 24 | { 25 | NSDictionary *infoDictionary = [[NSBundle mainBundle] infoDictionary]; 26 | return infoDictionary[(NSString *)kCFBundleVersionKey] ?: [NSString new]; 27 | } 28 | 29 | @end 30 | -------------------------------------------------------------------------------- /Tests/WordPressSharedTests/StringURLValidationTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import WordPressShared 3 | 4 | class StringURLValidationTests: XCTestCase { 5 | 6 | // MARK: - Invalid URLs 7 | 8 | func testInvalidURLs() { 9 | let urls = [ 10 | "invalidurl", 11 | "123123", 12 | "wwwwordpresscom"] 13 | 14 | for url in urls { 15 | guard !url.isValidURL() else { 16 | XCTFail("\(url) is valid (expected invalid).") 17 | continue 18 | } 19 | } 20 | } 21 | 22 | // MARK: - Valid URLs 23 | 24 | func testValidURLs() { 25 | let urls = [ 26 | "https://cheese-pc", 27 | "https://localhost", 28 | "www.wordpress.com", 29 | "http://www.wordpress.com"] 30 | 31 | for url in urls { 32 | guard url.isValidURL() else { 33 | XCTFail("\(url) is invalid (expected valid).") 34 | continue 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Tests/WordPressSharedTests/StringRemovingMatchesTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import WordPressShared 3 | 4 | class StringRemovingMatchesTests: XCTestCase { 5 | 6 | func testStringRemovingMatches() { 7 | let initial = "

Some Content

" 8 | let pattern = "

" 9 | let expected = "Some Content

" 10 | 11 | let final = initial.removingMatches(pattern: pattern) 12 | 13 | XCTAssertEqual(final, expected) 14 | } 15 | 16 | func testStringRemovingMatchesWithEmojis() { 17 | let initial = "🌎world🌎" 18 | let pattern = "🌎" 19 | let expected = "world" 20 | 21 | let final = initial.removingMatches(pattern: pattern) 22 | 23 | XCTAssertEqual(final, expected) 24 | } 25 | 26 | func testStringRemovingMatchesWithEmojis2() { 27 | let initial = "🌎world🌎" 28 | let pattern = "world" 29 | let expected = "🌎🌎" 30 | 31 | let final = initial.removingMatches(pattern: pattern) 32 | 33 | XCTAssertEqual(final, expected) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/Utility/NSString+Util.m: -------------------------------------------------------------------------------- 1 | #if SWIFT_PACKAGE 2 | #import "NSString+Util.h" 3 | #else 4 | #import 5 | #endif 6 | 7 | 8 | @implementation NSString (Util) 9 | 10 | - (bool)isEmpty { 11 | return self.length == 0; 12 | } 13 | 14 | - (NSString *)trim { 15 | NSCharacterSet *set = [NSCharacterSet whitespaceCharacterSet]; 16 | return [self stringByTrimmingCharactersInSet:set]; 17 | } 18 | 19 | - (NSNumber *)numericValue { 20 | return [NSNumber numberWithUnsignedLongLong:[self longLongValue]]; 21 | } 22 | 23 | - (CGSize)suggestedSizeWithFont:(UIFont *)font width:(CGFloat)width { 24 | CGRect bounds = [self boundingRectWithSize:CGSizeMake(width, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName: font} context:nil]; 25 | return bounds.size; 26 | } 27 | 28 | @end 29 | 30 | @implementation NSObject (NumericValueHack) 31 | - (NSNumber *)numericValue { 32 | if ([self isKindOfClass:[NSNumber class]]) { 33 | return (NSNumber *)self; 34 | } 35 | return nil; 36 | } 37 | @end 38 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Utility/NSString+Swift.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | extension NSString { 5 | 6 | /// Returns the string's hostname, if any 7 | /// 8 | @objc public func hostname() -> String? { 9 | return URLComponents(string: self as String)?.host 10 | } 11 | 12 | /// Splits the lines contained in the current string, and returns the unique values in a NSSet instance 13 | /// 14 | @objc public func uniqueStringComponentsSeparatedByNewline() -> NSSet { 15 | let components = self.components(separatedBy: .newlines) 16 | 17 | let filtered = components.filter { !$0.isEmpty } 18 | 19 | let uniqueSet = NSMutableSet() 20 | uniqueSet.addObjects(from: filtered) 21 | 22 | return uniqueSet 23 | } 24 | 25 | /// Validates the current string. Returns true if passes validation 26 | /// 27 | @objc public func isValidEmail() -> Bool { 28 | let emailRegex = "^.+@.+$" 29 | let emailTest = NSPredicate(format: "SELF MATCHES %@", emailRegex) 30 | 31 | return emailTest.evaluate(with: self) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/Utility/WPMapFilterReduce.m: -------------------------------------------------------------------------------- 1 | #import "WPMapFilterReduce.h" 2 | 3 | @implementation NSArray (WPMapFilterReduce) 4 | 5 | - (NSArray *)wp_map:(WPMapBlock)mapBlock 6 | { 7 | NSMutableArray *results = [NSMutableArray arrayWithCapacity:self.count]; 8 | for (id obj in self) { 9 | id objectToAdd = mapBlock(obj); 10 | if (objectToAdd) { 11 | [results addObject:objectToAdd]; 12 | } 13 | } 14 | return [NSArray arrayWithArray:results]; 15 | } 16 | 17 | - (NSArray *)wp_filter:(WPFilterBlock)filterBlock 18 | { 19 | NSMutableArray *results = [NSMutableArray arrayWithCapacity:self.count]; 20 | for (id obj in self) { 21 | if (filterBlock(obj)) { 22 | [results addObject:obj]; 23 | } 24 | } 25 | return [NSArray arrayWithArray:results]; 26 | } 27 | 28 | - (id)wp_reduce:(WPReduceBlock)reduceBlock withInitialValue:(id)initial 29 | { 30 | id accumulator = initial; 31 | for (id obj in self) { 32 | accumulator = reduceBlock(accumulator, obj); 33 | } 34 | return accumulator; 35 | } 36 | 37 | @end 38 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/Utility/UIDevice+Helpers.m: -------------------------------------------------------------------------------- 1 | #import "UIDevice+Helpers.h" 2 | 3 | static NSString * const WordPressIdentifierDefaultsKey = @"WordPressIdentifier"; 4 | 5 | @implementation UIDevice (Helpers) 6 | 7 | - (NSString *)wordPressIdentifier 8 | { 9 | NSString *uuid; 10 | if ([[UIDevice currentDevice] respondsToSelector:@selector(identifierForVendor)]) { 11 | uuid = [[[UIDevice currentDevice] identifierForVendor] UUIDString]; 12 | } else { 13 | NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; 14 | uuid = [defaults objectForKey:WordPressIdentifierDefaultsKey]; 15 | if (!uuid) { 16 | uuid = [self generateUUID]; 17 | [defaults setObject:uuid forKey:WordPressIdentifierDefaultsKey]; 18 | [defaults synchronize]; 19 | } 20 | } 21 | return uuid; 22 | } 23 | 24 | - (NSString *)generateUUID 25 | { 26 | CFUUIDRef theUUID = CFUUIDCreate(NULL); 27 | CFStringRef string = CFUUIDCreateString(NULL, theUUID); 28 | CFRelease(theUUID); 29 | return (__bridge_transfer NSString *)string; 30 | } 31 | 32 | @end 33 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Utility/String+StripGutenbergContentForExcerpt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// This extension provides logic for stripping some Gutenberg content that should not be 4 | /// shown when generating post excerpts. 5 | /// 6 | extension String { 7 | 8 | /// This method is the main entry point to generate excerpts for Gutenberg content. 9 | /// 10 | public func strippingGutenbergContentForExcerpt() -> String { 11 | return strippingGutenbergGalleries().strippingGutenbergVideoPress() 12 | } 13 | 14 | /// Strips Gutenberg galleries from strings. 15 | /// 16 | private func strippingGutenbergGalleries() -> String { 17 | let pattern = "(?s)" 18 | 19 | return removingMatches(pattern: pattern, options: .caseInsensitive) 20 | } 21 | 22 | /// Strips VideoPress references from Gutenberg VideoPress and Video blocks. 23 | /// 24 | private func strippingGutenbergVideoPress() -> String { 25 | let pattern = "(?s)\n?" 26 | 27 | return removingMatches(pattern: pattern, options: .caseInsensitive) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/include/WPMapFilterReduce.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | typedef id (^WPMapBlock)(id obj); 4 | typedef BOOL (^WPFilterBlock)(id obj); 5 | typedef id (^WPReduceBlock)(id accumulator, id obj); 6 | 7 | @interface NSArray (WPMapFilterReduce) 8 | 9 | /** 10 | Transforms values in an array 11 | 12 | The resulting array will include the results of calling mapBlock for each of 13 | the receiver array objects. If mapBlock returns nil that value will be missing 14 | from the resulting array. 15 | */ 16 | - (NSArray *)wp_map:(WPMapBlock)mapBlock; 17 | 18 | /** 19 | Filters an array to only include values that satisfy the filter block 20 | */ 21 | - (NSArray *)wp_filter:(WPFilterBlock)filterBlock; 22 | 23 | /** 24 | Combines the array values into a single value 25 | 26 | The reduce block is called for each value. The first time it's sent the initial 27 | value, and subsequent calls will use the result of the previous call as the 28 | accumulator. 29 | 30 | For instance, to calculate the sum of all items: 31 | [array wp_reduce:^id(id accumulator, id obj) { 32 | return @([accumulator longLongValue] + [obj longLongValue]); 33 | } withInitialValue:@0]; 34 | */ 35 | - (id)wp_reduce:(WPReduceBlock)reduceBlock withInitialValue:(id)initial; 36 | 37 | @end 38 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/WordPressShared.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | //! Project version number for WordPressShared. 4 | FOUNDATION_EXPORT double WordPressSharedVersionNumber; 5 | 6 | //! Project version string for WordPressShared. 7 | FOUNDATION_EXPORT const unsigned char WordPressSharedVersionString[]; 8 | 9 | // In this header, you should import all the public headers of your framework using statements like #import 10 | 11 | #import 12 | #import 13 | #import 14 | #import 15 | #import 16 | #import 17 | #import 18 | #import 19 | #import 20 | #import 21 | #import 22 | #import 23 | #import 24 | #import 25 | #import 26 | #import 27 | #import 28 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Utility/Secret.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Wraps a value that contains sensitive information to prevent accidental logging 4 | /// 5 | /// Usage example 6 | /// 7 | /// ``` 8 | /// let password = Secret("my secret password") 9 | /// print(password) // Prints "--redacted--" 10 | /// print(password.secretValue) // Prints "my secret password" 11 | /// ``` 12 | /// 13 | public struct Secret { 14 | public let secretValue: T 15 | 16 | public init(_ secretValue: T) { 17 | self.secretValue = secretValue 18 | } 19 | } 20 | 21 | extension Secret: RawRepresentable { 22 | public typealias RawValue = T 23 | 24 | public init?(rawValue: Self.RawValue) { 25 | self.init(rawValue) 26 | } 27 | 28 | public var rawValue: T { 29 | return secretValue 30 | } 31 | } 32 | 33 | extension Secret: CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable { 34 | private static var redacted: String { 35 | return "--redacted--" 36 | } 37 | 38 | public var description: String { 39 | return Secret.redacted 40 | } 41 | 42 | public var debugDescription: String { 43 | return Secret.redacted 44 | } 45 | 46 | public var customMirror: Mirror { 47 | return Mirror(reflecting: Secret.redacted) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/include/WPSharedLogging.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import "WordPressLoggingDelegate.h" 3 | 4 | NS_ASSUME_NONNULL_BEGIN 5 | 6 | FOUNDATION_EXTERN id _Nullable WPSharedGetLoggingDelegate(void); 7 | FOUNDATION_EXTERN void WPSharedSetLoggingDelegate(id _Nullable logger); 8 | 9 | FOUNDATION_EXTERN void WPSharedLogError(NSString *str, ...) NS_FORMAT_FUNCTION(1, 2); 10 | FOUNDATION_EXTERN void WPSharedLogWarning(NSString *str, ...) NS_FORMAT_FUNCTION(1, 2); 11 | FOUNDATION_EXTERN void WPSharedLogInfo(NSString *str, ...) NS_FORMAT_FUNCTION(1, 2); 12 | FOUNDATION_EXTERN void WPSharedLogDebug(NSString *str, ...) NS_FORMAT_FUNCTION(1, 2); 13 | FOUNDATION_EXTERN void WPSharedLogVerbose(NSString *str, ...) NS_FORMAT_FUNCTION(1, 2); 14 | 15 | FOUNDATION_EXTERN void WPSharedLogvError(NSString *str, va_list args) NS_FORMAT_FUNCTION(1, 0); 16 | FOUNDATION_EXTERN void WPSharedLogvWarning(NSString *str, va_list args) NS_FORMAT_FUNCTION(1, 0); 17 | FOUNDATION_EXTERN void WPSharedLogvInfo(NSString *str, va_list args) NS_FORMAT_FUNCTION(1, 0); 18 | FOUNDATION_EXTERN void WPSharedLogvDebug(NSString *str, va_list args) NS_FORMAT_FUNCTION(1, 0); 19 | FOUNDATION_EXTERN void WPSharedLogvVerbose(NSString *str, va_list args) NS_FORMAT_FUNCTION(1, 0); 20 | 21 | NS_ASSUME_NONNULL_END 22 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Utility/NSString+Summary.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// This is an extension to NSString that provides logic to summarize HTML content, 4 | /// and convert HTML into plain text. 5 | /// 6 | extension NSString { 7 | 8 | static let PostDerivedSummaryLength = 150 9 | 10 | /// Create a summary for the post based on the post's content. 11 | /// 12 | /// - Returns: A summary for the post. 13 | /// 14 | @objc 15 | public func summarized() -> String { 16 | let characterSet = CharacterSet(charactersIn: "\n") 17 | 18 | return (self as String).strippingGutenbergContentForExcerpt() 19 | .strippingShortcodes() 20 | .makePlainText() 21 | .trimmingCharacters(in: characterSet) 22 | .ellipsizing(withMaxLength: NSString.PostDerivedSummaryLength, preserveWords: true) 23 | } 24 | 25 | /// Converts HTML content into plain text by stripping HTML tags and decodinig XML chars. 26 | /// Transforms the specified string to plain text. HTML markup is removed and HTML entities are decoded. 27 | /// 28 | /// - Returns: The transformed string. 29 | /// 30 | @objc 31 | public func makePlainText() -> String { 32 | let characterSet = NSCharacterSet.whitespacesAndNewlines 33 | 34 | return strippingHTML() 35 | .decodingXMLCharacters() 36 | .trimmingCharacters(in: characterSet) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/Utility/DateUtils.m: -------------------------------------------------------------------------------- 1 | #import "DateUtils.h" 2 | 3 | @implementation DateUtils 4 | 5 | + (NSDate *)dateFromISOString:(NSString *)dateString 6 | { 7 | NSArray *formats = @[@"yyyy-MM-dd'T'HH:mm:ssZZZZZ", @"yyyy-MM-dd HH:mm:ss"]; 8 | NSDate *date = nil; 9 | if ([dateString length] == 25) { 10 | NSRange rng = [dateString rangeOfString:@":" options:NSBackwardsSearch range:NSMakeRange(20, 5)]; 11 | if (rng.location != NSNotFound) { 12 | dateString = [dateString stringByReplacingCharactersInRange:rng withString:@""]; 13 | } 14 | } 15 | NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init]; 16 | dateFormatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; 17 | dateFormatter.timeZone = [NSTimeZone timeZoneWithName:@"GMT"]; 18 | for (NSString *dateFormat in formats) { 19 | [dateFormatter setDateFormat:dateFormat]; 20 | date = [dateFormatter dateFromString:dateString]; 21 | if (date){ 22 | return date; 23 | } 24 | } 25 | return date; 26 | } 27 | 28 | + (NSString *)isoStringFromDate:(NSDate *)date 29 | { 30 | NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init]; 31 | dateFormatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; 32 | dateFormatter.timeZone = [NSTimeZone timeZoneWithName:@"GMT"]; 33 | [dateFormatter setDateFormat:@"yyyy-MM-dd'T'HH:mm:ssZZZZZ"]; 34 | return [dateFormatter stringFromDate:date]; 35 | } 36 | 37 | @end 38 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/include/DisplayableImageHelper.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | /** 4 | Helper for searching a post's content or attachments for an image suitable for 5 | using as the displayed image in the post list. 6 | */ 7 | @interface DisplayableImageHelper : NSObject 8 | 9 | /** 10 | Get the url path of the image to display for a post. 11 | 12 | @param attachmentsDict A dictionary representing a posts attachments from the REST API. 13 | @param content The post content. The attachment url must exist in the content. 14 | @return The url path for the featured image or nil 15 | */ 16 | + (NSString *)searchPostAttachmentsForImageToDisplay:(NSDictionary *)attachmentsDict existingInContent:(NSString *)content; 17 | 18 | /** 19 | Search the passed string for an image that is a good candidate to feature. 20 | 21 | @details Loops over all img tags in the passed html content, extracts the URL from the 22 | src attribute and checks for an acceptable width. The image URL with the best 23 | width is returned. 24 | @param content The content string to search. 25 | @return The URL path for the image or an empty string. 26 | */ 27 | + (NSString *)searchPostContentForImageToDisplay:(NSString *)content; 28 | 29 | /** 30 | Find attachments ids in post content 31 | 32 | @param content The content string to search 33 | 34 | @return A set with all the attachment id that where found in galleries 35 | */ 36 | + (NSSet *)searchPostContentForAttachmentIdsInGalleries:(NSString *)content; 37 | 38 | @end 39 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Utility/Debouncer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | //From : https://github.com/webadnan/swift-debouncer 4 | 5 | /// This class de-bounces the execution of a provided callback. 6 | /// It also offers a mechanism to immediately trigger the scheduled call if necessary. 7 | /// 8 | public final class Debouncer { 9 | private var callback: (() -> Void)? 10 | private let delay: Double 11 | private var timer: Timer? 12 | 13 | // MARK: - Init & deinit 14 | 15 | public init(delay: Double, callback: (() -> Void)? = nil) { 16 | self.delay = delay 17 | self.callback = callback 18 | } 19 | 20 | deinit { 21 | if let timer = timer, timer.fireDate >= Date() { 22 | timer.invalidate() 23 | callback?() 24 | } 25 | } 26 | 27 | // MARK: - Debounce Request 28 | 29 | public func cancel() { 30 | timer?.invalidate() 31 | } 32 | 33 | public func call(immediate: Bool = false, callback: (() -> Void)? = nil) { 34 | timer?.invalidate() 35 | 36 | if let newCallback = callback { 37 | self.callback = newCallback 38 | } 39 | 40 | if immediate { 41 | immediateCallback() 42 | } else { 43 | scheduleCallback() 44 | } 45 | } 46 | 47 | // MARK: - Callback interaction 48 | 49 | private func immediateCallback() { 50 | timer = nil 51 | callback?() 52 | } 53 | 54 | private func scheduleCallback() { 55 | timer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [callback] timer in 56 | callback?() 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/Views/WPTableViewCell.m: -------------------------------------------------------------------------------- 1 | #import "WPTableViewCell.h" 2 | #import "WPDeviceIdentification.h" 3 | 4 | CGFloat const WPTableViewFixedWidth = 600; 5 | 6 | @implementation WPTableViewCell 7 | 8 | - (void)setForceCustomCellMargins:(BOOL)forceCustomCellMargins 9 | { 10 | if (_forceCustomCellMargins != forceCustomCellMargins) { 11 | _forceCustomCellMargins = forceCustomCellMargins; 12 | [self setClipsToBounds:forceCustomCellMargins]; 13 | } 14 | } 15 | 16 | - (void)setFrame:(CGRect)frame { 17 | if (self.forceCustomCellMargins) { 18 | CGFloat width = self.superview.frame.size.width; 19 | // On iPad, add a margin around tables 20 | if ([WPDeviceIdentification isiPad] && width > WPTableViewFixedWidth) { 21 | CGFloat x = (width - WPTableViewFixedWidth) / 2; 22 | // If origin.x is not equal to x we add the value. 23 | // This is a semi-fix / work around for an issue positioning cells on 24 | // iOS 8 when editing a table view and the delete button is visible. 25 | if (x != frame.origin.x) { 26 | frame.origin.x += x; 27 | } else { 28 | frame.origin.x = x; 29 | } 30 | frame.size.width = WPTableViewFixedWidth; 31 | } 32 | } 33 | [super setFrame:frame]; 34 | } 35 | 36 | - (void)layoutSubviews { 37 | [super layoutSubviews]; 38 | if (self.forceCustomCellMargins) { 39 | // Need to set the origin again on iPad (for margins) 40 | CGFloat width = self.superview.frame.size.width; 41 | if ([WPDeviceIdentification isiPad] && width > WPTableViewFixedWidth) { 42 | CGRect frame = self.frame; 43 | frame.origin.x = (width - WPTableViewFixedWidth) / 2; 44 | self.frame = frame; 45 | } 46 | } 47 | } 48 | 49 | @end 50 | -------------------------------------------------------------------------------- /WordPressShared.podspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # rubocop:disable Metrics/BlockLength 4 | 5 | Pod::Spec.new do |s| 6 | s.name = 'WordPressShared' 7 | s.version = '2.4.0' 8 | 9 | s.summary = 'Shared components used in building the WordPress iOS apps and other library components.' 10 | s.description = <<-DESC 11 | Shared components used in building the WordPress iOS apps and other library components. 12 | 13 | This is the first step required to build WordPress-iOS with UI components. 14 | DESC 15 | 16 | s.homepage = 'https://github.com/wordpress-mobile/WordPress-iOS-Shared' 17 | s.license = { type: 'GPLv2', file: 'LICENSE' } 18 | s.author = { 'The WordPress Mobile Team' => 'mobile@wordpress.org' } 19 | 20 | s.platform = :ios, '13.0' 21 | s.swift_version = '5.0' 22 | 23 | s.source = { git: 'https://github.com/wordpress-mobile/WordPress-iOS-Shared.git', tag: s.version.to_s } 24 | s.source_files = ['Sources/**/*.{h,m,swift}'] 25 | s.public_header_files = 'Sources/WordPressSharedObjC/include', 'Sources/WordPressSharedObjC/WordPressShared.h' 26 | s.private_header_files = 'Sources/WordPressSharedObjC/Private/*.h' 27 | s.resource_bundles = { 28 | WordPressShared: [ 29 | 'Sources/WordPressShared/Resources/*.{ttf,otf,json}', 30 | 'Sources/WordPressSharedObjC/Resources/*.{ttf,otf,json}' 31 | ] 32 | } 33 | 34 | s.test_spec do |test| 35 | test.source_files = [ 36 | 'Tests/WordPressSharedTests/**/*.{swift}', 37 | 'Tests/WordPressSharedTestsObjC/**/*.{h,m}' 38 | ] 39 | test.resources = 'Tests/WordPressSharedObjCTests/Resources/*.{jpg,gif}' 40 | end 41 | end 42 | 43 | # rubocop:enable Metrics/BlockLength 44 | -------------------------------------------------------------------------------- /.buildkite/pipeline.yml: -------------------------------------------------------------------------------- 1 | # Nodes with values to reuse in the pipeline. 2 | common_params: 3 | plugins: &common_plugins 4 | - automattic/a8c-ci-toolkit#3.1.0 5 | env: &common_env 6 | IMAGE_ID: xcode-15.3-v3 7 | 8 | # This is the default pipeline – it will build and test the pod 9 | steps: 10 | ######################## 11 | # Validate Swift Package 12 | ######################## 13 | - label: "🔬 Validate Swift Package" 14 | key: "test" 15 | command: validate_swift_package 16 | env: *common_env 17 | plugins: *common_plugins 18 | artifact_paths: 19 | - .build/logs/*.log 20 | - .build/derived-data/Logs/**/*.xcactivitylog 21 | 22 | ################# 23 | # Validate Podspec 24 | ################# 25 | - label: "🔬 Validate Podspec" 26 | key: "validate" 27 | command: validate_podspec --patch-cocoapods 28 | env: *common_env 29 | plugins: *common_plugins 30 | artifact_paths: ".build/logs/*.log" 31 | 32 | ################# 33 | # Lint 34 | ################# 35 | - label: "🧹 Lint" 36 | key: "lint" 37 | command: lint_pod 38 | env: *common_env 39 | plugins: *common_plugins 40 | 41 | - label: ":swift: SwiftLint" 42 | key: "swiftlint" 43 | command: run_swiftlint --strict 44 | plugins: *common_plugins 45 | notify: 46 | - github_commit_status: 47 | context: "SwiftLint" 48 | agents: 49 | queue: "default" 50 | 51 | ################# 52 | # Publish the Podspec (if we're building a tag) 53 | ################# 54 | - label: "⬆️ Publish Podspec" 55 | key: "publish" 56 | command: .buildkite/commands/publish-pod.sh 57 | env: *common_env 58 | plugins: *common_plugins 59 | depends_on: 60 | - test 61 | - validate 62 | - lint 63 | - swiftlint 64 | if: build.tag != null 65 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/include/PhotonImageURLHelper.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | 4 | /** 5 | Helper class for creating a photon URL from the passed image URL. 6 | */ 7 | @interface PhotonImageURLHelper : NSObject 8 | 9 | /** 10 | Create a "photonized" URL from the passed image URL and size. 11 | The source image is resized and the URL is constructed with a 12 | default 80% quality as a speed/size optimization. 13 | 14 | @param size The desired "points" size of the photon image. The scale of the screen will be 15 | multiplied to this value. If height is set to zero the returned image will have a 16 | height proportional to the requested width. 17 | @param url The URL to the source image. 18 | 19 | @return A URL to the photon service with the source image as its subject. 20 | */ 21 | + (NSURL *)photonURLWithSize:(CGSize)size forImageURL:(NSURL *)url; 22 | 23 | 24 | /** 25 | Create a "photonized" URL from the passed arguments. 26 | 27 | @param size The desired "points" size of the photon image. The scale of the screen will be 28 | multiplied to this value. If height is set to zero the returned image will have a height 29 | proportional to the requested width. 30 | @param url The URL to the source image. 31 | @param forceResize By default Photon does not upscale beyond a certain percentage. 32 | Setting this to YES forces the returned image to match the specified size. 33 | @param quality An integer value 1 - 100. Passed values are constrained to this range. 34 | 35 | @return A URL to the photon service with the source image as its subject. 36 | */ 37 | + (NSURL *)photonURLWithSize:(CGSize)size 38 | forImageURL:(NSURL *)url 39 | forceResize:(BOOL)forceResize 40 | imageQuality:(NSUInteger)quality; 41 | 42 | @end 43 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Analytics/AnalyticsEvent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if SWIFT_PACKAGE 4 | import WordPressSharedObjC 5 | #endif 6 | 7 | /// This struct represents an analytics event. 8 | /// Declaring this class as final is a design choice to promote a simpler usage and implement events 9 | /// through parametrization of the `name` and `properties` properties. 10 | /// 11 | /// An example of a static event definition (in the client App or Pod): 12 | /// 13 | /// ~~~ 14 | /// extension AnalyticsEvent { 15 | /// static let loginStart = AnalyticsEvent(name: "login", properties: ["step": "start"]) 16 | /// } 17 | /// ~~~ 18 | /// 19 | /// An example of a dynamic / parametrized event definition (in the client App or Pod): 20 | /// 21 | /// ~~~ 22 | /// extension AnalyticsEvent { 23 | /// enum LoginStep: String { 24 | /// case start 25 | /// case success 26 | /// } 27 | /// 28 | /// static func login(step: LoginStep) -> AnalyticsEvent { 29 | /// let properties = [ 30 | /// "step": step.rawValue 31 | /// ] 32 | /// 33 | /// return AnalyticsEvent(name: "login", properties: properties) 34 | /// } 35 | /// } 36 | /// ~~~ 37 | /// 38 | /// Examples of tracking calls (in the client App or Pod): 39 | /// 40 | /// ~~~ 41 | /// WPAnalytics.track(.login(step: .start)) 42 | /// WPAnalytics.track(.loginStart) 43 | /// ~~~ 44 | /// 45 | public final class AnalyticsEvent { 46 | public let name: String 47 | public let properties: [String: String] 48 | 49 | public init(name: String, properties: [String: String]) { 50 | self.name = name 51 | self.properties = properties 52 | } 53 | } 54 | 55 | extension WPAnalytics { 56 | public static func track(_ event: AnalyticsEvent) { 57 | WPAnalytics.trackString(event.name, withProperties: event.properties) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Tests/WordPressSharedTests/StringHelperTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import WordPressShared 4 | 5 | class StringHelperTests: XCTestCase { 6 | 7 | override func setUp() { 8 | super.setUp() 9 | // Put setup code here. This method is called before the invocation of each test method in the class. 10 | } 11 | 12 | override func tearDown() { 13 | // Put teardown code here. This method is called after the invocation of each test method in the class. 14 | super.tearDown() 15 | } 16 | 17 | func testTrim() { 18 | let trimmedString = "string string" 19 | let sourceString = " \(trimmedString) " 20 | XCTAssert(trimmedString == sourceString.trim()) 21 | } 22 | 23 | func testRemovePrefix() { 24 | let string = "X-Post: This is a test" 25 | XCTAssertEqual("This is a test", string.removingPrefix("X-Post: ")) 26 | XCTAssertEqual(string, string.removingPrefix("Something Else")) 27 | } 28 | 29 | func testRemoveSuffix() { 30 | let string = "http://example.com/" 31 | XCTAssertEqual("http://example.com", string.removingSuffix("/")) 32 | XCTAssertEqual("http://example", string.removingSuffix(".com/")) 33 | XCTAssertEqual(string, string.removingSuffix(".org/")) 34 | } 35 | 36 | func testRemovePrefixPattern() { 37 | let string = "X-Post: This is a test" 38 | XCTAssertEqual("This is a test", try! string.removingPrefix(pattern: "X-.*?: +")) 39 | XCTAssertEqual(string, try! string.removingPrefix(pattern: "Th.* ")) 40 | } 41 | 42 | func testRemoveSuffixPattern() { 43 | let string = "X-Post: This is a test" 44 | XCTAssertEqual("X-Post: This is", try! string.removingSuffix(pattern: "( a)? +test")) 45 | XCTAssertEqual(string, try! string.removingSuffix(pattern: "Th.* ")) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Tests/WordPressSharedTests/EmailFormatValidatorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import WordPressShared 3 | 4 | class EmailFormatValidatorTests: XCTestCase { 5 | 6 | func testValidEmailAddresses() { 7 | XCTAssertTrue(EmailFormatValidator.validate(string: "e@example.com")) 8 | XCTAssertTrue(EmailFormatValidator.validate(string: "example@example.com")) 9 | XCTAssertTrue(EmailFormatValidator.validate(string: "example@example-example.com")) 10 | XCTAssertTrue(EmailFormatValidator.validate(string: "example@example.example.example.com")) 11 | XCTAssertTrue(EmailFormatValidator.validate(string: "example.example+example@example.com")) 12 | } 13 | 14 | func testInvalidEmailAddresses() { 15 | XCTAssertFalse(EmailFormatValidator.validate(string: "")) 16 | XCTAssertFalse(EmailFormatValidator.validate(string: "example")) 17 | XCTAssertFalse(EmailFormatValidator.validate(string: "example@@example.com")) 18 | XCTAssertFalse(EmailFormatValidator.validate(string: "example@example@.com")) 19 | XCTAssertFalse(EmailFormatValidator.validate(string: "@example.com")) 20 | XCTAssertFalse(EmailFormatValidator.validate(string: "example@example")) 21 | XCTAssertFalse(EmailFormatValidator.validate(string: "example@.com")) 22 | XCTAssertFalse(EmailFormatValidator.validate(string: "example@example..com")) 23 | XCTAssertFalse(EmailFormatValidator.validate(string: "example@.example.com")) 24 | XCTAssertFalse(EmailFormatValidator.validate(string: "example@example.com.")) 25 | XCTAssertFalse(EmailFormatValidator.validate(string: "example@examp?.com")) 26 | XCTAssertFalse(EmailFormatValidator.validate(string: "example@exam_ple.com")) 27 | XCTAssertFalse(EmailFormatValidator.validate(string: "examp***le@exam_ple.com")) 28 | XCTAssertFalse(EmailFormatValidator.validate(string: "example@exam ple.com")) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tests/WordPressSharedTests/EmailTypoCheckerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import WordPressShared 3 | 4 | class EmailTypoCheckerTests: XCTestCase { 5 | 6 | func testSuggestions() { 7 | XCTAssertEqual(EmailTypoChecker.guessCorrection(email: "hello@mop.com"), "hello@mop.com") 8 | XCTAssertEqual(EmailTypoChecker.guessCorrection(email: "hello@gmail.com"), "hello@gmail.com") 9 | XCTAssertEqual(EmailTypoChecker.guessCorrection(email: "hello"), "hello") 10 | XCTAssertEqual(EmailTypoChecker.guessCorrection(email: "hello@"), "hello@") 11 | XCTAssertEqual(EmailTypoChecker.guessCorrection(email: "@"), "@") 12 | XCTAssertEqual(EmailTypoChecker.guessCorrection(email: ""), "") 13 | XCTAssertEqual(EmailTypoChecker.guessCorrection(email: "@hello"), "@hello") 14 | XCTAssertEqual(EmailTypoChecker.guessCorrection(email: "@hello.com"), "@hello.com") 15 | XCTAssertEqual(EmailTypoChecker.guessCorrection(email: "kikoo@gmail.com"), "kikoo@gmail.com") 16 | XCTAssertEqual(EmailTypoChecker.guessCorrection(email: "kikoo@azdoij.cm"), "kikoo@azdoij.cm") 17 | 18 | XCTAssertEqual(EmailTypoChecker.guessCorrection(email: "hello@gmial.com"), "hello@gmail.com") 19 | XCTAssertEqual(EmailTypoChecker.guessCorrection(email: "hello@gmai.com"), "hello@gmail.com") 20 | XCTAssertEqual(EmailTypoChecker.guessCorrection(email: "hello@yohoo.com"), "hello@yahoo.com") 21 | XCTAssertEqual(EmailTypoChecker.guessCorrection(email: "hello@yhoo.com"), "hello@yahoo.com") 22 | XCTAssertEqual(EmailTypoChecker.guessCorrection(email: "hello@ayhoo.com"), "hello@yahoo.com") 23 | XCTAssertEqual(EmailTypoChecker.guessCorrection(email: "hello@yhoo.com"), "hello@yahoo.com") 24 | XCTAssertEqual(EmailTypoChecker.guessCorrection(email: "hello@outloo.com"), "hello@outlook.com") 25 | XCTAssertEqual(EmailTypoChecker.guessCorrection(email: "hello@comcats.com"), "hello@comcast.com") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Utility/WPImageURLHelper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Helper class to create a WordPress URL for downloading images with size parameters. 4 | open class WPImageURLHelper: NSObject { 5 | /** 6 | Adds to the provided url width and height parameters to allow the image to be resized on the server 7 | 8 | - parameter size: the required pixel size for the image. If height is set to zero the 9 | returned image will have a height proportional to the requested width and vice versa. 10 | - parameter url: the original url for the image 11 | 12 | - returns: an URL with the added query parameters. 13 | 14 | - note: If there is any problem with the original URL parsing, the original URL is returned with no changes. 15 | */ 16 | @objc open class func imageURLWithSize(_ size: CGSize, forImageURL url: URL) -> URL { 17 | guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) else { 18 | return url 19 | } 20 | var newQueryItems = [URLQueryItem]() 21 | if let queryItems = urlComponents.queryItems { 22 | for queryItem in queryItems { 23 | if queryItem.name != "w" && queryItem.name != "h" { 24 | newQueryItems.append(queryItem) 25 | } 26 | } 27 | } 28 | let height = Int(size.height) 29 | let width = Int(size.width) 30 | if height != 0 { 31 | let heightItem = URLQueryItem(name: "h", value: "\(height)") 32 | newQueryItems.append(heightItem) 33 | } 34 | 35 | if width != 0 { 36 | let widthItem = URLQueryItem(name: "w", value: "\(width)") 37 | newQueryItems.append(widthItem) 38 | } 39 | 40 | urlComponents.queryItems = newQueryItems 41 | guard let resultURL = urlComponents.url else { 42 | return url 43 | } 44 | return resultURL 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Tests/WordPressSharedObjCTests/LoggingTests.m: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | @import WordPressSharedObjC; 4 | 5 | @interface CaptureLogs : NSObject 6 | 7 | @property (nonatomic, strong) NSMutableArray *infoLogs; 8 | @property (nonatomic, strong) NSMutableArray *errorLogs; 9 | 10 | @end 11 | 12 | @implementation CaptureLogs 13 | 14 | - (instancetype)init 15 | { 16 | if ((self = [super init])) { 17 | self.infoLogs = [NSMutableArray new]; 18 | self.errorLogs = [NSMutableArray new]; 19 | } 20 | return self; 21 | } 22 | 23 | - (void)logInfo:(NSString *)str 24 | { 25 | [self.infoLogs addObject:str]; 26 | } 27 | 28 | - (void)logError:(NSString *)str 29 | { 30 | [self.errorLogs addObject:str]; 31 | } 32 | 33 | @end 34 | 35 | @interface LoggingTest : XCTestCase 36 | 37 | @property (nonatomic, strong) CaptureLogs *logger; 38 | 39 | @end 40 | 41 | @implementation LoggingTest 42 | 43 | - (void)setUp 44 | { 45 | self.logger = [CaptureLogs new]; 46 | WPSharedSetLoggingDelegate(self.logger); 47 | } 48 | 49 | - (void)testLogging 50 | { 51 | WPSharedLogInfo(@"This is an info log"); 52 | WPSharedLogInfo(@"This is an info log %@", @"with an argument"); 53 | XCTAssertEqualObjects(self.logger.infoLogs, (@[@"This is an info log", @"This is an info log with an argument"])); 54 | 55 | WPSharedLogError(@"This is an error log"); 56 | WPSharedLogError(@"This is an error log %@", @"with an argument"); 57 | XCTAssertEqualObjects(self.logger.errorLogs, (@[@"This is an error log", @"This is an error log with an argument"])); 58 | } 59 | 60 | - (void)testUnimplementedLoggingMethod 61 | { 62 | XCTAssertNoThrow(WPSharedLogVerbose(@"verbose logging is not implemented")); 63 | } 64 | 65 | - (void)testNoLogging 66 | { 67 | WPSharedSetLoggingDelegate(nil); 68 | XCTAssertNoThrow(WPSharedLogInfo(@"this log should not be printed")); 69 | XCTAssertEqual(self.logger.infoLogs.count, 0); 70 | } 71 | 72 | @end 73 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/include/WPDeviceIdentification.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | 4 | /** 5 | * @class WPDeviceIdentification 6 | * @brief Methods for device and iOS identification should go here. 7 | */ 8 | @interface WPDeviceIdentification : NSObject 9 | 10 | /** 11 | * @brief Call this method to know if the current device is an iPhone. 12 | * 13 | * @returns YES if the device is an iPhone. NO otherwise. 14 | */ 15 | + (BOOL)isiPhone; 16 | 17 | /** 18 | * @brief Call this method to know if the current device is an iPad. 19 | * 20 | * @returns YES if the device is an iPad. NO otherwise. 21 | */ 22 | + (BOOL)isiPad; 23 | 24 | /** 25 | * @brief Call this method to know if the current device has a retina screen. 26 | * 27 | * @returns YES if the device has a retina screen. NO otherwise. 28 | */ 29 | + (BOOL)isRetina; 30 | 31 | /** 32 | * @brief Call this method to know if the current device is an iPhone6. 33 | * 34 | * @returns YES if the device is an iPhone6. NO otherwise. 35 | */ 36 | + (BOOL)isiPhoneSix; 37 | 38 | /** 39 | * @brief Call this method to know if the current device is an iPhone6+. 40 | * 41 | * @returns YES if the device is an iPhone6+. NO otherwise. 42 | */ 43 | + (BOOL)isiPhoneSixPlus; 44 | 45 | /** 46 | * @brief Call this method to know if the current device is a Plus sized 47 | * phone (6+, 6s+, 7+) , at its native scale. 48 | * 49 | * @returns YES if the device is a Plus phone. NO otherwise. 50 | */ 51 | + (BOOL)isUnzoomediPhonePlus; 52 | 53 | /** 54 | * @brief Call this method to know if the current device is running iOS version older than 9. 55 | * 56 | * @returns YES if the device is running iOS version older than 9. NO otherwise. 57 | */ 58 | + (BOOL)isiOSVersionEarlierThan9; 59 | 60 | /** 61 | * @brief Call this method to know if the current device is running iOS version older than 10. 62 | * 63 | * @returns YES if the device is running iOS version older than 10. NO otherwise. 64 | */ 65 | + (BOOL)isiOSVersionEarlierThan10; 66 | 67 | @end 68 | -------------------------------------------------------------------------------- /Tests/WordPressSharedObjCTests/PhotonImageURLHelperTest.m: -------------------------------------------------------------------------------- 1 | #import 2 | #import "PhotonImageURLHelper.h" 3 | 4 | @interface PhotonImageURLHelperTest : XCTestCase 5 | 6 | @end 7 | 8 | @implementation PhotonImageURLHelperTest 9 | 10 | - (void)setUp 11 | { 12 | [super setUp]; 13 | } 14 | 15 | - (void)tearDown 16 | { 17 | [super tearDown]; 18 | } 19 | 20 | - (void)testPhotonURLForURLSupportsHTTPS 21 | { 22 | // arbitrary size 23 | CGSize size = CGSizeMake(300, 150); 24 | NSURL *photonURL; 25 | NSString *domainPathQueryStringForImage = @"blog.example.com/wp-content/images/image-name.jpg?w=1000"; 26 | 27 | NSURL *httpsURL = [NSURL URLWithString:[NSString stringWithFormat:@"https://%@", domainPathQueryStringForImage]]; 28 | photonURL = [PhotonImageURLHelper photonURLWithSize:size forImageURL:httpsURL]; 29 | XCTAssertNotNil(photonURL, @"A valid URL should be returned, got nil instead."); 30 | XCTAssertTrue([[photonURL host] isEqualToString:@"i0.wp.com"], @"A Photon URL should be returned, a url with a different host was returned instead."); 31 | XCTAssertTrue(([[photonURL query] rangeOfString:@"&ssl=1"].location != NSNotFound), @"The Photon URL should be formatted for ssl."); 32 | 33 | NSURL *httpURL = [NSURL URLWithString:[NSString stringWithFormat:@"http://%@", domainPathQueryStringForImage]]; 34 | photonURL = [PhotonImageURLHelper photonURLWithSize:size forImageURL:httpURL]; 35 | XCTAssertNotNil(photonURL, @"A valid URL should be returned, got nil instead."); 36 | XCTAssertTrue([[photonURL host] isEqualToString:@"i0.wp.com"], @"A Photon URL should be returned, a url with a different host was returned instead."); 37 | XCTAssertFalse(([[photonURL query] rangeOfString:@"&ssl=1"].location != NSNotFound), @"The Photon URL should not be formatted for ssl."); 38 | } 39 | 40 | - (void)testPhotonURLReturnsUnChanged 41 | { 42 | // arbitrary size 43 | CGSize size = CGSizeMake(300, 150); 44 | NSString *path = @"https://i0.wp.com/path/to/image.jpg"; 45 | NSURL *url = [NSURL URLWithString:path]; 46 | NSURL *photonURL = [PhotonImageURLHelper photonURLWithSize:size forImageURL:url]; 47 | 48 | XCTAssertTrue([[photonURL absoluteString] isEqualToString:path]); 49 | } 50 | 51 | @end 52 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/Logging/WPSharedLogging.m: -------------------------------------------------------------------------------- 1 | #import "WPSharedLogging.h" 2 | 3 | static id wordPressSharedLogger = nil; 4 | 5 | id _Nullable WPSharedGetLoggingDelegate(void) 6 | { 7 | return wordPressSharedLogger; 8 | } 9 | 10 | void WPSharedSetLoggingDelegate(id _Nullable logger) 11 | { 12 | wordPressSharedLogger = logger; 13 | } 14 | 15 | #define WPSharedLogv(logFunc) \ 16 | ({ \ 17 | id logger = WPSharedGetLoggingDelegate(); \ 18 | if (logger == NULL) { \ 19 | NSLog(@"[WordPress-Shared] Warning: please call `WPSharedSetLoggingDelegate` to set a error logger."); \ 20 | return; \ 21 | } \ 22 | if (![logger respondsToSelector:@selector(logFunc)]) { \ 23 | NSLog(@"[WordPress-Shared] Warning: %@ does not implement " #logFunc, logger); \ 24 | return; \ 25 | } \ 26 | /* Originally `performSelector:withObject:` was used to call the logging function, but for unknown reason */ \ 27 | /* it causes a crash on `objc_retain`. So I have to switch to this strange "syntax" to call the logging function directly. */ \ 28 | [logger logFunc [[NSString alloc] initWithFormat:str arguments:args]]; \ 29 | }) 30 | 31 | #define WPSharedLog(logFunc) \ 32 | ({ \ 33 | va_list args; \ 34 | va_start(args, str); \ 35 | WPSharedLogv(logFunc); \ 36 | va_end(args); \ 37 | }) 38 | 39 | void WPSharedLogError(NSString *str, ...) { WPSharedLog(logError:); } 40 | void WPSharedLogWarning(NSString *str, ...) { WPSharedLog(logWarning:); } 41 | void WPSharedLogInfo(NSString *str, ...) { WPSharedLog(logInfo:); } 42 | void WPSharedLogDebug(NSString *str, ...) { WPSharedLog(logDebug:); } 43 | void WPSharedLogVerbose(NSString *str, ...) { WPSharedLog(logVerbose:); } 44 | 45 | void WPSharedLogvError(NSString *str, va_list args) { WPSharedLogv(logError:); } 46 | void WPSharedLogvWarning(NSString *str, va_list args) { WPSharedLogv(logWarning:); } 47 | void WPSharedLogvInfo(NSString *str, va_list args) { WPSharedLogv(logInfo:); } 48 | void WPSharedLogvDebug(NSString *str, va_list args) { WPSharedLogv(logDebug:); } 49 | void WPSharedLogvVerbose(NSString *str, va_list args) { WPSharedLogv(logVerbose:); } 50 | -------------------------------------------------------------------------------- /Tests/WordPressSharedObjCTests/WPMapFilterReduceTest.m: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | #import "WPMapFilterReduce.h" 4 | 5 | @interface WPMapFilterReduceTest : XCTestCase 6 | 7 | @end 8 | 9 | @implementation WPMapFilterReduceTest 10 | 11 | - (void)testMap { 12 | NSArray *test = @[ @1, @2, @10 ]; 13 | NSArray *result = [test wp_map:^id(NSNumber *obj) { 14 | return @( [obj integerValue] * 10 ); 15 | }]; 16 | NSArray *expected = @[ @10, @20, @100 ]; 17 | XCTAssertEqualObjects(expected, result); 18 | } 19 | 20 | - (void)testMapDoesntCrashWithNilValues { 21 | NSArray *test = @[ @1, @2, @10 ]; 22 | NSArray *result = [test wp_map:^id(NSNumber *obj) { 23 | if ([obj integerValue] > 5) { 24 | return nil; 25 | } 26 | return @( [obj integerValue] * 10 ); 27 | }]; 28 | NSArray *expected = @[ @10, @20 ]; 29 | XCTAssertEqualObjects(expected, result); 30 | } 31 | 32 | - (void)testMapPerformance { 33 | NSMutableArray *testArray = [NSMutableArray arrayWithCapacity:1000]; 34 | for (int i = 0; i < 10000; i++) { 35 | [testArray addObject:@(i)]; 36 | }; 37 | 38 | [self measureBlock:^{ 39 | [testArray wp_map:^id(id obj) { 40 | return @( [obj integerValue] * 10 ); 41 | }]; 42 | }]; 43 | } 44 | 45 | - (void)testFilter 46 | { 47 | NSArray *test = @[ @1, @2, @10 ]; 48 | NSArray *result = [test wp_filter:^BOOL(NSNumber *obj) { 49 | return [obj integerValue] > 5; 50 | }]; 51 | NSArray *expected = @[ @10 ]; 52 | XCTAssertEqualObjects(expected, result); 53 | } 54 | 55 | - (void)testFilterPerformance { 56 | NSMutableArray *testArray = [NSMutableArray arrayWithCapacity:1000]; 57 | for (int i = 0; i < 10000; i++) { 58 | [testArray addObject:@(i)]; 59 | }; 60 | 61 | [self measureBlock:^{ 62 | [testArray wp_filter:^BOOL(id obj) { 63 | return [obj integerValue] % 10 == 5; 64 | }]; 65 | }]; 66 | } 67 | 68 | - (void)testReduce 69 | { 70 | NSArray *test = @[ @1, @2, @3, @4, @5, @6, @7, @8, @9, @10 ]; 71 | NSNumber *result = [test wp_reduce:^id(id accumulator, id obj) { 72 | return @([accumulator longLongValue] + [obj longLongValue]); 73 | } withInitialValue:@0]; 74 | NSNumber *expected = @55; 75 | XCTAssertEqualObjects(expected, result); 76 | } 77 | 78 | @end 79 | -------------------------------------------------------------------------------- /Tests/WordPressSharedTests/LoggingTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import WordPressShared 4 | 5 | private class CaptureLogs: NSObject, WordPressLoggingDelegate { 6 | var verboseLogs = [String]() 7 | var debugLogs = [String]() 8 | var infoLogs = [String]() 9 | var warningLogs = [String]() 10 | var errorLogs = [String]() 11 | 12 | func logError(_ str: String) { 13 | errorLogs.append(str) 14 | } 15 | 16 | func logWarning(_ str: String) { 17 | warningLogs.append(str) 18 | } 19 | 20 | func logInfo(_ str: String) { 21 | infoLogs.append(str) 22 | } 23 | 24 | func logDebug(_ str: String) { 25 | debugLogs.append(str) 26 | } 27 | 28 | func logVerbose(_ str: String) { 29 | verboseLogs.append(str) 30 | } 31 | 32 | } 33 | 34 | class LoggingTest: XCTestCase { 35 | 36 | private let logger = CaptureLogs() 37 | 38 | override func setUp() { 39 | WPSharedSetLoggingDelegate(logger) 40 | } 41 | 42 | func testLogging() { 43 | WPSharedLogVerbose("This is a verbose log") 44 | WPSharedLogVerbose("This is a verbose log %@", "with an argument") 45 | XCTAssertEqual(self.logger.verboseLogs, ["This is a verbose log", "This is a verbose log with an argument"]) 46 | 47 | WPSharedLogDebug("This is a debug log") 48 | WPSharedLogDebug("This is a debug log %@", "with an argument") 49 | XCTAssertEqual(self.logger.debugLogs, ["This is a debug log", "This is a debug log with an argument"]) 50 | 51 | WPSharedLogInfo("This is an info log") 52 | WPSharedLogInfo("This is an info log %@", "with an argument") 53 | XCTAssertEqual(self.logger.infoLogs, ["This is an info log", "This is an info log with an argument"]) 54 | 55 | WPSharedLogWarning("This is a warning log") 56 | WPSharedLogWarning("This is a warning log %@", "with an argument") 57 | XCTAssertEqual(self.logger.warningLogs, ["This is a warning log", "This is a warning log with an argument"]) 58 | 59 | WPSharedLogError("This is an error log") 60 | WPSharedLogError("This is an error log %@", "with an argument") 61 | XCTAssertEqual(self.logger.errorLogs, ["This is an error log", "This is an error log with an argument"]) 62 | } 63 | 64 | func testNoLogging() { 65 | WPSharedSetLoggingDelegate(nil) 66 | XCTAssertNoThrow(WPSharedLogInfo("this log should not be printed")) 67 | XCTAssertEqual(self.logger.infoLogs.count, 0) 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | The format of this document is inspired by [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 4 | 5 | 32 | 33 | ## Unreleased 34 | 35 | ### Breaking Changes 36 | 37 | _None._ 38 | 39 | ### New Features 40 | 41 | _None._ 42 | 43 | ### Bug Fixes 44 | 45 | _None._ 46 | 47 | ### Internal Changes 48 | 49 | _None._ 50 | 51 | ## 2.4.0 52 | 53 | ### New Features 54 | 55 | - Added an analytics event for Stats Subscribers [#356] 56 | 57 | ## 2.3.1 58 | 59 | ### Bug Fixes 60 | 61 | - Remove video block content from post excerpt [#352] 62 | 63 | ## 2.3.0 64 | 65 | ### New Features 66 | 67 | - Add editor upload paused event [#343] 68 | 69 | ## 2.2.0 70 | 71 | ### New Features 72 | 73 | - Strip Gutenberg VideoPress block for excerpt [#339] 74 | 75 | ## 2.1.0 76 | 77 | ### New Features 78 | 79 | - Add `ConsoleLogger`, a `WordPressLoggingDelegate` implementation that can be used during development and debugging [#335] 80 | 81 | ## 2.0.1 82 | 83 | ### Internal Changes 84 | 85 | - Fix an occasional crash caused by `performSelector:withObject:` [#328] 86 | - Replace the symbolic links in the include directory with real header files [#329] 87 | 88 | ## 2.0.0 89 | 90 | ### Breaking Changes 91 | 92 | - Remove CocoaLumberjack. The app needs to provide a `WordPressLoggingDelegate` implementation [#325] 93 | 94 | ### New Features 95 | 96 | - Add Swift Package Manager support [#321] 97 | 98 | ### Bug Fixes 99 | 100 | - Fix an issue where 'pod install' produces a 'duplicate UUID' warning. [#327] 101 | 102 | ### Internal Changes 103 | 104 | - Add this changelog entry about changelog itself [#317] 105 | - Remove FormatterKit [#320] 106 | - Move away from Specta, use Quick instead [#319] 107 | - Fix an occasional crash caused by `performSelector:withObject:` [#328] 108 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Views/WPStyleGuide+SerifFonts.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | #if SWIFT_PACKAGE 5 | import WordPressSharedObjC 6 | #endif 7 | 8 | /// WPStyleGuide Extension to use serif fonts. 9 | /// 10 | extension WPStyleGuide { 11 | /// Returns the system serif font (New York) for iOS 13+ but defaults to noto for older os's 12 | @objc public class func serifFontForTextStyle( 13 | _ style: UIFont.TextStyle, 14 | fontWeight weight: UIFont.Weight = .regular) -> UIFont { 15 | 16 | guard #available(iOS 13, *) else { 17 | return WPStyleGuide.notoFontForTextStyle(style, fontWeight: weight) 18 | } 19 | 20 | return scaledFont(for: style, weight: weight, design: .serif) 21 | } 22 | 23 | // Returns the system serif font (New York) for iOS 13+ but defaults to noto for older os's, at the default size for the specified style 24 | @objc public class func fixedSerifFontForTextStyle(_ style: UIFont.TextStyle, 25 | fontWeight weight: UIFont.Weight = .regular) -> UIFont { 26 | 27 | let defaultContentSizeCategory = UITraitCollection(preferredContentSizeCategory: .large) // .large is the default 28 | let fontSize = UIFontDescriptor.preferredFontDescriptor(withTextStyle: style, compatibleWith: defaultContentSizeCategory).pointSize 29 | 30 | guard #available(iOS 13, *), 31 | let fontDescriptor = UIFont.systemFont(ofSize: fontSize, weight: weight).fontDescriptor.withDesign(.serif) else { 32 | switch weight { 33 | case .bold, .semibold, .heavy, .black: 34 | return WPStyleGuide.fixedBoldNotoFontWithSize(fontSize) 35 | default: 36 | return WPStyleGuide.fixedNotoFontWithSize(fontSize) 37 | } 38 | } 39 | 40 | // Uses size from original font, so we don't want to override it here. 41 | return UIFont(descriptor: fontDescriptor, size: 0.0) 42 | } 43 | 44 | private class func notoFontForTextStyle(_ style: UIFont.TextStyle, 45 | fontWeight weight: UIFont.Weight = .regular) -> UIFont { 46 | var font: UIFont 47 | 48 | switch weight { 49 | // Map all the bold weights to the bold font 50 | case .bold, .semibold, .heavy, .black: 51 | font = WPStyleGuide.notoBoldFontForTextStyle(style) 52 | default: 53 | font = WPStyleGuide.notoFontForTextStyle(style) 54 | } 55 | 56 | return font 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/include/WPStyleGuide.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | 4 | NS_ASSUME_NONNULL_BEGIN 5 | 6 | @class WPTextFieldTableViewCell; 7 | @interface WPStyleGuide : NSObject 8 | 9 | // Fonts 10 | + (UIFont *)subtitleFont; 11 | + (NSDictionary *)subtitleAttributes; 12 | + (UIFont *)subtitleFontItalic; 13 | + (NSDictionary *)subtitleItalicAttributes; 14 | + (UIFont *)subtitleFontBold; 15 | + (NSDictionary *)subtitleAttributesBold; 16 | + (UIFont *)labelFont; 17 | + (UIFont *)labelFontNormal; 18 | + (NSDictionary *)labelAttributes; 19 | + (UIFont *)regularTextFont; 20 | + (UIFont *)regularTextFontSemiBold; 21 | + (NSDictionary *)regularTextAttributes; 22 | + (UIFont *)tableviewTextFont; 23 | + (UIFont *)tableviewSubtitleFont; 24 | + (UIFont *)tableviewSectionHeaderFont; 25 | + (UIFont *)tableviewSectionFooterFont; 26 | 27 | // Color 28 | + (UIColor *)wordPressBlue; 29 | + (UIColor *)lightBlue; 30 | + (UIColor *)mediumBlue; 31 | + (UIColor *)darkBlue; 32 | + (UIColor *)grey; 33 | + (UIColor *)lightGrey; 34 | + (UIColor *)greyLighten30; 35 | + (UIColor *)greyLighten20; 36 | + (UIColor *)greyLighten10; 37 | + (UIColor *)greyDarken10; 38 | + (UIColor *)greyDarken20; 39 | + (UIColor *)greyDarken30; 40 | + (UIColor *)darkGrey; 41 | + (UIColor *)jazzyOrange; 42 | + (UIColor *)fireOrange; 43 | + (UIColor *)validGreen; 44 | + (UIColor *)warningYellow; 45 | + (UIColor *)errorRed; 46 | + (UIColor *)alertYellowDark; 47 | + (UIColor *)alertYellowLighter; 48 | + (UIColor *)alertRedDarker; 49 | 50 | // Misc 51 | + (UIColor *)keyboardColor; 52 | + (UIColor *)textFieldPlaceholderGrey; 53 | 54 | // Bar Button Styles 55 | + (UIBarButtonItemStyle)barButtonStyleForDone; 56 | + (UIBarButtonItemStyle)barButtonStyleForBordered; 57 | + (void)setLeftBarButtonItemWithCorrectSpacing:(UIBarButtonItem *)barButtonItem forNavigationItem:(UINavigationItem *)navigationItem; 58 | + (void)setRightBarButtonItemWithCorrectSpacing:(UIBarButtonItem *)barButtonItem forNavigationItem:(UINavigationItem *)navigationItem; 59 | + (void)configureNavigationBarAppearance; 60 | + (void)configureDocumentPickerNavBarAppearance; 61 | 62 | // Move to a feature category 63 | + (UIColor *)buttonActionColor; 64 | + (UIColor *)nuxFormText; 65 | + (UIColor *)nuxFormPlaceholderText; 66 | 67 | // Deprecated Colors 68 | + (UIColor *)baseLighterBlue; 69 | + (UIColor *)baseDarkerBlue; 70 | + (UIColor *)newKidOnTheBlockBlue; 71 | + (UIColor *)midnightBlue; 72 | + (UIColor *)bigEddieGrey; 73 | + (UIColor *)littleEddieGrey; 74 | + (UIColor *)whisperGrey; 75 | + (UIColor *)allTAllShadeGrey; 76 | + (UIColor *)readGrey; 77 | + (UIColor *)itsEverywhereGrey; 78 | + (UIColor *)darkAsNightGrey; 79 | + (UIColor *)validationErrorRed; 80 | 81 | @end 82 | 83 | NS_ASSUME_NONNULL_END 84 | -------------------------------------------------------------------------------- /Tests/WordPressSharedObjCTests/NSStringSwiftTests.m: -------------------------------------------------------------------------------- 1 | #import 2 | @import WordPressShared; 3 | 4 | @interface NSStringSwiftTest : XCTestCase 5 | 6 | @end 7 | 8 | @implementation NSStringSwiftTest 9 | 10 | - (void)testHostname 11 | { 12 | NSString *samplePlainURL = @"http://www.wordpress.com"; 13 | NSString *sampleStrippedURL = @"www.wordpress.com"; 14 | XCTAssertEqualObjects(samplePlainURL.hostname, sampleStrippedURL, @"Invalid Stripped String"); 15 | 16 | NSString *sampleSecureURL = @"https://www.wordpress.com"; 17 | XCTAssertEqualObjects(sampleSecureURL.hostname, sampleStrippedURL, @"Invalid Stripped String"); 18 | 19 | NSString *sampleComplexURL = @"http://www.wordpress.com?var=http://wordpress.org"; 20 | XCTAssertEqualObjects(sampleComplexURL.hostname, sampleStrippedURL, @"Invalid Stripped String"); 21 | 22 | NSString *samplePlainCapsURL = @"http://www.WordPress.com"; 23 | NSString *sampleStrippedCapsURL = @"www.WordPress.com"; 24 | XCTAssertEqualObjects(samplePlainCapsURL.hostname, sampleStrippedCapsURL, @"Invalid Stripped String"); 25 | } 26 | 27 | - (void)testUniqueStringComponentsSeparatedByWhitespaceCorrectlyReturnsASetWithItsWords 28 | { 29 | NSString *testString = @"first\nsecond third\nfourth fifth"; 30 | NSSet *testSet = [testString uniqueStringComponentsSeparatedByNewline]; 31 | XCTAssert([testSet containsObject:@"first"], @"Missing line"); 32 | XCTAssert([testSet containsObject:@"second third"], @"Missing line"); 33 | XCTAssert([testSet containsObject:@"fourth fifth"], @"Missing line"); 34 | XCTAssert([testSet count] == 3, @"Invalid count"); 35 | } 36 | 37 | - (void)testUniqueStringComponentsSeparatedByWhitespaceDoesntAddEmptyStrings 38 | { 39 | NSString *testString = @""; 40 | NSSet *testSet = [testString uniqueStringComponentsSeparatedByNewline]; 41 | XCTAssert([testSet count] == 0, @"Invalid count"); 42 | } 43 | 44 | - (void)testIsValidEmail 45 | { 46 | // Although rare, TLDs can have email too 47 | XCTAssertTrue([@"koke@com" isValidEmail]); 48 | 49 | // Unusual but valid! 50 | XCTAssertTrue([@"\"Jorge Bernal\"@example.com" isValidEmail]); 51 | 52 | // The hyphen is permitted if it is surrounded by characters, digits or hyphens, 53 | // although it is not to start or end a label 54 | XCTAssertTrue([@"koke@-example.com" isValidEmail]); 55 | XCTAssertTrue([@"koke@example-.com" isValidEmail]); 56 | 57 | // https://en.wikipedia.org/wiki/International_email 58 | XCTAssertTrue([@"用户@例子.广告" isValidEmail]); 59 | XCTAssertTrue([@"उपयोगकर्ता@उदाहरण.कॉम" isValidEmail]); 60 | 61 | // Now, the invalid scenario 62 | XCTAssertFalse([@"notavalid.email" isValidEmail]); 63 | } 64 | 65 | @end 66 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.10 2 | 3 | import Foundation 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "WordPressShared", 8 | platforms: [.iOS(.v13), .macOS(.v12)], 9 | products: [ 10 | .library(name: "WordPressShared", targets: ["WordPressShared"]) 11 | ], 12 | dependencies: [ 13 | .package(url: "https://github.com/buildkite/test-collector-swift", from: "0.3.0"), 14 | .package(url: "https://github.com/realm/SwiftLint", exact: loadSwiftLintVersion()) 15 | ], 16 | targets: [ 17 | .target( 18 | name: "WordPressSharedObjC", 19 | resources: [.process("Resources")] 20 | ), 21 | .target( 22 | name: "WordPressShared", 23 | dependencies: [ 24 | .target(name: "WordPressSharedObjC"), 25 | ], 26 | resources: [.process("Resources")], 27 | plugins: [ 28 | .plugin(name: "SwiftLintPlugin", package: "SwiftLint") 29 | ] 30 | ), 31 | .testTarget( 32 | name: "WordPressSharedTests", 33 | dependencies: [ 34 | .target(name: "WordPressShared"), 35 | .product(name: "BuildkiteTestCollector", package: "test-collector-swift") 36 | ], 37 | plugins: [ 38 | .plugin(name: "SwiftLintPlugin", package: "SwiftLint") 39 | ] 40 | ), 41 | .testTarget( 42 | name: "WordPressSharedObjCTests", 43 | dependencies: [ 44 | .target(name: "WordPressShared"), 45 | .product(name: "BuildkiteTestCollector", package: "test-collector-swift") 46 | ], 47 | resources: [.process("Resources")], 48 | plugins: [ 49 | .plugin(name: "SwiftLintPlugin", package: "SwiftLint") 50 | ] 51 | ), 52 | ] 53 | ) 54 | 55 | func loadSwiftLintVersion() -> Version { 56 | let swiftLintConfigURL = URL(fileURLWithPath: #file) 57 | .deletingLastPathComponent() 58 | .appendingPathComponent(".swiftlint.yml") 59 | 60 | guard let yamlString = try? String(contentsOf: swiftLintConfigURL) else { 61 | fatalError("Failed to read SwiftLint config file at \(swiftLintConfigURL).") 62 | } 63 | 64 | guard let versionLine = yamlString.components(separatedBy: .newlines) 65 | .first(where: { $0.contains("swiftlint_version") }) else { 66 | fatalError("SwiftLint version not found in YAML file.") 67 | } 68 | 69 | // Assumes the format `swiftlint_version: ` 70 | guard let version = Version(versionLine.components(separatedBy: ":") 71 | .last? 72 | .trimmingCharacters(in: .whitespaces) ?? "") else { 73 | fatalError("Failed to extract SwiftLint version.") 74 | } 75 | 76 | return version 77 | } 78 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "1f7986484b7e8b057dd70f8be4c133ec09b8168d92949426b9d7e9ffb9521815", 3 | "pins" : [ 4 | { 5 | "identity" : "collectionconcurrencykit", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/JohnSundell/CollectionConcurrencyKit.git", 8 | "state" : { 9 | "revision" : "b4f23e24b5a1bff301efc5e70871083ca029ff95", 10 | "version" : "0.2.0" 11 | } 12 | }, 13 | { 14 | "identity" : "cryptoswift", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", 17 | "state" : { 18 | "revision" : "7892a123f7e8d0fe62f9f03728b17bbd4f94df5c", 19 | "version" : "1.8.1" 20 | } 21 | }, 22 | { 23 | "identity" : "sourcekitten", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/jpsim/SourceKitten.git", 26 | "state" : { 27 | "revision" : "b6dc09ee51dfb0c66e042d2328c017483a1a5d56", 28 | "version" : "0.34.1" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-argument-parser", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/apple/swift-argument-parser.git", 35 | "state" : { 36 | "revision" : "8f4d2753f0e4778c76d5f05ad16c74f707390531", 37 | "version" : "1.2.3" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-syntax", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/apple/swift-syntax.git", 44 | "state" : { 45 | "revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036", 46 | "version" : "509.0.2" 47 | } 48 | }, 49 | { 50 | "identity" : "swiftlint", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/realm/SwiftLint", 53 | "state" : { 54 | "revision" : "f17a4f9dfb6a6afb0408426354e4180daaf49cee", 55 | "version" : "0.54.0" 56 | } 57 | }, 58 | { 59 | "identity" : "swiftytexttable", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/scottrhoyt/SwiftyTextTable.git", 62 | "state" : { 63 | "revision" : "c6df6cf533d120716bff38f8ff9885e1ce2a4ac3", 64 | "version" : "0.9.0" 65 | } 66 | }, 67 | { 68 | "identity" : "swxmlhash", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/drmohundro/SWXMLHash.git", 71 | "state" : { 72 | "revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f", 73 | "version" : "7.0.2" 74 | } 75 | }, 76 | { 77 | "identity" : "test-collector-swift", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/buildkite/test-collector-swift", 80 | "state" : { 81 | "revision" : "77c7f492f5c1c9ca159f73d18f56bbd1186390b0", 82 | "version" : "0.3.0" 83 | } 84 | }, 85 | { 86 | "identity" : "yams", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/jpsim/Yams.git", 89 | "state" : { 90 | "revision" : "0d9ee7ea8c4ebd4a489ad7a73d5c6cad55d6fed3", 91 | "version" : "5.0.6" 92 | } 93 | } 94 | ], 95 | "version" : 3 96 | } 97 | -------------------------------------------------------------------------------- /Tests/WordPressSharedTests/NSStringSummaryTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import WordPressShared 3 | 4 | class NSStringSummaryTests: XCTestCase { 5 | 6 | func testSummaryForContent() { 7 | let content = "

Lorem ipsum dolor sit amet, [shortcode param=\"value\"]consectetur[/shortcode] adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.

Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

" 8 | let expectedSummary = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, …" 9 | 10 | let summary = content.summarized() 11 | 12 | XCTAssertEqual(summary, expectedSummary) 13 | } 14 | 15 | func testSummaryForContentWithGallery() { 16 | let content = "

Some Content

" 17 | let expectedSummary = "Some Content" 18 | 19 | let summary = content.summarized() 20 | 21 | XCTAssertEqual(summary, expectedSummary) 22 | } 23 | 24 | func testSummaryForContentWithGallery2() { 25 | let content = "

Before

\n

After

" 26 | let expectedSummary = "Before\nAfter" 27 | 28 | let summary = content.summarized() 29 | 30 | XCTAssertEqual(summary, expectedSummary) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/WordPressSharedTests/DebouncerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import WordPressShared 3 | 4 | class DebouncerTests: XCTestCase { 5 | 6 | /// Tests that the debouncer runs within an accurate time range normally. 7 | /// 8 | func testDebouncerRunsNormally() { 9 | let timerDelay = 0.5 10 | let allowedError = 0.5 11 | let minDelay = timerDelay * (1 - allowedError) 12 | let maxDelay = timerDelay * (1 + allowedError) 13 | let testTimeout = maxDelay + 0.01 14 | 15 | let startDate = Date() 16 | let debouncerHasRunAccurately = XCTestExpectation(description: "The debouncer should run within an accurate time range normally.") 17 | 18 | let debouncer = Debouncer(delay: timerDelay) { 19 | let actualDelay = Date().timeIntervalSince(startDate) 20 | 21 | if actualDelay >= minDelay 22 | && actualDelay <= maxDelay { 23 | 24 | debouncerHasRunAccurately.fulfill() 25 | } else { 26 | XCTFail("Actual delay was: \(actualDelay))") 27 | } 28 | } 29 | debouncer.call() 30 | 31 | wait(for: [debouncerHasRunAccurately], timeout: testTimeout) 32 | } 33 | 34 | /// Tests that the debouncer runs immediately if its released. 35 | /// 36 | func testDebouncerRunsImmediatelyIfReleased() { 37 | let debouncerHasRun = XCTestExpectation(description: "The debouncer should run immediately if it's released") 38 | 39 | Debouncer(delay: 0.5) { 40 | debouncerHasRun.fulfill() 41 | }.call() 42 | 43 | wait(for: [debouncerHasRun], timeout: 0) 44 | } 45 | 46 | /// Tests that we can cancel the debouncer's operation. 47 | /// 48 | func testDebouncerCanBeCancelled() { 49 | let debouncerDelay = 0.2 50 | let testTimeout = debouncerDelay * 2 51 | let debouncerHasRun = XCTestExpectation(description: "The debouncer's operation should be cancellable.") 52 | debouncerHasRun.isInverted = true 53 | 54 | let debouncer = Debouncer(delay: debouncerDelay) { 55 | debouncerHasRun.fulfill() 56 | } 57 | 58 | debouncer.call() 59 | debouncer.cancel() 60 | 61 | wait(for: [debouncerHasRun], timeout: testTimeout) 62 | } 63 | 64 | /// Tests that the debouncer works fine when used with an ad hoc callback. 65 | /// 66 | func testDebouncerWithAdHocCallback() { 67 | let timerDelay = 0.5 68 | let allowedError = 0.5 69 | let minDelay = timerDelay * (1 - allowedError) 70 | let maxDelay = timerDelay * (1 + allowedError) 71 | let testTimeout = maxDelay + 0.01 72 | 73 | let startDate = Date() 74 | let debouncerHasRunAccurately = XCTestExpectation(description: "The debouncer should run within an accurate time range normally.") 75 | 76 | let debouncer = Debouncer(delay: timerDelay) 77 | debouncer.call() { 78 | let actualDelay = Date().timeIntervalSince(startDate) 79 | 80 | if actualDelay >= minDelay 81 | && actualDelay <= maxDelay { 82 | 83 | debouncerHasRunAccurately.fulfill() 84 | } else { 85 | XCTFail("Actual delay was: \(actualDelay))") 86 | } 87 | } 88 | 89 | wait(for: [debouncerHasRunAccurately], timeout: testTimeout) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Tests/WordPressSharedTests/NSDateHelperTest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | @testable import WordPressShared 4 | 5 | class NSDateHelperTest: XCTestCase { 6 | struct Data { 7 | let year: Int 8 | let month: Int 9 | let day: Int 10 | 11 | var dateString: String { 12 | return "\(year)-\(month)-\(day)" 13 | } 14 | } 15 | 16 | let data = Data(year: 2019, month: 02, day: 17) 17 | var date: Date? 18 | var dateFormatter: DateFormatter { 19 | let formatter = DateFormatter() 20 | formatter.dateFormat = "yyyy-MM-dd" 21 | return formatter 22 | } 23 | 24 | override func setUp() { 25 | NSTimeZone.default = TimeZone(secondsFromGMT: 0)! 26 | date = dateFormatter.date(from: data.dateString) 27 | } 28 | 29 | func testDateAndTimeComponents() { 30 | XCTAssertNotNil(date) 31 | 32 | let components = date!.dateAndTimeComponents() 33 | XCTAssertEqual(components.year, data.year) 34 | XCTAssertEqual(components.month, data.month) 35 | XCTAssertEqual(components.day, data.day) 36 | } 37 | 38 | /// Verifies that `mediumString` produces relative format strings when less than 7 days have elapsed. 39 | /// If this test is failing, check that the Test Plan is still using en-US as its language 40 | func testToMediumStringRelativeString() { 41 | let date = Date() 42 | 43 | XCTAssertEqual(date.toMediumString(), "now") 44 | 45 | XCTAssertEqual(date.addingTimeInterval(-60*5).toMediumString(), "5 minutes ago") 46 | XCTAssertEqual(date.addingTimeInterval(1).addingTimeInterval(60*5).toMediumString(), "in 5 minutes") 47 | 48 | XCTAssertEqual(date.addingTimeInterval(-60*60*2).toMediumString(), "2 hours ago") 49 | XCTAssertEqual(date.addingTimeInterval(1).addingTimeInterval(60*60*2).toMediumString(), "in 2 hours") 50 | 51 | XCTAssertEqual(date.addingTimeInterval(-60*60*24).toMediumString(), "yesterday") 52 | XCTAssertEqual(date.addingTimeInterval(1).addingTimeInterval(60*60*24).toMediumString(), "tomorrow") 53 | 54 | XCTAssertEqual(date.addingTimeInterval(-60*60*24*6).toMediumString(), "6 days ago") 55 | XCTAssertEqual(date.addingTimeInterval(1).addingTimeInterval(60*60*24*6).toMediumString(), "in 6 days") 56 | } 57 | 58 | /// Verifies that `mediumStringWithTime` takes into account the time zone adjustment 59 | /// 60 | /// This legacy test is a bit silly because it is simply testing that the code calls `DateFormatter` with the expected configuration. 61 | /// This was done to make the test robust against underlying changes in `DateFormatter`'s behavior. 62 | /// Example failure this avoids: https://buildkite.com/automattic/wordpress-shared-ios/builds/235#018ed45e-c2be-40e5-9759-6bd7c0735ce9/6-2623 63 | func testMediumStringTimeZoneAdjust() { 64 | let date = Date() 65 | let timeZone = TimeZone(secondsFromGMT: Calendar.current.timeZone.secondsFromGMT() - (60 * 60)) 66 | XCTAssertEqual(date.toMediumString(inTimeZone: timeZone), "now") 67 | 68 | let timeFormatter = DateFormatter() 69 | timeFormatter.doesRelativeDateFormatting = true 70 | timeFormatter.dateStyle = .medium 71 | timeFormatter.timeStyle = .short 72 | let withoutTimeZoneAdjust = timeFormatter.string(from: date) 73 | 74 | XCTAssertEqual(date.mediumStringWithTime(), withoutTimeZoneAdjust) 75 | 76 | timeFormatter.timeZone = timeZone 77 | let withTimeZoneAdjust = timeFormatter.string(from: date) 78 | 79 | XCTAssertEqual(date.mediumStringWithTime(timeZone: timeZone), withTimeZoneAdjust) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/Analytics/WPAnalytics.m: -------------------------------------------------------------------------------- 1 | #import "WPAnalytics.h" 2 | 3 | NSString *const WPAnalyticsStatEditorPublishedPostPropertyCategory = @"with_categories"; 4 | NSString *const WPAnalyticsStatEditorPublishedPostPropertyPhoto = @"with_photos"; 5 | NSString *const WPAnalyticsStatEditorPublishedPostPropertyTag = @"with_tags"; 6 | NSString *const WPAnalyticsStatEditorPublishedPostPropertyVideo = @"with_videos"; 7 | 8 | @implementation WPAnalytics 9 | 10 | + (NSMutableArray *)trackers 11 | { 12 | static NSMutableArray *trackers = nil; 13 | 14 | static dispatch_once_t predicate; 15 | dispatch_once(&predicate, ^{ 16 | trackers = [[NSMutableArray alloc] init]; 17 | }); 18 | 19 | return trackers; 20 | } 21 | 22 | + (void)registerTracker:(id)tracker 23 | { 24 | NSParameterAssert(tracker != nil); 25 | [[self trackers] addObject:tracker]; 26 | } 27 | 28 | + (void)clearTrackers 29 | { 30 | [[self trackers] removeAllObjects]; 31 | } 32 | 33 | + (void)beginTimerForStat:(WPAnalyticsStat)stat 34 | { 35 | for (id tracker in [self trackers]) { 36 | if ([tracker respondsToSelector:@selector(beginTimerForStat:)]) { 37 | [tracker beginTimerForStat:stat]; 38 | } 39 | } 40 | } 41 | 42 | + (void)endTimerForStat:(WPAnalyticsStat)stat withProperties:(NSDictionary *)properties 43 | { 44 | for (id tracker in [self trackers]) { 45 | if ([tracker respondsToSelector:@selector(endTimerForStat:withProperties:)]) { 46 | [tracker endTimerForStat:stat withProperties:properties]; 47 | } 48 | } 49 | } 50 | 51 | + (void)track:(WPAnalyticsStat)stat 52 | { 53 | for (id tracker in [self trackers]) { 54 | [tracker track:stat]; 55 | } 56 | } 57 | 58 | + (void)track:(WPAnalyticsStat)stat withProperties:(NSDictionary *)properties 59 | { 60 | NSParameterAssert(properties != nil); 61 | for (id tracker in [self trackers]) { 62 | [tracker track:stat withProperties:properties]; 63 | } 64 | } 65 | 66 | + (void)trackString:(NSString *)event 67 | { 68 | for (id tracker in [self trackers]) { 69 | [tracker trackString:event]; 70 | } 71 | } 72 | 73 | + (void)trackString:(NSString *)event withProperties:(NSDictionary *)properties 74 | { 75 | NSParameterAssert(properties != nil); 76 | for (id tracker in [self trackers]) { 77 | [tracker trackString:event withProperties:properties]; 78 | } 79 | } 80 | 81 | + (void)beginSession 82 | { 83 | for (id tracker in [self trackers]) { 84 | if ([tracker respondsToSelector:@selector(beginSession)]) { 85 | [tracker beginSession]; 86 | } 87 | } 88 | } 89 | 90 | + (void)endSession 91 | { 92 | for (id tracker in [self trackers]) { 93 | if ([tracker respondsToSelector:@selector(endSession)]) { 94 | [tracker endSession]; 95 | } 96 | } 97 | } 98 | 99 | + (void)refreshMetadata 100 | { 101 | for (id tracker in [self trackers]) { 102 | if ([tracker respondsToSelector:@selector(refreshMetadata)]) { 103 | [tracker refreshMetadata]; 104 | } 105 | } 106 | } 107 | 108 | + (void)clearQueuedEvents 109 | { 110 | for (idtracker in [self trackers]) { 111 | if ([tracker respondsToSelector:@selector(clearQueuedEvents)]) { 112 | [tracker clearQueuedEvents]; 113 | } 114 | } 115 | } 116 | 117 | @end 118 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/Views/WPNUXUtility.m: -------------------------------------------------------------------------------- 1 | #import "WPNUXUtility.h" 2 | #import "WPFontManager.h" 3 | 4 | 5 | @implementation WPNUXUtility 6 | 7 | #pragma mark - Fonts 8 | 9 | + (UIFont *)textFieldFont 10 | { 11 | return [WPFontManager systemRegularFontOfSize:16.0]; 12 | } 13 | 14 | + (UIFont *)descriptionTextFont 15 | { 16 | return [WPFontManager systemRegularFontOfSize:15.0]; 17 | } 18 | 19 | + (UIFont *)titleFont 20 | { 21 | return [WPFontManager systemLightFontOfSize:24.0]; 22 | } 23 | 24 | + (UIFont *)swipeToContinueFont 25 | { 26 | return [WPFontManager systemRegularFontOfSize:10.0]; 27 | } 28 | 29 | + (UIFont *)tosLabelFont 30 | { 31 | return [WPFontManager systemRegularFontOfSize:12.0]; 32 | } 33 | 34 | + (UIFont *)tosLabelSmallerFont 35 | { 36 | return [WPFontManager systemRegularFontOfSize:9.0]; 37 | } 38 | 39 | + (UIFont *)confirmationLabelFont 40 | { 41 | return [WPFontManager systemRegularFontOfSize:14.0]; 42 | } 43 | 44 | #pragma mark - Colors 45 | 46 | + (UIColor *)bottomPanelLineColor 47 | { 48 | return [UIColor colorWithRed:43/255.0f green:153/255.0f blue:193/255.0f alpha:1.0f]; 49 | } 50 | 51 | + (UIColor *)descriptionTextColor 52 | { 53 | return [UIColor colorWithRed:187.0/255.0 green:221.0/255.0 blue:237.0/255.0 alpha:1.0]; 54 | } 55 | 56 | + (UIColor *)bottomPanelBackgroundColor 57 | { 58 | return [self backgroundColor]; 59 | } 60 | 61 | + (UIColor *)swipeToContinueTextColor 62 | { 63 | return [UIColor colorWithRed:255.0 green:255.0 blue:255.0 alpha:0.3]; 64 | } 65 | 66 | + (UIColor *)confirmationLabelColor 67 | { 68 | return [UIColor colorWithRed:188.0/255.0 green:221.0/255.0 blue:236.0/255.0 alpha:1.0]; 69 | } 70 | 71 | + (UIColor *)backgroundColor 72 | { 73 | return [UIColor colorWithRed:46.0/255.0 green:162.0/255.0 blue:204.0/255.0 alpha:1.0]; 74 | } 75 | 76 | + (UIColor *)tosLabelColor 77 | { 78 | return [self descriptionTextColor]; 79 | } 80 | 81 | #pragma mark - Helper Methods 82 | 83 | + (void)centerViews:(NSArray *)controls withStartingView:(UIView *)startingView andEndingView:(UIView *)endingView forHeight:(CGFloat)viewHeight 84 | { 85 | CGFloat heightOfControls = CGRectGetMaxY(endingView.frame) - CGRectGetMinY(startingView.frame); 86 | CGFloat startingYForCenteredControls = floorf((viewHeight - heightOfControls)/2.0); 87 | CGFloat offsetToCenter = CGRectGetMinY(startingView.frame) - startingYForCenteredControls; 88 | 89 | for (UIControl *control in controls) { 90 | CGRect frame = control.frame; 91 | frame.origin.y -= offsetToCenter; 92 | control.frame = frame; 93 | } 94 | } 95 | 96 | + (void)configurePageControlTintColors:(UIPageControl *)pageControl 97 | { 98 | // This only works on iOS6+ 99 | if ([pageControl respondsToSelector:@selector(pageIndicatorTintColor)]) { 100 | UIColor *currentPageTintColor = [UIColor colorWithRed:187.0/255.0 green:221.0/255.0 blue:237.0/255.0 alpha:1.0]; 101 | UIColor *pageIndicatorTintColor = [UIColor colorWithRed:38.0/255.0 green:151.0/255.0 blue:197.0/255.0 alpha:1.0]; 102 | pageControl.pageIndicatorTintColor = pageIndicatorTintColor; 103 | pageControl.currentPageIndicatorTintColor = currentPageTintColor; 104 | } 105 | } 106 | 107 | + (NSDictionary *)titleAttributesWithColor:(UIColor *)color { 108 | 109 | NSMutableParagraphStyle *paragraphStyle = [[NSMutableParagraphStyle alloc] init]; 110 | paragraphStyle.lineHeightMultiple = 0.9; 111 | paragraphStyle.lineBreakMode = NSLineBreakByWordWrapping; 112 | paragraphStyle.alignment = NSTextAlignmentCenter; 113 | NSDictionary *attributes = @{NSFontAttributeName: [UIFont preferredFontForTextStyle:UIFontTextStyleTitle2], 114 | NSForegroundColorAttributeName: color, 115 | NSParagraphStyleAttributeName: paragraphStyle}; 116 | return attributes; 117 | } 118 | 119 | @end 120 | -------------------------------------------------------------------------------- /Tests/WordPressSharedTests/LanguagesTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import WordPressShared 3 | 4 | 5 | 6 | class LanguagesTests: XCTestCase { 7 | func testLanguagesEffectivelyLoadJsonFile() { 8 | let languages = WordPressComLanguageDatabase() 9 | 10 | XCTAssert(languages.all.count != 0) 11 | XCTAssert(languages.popular.count != 0) 12 | } 13 | 14 | func testAllLanguagesHaveValidFields() { 15 | let languages = WordPressComLanguageDatabase() 16 | let sum = languages.all + languages.popular 17 | 18 | for language in sum { 19 | XCTAssert(language.slug.count > 0) 20 | XCTAssert(language.name.count > 0) 21 | } 22 | } 23 | 24 | func testAllLanguagesContainPopularLanguages() { 25 | let languages = WordPressComLanguageDatabase() 26 | 27 | for language in languages.popular { 28 | let filtered = languages.all.filter { $0.id == language.id } 29 | XCTAssert(filtered.count == 1) 30 | } 31 | } 32 | 33 | func testNameForLanguageWithIdentifierReturnsTheRightName() { 34 | let languages = WordPressComLanguageDatabase() 35 | 36 | let english = languages.nameForLanguageWithId(en) 37 | let spanish = languages.nameForLanguageWithId(es) 38 | 39 | XCTAssert(english == "English") 40 | XCTAssert(spanish == "Español") 41 | } 42 | 43 | func testDeviceLanguageReturnsValueForSpanish() { 44 | let languages = WordPressComLanguageDatabase() 45 | languages._overrideDeviceLanguageCode("es") 46 | 47 | XCTAssertEqual(languages.deviceLanguage.id, es) 48 | } 49 | 50 | func testDeviceLanguageReturnsValueForSpanishSpainLowercase() { 51 | let languages = WordPressComLanguageDatabase() 52 | languages._overrideDeviceLanguageCode("es-es") 53 | 54 | XCTAssertEqual(languages.deviceLanguage.id, es) 55 | } 56 | 57 | func testDeviceLanguageReturnsValueForSpanishSpain() { 58 | let languages = WordPressComLanguageDatabase() 59 | languages._overrideDeviceLanguageCode("es-ES") 60 | 61 | XCTAssertEqual(languages.deviceLanguage.id, es) 62 | } 63 | 64 | func testDeviceLanguageReturnsEnglishForUnknownLanguage() { 65 | let languages = WordPressComLanguageDatabase() 66 | languages._overrideDeviceLanguageCode("not-a-language") 67 | 68 | XCTAssertEqual(languages.deviceLanguage.id, en) 69 | } 70 | 71 | func testDeviceLanguageReturnsValueForSpanishSpainExtra() { 72 | let languages = WordPressComLanguageDatabase() 73 | languages._overrideDeviceLanguageCode("es-ES-extra") 74 | 75 | XCTAssertEqual(languages.deviceLanguage.id, es) 76 | } 77 | 78 | func testDeviceLanguageReturnsValueForSpanishNO() { 79 | let languages = WordPressComLanguageDatabase() 80 | languages._overrideDeviceLanguageCode("es-NO") 81 | 82 | XCTAssertEqual(languages.deviceLanguage.id, es) 83 | } 84 | 85 | func testDeviceLanguageReturnsZhCNForZhHans() { 86 | let languages = WordPressComLanguageDatabase() 87 | languages._overrideDeviceLanguageCode("zh-Hans") 88 | 89 | XCTAssertEqual(languages.deviceLanguage.id, zhCN) 90 | } 91 | 92 | func testDeviceLanguageReturnsZhTWForZhHant() { 93 | let languages = WordPressComLanguageDatabase() 94 | languages._overrideDeviceLanguageCode("zh-Hant") 95 | 96 | XCTAssertEqual(languages.deviceLanguage.id, zhTW) 97 | } 98 | 99 | func testDeviceLanguageReturnsZhCNForZhHansES() { 100 | let languages = WordPressComLanguageDatabase() 101 | languages._overrideDeviceLanguageCode("zh-Hans-ES") 102 | 103 | XCTAssertEqual(languages.deviceLanguage.id, zhCN) 104 | } 105 | 106 | func testDeviceLanguageReturnsZhTWForZhHantES() { 107 | let languages = WordPressComLanguageDatabase() 108 | languages._overrideDeviceLanguageCode("zh-Hant-ES") 109 | 110 | XCTAssertEqual(languages.deviceLanguage.id, zhTW) 111 | } 112 | 113 | 114 | fileprivate let en = 1 115 | fileprivate let es = 19 116 | fileprivate let zhCN = 449 117 | fileprivate let zhTW = 452 118 | 119 | } 120 | -------------------------------------------------------------------------------- /Tests/WordPressSharedTests/StringStripGutenbergContentForExcerptTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import WordPressShared 3 | 4 | class StringStripGutenbergContentForExcerptTests: XCTestCase { 5 | 6 | func testStrippingGutenbergContentForExcerpt() { 7 | let content = "

Some Content

" 8 | let expectedSummary = "

Some Content

" 9 | 10 | let summary = content.strippingGutenbergContentForExcerpt() 11 | 12 | XCTAssertEqual(summary, expectedSummary) 13 | } 14 | 15 | func testStrippingGutenbergContentForExcerptWithGallery() { 16 | let content = "

Some Content

" 17 | let expectedSummary = "

Some Content

" 18 | 19 | let summary = content.strippingGutenbergContentForExcerpt() 20 | 21 | XCTAssertEqual(summary, expectedSummary) 22 | } 23 | 24 | func testStrippingGutenbergContentForExcerptWithGallery2() { 25 | let content = "

Before

\n

After

" 26 | let expectedSummary = "

Before

\n

After

" 27 | 28 | let summary = content.strippingGutenbergContentForExcerpt() 29 | 30 | XCTAssertEqual(summary, expectedSummary) 31 | } 32 | 33 | func testStrippingGutenbergContentForExcerptWithVideoPress() { 34 | let content = "

Before

\n\n
\nhttps://videopress.com/v/AbCDe?resizeToParent=true&cover=true&preloadContent=metadata&useAverageColor=true\n
\n\n

After

" 35 | let expectedSummary = "

Before

\n

After

" 36 | 37 | let summary = content.strippingGutenbergContentForExcerpt() 38 | 39 | XCTAssertEqual(summary, expectedSummary) 40 | } 41 | 42 | func testStrippingGutenbergContentForExcerptWithVideoPress2() { 43 | let content = "

Before

\n\n
\nhttps://videopress.com/v/AbCDe?resizeToParent=true&cover=true&preloadContent=metadata&useAverageColor=true\n
\n\n

After

" 44 | let expectedSummary = "

Before

\n

After

" 45 | 46 | let summary = content.strippingGutenbergContentForExcerpt() 47 | 48 | XCTAssertEqual(summary, expectedSummary) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Utility/EmailFormatValidator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Test a string to see if it resembles an email address. The checks in this 4 | /// class are based on those in wp-includes/formatting.php `is_email`. 5 | /// 6 | open class EmailFormatValidator { 7 | 8 | /// Validate the specified string. 9 | /// 10 | public class func validate(string: String) -> Bool { 11 | let str = string as NSString 12 | // Test for the minimum length the email can be 13 | if !isMinEmailLength(str) { 14 | return false 15 | } 16 | 17 | // Test for an @ character after the first position 18 | if !hasAtSign(str) { 19 | return false 20 | } 21 | 22 | // Split out the local and domain parts 23 | let arr = str.components(separatedBy: "@") 24 | if arr.count != 2 { 25 | return false 26 | } 27 | 28 | let local = arr[0] as NSString 29 | let domain = arr[1] as NSString 30 | 31 | // Local part 32 | 33 | // Test for invalid characters 34 | if containsLocalPartForbiddenCharacters(local) { 35 | return false 36 | } 37 | 38 | // Domain part 39 | 40 | // Test for sequences of periods 41 | if containsPeriodSequence(domain) { 42 | return false 43 | } 44 | 45 | // Test for whitespace 46 | if containsWhitespace(domain) { 47 | return false 48 | } 49 | 50 | // Test for leading/trailing periods 51 | if containsLeadingOrTrailingPeriod(domain) { 52 | return false 53 | } 54 | 55 | // Check for unallowed characters 56 | if containsDomainPartForbiddenCharacters(domain) { 57 | return false 58 | } 59 | 60 | // Split the domain into parts. Assume a minimum of two parts. 61 | if !resemblesHostname(domain) { 62 | return false 63 | } 64 | 65 | return true 66 | } 67 | 68 | 69 | /// Checks that the supplied string is at least the expected minimum email length. 70 | /// 71 | private class func isMinEmailLength(_ str: NSString) -> Bool { 72 | return str.length >= 6 73 | } 74 | 75 | 76 | /// Checks if the supplied string contains an @ sign that is not the first character. 77 | /// 78 | private class func hasAtSign(_ str: NSString) -> Bool { 79 | return str.range(of: "@").location > 0 80 | } 81 | 82 | 83 | /// Test that the string contains characters permitted in the local part of an email address. 84 | /// 85 | private class func containsLocalPartForbiddenCharacters(_ str: NSString) -> Bool { 86 | // match for allowed characters 87 | let regex = "^[a-zA-Z0-9!#$%&'*+\\/=?^_`{|}~\\.-]+$" 88 | let match = NSPredicate(format: "SELF MATCHES %@", regex) 89 | return !match.evaluate(with: str) 90 | } 91 | 92 | 93 | /// Check if the string contains more than one period in a row. 94 | /// 95 | private class func containsPeriodSequence(_ str: NSString) -> Bool { 96 | return str.contains("..") 97 | } 98 | 99 | 100 | /// Check if the string contains any whitespace or newline characters. 101 | /// 102 | private class func containsWhitespace(_ str: NSString) -> Bool { 103 | return str.rangeOfCharacter(from: .whitespacesAndNewlines).location != NSNotFound 104 | } 105 | 106 | 107 | /// Check if the string contains any leading or trailing periods. 108 | /// 109 | private class func containsLeadingOrTrailingPeriod(_ str: NSString) -> Bool { 110 | return str.hasPrefix(".") || str.hasSuffix(".") 111 | } 112 | 113 | 114 | /// Check if the string contains characters forbidden in the domain part of an email address. 115 | /// 116 | private class func containsDomainPartForbiddenCharacters(_ str: NSString) -> Bool { 117 | // Match for allowed characters 118 | // If the match evaluates to true, then the string contains at least one forbidden character 119 | let regex = "^[a-zA-Z0-9-.]+$" 120 | let match = NSPredicate(format: "SELF MATCHES %@", regex) 121 | return !match.evaluate(with: str) 122 | } 123 | 124 | 125 | /// Check if the supplied string resembles a host name. 126 | /// 127 | private class func resemblesHostname(_ str: NSString) -> Bool { 128 | // A host name should have two or more parts 129 | let parts = str.components(separatedBy: ".") 130 | return parts.count > 1 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/Utility/PhotonImageURLHelper.m: -------------------------------------------------------------------------------- 1 | #import "PhotonImageURLHelper.h" 2 | 3 | @implementation PhotonImageURLHelper 4 | 5 | static const NSUInteger DefaultPhotonImageQuality = 80; 6 | static const NSInteger MaxPhotonImageQuality = 100; 7 | static const NSInteger MinPhotonImageQuality = 1; 8 | 9 | + (NSURL *)photonURLWithSize:(CGSize)size forImageURL:(NSURL *)url 10 | { 11 | return [self photonURLWithSize:size forImageURL:url forceResize:YES imageQuality:DefaultPhotonImageQuality]; 12 | } 13 | 14 | + (NSURL *)photonURLWithSize:(CGSize)size forImageURL:(NSURL *)url forceResize:(BOOL)forceResize imageQuality:(NSUInteger)quality 15 | { 16 | // Photon will fail if the URL doesn't end in one of the accepted extensions 17 | NSArray *acceptedImageTypes = @[@"gif", @"jpg", @"jpeg", @"png"]; 18 | if ([acceptedImageTypes indexOfObject:url.pathExtension] == NSNotFound) { 19 | if (![url scheme]) { 20 | return [NSURL URLWithString:[NSString stringWithFormat:@"http://%@", [url absoluteString]]]; 21 | } 22 | return url; 23 | } 24 | 25 | NSString *urlString = [url absoluteString]; 26 | CGFloat scale = [[UIScreen mainScreen] scale]; 27 | size.width *= scale; 28 | size.height *= scale; 29 | quality = MIN(MAX(quality, MinPhotonImageQuality), MaxPhotonImageQuality); 30 | 31 | // If the URL is already a Photon URL reject its photon params, and substitute our own. 32 | if ([self isURLPhotonURL:url]) { 33 | NSRange range = [urlString rangeOfString:@"?" options:NSBackwardsSearch]; 34 | if (range.location != NSNotFound) { 35 | BOOL useSSL = ([urlString rangeOfString:@"ssl=1"].location != NSNotFound); 36 | urlString = [urlString substringToIndex:range.location]; 37 | NSString *queryString = [self photonQueryStringForSize:size usingSSL:useSSL forceResize:forceResize quality:quality]; 38 | urlString = [NSString stringWithFormat:@"%@?%@", urlString, queryString]; 39 | return [NSURL URLWithString:urlString]; 40 | } 41 | // Saftey net. Don't photon photon! 42 | return url; 43 | } 44 | 45 | // Compose the URL 46 | NSRange range = [urlString rangeOfString:@"://"]; 47 | if (range.location != NSNotFound && range.location < 6) { 48 | urlString = [urlString substringFromIndex:(range.location + range.length)]; 49 | } 50 | 51 | // Photon rejects resizing mshots 52 | if ([urlString rangeOfString:@"/mshots/"].location != NSNotFound) { 53 | if (size.height == 0) { 54 | urlString = [urlString stringByAppendingFormat:@"?w=%i", (int)size.width]; 55 | } else { 56 | urlString = [urlString stringByAppendingFormat:@"?w=%i&h=%i", (int)size.width, (int)size.height]; 57 | } 58 | return [NSURL URLWithString:urlString]; 59 | } 60 | 61 | // Strip original resizing parameters, or we might get an image too small 62 | NSRange imgpressRange = [urlString rangeOfString:@"?w="]; 63 | if (imgpressRange.location != NSNotFound) { 64 | urlString = [urlString substringToIndex:imgpressRange.location]; 65 | } 66 | 67 | BOOL useSSL = [[url scheme] isEqualToString:@"https"]; 68 | NSString *queryString = [self photonQueryStringForSize:size usingSSL:useSSL forceResize:forceResize quality:quality]; 69 | NSString *photonURLString = [NSString stringWithFormat:@"https://i0.wp.com/%@?%@", urlString, queryString]; 70 | return [NSURL URLWithString:photonURLString]; 71 | } 72 | 73 | /** 74 | Constructs a Photon query string from the supplied parameters. 75 | */ 76 | + (NSString *)photonQueryStringForSize:(CGSize)size usingSSL:(BOOL)useSSL forceResize:(BOOL)forceResize quality:(NSUInteger)quality 77 | { 78 | NSString *queryString; 79 | if (size.height == 0) { 80 | queryString = [NSString stringWithFormat:@"w=%i", (int)size.width]; 81 | } else { 82 | NSString *method = forceResize ? @"resize" : @"fit"; 83 | queryString = [NSString stringWithFormat:@"%@=%.0f,%.0f", method, size.width, size.height]; 84 | } 85 | 86 | if (useSSL) { 87 | queryString = [NSString stringWithFormat:@"%@&ssl=1", queryString]; 88 | } 89 | 90 | queryString = [NSString stringWithFormat:@"quality=%lu&%@", (unsigned long)quality, queryString]; 91 | 92 | return queryString; 93 | } 94 | 95 | /** 96 | Inspects the specified URL to see if its uses Photon. 97 | 98 | @return True if the URL is a photon URL. False otherwise. 99 | */ 100 | + (BOOL)isURLPhotonURL:(NSURL *)url 101 | { 102 | static NSRegularExpression *regex; 103 | static dispatch_once_t onceToken; 104 | dispatch_once(&onceToken, ^{ 105 | NSError *error; 106 | regex = [NSRegularExpression regularExpressionWithPattern:@"i\\d+\\.wp\\.com" options:NSRegularExpressionCaseInsensitive error:&error]; 107 | }); 108 | NSString *host = [url host]; 109 | if ([host length] > 0) { // relative URLs may not have a host 110 | NSInteger count = [regex numberOfMatchesInString:host options:0 range:NSMakeRange(0, [host length])]; 111 | if (count > 0) { 112 | return YES; 113 | } 114 | } 115 | return NO; 116 | } 117 | 118 | @end 119 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/Utility/WPFontManager.m: -------------------------------------------------------------------------------- 1 | #import "WPFontManager.h" 2 | #import 3 | 4 | @implementation WPFontManager 5 | 6 | static NSString * const FontTypeTTF = @"ttf"; 7 | static NSString * const FontTypeOTF = @"otf"; 8 | 9 | #pragma mark - System Fonts 10 | 11 | + (UIFont *)systemLightFontOfSize:(CGFloat)size 12 | { 13 | return [UIFont systemFontOfSize:size weight:UIFontWeightLight]; 14 | } 15 | 16 | + (UIFont *)systemItalicFontOfSize:(CGFloat)size 17 | { 18 | return [UIFont italicSystemFontOfSize:size]; 19 | } 20 | 21 | + (UIFont *)systemBoldFontOfSize:(CGFloat)size 22 | { 23 | return [UIFont systemFontOfSize:size weight:UIFontWeightBold]; 24 | } 25 | 26 | + (UIFont *)systemSemiBoldFontOfSize:(CGFloat)size 27 | { 28 | return [UIFont systemFontOfSize:size weight:UIFontWeightSemibold]; 29 | } 30 | 31 | + (UIFont *)systemRegularFontOfSize:(CGFloat)size 32 | { 33 | return [UIFont systemFontOfSize:size weight:UIFontWeightRegular]; 34 | } 35 | 36 | + (UIFont *)systemMediumFontOfSize:(CGFloat)size 37 | { 38 | return [UIFont systemFontOfSize:size weight:UIFontWeightMedium]; 39 | } 40 | 41 | #pragma mark - Noto Fonts 42 | 43 | static NSString* const NotoBoldFontName = @"NotoSerif-Bold"; 44 | static NSString* const NotoBoldFileName = @"NotoSerif-Bold"; 45 | static NSString* const NotoBoldItalicFontName = @"NotoSerif-BoldItalic"; 46 | static NSString* const NotoBoldItalicFileName = @"NotoSerif-BoldItalic"; 47 | static NSString* const NotoItalicFontName = @"NotoSerif-Italic"; 48 | static NSString* const NotoItalicFileName = @"NotoSerif-Italic"; 49 | static NSString* const NotoRegularFontName = @"NotoSerif"; 50 | static NSString* const NotoRegularFileName = @"NotoSerif-Regular"; 51 | 52 | + (void)loadNotoFontFamily 53 | { 54 | static dispatch_once_t onceToken; 55 | dispatch_once(&onceToken, ^{ 56 | [self loadFontNamed:NotoRegularFontName resourceNamed:NotoRegularFileName withExtension:FontTypeTTF]; 57 | [self loadFontNamed:NotoBoldFileName resourceNamed:NotoBoldFileName withExtension:FontTypeTTF]; 58 | [self loadFontNamed:NotoBoldItalicFontName resourceNamed:NotoBoldItalicFileName withExtension:FontTypeTTF]; 59 | [self loadFontNamed:NotoItalicFontName resourceNamed:NotoItalicFileName withExtension:FontTypeTTF]; 60 | }); 61 | } 62 | 63 | + (UIFont *)notoBoldFontOfSize:(CGFloat)size 64 | { 65 | return [self fontNamed:NotoBoldFontName resourceName:NotoBoldFileName fontType:FontTypeTTF size:size]; 66 | } 67 | 68 | + (UIFont *)notoBoldItalicFontOfSize:(CGFloat)size; 69 | { 70 | return [self fontNamed:NotoBoldItalicFontName resourceName:NotoBoldItalicFileName fontType:FontTypeTTF size:size]; 71 | } 72 | 73 | + (UIFont *)notoItalicFontOfSize:(CGFloat)size; 74 | { 75 | return [self fontNamed:NotoItalicFontName resourceName:NotoItalicFileName fontType:FontTypeTTF size:size]; 76 | } 77 | 78 | + (UIFont *)notoRegularFontOfSize:(CGFloat)size 79 | { 80 | return [self fontNamed:NotoRegularFontName resourceName:NotoRegularFileName fontType:FontTypeTTF size:size]; 81 | } 82 | 83 | 84 | #pragma mark - Private Methods 85 | 86 | + (UIFont *)fontNamed:(NSString *)fontName resourceName:(NSString *)resourceName fontType:(NSString *)fontType size:(CGFloat)size 87 | { 88 | UIFont *font = [UIFont fontWithName:fontName size:size]; 89 | if (!font) { 90 | [[self class] loadFontResourceNamed:resourceName withExtension:fontType]; 91 | font = [UIFont fontWithName:fontName size:size]; 92 | 93 | // safe fallback 94 | if (!font) { 95 | font = [UIFont systemFontOfSize:size]; 96 | } 97 | } 98 | 99 | return font; 100 | } 101 | 102 | + (void)loadFontNamed:(NSString *)fontName resourceNamed:(NSString *)resourceName withExtension:(NSString *)extension { 103 | UIFont *font = [UIFont fontWithName:fontName size:UIFont.systemFontSize]; 104 | if (!font) { 105 | [self loadFontResourceNamed:resourceName withExtension:FontTypeTTF]; 106 | } 107 | } 108 | 109 | + (void)loadFontResourceNamed:(NSString *)name withExtension:(NSString *)extension 110 | { 111 | NSURL *url = [[self resourceBundle] URLForResource:name withExtension:extension]; 112 | 113 | CFErrorRef error; 114 | if (!CTFontManagerRegisterFontsForURL((CFURLRef)url, kCTFontManagerScopeProcess, &error)) { 115 | CFStringRef errorDescription = CFErrorCopyDescription(error); 116 | NSLog(@"Failed to load font: %@", errorDescription); 117 | CFRelease(errorDescription); 118 | } 119 | 120 | return; 121 | } 122 | 123 | + (NSBundle *)resourceBundle { 124 | #if SWIFT_PACKAGE 125 | return SWIFTPM_MODULE_BUNDLE; 126 | #else 127 | // When installed via CocoaPods, the resources will be bundled under `WordPressShared.bundle`. 128 | // This follows the implementation from `NSBundle+WordPressShared`. 129 | NSBundle *defaultBundle = [NSBundle bundleForClass:[self class]]; 130 | NSURL *sharedBundleURL = [defaultBundle.resourceURL URLByAppendingPathComponent:@"WordPressShared.bundle"]; 131 | NSBundle *sharedBundle = [NSBundle bundleWithURL:sharedBundleURL]; 132 | if (sharedBundle) { 133 | return sharedBundle; 134 | } 135 | return defaultBundle; 136 | #endif 137 | } 138 | 139 | @end 140 | -------------------------------------------------------------------------------- /Tests/WordPressSharedObjCTests/DisplayableImageHelperTest.m: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | #import "DisplayableImageHelper.h" 4 | 5 | static NSString * const PathForAttachmentD = @"http://www.example.com/exampleD.png"; 6 | 7 | @interface DisplayableImageHelper() 8 | + (NSArray *)filteredAttachmentsArray:(NSArray *)attachments; 9 | @end 10 | 11 | @interface DisplayableImageHelperTest : XCTestCase 12 | @end 13 | 14 | 15 | 16 | @implementation DisplayableImageHelperTest 17 | 18 | - (void)setUp { 19 | [super setUp]; 20 | 21 | } 22 | 23 | - (void)tearDown { 24 | // Put teardown code here. This method is called after the invocation of each test method in the class. 25 | [super tearDown]; 26 | } 27 | 28 | - (NSDictionary *)attachmentsDictionary 29 | { 30 | NSDictionary *attachmentA = @{ 31 | @"mime_type":@"video/mp4", 32 | @"width":@"1000", 33 | @"URL":@"http://www.example.com/exampleA.mp4" 34 | }; 35 | NSDictionary *attachmentB = @{ 36 | @"mime_type":@"image/png", 37 | @"width":@"10", 38 | @"URL":@"http://www.example.com/exampleB.png" 39 | }; 40 | NSDictionary *attachmentC = @{ 41 | @"mime_type":@"image/png", 42 | @"width":@"100", 43 | @"URL":@"http://www.example.com/exampleC.png" 44 | }; 45 | NSDictionary *attachmentD = @{ 46 | @"mime_type":@"image/png", 47 | @"width":@"1000", 48 | @"URL":PathForAttachmentD 49 | }; 50 | 51 | return @{@"A":attachmentA, 52 | @"B":attachmentB, 53 | @"C":attachmentC, 54 | @"D":attachmentD}; 55 | 56 | } 57 | 58 | - (void)testSearchPostAttachmentsForImageToDisplay 59 | { 60 | NSDictionary *attachments = [self attachmentsDictionary]; 61 | NSArray *arr = @[ 62 | [[attachments objectForKey:@"A"] objectForKey:@"URL"], 63 | [[attachments objectForKey:@"B"] objectForKey:@"URL"], 64 | [[attachments objectForKey:@"C"] objectForKey:@"URL"], 65 | [[attachments objectForKey:@"D"] objectForKey:@"URL"], 66 | ]; 67 | 68 | NSString *content = [arr componentsJoinedByString:@" "]; 69 | NSString *path = [DisplayableImageHelper searchPostAttachmentsForImageToDisplay:attachments existingInContent:content]; 70 | XCTAssertTrue([path isEqualToString:PathForAttachmentD], @"Example D should be the matched attachment."); 71 | } 72 | 73 | - (void)testFilteredAttachmentsArray 74 | { 75 | NSArray *attachments = [[self attachmentsDictionary] allValues]; 76 | NSArray *filteredAttachments = [DisplayableImageHelper filteredAttachmentsArray:attachments]; 77 | 78 | NSMutableArray *mAttachments = [attachments mutableCopy]; 79 | [mAttachments removeObjectsInArray:filteredAttachments]; 80 | 81 | XCTAssertTrue([mAttachments count] == 1, @"The video attachment should be missing from the filtered array"); 82 | } 83 | 84 | - (void)testSearchPostContentForAttachmentIdsInGalleries 85 | { 86 | NSSet *idsSet = [DisplayableImageHelper searchPostContentForAttachmentIdsInGalleries:@"Hello gallery [gallery ids=\"823,822,821\" type=\"rectangular\"] Another gallery [gallery ids=\"823,900\"]"]; 87 | 88 | XCTAssertTrue([idsSet count] == 4, @"It should find four elements"); 89 | XCTAssertTrue([idsSet containsObject:@(823)], "It should find 823"); 90 | XCTAssertTrue([idsSet containsObject:@(900)], "It should find 900"); 91 | } 92 | 93 | - (void)testSearchPostContentForImageToDisplay 94 | { 95 | NSString *imageSrc= [DisplayableImageHelper searchPostContentForImageToDisplay:@"Img100 Img200 Img300 "]; 96 | XCTAssertTrue([imageSrc isEqualToString:@"http://photo.com/200.jpg"], @"It should find the 200.jpg"); 97 | 98 | imageSrc= [DisplayableImageHelper searchPostContentForImageToDisplay:@"Img200 Img100 Img300 "]; 99 | XCTAssertTrue([imageSrc isEqualToString:@"http://photo.com/300.jpg"], @"It should find the 300.jpg"); 100 | 101 | imageSrc= [DisplayableImageHelper searchPostContentForImageToDisplay:@"Img200 Img300 Img100"]; 102 | XCTAssertTrue(imageSrc.length == 0, @"It shouldn't find an image since none have a width"); 103 | 104 | imageSrc= [DisplayableImageHelper searchPostContentForImageToDisplay:@"Img100 "]; 105 | XCTAssertTrue(imageSrc.length == 0, @"It shouldn't find an image since the width is too small"); 106 | } 107 | 108 | @end 109 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # Using a remote configuration fails in Xcode 15.2 2 | # 3 | # parent_config: https://raw.githubusercontent.com/Automattic/swiftlint-config/0f8ab6388bd8d15a04391825ab125f80cfb90704/.swiftlint.yml 4 | # remote_timeout: 10.0 5 | # 6 | # Error details: 7 | # /usr/bin/sandbox-exec -p "(version 1) 8 | # (deny default) 9 | # (import \"system.sb\") 10 | # (allow file-read*) 11 | # (allow process*) 12 | # (allow mach-lookup (global-name \"com.apple.lsd.mapdb\")) 13 | # (allow file-write* 14 | # (subpath \"/private/tmp\") 15 | # (subpath \"/private/var/folders/dq/cdqxvx3s5ps75564rpmb_dc00000gn/T\") 16 | # ) 17 | # (deny file-write* 18 | # (subpath \"/Users/gio/Developer/a8c/WordPress-iOS-Shared\") 19 | # ) 20 | # (allow file-write* 21 | # (subpath \"/Users/gio/Developer/a8c/WordPress-iOS-Shared/DerivedData/WordPress-iOS-Shared/SourcePackages/plugins/wordpress-ios-shared.output/WordPressSharedTests/SwiftLintPlugin\") 22 | # ) 23 | # " /Users/gio/Developer/a8c/WordPress-iOS-Shared/DerivedData/WordPress-iOS-Shared/SourcePackages/artifacts/swiftlint/SwiftLintBinary/SwiftLintBinary.artifactbundle/swiftlint-0.54.0-macos/bin/swiftlint lint --quiet --force-exclude --cache-path /Users/gio/Developer/a8c/WordPress-iOS-Shared/DerivedData/WordPress-iOS-Shared/SourcePackages/plugins/wordpress-ios-shared.output/WordPressSharedTests/SwiftLintPlugin --config /Users/gio/Developer/a8c/WordPress-iOS-Shared/.swiftlint.yml /Users/gio/Developer/a8c/WordPress-iOS-Shared/Tests/WordPressSharedTests/DebouncerTests.swift 24 | # 25 | # While we figure that out, here is a copy of the remote configuration. 26 | # Some of the custom rules are useless in this context, but we kept them to keep consistency for when we'll move back to it. 27 | 28 | swiftlint_version: 0.54.0 29 | 30 | # Project configuration 31 | # 32 | excluded: 33 | - DerivedData 34 | - fastlane 35 | - Pods 36 | - Scripts 37 | # Automattic's CI caching setup may generate this in the project folder 38 | - Users/builder/Library/Caches/CocoaPods/Pods 39 | - vendor 40 | - .build/ # not in original configurartion! 41 | 42 | # Rules – Opt-in only, so we can progressively introduce new ones 43 | # 44 | only_rules: 45 | # Colons should be next to the identifier when specifying a type. 46 | - colon 47 | 48 | # There should be no space before and one after any comma. 49 | - comma 50 | 51 | # if,for,while,do statements shouldn't wrap their conditionals in parentheses. 52 | - control_statement 53 | 54 | # Arguments can be omitted when matching enums with associated types if they 55 | # are not used. 56 | - empty_enum_arguments 57 | 58 | # Prefer `() -> ` over `Void -> `. 59 | - empty_parameters 60 | 61 | # MARK comment should be in valid format. 62 | - mark 63 | 64 | # Opening braces should be preceded by a single space and on the same line as 65 | # the declaration. 66 | - opening_brace 67 | 68 | # Files should have a single trailing newline. 69 | - trailing_newline 70 | 71 | # Lines should not have trailing semicolons. 72 | - trailing_semicolon 73 | 74 | # Lines should not have trailing whitespace. 75 | - trailing_whitespace 76 | 77 | - custom_rules 78 | 79 | # Rules configuration 80 | # 81 | control_statement: 82 | severity: error 83 | 84 | trailing_whitespace: 85 | ignores_empty_lines: false 86 | ignores_comments: false 87 | 88 | # Custom rules 89 | # 90 | custom_rules: 91 | natural_content_alignment: 92 | name: "Natural Content Alignment" 93 | regex: '\.contentHorizontalAlignment(\s*)=(\s*)(\.left|\.right)' 94 | message: "Forcing content alignment left or right can affect the Right-to-Left layout. Use naturalContentHorizontalAlignment instead." 95 | severity: warning 96 | 97 | natural_text_alignment: 98 | name: "Natural Text Alignment" 99 | regex: '\.textAlignment(\s*)=(\s*).left' 100 | message: "Forcing text alignment to left can affect the Right-to-Left layout. Consider setting it to `natural`" 101 | severity: warning 102 | 103 | inverse_text_alignment: 104 | name: "Inverse Text Alignment" 105 | regex: '\.textAlignment(\s*)=(\s*).right' 106 | message: "When forcing text alignment to the right, be sure to handle the Right-to-Left layout case properly, and then silence this warning with this line `// swiftlint:disable:next inverse_text_alignment`" 107 | severity: warning 108 | 109 | localization_comment: 110 | name: "Localization Comment" 111 | regex: 'NSLocalizedString([^,]+,\s+comment:\s*"")' 112 | message: "Localized strings should include a description giving context for how the string is used." 113 | severity: warning 114 | 115 | string_interpolation_in_localized_string: 116 | name: "String Interpolation in Localized String" 117 | regex: 'NSLocalizedString\("[^"]*\\\(\S*\)' 118 | message: "Localized strings must not use interpolated variables. Instead, use `String(format:`" 119 | severity: error 120 | 121 | swiftui_localization: 122 | name: "SwiftUI Localization" 123 | regex: 'LocalizedStringKey' 124 | message: "Using `LocalizedStringKey` is incompatible with our tooling and doesn't allow you to provide a hint/context comment for translators either. Please use `NSLocalizedString` instead, even with SwiftUI code." 125 | severity: error 126 | excluded: '.*Widgets/.*' 127 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/Views/WPTextFieldTableViewCell.m: -------------------------------------------------------------------------------- 1 | #import "WPTextFieldTableViewCell.h" 2 | 3 | 4 | CGFloat const TextFieldPadding = 15.0f; 5 | 6 | @interface WPCellTextField : UITextField 7 | @property (nonatomic, assign) UIEdgeInsets textMargins; 8 | @end 9 | 10 | @interface WPTextFieldTableViewCell () 11 | 12 | @end 13 | 14 | @implementation WPTextFieldTableViewCell 15 | 16 | - (id)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier { 17 | self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; 18 | if (self) { 19 | 20 | self.selectionStyle = UITableViewCellSelectionStyleNone; 21 | self.accessoryType = UITableViewCellAccessoryNone; 22 | 23 | [self setupTextField]; 24 | } 25 | 26 | return self; 27 | } 28 | 29 | - (void)setupTextField 30 | { 31 | // Use a custom textField, see below for implementation of WPCellTextField. 32 | WPCellTextField *textField = [[WPCellTextField alloc] init]; 33 | textField.translatesAutoresizingMaskIntoConstraints = NO; 34 | textField.adjustsFontSizeToFitWidth = YES; 35 | textField.textColor = [UIColor blackColor]; 36 | textField.backgroundColor = [UIColor clearColor]; 37 | textField.autocorrectionType = UITextAutocorrectionTypeNo; 38 | textField.autocapitalizationType = UITextAutocapitalizationTypeNone; 39 | textField.textAlignment = NSTextAlignmentLeft; 40 | textField.clearButtonMode = UITextFieldViewModeNever; 41 | textField.enabled = YES; 42 | textField.contentVerticalAlignment = UIControlContentVerticalAlignmentCenter; 43 | textField.delegate = self; 44 | 45 | [self.contentView addSubview:textField]; 46 | UILayoutGuide *layoutGuide = self.contentView.readableContentGuide; 47 | [NSLayoutConstraint activateConstraints:@[ 48 | [textField.leadingAnchor constraintEqualToAnchor:layoutGuide.leadingAnchor], 49 | [textField.trailingAnchor constraintEqualToAnchor:layoutGuide.trailingAnchor], 50 | [textField.topAnchor constraintEqualToAnchor:self.contentView.topAnchor], 51 | [textField.bottomAnchor constraintEqualToAnchor:self.contentView.bottomAnchor] 52 | ]]; 53 | _textField = textField; 54 | } 55 | 56 | - (void)layoutSubviews 57 | { 58 | [super layoutSubviews]; 59 | 60 | UIEdgeInsets textMargins = UIEdgeInsetsZero; 61 | // Sergio Estevao: If there is a label with content we need to adjust the text Margin on the cell in order to not override the label 62 | if (self.textLabel.text.length != 0) { 63 | CGSize labelSize = [self.textLabel.text sizeWithAttributes:@{NSFontAttributeName:self.textLabel.font}]; 64 | textMargins.left = ceilf(labelSize.width) + TextFieldPadding; 65 | } 66 | WPCellTextField *textField = (WPCellTextField *)self.textField; 67 | textField.textMargins = textMargins; 68 | } 69 | 70 | - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { 71 | [self.textField becomeFirstResponder]; 72 | } 73 | 74 | - (BOOL)textFieldShouldReturn:(UITextField *)textField { 75 | if (self.shouldDismissOnReturn) { 76 | [self.textField resignFirstResponder]; 77 | } else { 78 | if (self.delegate) { 79 | [self.delegate cellWantsToSelectNextField:self]; 80 | } 81 | } 82 | return YES; 83 | } 84 | 85 | - (BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string { 86 | if (self.delegate && [self.delegate respondsToSelector:@selector(cellTextDidChange:)]) { 87 | double delayInSeconds = 0.1; 88 | dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC)); 89 | dispatch_after(popTime, dispatch_get_main_queue(), ^(void){ 90 | [self.delegate cellTextDidChange:self]; 91 | }); 92 | } 93 | return YES; 94 | } 95 | 96 | - (void)setShouldDismissOnReturn:(BOOL)shouldDismissOnReturn { 97 | _shouldDismissOnReturn = shouldDismissOnReturn; 98 | if (shouldDismissOnReturn) { 99 | self.textField.returnKeyType = UIReturnKeyDone; 100 | } else { 101 | self.textField.returnKeyType = UIReturnKeyNext; 102 | } 103 | } 104 | 105 | @end 106 | 107 | @implementation WPCellTextField 108 | 109 | /* 110 | * The textField's leading edge needs to follow the trailing edge of the textLabel 111 | * but the cell's textLabel layout runs the full width of the cell's contentView... 112 | * So instead we apply given margins to the textField's textRect and editingRect to 113 | * inset the text along the given margin, which currently is the textLabel's text width. 114 | * Brent C. Jul/13/2016 115 | */ 116 | 117 | - (void)setTextMargins:(UIEdgeInsets)textMargins 118 | { 119 | _textMargins = textMargins; 120 | [self setNeedsDisplay]; 121 | } 122 | 123 | - (CGRect)textRectWithMargins:(CGRect)rect 124 | { 125 | UIEdgeInsets margins = self.textMargins; 126 | rect.origin.x += margins.left; 127 | rect.origin.y += margins.top; 128 | rect.size.width -= margins.left + margins.right; 129 | rect.size.height -= margins.top + margins.bottom; 130 | return rect; 131 | } 132 | 133 | - (CGRect)textRectForBounds:(CGRect)bounds 134 | { 135 | CGRect rect = [super textRectForBounds:bounds]; 136 | return [self textRectWithMargins:rect]; 137 | } 138 | 139 | - (CGRect)editingRectForBounds:(CGRect)bounds 140 | { 141 | CGRect rect = [super editingRectForBounds:bounds]; 142 | return [self textRectWithMargins:rect]; 143 | } 144 | 145 | @end 146 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Utility/EmailTypoChecker.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | private let knownDomains = Set([ 4 | /* Default domains included */ 5 | "aol.com", "att.net", "comcast.net", "facebook.com", "gmail.com", "gmx.com", "googlemail.com", 6 | "google.com", "hotmail.com", "hotmail.co.uk", "mac.com", "me.com", "msn.com", 7 | "live.com", "sbcglobal.net", "verizon.net", "yahoo.com", "yahoo.co.uk", 8 | 9 | /* Other global domains */ 10 | "games.com" /* AOL */, "gmx.net", "hush.com", "hushmail.com", "icloud.com", "inbox.com", 11 | "lavabit.com", "love.com" /* AOL */, "outlook.com", "pobox.com", "rocketmail.com" /* Yahoo */, 12 | "safe-mail.net", "wow.com" /* AOL */, "ygm.com" /* AOL */, "ymail.com" /* Yahoo */, "zoho.com", "fastmail.fm", 13 | "yandex.com", 14 | 15 | /* United States ISP domains */ 16 | "bellsouth.net", "charter.net", "comcast.com", "cox.net", "earthlink.net", "juno.com", 17 | 18 | /* British ISP domains */ 19 | "btinternet.com", "virginmedia.com", "blueyonder.co.uk", "freeserve.co.uk", "live.co.uk", 20 | "ntlworld.com", "o2.co.uk", "orange.net", "sky.com", "talktalk.co.uk", "tiscali.co.uk", 21 | "virgin.net", "wanadoo.co.uk", "bt.com", 22 | 23 | /* Domains used in Asia */ 24 | "sina.com", "qq.com", "naver.com", "hanmail.net", "daum.net", "nate.com", "yahoo.co.jp", "yahoo.co.kr", "yahoo.co.id", "yahoo.co.in", "yahoo.com.sg", "yahoo.com.ph", 25 | 26 | /* French ISP domains */ 27 | "hotmail.fr", "live.fr", "laposte.net", "yahoo.fr", "wanadoo.fr", "orange.fr", "gmx.fr", "sfr.fr", "neuf.fr", "free.fr", 28 | 29 | /* German ISP domains */ 30 | "gmx.de", "hotmail.de", "live.de", "online.de", "t-online.de" /* T-Mobile */, "web.de", "yahoo.de", 31 | 32 | /* Russian ISP domains */ 33 | "mail.ru", "rambler.ru", "yandex.ru", "ya.ru", "list.ru", 34 | 35 | /* Belgian ISP domains */ 36 | "hotmail.be", "live.be", "skynet.be", "voo.be", "tvcablenet.be", "telenet.be", 37 | 38 | /* Argentinian ISP domains */ 39 | "hotmail.com.ar", "live.com.ar", "yahoo.com.ar", "fibertel.com.ar", "speedy.com.ar", "arnet.com.ar", 40 | 41 | /* Domains used in Mexico */ 42 | "hotmail.com", "gmail.com", "yahoo.com.mx", "live.com.mx", "yahoo.com", "hotmail.es", "live.com", "hotmail.com.mx", "prodigy.net.mx", "msn.com" 43 | ]) 44 | 45 | /// Provides suggestions to fix common typos on email addresses. 46 | /// 47 | /// It tries to match the email domain with a list of popular hosting providers, 48 | /// and suggest a correction if it looks like a typo. 49 | /// 50 | open class EmailTypoChecker: NSObject { 51 | /// Suggest a correction to a typo in the given email address. 52 | /// 53 | /// If it doesn't detect any typo, it returns the given email. 54 | /// 55 | @objc(guessCorrectionForEmail:) 56 | public static func guessCorrection(email: String) -> String { 57 | let components = email.components(separatedBy: "@") 58 | guard components.count == 2 else { 59 | return email 60 | } 61 | let (account, domain) = (components[0], components[1]) 62 | 63 | // If the domain name is empty, don't try to suggest anything 64 | guard !domain.isEmpty else { 65 | return email 66 | } 67 | 68 | // If the domain name is too long, don't try suggestion (resource consuming and useless) 69 | guard domain.count < lengthOfLongestKnownDomain() + 1 else { 70 | return email 71 | } 72 | 73 | let suggestedDomain = suggest(domain) 74 | return account + "@" + suggestedDomain 75 | } 76 | } 77 | 78 | private func suggest(_ word: String) -> String { 79 | if knownDomains.contains(word) { 80 | return word 81 | } 82 | 83 | let candidates = edits(word).filter({ knownDomains.contains($0) }) 84 | return candidates.first ?? word 85 | } 86 | 87 | private func edits(_ word: String) -> [String] { 88 | // deletes 89 | let deleted = deletes(word) 90 | let transposed = transposes(word) 91 | let replaced = alphabet.flatMap({ character in 92 | return replaces(character, ys: word) 93 | }) 94 | let inserted = alphabet.flatMap({ character in 95 | return between(character, ys: word) 96 | }) 97 | 98 | return deleted + transposed + replaced + inserted 99 | } 100 | 101 | private func deletes(_ word: String) -> [String] { 102 | return word.indices.map({ word.removing(at: $0) }) 103 | } 104 | 105 | private func transposes(_ word: String) -> [String] { 106 | return word.indices.compactMap({ index in 107 | let (i, j) = (index, word.index(after: index)) 108 | guard j < word.endIndex else { 109 | return nil 110 | } 111 | var copy = word 112 | copy.replaceSubrange(i...j, with: String(word[j]) + String(word[i])) 113 | return copy 114 | }) 115 | } 116 | 117 | private func replaces(_ x: Character, ys: String) -> [String] { 118 | guard let head = ys.first else { 119 | return [String(x)] 120 | } 121 | let tail = ys.dropFirst() 122 | return [String(x) + String(tail)] + replaces(x, ys: String(tail)).map({ String(head) + $0 }) 123 | } 124 | 125 | private func between(_ x: Character, ys: String) -> [String] { 126 | guard let head = ys.first else { 127 | return [String(x)] 128 | } 129 | let tail = ys.dropFirst() 130 | return [String(x) + String(ys)] + between(x, ys: String(tail)).map({ String(head) + $0 }) 131 | } 132 | 133 | private let alphabet = "abcdefghijklmnopqrstuvwxyz" 134 | 135 | private func lengthOfLongestKnownDomain() -> Int { 136 | return knownDomains.map({ $0.count }).max() ?? 0 137 | } 138 | -------------------------------------------------------------------------------- /Tests/WordPressSharedTests/RichContentFormatterTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import WordPressShared 3 | 4 | class RichContentFormatterTests: XCTestCase { 5 | 6 | func testRemoveInlineStyles() { 7 | let str = "

test

test

" 8 | let styleStr = "

test

test

" 9 | let sanitizedStr = RichContentFormatter.removeInlineStyles(styleStr) 10 | XCTAssertTrue(str == sanitizedStr, "The inline styles were not removed.") 11 | } 12 | 13 | 14 | func testRemoveForbiddenTags() { 15 | let str = "

test

test

" 16 | let styleStr = "

test

test

\n

" 17 | let sanitizedStr = RichContentFormatter.removeForbiddenTags(styleStr) 18 | XCTAssertTrue(str == sanitizedStr, "The forbidden tags were not removed.") 19 | } 20 | 21 | 22 | func testNormalizeParagraphs() { 23 | let str = "

test

\n\ntest\n\n

test

" 24 | let styleStr = "

test

\n\ntest\n\n
\n

test

\n" 25 | let sanitizedStr = RichContentFormatter.normalizeParagraphs(styleStr) 26 | XCTAssertTrue(str == sanitizedStr, "Not all paragraphs were normalized.") 27 | } 28 | 29 | 30 | func testFilterNewLines() { 31 | let str = "

test

\n\ntest\n\n

test

" 32 | let styleStr = "

test

\n\ntest\n\n
\n

test

\n" 33 | let sanitizedStr = RichContentFormatter.filterNewLines(styleStr) 34 | XCTAssertTrue(str == sanitizedStr, "Not all paragraphs were normalized.") 35 | } 36 | 37 | func testResizeGalleryImageURLsForContentEmptyString() { 38 | XCTAssertTrue("" == RichContentFormatter.resizeGalleryImageURL("", isPrivateSite: false)) 39 | } 40 | 41 | 42 | func testRemoveTrailingBRTags() { 43 | let str = "

test


test

" 44 | let styleStr = "

test


test



" 45 | let sanitizedStr = RichContentFormatter.removeTrailingBreakTags(styleStr) 46 | XCTAssertTrue(str == sanitizedStr, "The inline styles were not removed.") 47 | } 48 | 49 | 50 | func testRemoveGutenbergGalleryListMarkup() { 51 | let str = "Some text. Some text." 52 | let sanitizedString = RichContentFormatter.formatGutenbergGallery(str) as NSString 53 | // Checks if the UL was removed. 54 | var range = sanitizedString.range(of: "block-gallery") 55 | XCTAssertTrue(range.location == NSNotFound) 56 | // Checks if the LI was removed 57 | range = sanitizedString.range(of: "blocks-gallery") 58 | XCTAssertTrue(range.location == NSNotFound) 59 | // Checks if the FIGCAPTION was kept. 60 | range = sanitizedString.range(of: "figcaption") 61 | XCTAssertTrue(range.location != NSNotFound) 62 | } 63 | 64 | func testFormatVideoTags() { 65 | let str1 = "

Some text.

Some text.

" 66 | let sanitizedStr1 = RichContentFormatter.formatVideoTags(str1) as NSString 67 | XCTAssert(sanitizedStr1.contains("controls")) 68 | 69 | let str2 = "

Some text.

Some text.

" 70 | let sanitizedStr2 = RichContentFormatter.formatVideoTags(str2) as NSString 71 | XCTAssert(sanitizedStr2.contains(" controls ")) 72 | 73 | let str3 = "

Some text.

Some text.

" 74 | let sanitizedStr3 = RichContentFormatter.formatVideoTags(str3) as NSString 75 | XCTAssert(!sanitizedStr3.contains("controls controls")) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Utility/String+Helpers.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if SWIFT_PACKAGE 4 | import WordPressSharedObjC 5 | #endif 6 | 7 | extension String { 8 | public func stringByDecodingXMLCharacters() -> String { 9 | return NSString.decodeXMLCharacters(in: self) 10 | } 11 | 12 | public func stringByEncodingXMLCharacters() -> String { 13 | return NSString.encodeXMLCharacters(in: self) 14 | } 15 | 16 | public func trim() -> String { 17 | return trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) 18 | } 19 | 20 | /// Returns `self` if not empty, or `nil` otherwise 21 | /// 22 | public func nonEmptyString() -> String? { 23 | return isEmpty ? nil : self 24 | } 25 | 26 | /// Returns a string without the character at the specified index. 27 | /// This is a non-mutating version of `String.remove(at:)`. 28 | public func removing(at index: Index) -> String { 29 | var copy = self 30 | copy.remove(at: index) 31 | return copy 32 | } 33 | 34 | /// Returns a count of valid text characters. 35 | /// - Note : This implementation is influenced by `-wordCount` in `NSString+Helpers`. 36 | public var characterCount: Int { 37 | var charCount = 0 38 | 39 | if isEmpty == false { 40 | let textRange = startIndex.. String { 73 | var copy = self 74 | copy.removePrefix(prefix) 75 | return copy 76 | } 77 | 78 | /// Removes the prefix from the string that matches the given pattern, if any. 79 | /// 80 | /// Calling this method might invalidate any existing indices for use with this string. 81 | /// 82 | /// - Parameters: 83 | /// - pattern: The regular expression pattern to search for. Avoid using `^`. 84 | /// - options: The options applied to the regular expression during matching. 85 | /// 86 | /// - Throws: an error if it the pattern is not a valid regular expression. 87 | /// 88 | mutating func removePrefix(pattern: String, options: NSRegularExpression.Options = []) throws { 89 | let regexp = try NSRegularExpression(pattern: "^\(pattern)", options: options) 90 | let fullRange = NSRange(location: 0, length: (self as NSString).length) 91 | if let match = regexp.firstMatch(in: self, options: [], range: fullRange) { 92 | let matchRange = match.range 93 | self = (self as NSString).replacingCharacters(in: matchRange, with: "") 94 | } 95 | } 96 | 97 | /// Returns a string without the prefix that matches the given pattern, if it exists. 98 | /// 99 | /// - Parameters: 100 | /// - pattern: The regular expression pattern to search for. Avoid using `^`. 101 | /// - options: The options applied to the regular expression during matching. 102 | /// 103 | /// - Throws: an error if it the pattern is not a valid regular expression. 104 | /// 105 | func removingPrefix(pattern: String, options: NSRegularExpression.Options = []) throws -> String { 106 | var copy = self 107 | try copy.removePrefix(pattern: pattern, options: options) 108 | return copy 109 | } 110 | } 111 | 112 | // MARK: - Suffix removal 113 | 114 | public extension String { 115 | /// Removes the given suffix from the string, if exists. 116 | /// 117 | /// Calling this method might invalidate any existing indices for use with this string. 118 | /// 119 | /// - Parameters: 120 | /// - suffix: A possible suffix to remove from this string. 121 | /// 122 | mutating func removeSuffix(_ suffix: String) { 123 | if let suffixRange = range(of: suffix, options: [.backwards]), suffixRange.upperBound == endIndex { 124 | removeSubrange(suffixRange) 125 | } 126 | } 127 | 128 | /// Returns a string with the given suffix removed, if it exists. 129 | /// 130 | /// - Parameters: 131 | /// - suffix: A possible suffix to remove from this string. 132 | /// 133 | func removingSuffix(_ suffix: String) -> String { 134 | var copy = self 135 | copy.removeSuffix(suffix) 136 | return copy 137 | } 138 | 139 | /// Removes the suffix from the string that matches the given pattern, if any. 140 | /// 141 | /// Calling this method might invalidate any existing indices for use with this string. 142 | /// 143 | /// - Parameters: 144 | /// - pattern: The regular expression pattern to search for. Avoid using `$`. 145 | /// - options: The options applied to the regular expression during matching. 146 | /// 147 | /// - Throws: an error if it the pattern is not a valid regular expression. 148 | /// 149 | mutating func removeSuffix(pattern: String, options: NSRegularExpression.Options = []) throws { 150 | let regexp = try NSRegularExpression(pattern: "\(pattern)$", options: options) 151 | let fullRange = NSRange(location: 0, length: (self as NSString).length) 152 | if let match = regexp.firstMatch(in: self, options: [], range: fullRange) { 153 | let matchRange = match.range 154 | self = (self as NSString).replacingCharacters(in: matchRange, with: "") 155 | } 156 | } 157 | 158 | /// Returns a string without the suffix that matches the given pattern, if it exists. 159 | /// 160 | /// - Parameters: 161 | /// - pattern: The regular expression pattern to search for. Avoid using `$`. 162 | /// - options: The options applied to the regular expression during matching. 163 | /// 164 | /// - Throws: an error if it the pattern is not a valid regular expression. 165 | /// 166 | func removingSuffix(pattern: String, options: NSRegularExpression.Options = []) throws -> String { 167 | var copy = self 168 | try copy.removeSuffix(pattern: pattern, options: options) 169 | return copy 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Utility/Languages.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// This helper class allows us to map WordPress.com LanguageID's into human readable language strings. 4 | /// 5 | public class WordPressComLanguageDatabase: NSObject { 6 | // MARK: - Public Properties 7 | 8 | /// Languages considered 'popular' 9 | /// 10 | public let popular: [Language] 11 | 12 | /// Every supported language 13 | /// 14 | public let all: [Language] 15 | 16 | /// Returns both, Popular and All languages, grouped 17 | /// 18 | public let grouped: [[Language]] 19 | 20 | 21 | // MARK: - Public Methods 22 | 23 | /// Designated Initializer: will load the languages contained within the `Languages.json` file. 24 | /// 25 | public override init() { 26 | // Parse the json file 27 | let path = Bundle.wordPressSharedBundle.path(forResource: filename, ofType: "json") 28 | let raw = try! Data(contentsOf: URL(fileURLWithPath: path!)) 29 | let parsed = try! JSONSerialization.jsonObject(with: raw, options: [.mutableContainers, .mutableLeaves]) as? NSDictionary 30 | 31 | // Parse All + Popular: All doesn't contain Popular. Otherwise the json would have dupe data. Right? 32 | let parsedAll = Language.fromArray(parsed![Keys.all] as! [[String: Any]]) 33 | let parsedPopular = Language.fromArray(parsed![Keys.popular] as! [[String: Any]]) 34 | let merged = parsedAll + parsedPopular 35 | 36 | // Done! 37 | popular = parsedPopular 38 | all = merged.sorted { $0.name < $1.name } 39 | grouped = [popular] + [all] 40 | } 41 | 42 | 43 | /// Returns the Human Readable name for a given Language Identifier 44 | /// 45 | /// - Parameter languageId: The Identifier of the language. 46 | /// 47 | /// - Returns: A string containing the language name, or an empty string, in case it wasn't found. 48 | /// 49 | @objc public func nameForLanguageWithId(_ languageId: Int) -> String { 50 | return find(id: languageId)?.name ?? "" 51 | } 52 | 53 | /// Returns the Language with a given Language Identifier 54 | /// 55 | /// - Parameter id: The Identifier of the language. 56 | /// 57 | /// - Returns: The language with the matching Identifier, or nil, in case it wasn't found. 58 | /// 59 | public func find(id: Int) -> Language? { 60 | return all.first(where: { $0.id == id }) 61 | } 62 | 63 | /// Returns the current device language as the corresponding WordPress.com language ID. 64 | /// If the language is not supported, it returns 1 (English). 65 | /// 66 | /// This is a wrapper for Objective-C, Swift code should use deviceLanguage directly. 67 | /// 68 | @objc(deviceLanguageId) 69 | public func deviceLanguageIdNumber() -> NSNumber { 70 | return NSNumber(value: deviceLanguage.id) 71 | } 72 | 73 | /// Returns the slug string for the current device language. 74 | /// If the language is not supported, it returns "en" (English). 75 | /// 76 | /// This is a wrapper for Objective-C, Swift code should use deviceLanguage directly. 77 | /// 78 | @objc(deviceLanguageSlug) 79 | public func deviceLanguageSlugString() -> String { 80 | return deviceLanguage.slug 81 | } 82 | 83 | /// Returns the current device language as the corresponding WordPress.com language. 84 | /// If the language is not supported, it returns English. 85 | /// 86 | public var deviceLanguage: Language { 87 | let variants = LanguageTagVariants(string: deviceLanguageCode) 88 | for variant in variants { 89 | if let match = self.languageWithSlug(variant) { 90 | return match 91 | } 92 | } 93 | return languageWithSlug("en")! 94 | } 95 | 96 | /// Searches for a WordPress.com language that matches a language tag. 97 | /// 98 | fileprivate func languageWithSlug(_ slug: String) -> Language? { 99 | let search = languageCodeReplacements[slug] ?? slug 100 | 101 | // Use lazy evaluation so we stop filtering as soon as we got the first match 102 | return all.lazy.filter({ $0.slug == search }).first 103 | } 104 | 105 | /// Overrides the device language. For testing purposes only. 106 | /// 107 | @objc func _overrideDeviceLanguageCode(_ code: String) { 108 | deviceLanguageCode = code.lowercased() 109 | } 110 | 111 | // MARK: - Public Nested Classes 112 | 113 | /// Represents a Language supported by WordPress.com 114 | /// 115 | public class Language: Equatable { 116 | /// Language Unique Identifier 117 | /// 118 | public let id: Int 119 | 120 | /// Human readable Language name 121 | /// 122 | public let name: String 123 | 124 | /// Language's Slug String 125 | /// 126 | public let slug: String 127 | 128 | /// Localized description for the current language 129 | /// 130 | public var description: String { 131 | return (Locale.current as NSLocale).displayName(forKey: NSLocale.Key.identifier, value: slug) ?? name 132 | } 133 | 134 | 135 | 136 | /// Designated initializer. Will fail if any of the required properties is missing 137 | /// 138 | init?(dict: [String: Any]) { 139 | guard let unwrappedId = (dict[Keys.identifier] as? NSNumber)?.intValue, 140 | let unwrappedSlug = dict[Keys.slug] as? String, 141 | let unwrappedName = dict[Keys.name] as? String else { 142 | id = Int.min 143 | name = String() 144 | slug = String() 145 | return nil 146 | } 147 | 148 | id = unwrappedId 149 | name = unwrappedName 150 | slug = unwrappedSlug 151 | } 152 | 153 | 154 | /// Given an array of raw languages, will return a parsed array. 155 | /// 156 | public static func fromArray(_ array: [[String: Any]] ) -> [Language] { 157 | return array.compactMap { 158 | return Language(dict: $0) 159 | } 160 | } 161 | 162 | public static func == (lhs: Language, rhs: Language) -> Bool { 163 | return lhs.id == rhs.id 164 | } 165 | } 166 | 167 | // MARK: - Private Variables 168 | 169 | /// The device's current preferred language, or English if there's no preferred language. 170 | /// 171 | fileprivate lazy var deviceLanguageCode: String = { 172 | return NSLocale.preferredLanguages.first?.lowercased() ?? "en" 173 | }() 174 | 175 | 176 | // MARK: - Private Constants 177 | fileprivate let filename = "Languages" 178 | 179 | // (@koke 2016-04-29) I'm not sure how correct this mapping is, but it matches 180 | // what we do for the app translations, so they will at least be consistent 181 | fileprivate let languageCodeReplacements: [String: String] = [ 182 | "zh-hans": "zh-cn", 183 | "zh-hant": "zh-tw" 184 | ] 185 | 186 | 187 | // MARK: - Private Nested Structures 188 | 189 | /// Keys used to parse the raw languages. 190 | /// 191 | fileprivate struct Keys { 192 | static let popular = "popular" 193 | static let all = "all" 194 | static let identifier = "i" 195 | static let slug = "s" 196 | static let name = "n" 197 | } 198 | } 199 | 200 | /// Provides a sequence of language tags from the specified string, from more to less specific 201 | /// For instance, "zh-Hans-HK" will yield `["zh-Hans-HK", "zh-Hans", "zh"]` 202 | /// 203 | private struct LanguageTagVariants: Sequence { 204 | let string: String 205 | 206 | func makeIterator() -> AnyIterator { 207 | var components = string.components(separatedBy: "-") 208 | return AnyIterator { 209 | guard !components.isEmpty else { 210 | return nil 211 | } 212 | 213 | let current = components.joined(separator: "-") 214 | components.removeLast() 215 | 216 | return current 217 | } 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/Utility/WPDeviceIdentification.m: -------------------------------------------------------------------------------- 1 | #import "WPDeviceIdentification.h" 2 | #include 3 | 4 | static NSString* const WPDeviceModelNameiPhone6 = @"iPhone 6"; 5 | static NSString* const WPDeviceModelNameiPhone6Plus = @"iPhone 6 Plus"; 6 | static NSString* const WPDeviceModelNameiPadSimulator = @"iPad Simulator"; 7 | static NSString* const WPDeviceModelNameiPhoneSimulator = @"iPhone Simulator"; 8 | 9 | // Device Names 10 | static NSString* const WPDeviceNameiPad1 = @"iPad 1"; 11 | static NSString* const WPDeviceNameiPad2 = @"iPad 2"; 12 | static NSString* const WPDeviceNameiPad3 = @"iPad 3"; 13 | static NSString* const WPDeviceNameiPad4 = @"iPad 4"; 14 | static NSString* const WPDeviceNameiPadAir1 = @"iPad Air 1"; 15 | static NSString* const WPDeviceNameiPadAir2 = @"iPad Air 2"; 16 | static NSString* const WPDeviceNameiPadMini1 = @"iPad Mini 1"; 17 | static NSString* const WPDeviceNameiPadMini2 = @"iPad Mini 2"; 18 | static NSString* const WPDeviceNameiPhone1 = @"iPhone 1"; 19 | static NSString* const WPDeviceNameiPhone3 = @"iPhone 3"; 20 | static NSString* const WPDeviceNameiPhone3gs = @"iPhone 3GS"; 21 | static NSString* const WPDeviceNameiPhone4 = @"iPhone 4"; 22 | static NSString* const WPDeviceNameiPhone4s = @"iPhone 4S"; 23 | static NSString* const WPDeviceNameiPhone5 = @"iPhone 5"; 24 | static NSString* const WPDeviceNameiPhone5c = @"iPhone 5C"; 25 | static NSString* const WPDeviceNameiPhone5s = @"iPhone 5S"; 26 | static NSString* const WPDeviceNameiPhone6 = @"iPhone 6"; 27 | static NSString* const WPDeviceNameiPhone6Plus = @"iPhone 6 Plus"; 28 | static NSString* const WPDeviceNameiPodTouch1 = @"iPod Touch 1"; 29 | static NSString* const WPDeviceNameiPodTouch2 = @"iPod Touch 2"; 30 | static NSString* const WPDeviceNameiPodTouch3 = @"iPod Touch 3"; 31 | static NSString* const WPDeviceNameiPodTouch4 = @"iPod Touch 4"; 32 | static NSString* const WPDeviceNameSimulator = @"Simulator"; 33 | 34 | @implementation WPDeviceIdentification 35 | 36 | #pragma mark - System info 37 | 38 | + (struct utsname)systemInfo 39 | { 40 | struct utsname systemInfo; 41 | 42 | uname(&systemInfo); 43 | 44 | return systemInfo; 45 | } 46 | 47 | + (NSString*)deviceName 48 | { 49 | // Credits: original list taken from this URL 50 | // http://stackoverflow.com/questions/26028918/ios-how-to-determine-iphone-model-in-swift 51 | // 52 | NSDictionary* devices = @{@"i386": WPDeviceNameSimulator, 53 | @"x86_64": WPDeviceNameSimulator, 54 | @"iPod1,1": WPDeviceNameiPodTouch1, // (Original) 55 | @"iPod2,1": WPDeviceNameiPodTouch2, // (Second Generation) 56 | @"iPod3,1": WPDeviceNameiPodTouch3, // (Third Generation) 57 | @"iPod4,1": WPDeviceNameiPodTouch4, // (Fourth Generation) 58 | @"iPhone1,1": WPDeviceNameiPhone1, // (Original) 59 | @"iPhone1,2": WPDeviceNameiPhone3, // (3G) 60 | @"iPhone2,1": WPDeviceNameiPhone3gs, // (3GS) 61 | @"iPad1,1": WPDeviceNameiPad1, // (Original) 62 | @"iPad2,1": WPDeviceNameiPad2, // 63 | @"iPad3,1": WPDeviceNameiPad3, // (3rd Generation) 64 | @"iPhone3,1": WPDeviceNameiPhone4, // 65 | @"iPhone3,2": WPDeviceNameiPhone4, // 66 | @"iPhone4,1": WPDeviceNameiPhone4s, // 67 | @"iPhone5,1": WPDeviceNameiPhone5, // (model A1428, AT&T/Canada) 68 | @"iPhone5,2": WPDeviceNameiPhone5, // (model A1429, everything else) 69 | @"iPad3,4": WPDeviceNameiPad4, // (4th Generation) 70 | @"iPad2,5": WPDeviceNameiPadMini1, // (Original) 71 | @"iPhone5,3": WPDeviceNameiPhone5c, // (model A1456, A1532 | GSM) 72 | @"iPhone5,4": WPDeviceNameiPhone5c, // (model A1507, A1516, A1526 (China), A1529 | Global) 73 | @"iPhone6,1": WPDeviceNameiPhone5s, // (model A1433, A1533 | GSM) 74 | @"iPhone6,2": WPDeviceNameiPhone5s, // (model A1457, A1518, A1528 (China), A1530 | Global) 75 | @"iPad4,1": WPDeviceNameiPadAir1, // 5th Generation iPad (iPad Air) - Wifi 76 | @"iPad4,2": WPDeviceNameiPadAir2, // 5th Generation iPad (iPad Air) - Cellular 77 | @"iPad4,4": WPDeviceNameiPadMini2, // (2nd Generation iPad Mini - Wifi) 78 | @"iPad4,5": WPDeviceNameiPadMini2, // (2nd Generation iPad Mini - Cellular) 79 | @"iPhone7,1": WPDeviceNameiPhone6Plus, // All iPhone 6 Plus's 80 | @"iPhone7,2": WPDeviceNameiPhone6 // All iPhone 6's 81 | }; 82 | 83 | struct utsname systemInfo = [self systemInfo]; 84 | 85 | NSString* deviceIdentifier = [NSString stringWithCString:systemInfo.machine encoding:NSUTF8StringEncoding]; 86 | 87 | return devices[deviceIdentifier]; 88 | } 89 | 90 | #pragma mark - Device identification 91 | 92 | + (BOOL)isiPhone { 93 | return ![self isiPad]; 94 | } 95 | 96 | + (BOOL)isiPad { 97 | return [UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad; 98 | } 99 | 100 | + (BOOL)isRetina { 101 | return ([[UIScreen mainScreen] respondsToSelector:@selector(scale)] && [[UIScreen mainScreen] scale] > 1); 102 | } 103 | 104 | 105 | + (BOOL)isiPhoneSix 106 | { 107 | NSString* deviceName = [self deviceName]; 108 | BOOL result = NO; 109 | 110 | if ([deviceName isEqualToString:WPDeviceNameSimulator]) { 111 | // IMPORTANT: this aproximation is only used when testing using the simulator. It's 112 | // basically our best bet at identifying the device in lack of a better method. This 113 | // aproximation may need adjusting when new devices come out. 114 | // 115 | result = ([self isiPhone] 116 | && [[UIScreen mainScreen] respondsToSelector:@selector(nativeScale)] 117 | && [[UIScreen mainScreen] respondsToSelector:@selector(nativeBounds)] 118 | && [[UIScreen mainScreen] nativeScale] == 2 119 | && CGRectGetHeight([[UIScreen mainScreen] nativeBounds]) == 1334); 120 | } else { 121 | result = [deviceName isEqualToString:WPDeviceNameiPhone6]; 122 | } 123 | 124 | return result; 125 | } 126 | 127 | + (BOOL)isiPhoneSixPlus 128 | { 129 | NSString* deviceName = [self deviceName]; 130 | BOOL result = NO; 131 | 132 | if ([deviceName isEqualToString:WPDeviceNameSimulator]) { 133 | // IMPORTANT: this aproximation is only used when testing using the simulator. It's 134 | // basically our best bet at identifying the device in lack of a better method. This 135 | // aproximation may need adjusting when new devices come out. 136 | // 137 | result = ([self isiPhone] 138 | && [[UIScreen mainScreen] respondsToSelector:@selector(nativeScale)] 139 | && [[UIScreen mainScreen] respondsToSelector:@selector(nativeBounds)] 140 | && [[UIScreen mainScreen] nativeScale] > 2.5 141 | && CGRectGetHeight([[UIScreen mainScreen] nativeBounds]) == 2208); 142 | } else { 143 | result = [deviceName isEqualToString:WPDeviceNameiPhone6Plus]; 144 | } 145 | 146 | return result; 147 | } 148 | 149 | + (BOOL)isUnzoomediPhonePlus 150 | { 151 | CGRect bounds = UIScreen.mainScreen.fixedCoordinateSpace.bounds; 152 | CGFloat unzoomediPhonePlusHeight = 736.0; 153 | 154 | return UIScreen.mainScreen.scale == 3.0 && bounds.size.height == unzoomediPhonePlusHeight; 155 | } 156 | 157 | + (BOOL)isiOSVersionEarlierThan9 158 | { 159 | return [[[UIDevice currentDevice] systemVersion] floatValue] < 9.0; 160 | } 161 | 162 | + (BOOL)isiOSVersionEarlierThan10 163 | { 164 | return [[[UIDevice currentDevice] systemVersion] floatValue] < 10.0; 165 | } 166 | 167 | @end 168 | -------------------------------------------------------------------------------- /Tests/WordPressSharedObjCTests/NSStringHelpersTests.m: -------------------------------------------------------------------------------- 1 | #import 2 | #import "NSString+Helpers.h" 3 | 4 | @interface NSString () 5 | + (NSString *)emojiCharacterFromCoreEmojiFilename:(NSString *)filename; 6 | + (NSString *)emojiFromCoreEmojiImageTag:(NSString *)tag; 7 | @end 8 | 9 | @interface NSStringHelpersTest : XCTestCase 10 | 11 | @end 12 | 13 | @implementation NSStringHelpersTest 14 | 15 | - (void)testEllipsizing 16 | { 17 | NSString *sampleText = @"The quick brown fox jumps over the lazy dog."; 18 | XCTAssertTrue([[sampleText stringByEllipsizingWithMaxLength:14 preserveWords:YES] isEqualToString:@"The quick …"], @"Incorrect Result."); 19 | XCTAssertTrue([[sampleText stringByEllipsizingWithMaxLength:14 preserveWords:NO] isEqualToString:@"The quick bro…"], @"Incorrect Result."); 20 | XCTAssertTrue([[sampleText stringByEllipsizingWithMaxLength:100 preserveWords:NO] isEqualToString:sampleText], @"Incorrect Result."); 21 | XCTAssertTrue([[sampleText stringByEllipsizingWithMaxLength:0 preserveWords:NO] isEqualToString:@""], @"Incorrect Result."); 22 | 23 | NSString *url = @"http://www.wordpress.com"; 24 | XCTAssertTrue([[url stringByEllipsizingWithMaxLength:8 preserveWords:YES] isEqualToString:@"http://…"], @"Incorrect Result."); 25 | 26 | NSString *longSingleWord = @"ThisIsALongSingleWordThatIsALittleWeird"; 27 | XCTAssertTrue([[longSingleWord stringByEllipsizingWithMaxLength:8 preserveWords:YES] isEqualToString:@"ThisIsA…"], @"Incorrect Result."); 28 | } 29 | 30 | - (void)testIsWordPressComPathWithValidDotcomRootPaths 31 | { 32 | NSArray *validDotcomUrls = @[ 33 | @"http://wordpress.com", 34 | @"http://www.wordpress.com", 35 | @"http://www.WordPress.com", 36 | @"http://www.WordPress.com/", 37 | @"https://wordpress.com", 38 | @"https://www.wordpress.com", 39 | @"https://www.WordPress.com", 40 | @"https://www.WordPress.com/" 41 | ]; 42 | 43 | for (NSString *validDotcomPath in validDotcomUrls) { 44 | XCTAssertTrue(validDotcomPath.isWordPressComPath, @"Something went wrong. Better call Saul"); 45 | } 46 | } 47 | 48 | - (void)testIsWordPressComPathWithInvalidDotcomRootPaths 49 | { 50 | NSArray *invalidDotcomUrls = @[ 51 | @"http://Zwordpress.com", 52 | @"http://www.Zwordpress.com", 53 | @"http://www.ZWordPress.com", 54 | @"https://Zwordpress.com" 55 | ]; 56 | 57 | for (NSString *invalidDotcomPath in invalidDotcomUrls) { 58 | XCTAssertFalse(invalidDotcomPath.isWordPressComPath, @"Something went wrong. Better call Saul"); 59 | } 60 | } 61 | 62 | - (void)testIsWordPressComPathWithMissingProtocol 63 | { 64 | NSArray *validDotcomUrls = @[ 65 | @"wordpress.com", 66 | @"wordpress.com/something", 67 | @"www.wordpress.com", 68 | @"www.WordPress.com", 69 | @"www.WordPress.com/something", 70 | ]; 71 | 72 | for (NSString *validDotcomPath in validDotcomUrls) { 73 | XCTAssertTrue(validDotcomPath.isWordPressComPath, @"Something went wrong. Better call Saul"); 74 | } 75 | } 76 | 77 | - (void)testIsWordPressComPathWithValidPathsWithSubdomains 78 | { 79 | NSArray *validDotcomUrls = @[ 80 | @"http://blog.wordpress.com", 81 | @"http://blog.WordPress.com", 82 | @"https://blog.wordpress.com", 83 | @"https://blog.WordPress.com", 84 | @"http://blog.WordPress.com/some", 85 | @"http://blog.WordPress.com/some/thing/else" 86 | ]; 87 | 88 | for (NSString *validDotcomPath in validDotcomUrls) { 89 | XCTAssertTrue(validDotcomPath.isWordPressComPath, @"Something went wrong. Better call Saul"); 90 | } 91 | } 92 | 93 | - (void)testIsWordPressComPathWithInvalidProtocols 94 | { 95 | NSArray *invalidDotcomUrls = @[ 96 | @"hppt://wordpress.com", 97 | @"httpz://www.wordpress.com", 98 | @"httpsz://www.WordPress.com", 99 | @"zzzzzz://wordpress.com" 100 | ]; 101 | 102 | for (NSString *invalidDotcomPath in invalidDotcomUrls) { 103 | XCTAssertFalse(invalidDotcomPath.isWordPressComPath, @"Something went wrong. Better call Saul"); 104 | } 105 | } 106 | 107 | - (void)testWordCount 108 | { 109 | NSString *testEmptyPhrase = @""; 110 | XCTAssert([testEmptyPhrase wordCount] == 0, @"Word count should be zero on a empty string"); 111 | 112 | NSString *testPhraseEnglish = @"The lazy fox jumped over the fence."; 113 | XCTAssert([testPhraseEnglish wordCount] == 7, @"Word count should be seven"); 114 | 115 | NSString *testPhraseSpanish = @"El zorro perezoso saltó por encima de la valla."; 116 | XCTAssert([testPhraseSpanish wordCount] == 9, @"Word count should be nine"); 117 | 118 | NSString *testPhraseFrench = @"Le renard paresseux sauté par-dessus la clôture."; 119 | XCTAssert([testPhraseFrench wordCount] == 8, @"Word count should be eight"); 120 | 121 | NSString *testPhrasePortuguese = @"A raposa preguiçosa saltou a cerca."; 122 | XCTAssert([testPhrasePortuguese wordCount] == 6, @"Word count should be six"); 123 | } 124 | 125 | - (void)testStringByReplacingHTMLEmoticonsWithEmoji 126 | { 127 | NSString *emoji = @"\U0001F600"; 128 | NSString *imageTag = @""; 129 | NSString *replacedString = [imageTag stringByReplacingHTMLEmoticonsWithEmoji]; 130 | 131 | XCTAssert([replacedString isEqualToString:emoji], @"The image tag was not replaced with an emoji string"); 132 | } 133 | 134 | - (void)testEmojiFromCoreEmojiImageTag 135 | { 136 | NSString *emoji = @"😜"; 137 | NSString *imageTagTestingAlt = @"\"😜\""; 138 | NSString *imageTagTestingFilename = @""; 139 | 140 | // Test emoji found from alt text 141 | NSString *str = [NSString emojiFromCoreEmojiImageTag:imageTagTestingAlt]; 142 | XCTAssert([str isEqualToString:emoji], @"The expected emoji was not retrieved from the image tag's alt text"); 143 | 144 | // Test emoji found from file path 145 | str = [NSString emojiFromCoreEmojiImageTag:imageTagTestingFilename]; 146 | XCTAssert([str isEqualToString:emoji], @"The expected emoji was not retrieved from the image tag's image file name"); 147 | } 148 | 149 | - (void)testEmojiUnicodeFromCoreEmojiFilename 150 | { 151 | NSString *copyright = @"A9"; 152 | // Test emoji <= 0xFFFF 153 | NSString *emojiString = [NSString emojiCharacterFromCoreEmojiFilename:copyright]; 154 | XCTAssert([emojiString isEqualToString:@"\u00A9"], @"The emoji filename was not converted to a unicode code point."); 155 | 156 | NSString *smilingImp = @"1F608"; 157 | // Test emoji > 0xFFFF 158 | emojiString = [NSString emojiCharacterFromCoreEmojiFilename:smilingImp]; 159 | XCTAssert([emojiString isEqualToString:@"\U0001F608"], @"The emoji filename was not converted to a unicode code point."); 160 | 161 | NSString *flag = @"1f1fa-1f1f8"; 162 | // Test surrogate pair 163 | emojiString = [NSString emojiCharacterFromCoreEmojiFilename:flag]; 164 | XCTAssert([emojiString isEqualToString:@"\U0001f1fa\U0001f1f8"], @"The emoji filename was not converted to a unicode code point."); 165 | 166 | NSString *invalid = @"ZZZZZ"; 167 | emojiString = [NSString emojiCharacterFromCoreEmojiFilename:invalid]; 168 | XCTAssert([emojiString length] == 0, @"Should return an empty string for an invalid file name."); 169 | } 170 | 171 | - (void)testEmojiDoesNotEatUpImages 172 | { 173 | NSString *emoji = @"\U0001F600"; 174 | NSString *imageTag = @""; 175 | NSString *replacedString = [imageTag stringByReplacingHTMLEmoticonsWithEmoji]; 176 | NSString *expected = [@"" stringByAppendingString:emoji]; 177 | 178 | XCTAssertEqualObjects(expected, replacedString, @"The image tag was not replaced with an emoji string"); 179 | } 180 | 181 | - (void)testNormalizeWhitespace 182 | { 183 | NSString *sourceString = @"This is a \n\n\n test string. "; 184 | NSString *expectedString = @"This is a test string. "; 185 | 186 | XCTAssertTrue([expectedString isEqualToString:[sourceString stringByNormalizingWhitespace]]); 187 | } 188 | 189 | @end 190 | -------------------------------------------------------------------------------- /Sources/WordPressShared/Utility/NSDate+Helpers.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Date { 4 | /// Private Date Formatters 5 | /// 6 | fileprivate struct DateFormatters { 7 | static let iso8601: DateFormatter = { 8 | let formatter = DateFormatter() 9 | formatter.locale = Locale(identifier: "en_US_POSIX") 10 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" 11 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 12 | return formatter 13 | }() 14 | 15 | static let iso8601WithMilliseconds: DateFormatter = { 16 | let formatter = DateFormatter() 17 | formatter.locale = Locale(identifier: "en_US_POSIX") 18 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" 19 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 20 | return formatter 21 | }() 22 | 23 | static let rfc1123: DateFormatter = { 24 | let formatter = DateFormatter() 25 | formatter.locale = Locale(identifier: "en_US_POSIX") 26 | formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss z" 27 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 28 | return formatter 29 | }() 30 | 31 | static let mediumDate: DateFormatter = { 32 | let formatter = DateFormatter() 33 | formatter.dateStyle = .medium 34 | formatter.timeStyle = .none 35 | return formatter 36 | }() 37 | 38 | static let mediumDateTime: DateFormatter = { 39 | let formatter = DateFormatter() 40 | formatter.doesRelativeDateFormatting = true 41 | formatter.dateStyle = .medium 42 | formatter.timeStyle = .short 43 | return formatter 44 | }() 45 | 46 | static let mediumUTCDateTime: DateFormatter = { 47 | let formatter = DateFormatter() 48 | formatter.dateStyle = .medium 49 | formatter.timeStyle = .short 50 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 51 | return formatter 52 | }() 53 | 54 | static let longUTCDate: DateFormatter = { 55 | let formatter = DateFormatter() 56 | formatter.dateStyle = .long 57 | formatter.timeStyle = .none 58 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 59 | return formatter 60 | }() 61 | 62 | static let shortDateTime: DateFormatter = { 63 | let formatter = DateFormatter() 64 | formatter.doesRelativeDateFormatting = true 65 | formatter.dateStyle = .short 66 | formatter.timeStyle = .short 67 | return formatter 68 | }() 69 | } 70 | 71 | /// Returns a NSDate Instance, given it's ISO8601 String Representation 72 | /// 73 | public static func dateWithISO8601String(_ string: String) -> Date? { 74 | return DateFormatters.iso8601.date(from: string) 75 | } 76 | 77 | /// Returns a NSDate Instance, given it's ISO8601 String Representation with milliseconds 78 | /// 79 | public static func dateWithISO8601WithMillisecondsString(_ string: String) -> Date? { 80 | return DateFormatters.iso8601WithMilliseconds.date(from: string) 81 | } 82 | 83 | /// Returns a NSDate instance with only its Year / Month / Weekday / Day set. Removes the time! 84 | /// 85 | public func normalizedDate() -> Date { 86 | 87 | var calendar = Calendar.current 88 | calendar.timeZone = TimeZone.autoupdatingCurrent 89 | 90 | let flags: NSCalendar.Unit = [.day, .weekOfYear, .month, .year] 91 | 92 | let components = (calendar as NSCalendar).components(flags, from: self) 93 | 94 | var normalized = DateComponents() 95 | normalized.year = components.year 96 | normalized.month = components.month 97 | normalized.weekday = components.weekday 98 | normalized.day = components.day 99 | 100 | return calendar.date(from: normalized) ?? self 101 | } 102 | 103 | /// Formats the current NSDate instance using the RFC1123 Standard 104 | /// 105 | public func toStringAsRFC1123() -> String { 106 | return DateFormatters.rfc1123.string(from: self) 107 | } 108 | 109 | @available(*, deprecated, renamed: "toMediumString", message: "Removed to help drop the deprecated `FormatterKit` dependency – @jkmassel, Mar 2021") 110 | public func mediumString(timeZone: TimeZone? = nil) -> String { 111 | toMediumString(inTimeZone: timeZone) 112 | } 113 | 114 | /// Formats the current date as relative date if it's within a week of 115 | /// today, or with DateFormatter.Style.medium otherwise. 116 | /// - Parameter timeZone: An optional time zone used to adjust the date formatters. **NOTE**: This has no affect on relative time stamps. 117 | /// 118 | /// - Example: 22 hours from now 119 | /// - Example: 5 minutes ago 120 | /// - Example: 8 hours ago 121 | /// - Example: 2 days ago 122 | /// - Example: Jan 22, 2017 123 | /// 124 | public func toMediumString(inTimeZone timeZone: TimeZone? = nil) -> String { 125 | let relativeFormatter = RelativeDateTimeFormatter() 126 | relativeFormatter.dateTimeStyle = .named 127 | 128 | let absoluteFormatter = DateFormatters.mediumDate 129 | 130 | if let timeZone = timeZone { 131 | absoluteFormatter.timeZone = timeZone 132 | } 133 | 134 | let components = Calendar.current.dateComponents([.day], from: self, to: Date()) 135 | if let days = components.day, abs(days) < 7 { 136 | return relativeFormatter.localizedString(fromTimeInterval: timeIntervalSinceNow) 137 | } else { 138 | return absoluteFormatter.string(from: self) 139 | } 140 | } 141 | 142 | /// Formats the current date as a medium relative date/time. 143 | /// That is, it uses the `DateFormatter` `dateStyle` `.medium` and `timeStyle` `.short`. 144 | /// 145 | /// - Parameter timeZone: An optional time zone used to adjust the date formatters. 146 | public func mediumStringWithTime(timeZone: TimeZone? = nil) -> String { 147 | let formatter = DateFormatters.mediumDateTime 148 | if let timeZone = timeZone { 149 | formatter.timeZone = timeZone 150 | } 151 | return formatter.string(from: self) 152 | } 153 | 154 | /// Formats the current date as (non relative) long date (no time) in UTC. 155 | /// 156 | /// - Example: January 6th, 2018 157 | /// 158 | public func longUTCStringWithoutTime() -> String { 159 | return DateFormatters.longUTCDate.string(from: self) 160 | } 161 | 162 | /// Formats the current date as (non relattive) medium date/time in UTC. 163 | /// 164 | /// - Example: Jan 28, 2017, 1:51 PM 165 | /// 166 | public func mediumStringWithUTCTime() -> String { 167 | return DateFormatters.mediumUTCDateTime.string(from: self) 168 | } 169 | 170 | /// Formats the current date as a short relative date/time. 171 | /// 172 | /// - Example: Tomorrow, 6:45 AM 173 | /// - Example: Today, 8:09 AM 174 | /// - Example: Yesterday, 11:36 PM 175 | /// - Example: 1/28/17, 1:51 PM 176 | /// - Example: 1/22/17, 2:18 AM 177 | /// 178 | public func shortStringWithTime() -> String { 179 | return DateFormatters.shortDateTime.string(from: self) 180 | } 181 | 182 | @available(*, deprecated, message: "Not used, as far as I can tell – @jkmassel, Jan 2021") 183 | fileprivate func toStringForPageSections() -> String { 184 | let interval = timeIntervalSinceNow 185 | 186 | if interval > 0 && interval < 86400 { 187 | return NSLocalizedString("later today", comment: "Later today") 188 | } else { 189 | let formatter = RelativeDateTimeFormatter() 190 | formatter.unitsStyle = .short 191 | formatter.dateTimeStyle = .named 192 | 193 | return formatter.localizedString(fromTimeInterval: interval) 194 | } 195 | } 196 | 197 | /// Returns the date components object. 198 | /// 199 | public func dateAndTimeComponents() -> DateComponents { 200 | return Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], 201 | from: self) 202 | } 203 | } 204 | 205 | extension NSDate { 206 | @objc public static func dateWithISO8601String(_ string: String) -> NSDate? { 207 | return Date.DateFormatters.iso8601.date(from: string) as NSDate? 208 | } 209 | 210 | /// Formats the current date as relative date if it's within a week of 211 | /// today, or with NSDateFormatterMediumStyle otherwise. 212 | /// 213 | /// - Example: 22 hours from now 214 | /// - Example: 5 minutes ago 215 | /// - Example: 8 hours ago 216 | /// - Example: 2 days ago 217 | /// - Example: Jan 22, 2017 218 | /// 219 | @objc public func mediumString() -> String { 220 | return (self as Date).toMediumString() 221 | } 222 | 223 | /// Formats the current date as a medium relative date/time. 224 | /// 225 | /// - Example: Tomorrow, 6:45 AM 226 | /// - Example: Today, 8:09 AM 227 | /// - Example: Yesterday, 11:36 PM 228 | /// - Example: Jan 28, 2017, 1:51 PM 229 | /// - Example: Jan 22, 2017, 2:18 AM 230 | /// 231 | @objc public func mediumStringWithTime() -> String { 232 | return (self as Date).mediumStringWithTime() 233 | } 234 | 235 | /// Formats the current date as a short relative date/time. 236 | /// 237 | /// - Example: Tomorrow, 6:45 AM 238 | /// - Example: Today, 8:09 AM 239 | /// - Example: Yesterday, 11:36 PM 240 | /// - Example: 1/28/17, 1:51 PM 241 | /// - Example: 1/22/17, 2:18 AM 242 | /// 243 | @objc public func shortStringWithTime() -> String { 244 | return (self as Date).shortStringWithTime() 245 | } 246 | 247 | @available(*, deprecated, message: "Scheduled for removal with FormatterKit – if it's still used, we'll rewrite it with modern APIs") 248 | @objc public func toStringForPageSections() -> String { 249 | return (self as Date).toStringForPageSections() 250 | } 251 | 252 | /// Returns the date components object. 253 | /// 254 | @objc public func dateAndTimeComponents() -> NSDateComponents { 255 | return (self as Date).dateAndTimeComponents() as NSDateComponents 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /Sources/WordPressSharedObjC/Utility/DisplayableImageHelper.m: -------------------------------------------------------------------------------- 1 | #import "DisplayableImageHelper.h" 2 | #import "NSString+Util.h" 3 | 4 | static const NSInteger FeaturedImageMinimumWidth = 150; 5 | 6 | static NSString * const AttachmentsDictionaryKeyWidth = @"width"; 7 | static NSString * const AttachmentsDictionaryKeyURL = @"URL"; 8 | static NSString * const AttachmentsDictionaryKeyMimeType = @"mime_type"; 9 | 10 | @implementation DisplayableImageHelper 11 | 12 | + (NSInteger)widthOfAttachment:(NSDictionary *)attachment { 13 | NSInteger result = 0; 14 | id obj = [attachment objectForKey:AttachmentsDictionaryKeyWidth]; 15 | if ([obj isKindOfClass:NSNumber.class]) { 16 | NSNumber *number = (NSNumber *)obj; 17 | result = [number integerValue]; 18 | } else if ([obj isKindOfClass:NSString.class]) { 19 | NSNumberFormatter *numberFormatter = [[NSNumberFormatter alloc] init]; 20 | numberFormatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; 21 | NSNumber *number= [numberFormatter numberFromString:(NSString *)obj]; 22 | result = [number integerValue]; 23 | } 24 | return result; 25 | } 26 | 27 | + (NSString *)searchPostAttachmentsForImageToDisplay:(NSDictionary *)attachmentsDict existingInContent:(NSString *)content 28 | { 29 | NSArray *attachments = [attachmentsDict allValues]; 30 | if ([attachments count] == 0) { 31 | return nil; 32 | } 33 | 34 | NSString *imageToDisplay; 35 | 36 | attachments = [self filteredAttachmentsArray:attachments]; 37 | 38 | for (NSDictionary *attachment in attachments) { 39 | NSInteger width = [self widthOfAttachment:attachment]; 40 | if (width < FeaturedImageMinimumWidth) { 41 | // The remaining images are too small so just stop now. 42 | break; 43 | } 44 | id obj = attachment[AttachmentsDictionaryKeyURL]; 45 | if ([obj isKindOfClass:NSString.class]) { 46 | NSString *maybeImage = (NSString *)obj; 47 | if ([content containsString:maybeImage]) { 48 | imageToDisplay = maybeImage; 49 | break; 50 | } 51 | } 52 | } 53 | 54 | return imageToDisplay; 55 | } 56 | 57 | + (NSArray *)filteredAttachmentsArray:(NSArray *)attachments 58 | { 59 | NSString *key = AttachmentsDictionaryKeyMimeType; 60 | NSPredicate *predicate = [NSPredicate predicateWithFormat:@"%K BEGINSWITH %@", key, @"image"]; 61 | attachments = [attachments filteredArrayUsingPredicate:predicate]; 62 | attachments = [self sortAttachmentsArray:attachments]; 63 | return attachments; 64 | } 65 | 66 | + (NSArray *)sortAttachmentsArray:(NSArray *)attachments 67 | { 68 | return [attachments sortedArrayUsingComparator:^NSComparisonResult(NSDictionary *attachmentA, NSDictionary *attachmentB) { 69 | NSInteger widthA = [self widthOfAttachment:attachmentA]; 70 | NSInteger widthB = [self widthOfAttachment:attachmentB]; 71 | 72 | if (widthA < widthB) { 73 | return NSOrderedDescending; 74 | } else if (widthA > widthB) { 75 | return NSOrderedAscending; 76 | } else { 77 | return NSOrderedSame; 78 | } 79 | }]; 80 | } 81 | 82 | + (NSString *)searchPostContentForImageToDisplay:(NSString *)content 83 | { 84 | NSString *imageSrc = @""; 85 | // If there is no image tag in the content, just bail. 86 | if (!content || [content rangeOfString:@""; 96 | regex = [NSRegularExpression regularExpressionWithPattern:imgPattern options:NSRegularExpressionCaseInsensitive error:&error]; 97 | }); 98 | 99 | // Find all the image tags in the content passed. 100 | NSArray *matches = [regex matchesInString:content options:0 range:NSMakeRange(0, [content length])]; 101 | 102 | for (NSTextCheckingResult *match in matches) { 103 | NSString *tag = [content substringWithRange:match.range]; 104 | NSString *src = [self extractSrcFromImgTag:tag]; 105 | 106 | // Ignore WordPress emoji images 107 | if ([src rangeOfString:@"/images/core/emoji/"].location != NSNotFound || 108 | [src rangeOfString:@"/wp-includes/images/smilies/"].location != NSNotFound || 109 | [src rangeOfString:@"/wp-content/mu-plugins/wpcom-smileys/"].location != NSNotFound) { 110 | continue; 111 | } 112 | 113 | // Ignore .svg images since we can't display them in a UIImageView 114 | if ([src rangeOfString:@".svg"].location != NSNotFound) { 115 | continue; 116 | } 117 | 118 | // Check the tag for a good width 119 | NSInteger width = MAX([self widthFromElementAttribute:tag], [self widthFromQueryString:src]); 120 | if (width > FeaturedImageMinimumWidth) { 121 | imageSrc = src; 122 | break; 123 | } 124 | } 125 | if (imageSrc.length == 0) { 126 | imageSrc = [self searchContentBySizeClassForImageToFeature:content]; 127 | } 128 | 129 | return imageSrc; 130 | } 131 | 132 | + (NSSet *)searchPostContentForAttachmentIdsInGalleries:(NSString *)content 133 | { 134 | NSMutableSet *resultSet = [NSMutableSet set]; 135 | // If there is no gallery shortcode in the content, just bail. 136 | if (!content || [content rangeOfString:@"[gallery "].location == NSNotFound) { 137 | return resultSet; 138 | } 139 | 140 | // Get all the things 141 | static NSRegularExpression *regexGallery; 142 | static dispatch_once_t onceTokenRegexGallery; 143 | dispatch_once(&onceTokenRegexGallery, ^{ 144 | NSError *error; 145 | NSString *galleryPattern = @"\\[gallery[^]]+ids=\"([0-9,]*)\"[^]]*\\]"; 146 | regexGallery = [NSRegularExpression regularExpressionWithPattern:galleryPattern options:NSRegularExpressionCaseInsensitive error:&error]; 147 | }); 148 | 149 | // Find all the gallery shortcodes in the content passed. 150 | NSArray *matches = [regexGallery matchesInString:content options:0 range:NSMakeRange(0, [content length])]; 151 | 152 | for (NSTextCheckingResult *match in matches) { 153 | if (match.numberOfRanges < 2) { 154 | continue; 155 | } 156 | NSString *tag = [content substringWithRange:[match rangeAtIndex:1]]; 157 | NSSet *tagIds = [self idsFromGallery:tag]; 158 | [resultSet unionSet:tagIds]; 159 | } 160 | return resultSet; 161 | } 162 | 163 | /** 164 | Extract the path to an image from an image tag. 165 | 166 | @param tag An image tag. 167 | @return The value of the src param. 168 | */ 169 | + (NSString *)extractSrcFromImgTag:(NSString *)tag 170 | { 171 | static NSRegularExpression *regex; 172 | static dispatch_once_t onceToken; 173 | dispatch_once(&onceToken, ^{ 174 | NSError *error; 175 | NSString *srcPattern = @"src\\s*=\\s*(?:'|\")(.*?)(?:'|\")"; 176 | regex = [NSRegularExpression regularExpressionWithPattern:srcPattern options:NSRegularExpressionCaseInsensitive error:&error]; 177 | }); 178 | 179 | NSRange srcRng = [regex rangeOfFirstMatchInString:tag options:0 range:NSMakeRange(0, [tag length])]; 180 | NSString *src = [tag substringWithRange:srcRng]; 181 | NSCharacterSet *charSet = [NSCharacterSet characterSetWithCharactersInString:@"\"'="]; 182 | NSRange quoteRng = [src rangeOfCharacterFromSet:charSet]; 183 | src = [src substringFromIndex:quoteRng.location]; 184 | src = [src stringByTrimmingCharactersInSet:charSet]; 185 | return src; 186 | } 187 | 188 | /** 189 | Search the passed string for an image that is a good candidate to feature. 190 | @param content The content string to search. 191 | @return The url path for the image or an empty string. 192 | */ 193 | + (NSString *)searchContentBySizeClassForImageToFeature:(NSString *)content 194 | { 195 | NSString *str = @""; 196 | // If there is no image tag in the content, just bail. 197 | if (!content || [content rangeOfString:@"