Inserted the Wikipedia project into the Xcode project file.

This commit is contained in:
Javier Cicchelli 2023-04-08 17:23:02 +02:00
parent f670ad3e37
commit 66aa840ec3
4113 changed files with 407243 additions and 0 deletions

View File

@ -0,0 +1,44 @@
# For a detailed guide to building and testing on iOS, read the docs:
# https://circleci.com/docs/2.0/testing-ios/
version: 2.1
executors:
xcode:
macos:
xcode: 14.2.0
commands:
install_dependencies:
description: "Install dependencies"
steps:
- restore_cache:
key: 1-gems-{{ checksum "Gemfile.lock" }}
- run: bundle check || bundle install --path vendor/bundle
- save_cache:
key: 1-gems-{{ checksum "Gemfile.lock" }}
paths:
- vendor/bundle
work_around_swift_package_manager_bug:
description: "Work around a Swift package manager bug" # https://support.circleci.com/hc/en-us/articles/360044709573?input_string=unable%2Bto%2Baccess%2Bprivate%2Bswift%2Bpackage%2Brepository
steps:
- run: sudo defaults write com.apple.dt.Xcode IDEPackageSupportUseBuiltinSCM YES
- run: rm ~/.ssh/id_rsa || true
- run: for ip in $(dig @8.8.8.8 github.com +short); do ssh-keyscan github.com,$ip; ssh-keyscan $ip; done 2>/dev/null >> ~/.ssh/known_hosts || true
jobs:
test_pr:
executor: xcode
steps:
- checkout
- install_dependencies
- work_around_swift_package_manager_bug
- run:
name: Fastlane
command: bundle exec fastlane verify_pull_request
- store_test_results:
path: fastlane/test_output/
workflows:
test_pr:
jobs:
- test_pr

View File

@ -0,0 +1,61 @@
# https://clang.llvm.org/docs/ClangFormatStyleOptions.html
BasedOnStyle: LLVM
AccessModifierOffset: -2
AlignAfterOpenBracket: true
AlignEscapedNewlinesLeft: false
AlignOperands: true
AlignTrailingComments: true
AllowAllParametersOfDeclarationOnNextLine: true
AllowShortBlocksOnASingleLine: false
AllowShortCaseLabelsOnASingleLine: false
AllowShortFunctionsOnASingleLine: All
AllowShortIfStatementsOnASingleLine: false
AllowShortLoopsOnASingleLine: false
AlwaysBreakAfterDefinitionReturnType: false
AlwaysBreakBeforeMultilineStrings: false
AlwaysBreakTemplateDeclarations: false
BinPackArguments: true
BinPackParameters: true
BreakBeforeBinaryOperators: None
BreakBeforeBraces: Attach
BreakBeforeTernaryOperators: true
BreakConstructorInitializersBeforeComma: false
ColumnLimit: 0
CommentPragmas: '^ IWYU pragma:'
ConstructorInitializerAllOnOneLineOrOnePerLine: false
ConstructorInitializerIndentWidth: 4
ContinuationIndentWidth: 4
Cpp11BracedListStyle: true
DerivePointerAlignment: false
DisableFormat: false
ExperimentalAutoDetectBinPacking: false
IndentCaseLabels: true
IndentWidth: 4
IndentWrappedFunctionNames: false
KeepEmptyLinesAtTheStartOfBlocks: true
MaxEmptyLinesToKeep: 1
NamespaceIndentation: None
ObjCBlockIndentWidth: 4
ObjCSpaceAfterProperty: true
ObjCSpaceBeforeProtocolList: true
PenaltyBreakBeforeFirstCallParameter: 19
PenaltyBreakComment: 300
PenaltyBreakFirstLessLess: 120
PenaltyBreakString: 1000
PenaltyExcessCharacter: 1000000
PenaltyReturnTypeOnItsOwnLine: 60
PointerAlignment: Right
SpaceAfterCStyleCast: false
SpaceBeforeAssignmentOperators: true
SpaceBeforeParens: ControlStatements
SpaceInEmptyParentheses: false
SpacesBeforeTrailingComments: 1
SpacesInAngles: false
SpacesInCStyleCastParentheses: false
SpacesInContainerLiterals: false
SpacesInParentheses: false
SpacesInSquareBrackets: false
Standard: Cpp11
TabWidth: 8
UseTab: Never
SortIncludes: false

1
Apps/Wikipedia/.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1 @@
custom: ['https://donate.wikimedia.org/?utm_medium=githubRepo']

View File

@ -0,0 +1,10 @@
**Phabricator:**
### Notes
*
### Test Steps
1.
### Screenshots/Videos

View File

@ -0,0 +1,29 @@
# Transform localizations from TranslateWiki.net
#
# As of late 2020, TranslateWiki creates PRs automatically, in line with
# how they work with other repos. However, the strings still need to be
# translated to work well with the iOS app. This script runs when a new PR
# is created by TranslateWiki, to add everything the app needs to the PR.
#
name: Import localizations from TranslateWiki
on:
push:
branches:
- twn
jobs:
update-localizations:
runs-on: macOS-latest
steps:
- uses: actions/checkout@v2
- name: Update localizations
continue-on-error: true
run: |
$GITHUB_WORKSPACE/scripts/localization $GITHUB_WORKSPACE
git config user.name github-actions
git config user.email github-actions@github.com
git add .
git commit -m "Import translations from TranslateWiki"
git push

View File

@ -0,0 +1,3 @@
Wikipedia/assets/**
fastlane/**
www/node_modules/**

View File

@ -0,0 +1 @@
10.16.0

View File

@ -0,0 +1 @@
3.0.5

View File

@ -0,0 +1,37 @@
disabled_rules:
- class_delegate_protocol
- colon
- comma
- compiler_protocol_init
- cyclomatic_complexity
- file_length
- force_cast
- force_try
- for_where
- function_body_length
- function_parameter_count
- identifier_name
- is_disjoint
- large_tuple
- legacy_random
- line_length
- multiple_closures_with_trailing_closure
- nesting
- no_fallthrough_only
- operator_whitespace
- private_over_fileprivate
- redundant_objc_attribute
- redundant_optional_initialization
- redundant_string_enum_value
- shorthand_operator
- todo
- trailing_newline
- trailing_whitespace
- type_body_length
- type_name
- unneeded_break_in_switch
- unused_closure_parameter
- notification_center_detachment
vertical_whitespace:
max_empty_lines: 2

View File

@ -0,0 +1,37 @@
disabled_rules:
- class_delegate_protocol
- colon
- comma
- compiler_protocol_init
- cyclomatic_complexity
- file_length
- force_cast
- force_try
- for_where
- function_body_length
- function_parameter_count
- identifier_name
- is_disjoint
- large_tuple
- legacy_random
- line_length
- multiple_closures_with_trailing_closure
- nesting
- no_fallthrough_only
- operator_whitespace
- private_over_fileprivate
- redundant_objc_attribute
- redundant_optional_initialization
- redundant_string_enum_value
- shorthand_operator
- todo
- trailing_newline
- trailing_whitespace
- type_body_length
- type_name
- unneeded_break_in_switch
- unused_closure_parameter
- notification_center_detachment
vertical_whitespace:
max_empty_lines: 2

View File

@ -0,0 +1,7 @@
[
"-workspace", "Wikipedia.xcworkspace",
"-scheme", "Wikipedia",
"-configuration", "Debug",
"-sdk", "iphonesimulator",
"-destination", "platform=iOS Simulator,name=iPhone 6,OS=10.0"
]

View File

@ -0,0 +1 @@
14.2.0

View File

@ -0,0 +1 @@
The development of this software is covered by a [Code of Conduct](https://www.mediawiki.org/wiki/Code_of_Conduct).

View File

@ -0,0 +1,32 @@
# Contributing
We welcome volunteers to contribute to the Wikipedia iOS app codebase.
## Development instructions
Before developing, please read the [setup instructions](README.md).
Once your contributions are ready for review, add yourself in alphabetical order under contributors in `Code/AboutViewController.plist` and post a pull request on GitHub. One of the maintainers will review the PR. Thanks for contributing! 🎉
## What can I work on?
If you're looking for easy work, look at the tasks marked with the "good first bug" tag. [This link](https://phabricator.wikimedia.org/project/board/782/query/7vYTqNgpvqjh/) will show you all the "good first bug" tasks in the iOS backlog.
If you're ready to pick up more difficult work, look at the iOS backlog and pick something from the Bug Backlog column. [This link](https://phabricator.wikimedia.org/project/board/782/) will show you all the tasks in the iOS backlog. If the status of the task is unclear or you need more information, feel free to leave a comment and we'll try to respond as soon as possible.
## I found my task. What next?
Now you want to let the team know what you're working on.
1. In Phabricator, assign the task to yourself.
2. Add the tag representing the current release to the task. [This link](https://phabricator.wikimedia.org/search/query/WlSMhOAWTG73/) will take you to currently open releases. Tagging your task with the name of the release will add it to the release board.
3. On the release board, move the task to the "Doing" column.
4. When you're done developing, move the task to the "Needs Code Review" column.
## I don't want to work on my task any more.
You can let us know by unassigning the task and moving it back to the "Tasks from Product Backlog" column.
## How will I know that my contribution was accepted?
Your PR will be merged and your task will get moved to the "Ready for PM Signoff" column. This means that your contribution will be included in the upcoming release.
## Tips
[Wiki on how to use Phabricator](https://www.mediawiki.org/wiki/Phabricator/Project_management)

View File

@ -0,0 +1,106 @@
import Foundation
import Combine
/// Command line tool for updating prebuilt language lists and lookup tables used in the app
class WikipediaLanguageCommandLineUtility {
let pathComponents: [String]
let api = WikipediaLanguageCommandLineUtilityAPI()
/// - Parameter path: the path to the wikipedia-ios project folder
init(path: String) {
pathComponents = path.components(separatedBy: "/")
}
var cancellable: AnyCancellable?
/// Generates all the necessary files
func run(_ completion: @escaping () -> Void) {
cancellable = api.getSites().sink(receiveCompletion: { (result) in
switch result {
case .failure(let error):
print("Error fetching sites: \(error)")
abort()
default:
break
}
}) { (sites) in
let sortedSites = sites.sorted { (a, b) -> Bool in
return a.languageCode < b.languageCode
}
self.writeCodable(sortedSites, to: ["Wikipedia", "Code", "wikipedia-languages.json"])
self.cancellable = self.writeNamespaceFiles(with: sites) {
self.cancellable = self.writeCodemirrorConfig(with: sites, completion: {
completion()
})
}
}
}
private func getOutputFileURL(with components: [String]) -> URL {
let outputComponents = pathComponents + components
let outputPath = outputComponents.joined(separator: "/")
return URL(fileURLWithPath: outputPath)
}
private func writeCodable<T: Codable>(_ codable: T, to pathComponents: [String]) {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(codable)
let outputFileURL = getOutputFileURL(with: pathComponents)
try data.write(to: outputFileURL)
} catch let error {
print("Error writing to file: \(error)")
}
}
private func writeCodemirrorConfig(with sites: [Wikipedia], completion: @escaping () -> Void) -> AnyCancellable {
Publishers.MergeMany(sites.map { site in
api.getCodeMirrorConfigJSON(for: site.languageCode)
.map { ($0, site) }
})
.sink(receiveCompletion: { (result) in
switch result {
case .finished:
break
case .failure(let error):
print("Error writing codemirror config: \(error)")
}
completion()
}) { (value) in
let outputURL = self.getOutputFileURL(with: ["Wikipedia", "assets", "codemirror", "config", "codemirror-config-\(value.1.languageCode).json"])
try! value.0.write(to: outputURL, atomically: true, encoding: .utf8)
}
}
private func writeNamespaceFiles(with sites: [Wikipedia], completion: @escaping () -> Void) -> AnyCancellable? {
return Publishers.MergeMany(sites.map { site in
api.getSiteInfo(with: site.languageCode)
.map { ($0, site) }
})
.map {
(self.getSiteInfoLookup(with: $0.0), $0.1)
}
.sink(receiveCompletion: { (result) in
completion()
}) { (siteInfoTuple) in
self.writeCodable(siteInfoTuple.0, to: ["Wikipedia", "Code", "wikipedia-namespaces", "\(siteInfoTuple.1.languageCode).json"])
}
}
private func getSiteInfoLookup(with siteInfo: SiteInfo) -> WikipediaSiteInfoLookup {
var namespaces = [String: PageNamespace].init(minimumCapacity: siteInfo.query.namespaces.count + siteInfo.query.namespacealiases.count)
for (_, namespace) in siteInfo.query.namespaces {
namespaces[namespace.name.uppercased()] = PageNamespace(rawValue: namespace.id)
guard let canonical = namespace.canonical else {
continue
}
namespaces[canonical.uppercased()] = PageNamespace(rawValue: namespace.id)
}
for namespaceAlias in siteInfo.query.namespacealiases {
namespaces[namespaceAlias.alias.uppercased()] = PageNamespace(rawValue: namespaceAlias.id)
}
return WikipediaSiteInfoLookup(namespace: namespaces, mainpage: siteInfo.query.general.mainpage.uppercased())
}
}

View File

@ -0,0 +1,126 @@
import Foundation
import Combine
/// Utility for making API calls that update prebuilt lists of information about different language Wikipedias
class WikipediaLanguageCommandLineUtilityAPI {
// We can't use codable because this API's response has a dictionary with arbitrary keys mapped to values of mixed type.
// For example, the mixing of ints and dictionaries in "sitematrix":
// "sitematrix": {
// "count": 951,
// "0": {
// "code": "aa",
// "name": "Qaf\u00e1r af",
// ...
// },
// "1": {
// "code": "ab",
// "name":"аԥсшәа",
// ...
// },
// ...
// }
func getSites() -> AnyPublisher<[Wikipedia], Error> {
let sitematrixURL = URL(string: "https://meta.wikimedia.org/w/api.php?action=sitematrix&smsiteprop=url%7Cdbname%7Ccode%7Csitename%7Clang&format=json&formatversion=2&origin=*")!
return URLSession.shared
.dataTaskPublisher(for: sitematrixURL)
.tryMap { result -> [String: Any] in
/// See above as to why all of this is necessary instead of using codable
guard let jsonObject = try JSONSerialization.jsonObject(with: result.data, options: .allowFragments) as? [String: Any] else {
throw WikipediaLanguageUtilityAPIError.generic
}
return jsonObject
}
.map { jsonObject -> [Wikipedia] in
/// See above as to why all of this is necessary instead of using codable
guard let sitematrix = jsonObject["sitematrix"] as? [String: Any] else {
return []
}
var wikipedias = sitematrix.compactMap { (kv) -> Wikipedia? in
guard
let result = kv.value as? [String: Any],
let code = result["code"] as? String,
let name = result["name"] as? String,
let localname = result["localname"] as? String
else {
return nil
}
guard code != "no" else {
// Norwegian (Bokmål) has a different ISO code than it's subdomain, which is useful to reference in some instances (prepopulating preferredLanguages from iOS device languages, and choosing the correct alternative article language from the langlinks endpoint).
// https://phabricator.wikimedia.org/T276645
// https://phabricator.wikimedia.org/T272193
return Wikipedia(languageCode: code, languageName: name, localName: localname, altISOCode: "nb")
}
return Wikipedia(languageCode: code, languageName: name, localName: localname, altISOCode: nil)
}
// Add testwiki, it's not returned by the site matrix
wikipedias.append(Wikipedia(languageCode: "test", languageName: "Test", localName: "Test", altISOCode: nil))
return wikipedias
}.eraseToAnyPublisher()
}
func getSiteInfo(with languageCode: String) -> AnyPublisher<SiteInfo, Error> {
let siteInfoURL = URL(string: "https://\(languageCode).wikipedia.org/w/api.php?action=query&format=json&prop=&list=&meta=siteinfo&siprop=namespaces%7Cgeneral%7Cnamespacealiases&formatversion=2&origin=*")!
return URLSession.shared
.dataTaskPublisher(for: siteInfoURL)
.tryMap { (result) -> SiteInfo in
try JSONDecoder().decode(SiteInfo.self, from: result.data)
}.eraseToAnyPublisher()
}
func getCodeMirrorConfigJSON(for wikiLanguage: String) -> AnyPublisher<String, Error> {
let codeMirrorConfigURL = URL(string: "http://\(wikiLanguage).wikipedia.org/w/load.php?debug=false&lang=en&modules=ext.CodeMirror.data")!
return URLSession.shared.dataTaskPublisher(for: codeMirrorConfigURL)
.tryMap { (result) -> String in
guard
let responseString = String(data: result.data, encoding: .utf8),
let soughtSubstring = self.extractJSONString(from: responseString)
else {
throw WikipediaLanguageUtilityAPIError.generic
}
return soughtSubstring.replacingOccurrences(of: "!0", with: "true")
}.eraseToAnyPublisher()
}
private let jsonExtractionRegex = try! NSRegularExpression(pattern: #"(?:mw\.config\.set\()(.*?)(?:\);\n*\}\);)"#, options: [.dotMatchesLineSeparators])
private func extractJSONString(from responseString: String) -> String? {
let results = jsonExtractionRegex.matches(in: responseString, range: NSRange(responseString.startIndex..., in: responseString))
guard
results.count == 1,
let firstResult = results.first,
firstResult.numberOfRanges == 2,
let soughtCaptureGroupRange = Range(firstResult.range(at: 1), in: responseString)
else {
return nil
}
return String(responseString[soughtCaptureGroupRange])
}
}
enum WikipediaLanguageUtilityAPIError: Error {
case generic
}
struct SiteInfo: Codable {
struct Namespace: Codable {
let id: Int
let name: String
let canonical: String?
}
struct NamespaceAlias: Codable {
let id: Int
let alias: String
}
struct General: Codable {
let mainpage: String
}
struct Query: Codable {
let general: General
let namespaces: [String: Namespace]
let namespacealiases: [NamespaceAlias]
}
let query: Query
}

View File

@ -0,0 +1,17 @@
import Foundation
import Combine
/// **THIS IS NOT PART OF THE MAIN APP - IT'S A COMMAND LINE UTILITY**
let count = CommandLine.arguments.count
guard count > 1 else {
abort()
}
let path = CommandLine.arguments[1]
let utility = WikipediaLanguageCommandLineUtility(path: path)
utility.run {
exit(0)
}
dispatchMain()

View File

@ -0,0 +1,609 @@
import Foundation
/// **THIS IS NOT PART OF THE MAIN APP - IT'S A COMMAND LINE UTILITY**
fileprivate var dictionaryRegex: NSRegularExpression? = {
do {
return try NSRegularExpression(pattern: "(?:[{][{])(:?[^{]*)(?:[}][}])", options: [])
} catch {
assertionFailure("Localization regex failed to compile")
}
return nil
}()
fileprivate var curlyBraceRegex: NSRegularExpression? = {
do {
return try NSRegularExpression(pattern: "(?:[{][{][a-z]+:)(:?[^{]*)(?:[}][}])", options: [.caseInsensitive])
} catch {
assertionFailure("Localization regex failed to compile")
}
return nil
}()
fileprivate var twnTokenRegex: NSRegularExpression? = {
do {
return try NSRegularExpression(pattern: "(?:[$])(:?[0-9]+)", options: [])
} catch {
assertionFailure("Localization token regex failed to compile")
}
return nil
}()
fileprivate var iOSTokenRegex: NSRegularExpression? = {
do {
return try NSRegularExpression(pattern: "%([0-9]*)\\$?([@dDuUxXoOfeEgGcCsSpaAF])", options: [])
} catch {
assertionFailure("Localization token regex failed to compile")
}
return nil
}()
fileprivate var mwLocalizedStringRegex: NSRegularExpression? = {
do {
return try NSRegularExpression(pattern: "(?:WMLocalizedString\\(@\\\")(:?[^\"]+)(?:[^\\)]*\\))", options: [])
} catch {
assertionFailure("mwLocalizedStringRegex failed to compile")
}
return nil
}()
fileprivate var countPrefixRegex: NSRegularExpression? = {
do {
return try NSRegularExpression(pattern: "(:?^[^\\=]+)(?:=)", options: [])
} catch {
assertionFailure("countPrefixRegex failed to compile")
}
return nil
}()
// lookup from translatewiki prefix to iOS-supported stringsdict key
let keysByPrefix = [
"0":"zero",
// "1":"one" digits on translatewiki mean only use the translation when the replacement number === that digit. On iOS one, two, and few are more generic. for example, the translation for one could map to 1, 21, 31, etc
// "2":"two",
// "3":"few"
"zero":"zero",
"one":"one",
"two":"two",
"few":"few",
"many":"many",
"other":"other"
]
extension String {
var fullRange: NSRange {
return NSRange(startIndex..<endIndex, in: self)
}
var escapedString: String {
return self.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"").replacingOccurrences(of: "\n", with: "\\n")
}
/* supportsOneEquals indicates that the language's singular translation on iOS is only valid for n=1. digits on translatewiki mean only use the translation when the replacement number === that digit. On iOS one, two, and few are more generic. for example, the translation for one could map to 1, 21, 31, etc. Only use 1= for one when the iOS definition matches the translatewiki definition for a given language. */
func pluralDictionary(with keys: [String], tokens: [String: String], supportsOneEquals: Bool) -> NSDictionary? {
// https://developer.apple.com/library/content/documentation/MacOSX/Conceptual/BPInternational/StringsdictFileFormat/StringsdictFileFormat.html#//apple_ref/doc/uid/10000171i-CH16-SW1
guard let dictionaryRegex = dictionaryRegex else {
return nil
}
var remainingKeys = keys
let fullRange = self.fullRange
let mutableDictionary = NSMutableDictionary(capacity: 5)
let results = dictionaryRegex.matches(in: self, options: [], range: fullRange)
let nsSelf = self as NSString
// format is the full string with the plural tokens replaced by variables
// it will be built by enumerating through the matches for the plural regex
var format = ""
var location = 0
for result in results {
// append the next part of the string after the last match and before this one
format += nsSelf.substring(with: NSRange(location: location, length: result.range.location - location)).iOSNativeLocalization(tokens: tokens)
location = result.range.location + result.range.length
// get the contents of the match - the content between {{ and }}
let contents = dictionaryRegex.replacementString(for: result, in: self, offset: 0, template: "$1")
let components = contents.components(separatedBy: "|")
let countOfComponents = components.count
guard countOfComponents > 1 else {
continue
}
let pluralPrefix = "PLURAL:"
let firstComponent = components[0]
guard firstComponent.hasPrefix(pluralPrefix) else {
continue
}
if firstComponent == pluralPrefix {
print("Incorrectly formatted plural: \(self)")
abort()
}
let token = firstComponent.suffix(from: firstComponent.index(firstComponent.startIndex, offsetBy: 7)).trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
let nsToken = (token as NSString)
let tokenNumber = nsToken.substring(from: 1)
guard
let tokenInt = Int(tokenNumber),
tokenInt > 0
else {
continue
}
let keyDictionary = NSMutableDictionary(capacity: 5)
let formatValueType = tokens[tokenNumber] ?? "d"
keyDictionary["NSStringFormatSpecTypeKey"] = "NSStringPluralRuleType"
keyDictionary["NSStringFormatValueTypeKey"] = formatValueType
guard let countPrefixRegex = countPrefixRegex else {
abort()
}
var unlabeledComponents: [String] = []
for component in components[1..<countOfComponents] {
var keyForComponent: String?
var actualComponent: String? = component
guard let match = countPrefixRegex.firstMatch(in: component, options: [], range: component.fullRange) else {
if component.contains("=") {
print("Unsupported prefix: \(String(describing: component))")
abort()
}
unlabeledComponents.append(component)
continue
}
// Support for 0= 1= 2= zero= one= few= many=
let numberString = countPrefixRegex.replacementString(for: match, in: component, offset: 0, template: "$1")
if let key = (supportsOneEquals && (numberString == "1" || numberString == "one")) ? "one" : keysByPrefix[numberString] {
keyForComponent = key
remainingKeys = remainingKeys.filter({ (aKey) -> Bool in
return key != aKey
})
actualComponent = String(component.suffix(from: component.index(component.startIndex, offsetBy: match.range.length)))
} else {
#if DEBUG
print("Translatewiki prefix \(numberString) not supported on iOS for this language. Ignoring \(String(describing: component))")
#endif
}
guard let keyToInsert = keyForComponent, let componentToInsert = actualComponent else {
continue
}
keyDictionary[keyToInsert] = componentToInsert.iOSNativeLocalization(tokens: tokens)
}
if let other = unlabeledComponents.last {
keyDictionary["other"] = other.iOSNativeLocalization(tokens: tokens)
for (keyIndex, component) in unlabeledComponents.enumerated() {
guard
keyIndex < unlabeledComponents.count - 1,
keyIndex < remainingKeys.count
else {
break
}
keyDictionary[remainingKeys[keyIndex]] = component.iOSNativeLocalization(tokens: tokens)
}
} else if keyDictionary["other"] == nil {
if keyDictionary["many"] != nil {
keyDictionary["other"] = keyDictionary["many"]
} else {
print("missing default translation")
abort()
}
}
// set the variable name for the plural replacement
let key = "v\(tokenInt)"
// include the dictionary of possible replacements for the plural token
mutableDictionary[key] = keyDictionary
// append the variable name to the format string where the plural token used to be
format += "%#@\(key)@"
}
// append the final part of the string after the last match
format += nsSelf.substring(with: NSRange(location: location, length: nsSelf.length - location)).iOSNativeLocalization(tokens: tokens)
mutableDictionary["NSStringLocalizedFormatKey"] = format
return mutableDictionary
}
public func replacingMatches(fromRegex regex: NSRegularExpression, withFormat format: String) -> String {
let nativeLocalization = NSMutableString(string: self)
var offset = 0
let fullRange = NSRange(location: 0, length: nativeLocalization.length)
var index = 1
regex.enumerateMatches(in: self, options: [], range: fullRange) { (result, flags, stop) in
guard let result = result else {
return
}
var token = regex.replacementString(for: result, in: nativeLocalization as String, offset: offset, template: "$1")
// If the token doesn't have an index, give it one
// This allows us to support unordered tokens for single token strings
if token == "" {
token = "\(index)"
}
let replacement = String(format: format, token)
let replacementRange = NSRange(location: result.range.location + offset, length: result.range.length)
nativeLocalization.replaceCharacters(in: replacementRange, with: replacement)
offset += (replacement as NSString).length - result.range.length
index += 1
}
return nativeLocalization as String
}
public func replacingMatches(fromTokenRegex regex: NSRegularExpression, withFormat format: String, tokens: [String: String]) -> String {
let nativeLocalization = NSMutableString(string: self)
var offset = 0
let fullRange = NSRange(location: 0, length: nativeLocalization.length)
regex.enumerateMatches(in: self, options: [], range: fullRange) { (result, flags, stop) in
guard let result = result else {
return
}
let token = regex.replacementString(for: result, in: nativeLocalization as String, offset: offset, template: "$1")
let replacement = String(format: format, token, tokens[token] ?? "@")
let replacementRange = NSRange(location: result.range.location + offset, length: result.range.length)
nativeLocalization.replaceCharacters(in: replacementRange, with: replacement)
offset += (replacement as NSString).length - result.range.length
}
return nativeLocalization as String
}
func iOSNativeLocalization(tokens: [String: String]) -> String {
guard let tokenRegex = twnTokenRegex, let braceRegex = curlyBraceRegex else {
return ""
}
return self.replacingMatches(fromRegex: braceRegex, withFormat: "%@").replacingMatches(fromTokenRegex: tokenRegex, withFormat: "%%%@$%@", tokens: tokens)
}
var twnNativeLocalization: String {
guard let tokenRegex = iOSTokenRegex else {
return ""
}
return self.replacingMatches(fromRegex: tokenRegex, withFormat: "$%@")
}
var iOSTokenDictionary: [String: String] {
guard let iOSTokenRegex = iOSTokenRegex else {
print("Unable to compile iOS token regex")
abort()
}
var tokenDictionary = [String:String]()
iOSTokenRegex.enumerateMatches(in: self, options: [], range:self.fullRange, using: { (result, flags, stop) in
guard let result = result else {
return
}
var number = iOSTokenRegex.replacementString(for: result, in: self as String, offset: 0, template: "$1")
// treat an un-numbered token as 1
if number == "" {
number = "1"
}
let token = iOSTokenRegex.replacementString(for: result, in: self as String, offset: 0, template: "$2")
if tokenDictionary[number] == nil {
tokenDictionary[number] = token
} else if token != tokenDictionary[number] {
print("Internal token mismatch: \(self)")
abort()
}
})
return tokenDictionary
}
}
func writeStrings(fromDictionary dictionary: NSDictionary, toFile: String) throws {
var shouldWrite = true
if let existingDictionary = NSDictionary(contentsOfFile: toFile) {
shouldWrite = existingDictionary.count != dictionary.count
if !shouldWrite {
for (key, value) in dictionary {
guard let value = value as? String, let existingValue = existingDictionary[key] as? NSString else {
shouldWrite = true
break
}
shouldWrite = !existingValue.isEqual(to: value)
if shouldWrite {
break
}
}
}
}
guard shouldWrite else {
return
}
let folder = (toFile as NSString).deletingLastPathComponent
do {
try FileManager.default.createDirectory(atPath: folder, withIntermediateDirectories: true, attributes: nil)
} catch { }
let output = dictionary.descriptionInStringsFileFormat
try output.write(toFile: toFile, atomically: true, encoding: .utf16) // From Apple: Note: It is recommended that you save strings files using the UTF-16 encoding, which is the default encoding for standard strings files. It is possible to create strings files using other property-list formats, including binary property-list formats and XML formats that use the UTF-8 encoding, but doing so is not recommended. For more information about Unicode and its text encodings, go to http://www.unicode.org/ or http://en.wikipedia.org/wiki/Unicode.
}
// See "Localized Metadata" section here: https://docs.fastlane.tools/actions/deliver/
func fileURLForFastlaneMetadataFolder(for locale: String) -> URL {
return URL(fileURLWithPath:"\(path)/fastlane/metadata/\(locale)")
}
func fileURLForFastlaneMetadataFile(_ file: String, for locale: String) -> URL {
return fileURLForFastlaneMetadataFolder(for: locale).appendingPathComponent(file)
}
let defaultAppStoreMetadataLocale = "en-us"
func writeFastlaneMetadata(_ metadata: Any?, to filename: String, for locale: String) throws {
let metadataFileURL = fileURLForFastlaneMetadataFile(filename, for: locale)
guard let metadata = metadata as? String, metadata.count > 0 else {
let defaultDescriptionFileURL = fileURLForFastlaneMetadataFile(filename, for: defaultAppStoreMetadataLocale)
let fm = FileManager.default
try fm.removeItem(at: metadataFileURL)
try fm.copyItem(at: defaultDescriptionFileURL, to: metadataFileURL)
return
}
try metadata.write(to: metadataFileURL, atomically: true, encoding: .utf8)
}
func writeTWNStrings(fromDictionary dictionary: [String: String], toFile: String, escaped: Bool) throws {
var output = ""
let sortedDictionary = dictionary.sorted(by: { (kv1, kv2) -> Bool in
return kv1.key < kv2.key
})
for (key, value) in sortedDictionary {
output.append("\"\(key)\" = \"\(escaped ? value.escapedString : value)\";\n")
}
try output.write(toFile: toFile, atomically: true, encoding: .utf8)
}
func exportLocalizationsFromSourceCode(_ path: String) {
let iOSENPath = "\(path)/Wikipedia/iOS Native Localizations/en.lproj/Localizable.strings"
let twnQQQPath = "\(path)/Wikipedia/Localizations/qqq.lproj/Localizable.strings"
let twnENPath = "\(path)/Wikipedia/Localizations/en.lproj/Localizable.strings"
guard let iOSEN = NSDictionary(contentsOfFile: iOSENPath) else {
print("Unable to read \(iOSENPath)")
abort()
}
let twnQQQ = NSMutableDictionary()
let twnEN = NSMutableDictionary()
do {
let commentSet = CharacterSet(charactersIn: "/* ")
let quoteSet = CharacterSet(charactersIn: "\"")
let string = try String(contentsOfFile: iOSENPath)
let lines = string.components(separatedBy: .newlines)
var currentComment: String?
var currentKey: String?
var commentsByKey = [String: String]()
for line in lines {
let cleanedLine = line.trimmingCharacters(in: .whitespaces)
if cleanedLine.hasPrefix("/*") {
currentComment = cleanedLine.trimmingCharacters(in: commentSet)
currentKey = nil
} else if currentComment != nil {
let quotesRemoved = cleanedLine.trimmingCharacters(in: quoteSet)
if let range = quotesRemoved.range(of: "\" = \"") {
currentKey = String(quotesRemoved.prefix(upTo: range.lowerBound))
}
}
if let key = currentKey, let comment = currentComment {
commentsByKey[key] = comment
}
}
for (key, comment) in commentsByKey {
twnQQQ[key] = comment.twnNativeLocalization
}
try writeTWNStrings(fromDictionary: twnQQQ as! [String: String], toFile: twnQQQPath, escaped: false)
for (key, value) in iOSEN {
guard let value = value as? String, let key = key as? String else {
continue
}
twnEN[key] = value.twnNativeLocalization
}
try writeTWNStrings(fromDictionary: twnEN as! [String: String], toFile: twnENPath, escaped: true)
} catch let error {
print("Error exporting localizations: \(error)")
abort()
}
}
let locales: Set<String> = {
var identifiers = Locale.availableIdentifiers
if let filenames = try? FileManager.default.contentsOfDirectory(atPath: "\(path)/Wikipedia/iOS Native Localizations") {
let additional = filenames.compactMap { $0.components(separatedBy: ".").first?.lowercased() }
identifiers += additional
}
identifiers += ["ku"] // iOS 13 added support for ku but macOS 10.14 doesn't include it, add it manually. This line can be removed when macOS 10.15 ships.
return Set<String>(identifiers)
}()
// See supportsOneEquals documentation. Utilized this list: https://unicode-org.github.io/cldr-staging/charts/37/supplemental/language_plural_rules.html to verify languages where that applies for the cardinal -> one rule
let localesWhereMediaWikiPluralRulesDoNotMatchiOSPluralRulesForOne = {
return Set<String>(["be", "bs", "br", "ceb", "tzm", "hr", "fil", "is", "lv", "lt", "dsb", "mk", "gv", "prg", "ru", "gd", "sr", "sl", "uk", "hsb"]).intersection(locales)
}()
func localeIsAvailable(_ locale: String) -> Bool {
let prefix = locale.components(separatedBy: "-").first ?? locale
return locales.contains(prefix)
}
func importLocalizationsFromTWN(_ path: String) {
let enPath = "\(path)/Wikipedia/iOS Native Localizations/en.lproj/Localizable.strings"
guard let enDictionary = NSDictionary(contentsOfFile: enPath) as? [String: String] else {
print("Unable to read \(enPath)")
abort()
}
var enTokensByKey = [String: [String: String]]()
for (key, value) in enDictionary {
enTokensByKey[key] = value.iOSTokenDictionary
}
let fm = FileManager.default
do {
let keysByLanguage = ["pl": ["one", "few"], "sr": ["one", "few", "many"], "ru": ["one", "few", "many"]]
let defaultKeys = ["one"]
let appStoreMetadataLocales: [String: [String]] = [
"da": ["da"],
"de": ["de-de"],
"el": ["el"],
// "en": ["en-au", "en-ca", "en-gb"],
"es": ["es-mx", "es-es"],
"fi": ["fi"],
"fr": ["fr-ca", "fr-fr"],
"id": ["id"],
"it": ["it"],
"ja": ["ja"],
"ko": ["ko"],
"ms": ["ms"],
"nl": ["nl-nl"],
"no": ["no"],
"pt": ["pt-br", "pt-pt"],
"ru": ["ru"],
"sv": ["sv"],
"th": ["th"],
"tr": ["tr"],
"vi": ["vi"],
"zh-hans": ["zh-hans"],
"zh-hant": ["zh-hant"]
]
let contents = try fm.contentsOfDirectory(atPath: "\(path)/Wikipedia/Localizations")
var pathsForEnglishPlurals: [String] = [] // write english plurals to these paths as placeholders
var englishPluralDictionary: NSMutableDictionary?
for filename in contents {
guard let locale = filename.components(separatedBy: ".").first?.lowercased() else {
continue
}
let localeFolder = "\(path)/Wikipedia/iOS Native Localizations/\(locale).lproj"
guard localeIsAvailable(locale), let twnStrings = NSDictionary(contentsOfFile: "\(path)/Wikipedia/Localizations/\(locale).lproj/Localizable.strings") else {
try? fm.removeItem(atPath: localeFolder)
continue
}
let stringsDictFilePath = "\(localeFolder)/Localizable.stringsdict"
let stringsFilePath = "\(localeFolder)/Localizable.strings"
let stringsDict = NSMutableDictionary(capacity: twnStrings.count)
let strings = NSMutableDictionary(capacity: twnStrings.count)
for (key, value) in twnStrings {
guard let twnString = value as? String, let key = key as? String, let enTokens = enTokensByKey[key] else {
continue
}
let nativeLocalization = twnString.iOSNativeLocalization(tokens: enTokens)
let nativeLocalizationTokens = nativeLocalization.iOSTokenDictionary
guard nativeLocalizationTokens == enTokens else {
#if DEBUG
print("Mismatched tokens in \(locale) for \(key):\n\(enDictionary[key] ?? "")\n\(nativeLocalization)")
#endif
continue
}
if twnString.contains("{{PLURAL:") {
let lang = locale.components(separatedBy: "-").first ?? ""
let keys = keysByLanguage[lang] ?? defaultKeys
let supportsOneEquals = !localesWhereMediaWikiPluralRulesDoNotMatchiOSPluralRulesForOne.contains(lang)
stringsDict[key] = twnString.pluralDictionary(with: keys, tokens:enTokens, supportsOneEquals: supportsOneEquals)
strings[key] = nativeLocalization
} else {
strings[key] = nativeLocalization
}
}
if locale != "en" { // only write the english plurals, skip the main file
if strings.count > 0 {
try writeStrings(fromDictionary: strings, toFile: stringsFilePath)
} else {
try? fm.removeItem(atPath: stringsFilePath)
}
} else {
englishPluralDictionary = stringsDict
}
if let metadataLocales = appStoreMetadataLocales[locale] {
for metadataLocale in metadataLocales {
let folderURL = fileURLForFastlaneMetadataFolder(for: metadataLocale)
try fm.createDirectory(at: folderURL, withIntermediateDirectories: true, attributes: nil)
let infoPlistPath = "\(path)/Wikipedia/iOS Native Localizations/\(locale).lproj/InfoPlist.strings"
let infoPlist = NSDictionary(contentsOfFile: infoPlistPath)
try? writeFastlaneMetadata(strings["app-store-short-description"], to: "description.txt", for: metadataLocale)
try? writeFastlaneMetadata(strings["app-store-keywords"], to: "keywords.txt", for: metadataLocale)
try? writeFastlaneMetadata(nil, to: "marketing_url.txt", for: metadataLocale) // use nil to copy from en-US. all fields need to be specified.
try? writeFastlaneMetadata(infoPlist?["CFBundleDisplayName"], to: "name.txt", for: metadataLocale)
try? writeFastlaneMetadata(nil, to: "privacy_url.txt", for: metadataLocale) // use nil to copy from en-US. all fields need to be specified.
try? writeFastlaneMetadata(nil, to: "promotional_text.txt", for: metadataLocale) // use nil to copy from en-US. all fields need to be specified.
try? writeFastlaneMetadata(nil, to: "release_notes.txt", for: metadataLocale) // use nil to copy from en-US. all fields need to be specified.
try? writeFastlaneMetadata(strings["app-store-subtitle"], to: "subtitle.txt", for: metadataLocale)
try? writeFastlaneMetadata(nil, to: "support_url.txt", for: metadataLocale) // use nil to copy from en-US. all fields need to be specified.
}
} else {
let folderURL = fileURLForFastlaneMetadataFolder(for: locale)
try? fm.removeItem(at: folderURL)
}
if stringsDict.count > 0 {
stringsDict.write(toFile: stringsDictFilePath, atomically: true)
} else {
pathsForEnglishPlurals.append(stringsDictFilePath)
}
}
for stringsDictFilePath in pathsForEnglishPlurals {
englishPluralDictionary?.write(toFile: stringsDictFilePath, atomically: true)
}
} catch let error {
print("Error importing localizations: \(error)")
abort()
}
}
// Code that updated source translations
// var replacements = [String: String]()
// for (key, comment) in qqq {
// guard let value = en[key] else {
// continue
// }
// replacements[key] = "WMFLocalizedStringWithDefaultValue(@\"\(key.escapedString)\", nil, NSBundle.mainBundle, @\"\(value.iOSNativeLocalization.escapedString)\", \"\(comment.escapedString)\")"
// }
//
// let codePath = "WMF Framework"
// let contents = try FileManager.default.contentsOfDirectory(atPath: codePath)
// guard let mwLocalizedStringRegex = mwLocalizedStringRegex else {
// abort()
// }
// for filename in contents {
// do {
// let path = codePath + "/" + filename
// let string = try String(contentsOfFile: path)
// //let string = try String(contentsOf: #fileLiteral(resourceName: "WMFContentGroup+WMFFeedContentDisplaying.m"))
// let mutableString = NSMutableString(string: string)
// var offset = 0
// let fullRange = NSRange(location: 0, length: mutableString.length)
// mwLocalizedStringRegex.enumerateMatches(in: string, options: [], range: fullRange) { (result, flags, stop) in
// guard let result = result else {
// return
// }
// let key = mwLocalizedStringRegex.replacementString(for: result, in: mutableString as String, offset: offset, template: "$1")
// guard let replacement = replacements[key] else {
// return
// }
// let replacementRange = NSRange(location: result.range.location + offset, length: result.range.length)
// mutableString.replaceCharacters(in: replacementRange, with: replacement)
// offset += (replacement as NSString).length - replacementRange.length
// }
// try mutableString.write(toFile: path, atomically: true, encoding: String.Encoding.utf8.rawValue)
// } catch { }
// }

View File

@ -0,0 +1,26 @@
import Foundation
/// **THIS IS A COMMAND LINE UTILITY FOR LOCALIZATION**
let count = CommandLine.arguments.count
guard count > 1 else {
print("Please provide a path argument.")
exit(1)
}
let path = CommandLine.arguments[1]
print("Extracting localizations from source code...")
let extractProcess = Process.launchedProcess(launchPath: "\(path)/scripts/localization_extract", arguments: [path])
extractProcess.waitUntilExit()
if extractProcess.terminationStatus == 0 {
print("Localizations extracted successfully.")
print("Exporting localizations from source code...")
exportLocalizationsFromSourceCode(path)
print("Importing localizations from TWN...")
importLocalizationsFromTWN(path)
print("Localizations imported successfully.")
} else {
print("Failed to extract localizations from source code.")
exit(1)
}

View File

@ -0,0 +1,4 @@
/* Localized versions of Info.plist keys */
"CFBundleDisplayName" = "Wikipedia continue reading";
"CFBundleName" = "Wikipedia continue reading";

View File

@ -0,0 +1,164 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="M4Y-Lb-cyx">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
<capability name="Constraints to layout margins" minToolsVersion="6.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Today Continue Reading Widget View Controller-->
<scene sceneID="cwh-vc-ff4">
<objects>
<viewController id="M4Y-Lb-cyx" customClass="WMFTodayContinueReadingWidgetViewController" customModule="ContinueReadingWidget" customModuleProvider="target" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ft6-oW-KC0"/>
<viewControllerLayoutGuide type="bottom" id="FKl-LY-JtV"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" simulatedAppContext="notificationCenter" id="S3S-Oj-5AN" customClass="GroupedAccessibilityView" customModule="WMF">
<rect key="frame" x="0.0" y="0.0" width="320" height="100"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="af3-Uc-bMS">
<rect key="frame" x="219" y="7" width="86" height="86"/>
<constraints>
<constraint firstAttribute="height" constant="86" id="Nz9-5s-phP"/>
<constraint firstAttribute="width" constant="86" id="tPS-cg-OC1"/>
</constraints>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="number" keyPath="layer.cornerRadius">
<integer key="value" value="6"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text=" " textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="vq4-5M-Tnk">
<rect key="frame" x="16" y="10" width="195" height="20.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text=" " textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="CwR-CM-SDN">
<rect key="frame" x="16" y="30.5" width="195" height="26"/>
<constraints>
<constraint firstAttribute="height" priority="750" constant="26" id="HZt-Qf-DQT"/>
</constraints>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<view hidden="YES" opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="3Uu-vI-59e">
<rect key="frame" x="16" y="70" width="15.5" height="20"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text=" " textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="pDO-M3-3su">
<rect key="frame" x="6" y="0.0" width="3.5" height="20"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleCaption1"/>
<color key="textColor" red="0.33333333333333331" green="0.3529411764705882" blue="0.37254901960784315" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="0.40000000000000002" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstAttribute="height" constant="20" id="5UE-Nc-zWc"/>
<constraint firstItem="pDO-M3-3su" firstAttribute="leading" secondItem="3Uu-vI-59e" secondAttribute="leading" constant="6" id="O6t-YJ-eax"/>
<constraint firstItem="pDO-M3-3su" firstAttribute="top" secondItem="3Uu-vI-59e" secondAttribute="top" id="WId-7B-KqN"/>
<constraint firstAttribute="trailing" secondItem="pDO-M3-3su" secondAttribute="trailing" constant="6" id="dZE-Nv-NeO"/>
<constraint firstAttribute="bottom" secondItem="pDO-M3-3su" secondAttribute="bottom" id="hev-Z5-2XR"/>
</constraints>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="number" keyPath="layer.cornerRadius">
<integer key="value" value="4"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ZR6-x7-9Of" customClass="GroupedAccessibilityView" customModule="WMF">
<rect key="frame" x="16" y="20.5" width="288" height="59"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Explore Wikipedia for more articles to read" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="rMd-L0-hne">
<rect key="frame" x="0.0" y="21" width="288" height="38"/>
<constraints>
<constraint firstAttribute="height" relation="greaterThanOrEqual" constant="21" id="bOM-vw-pko"/>
</constraints>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="0.69999999999999996" colorSpace="calibratedRGB"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="No recently read articles" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Ejf-xt-rvu">
<rect key="frame" x="0.0" y="0.0" width="288" height="21"/>
<constraints>
<constraint firstAttribute="height" relation="greaterThanOrEqual" constant="21" id="Vl6-d1-Ot9"/>
</constraints>
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
<color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="0.69999999999999996" colorSpace="calibratedRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstItem="Ejf-xt-rvu" firstAttribute="top" secondItem="ZR6-x7-9Of" secondAttribute="top" id="55Z-x7-Jh9"/>
<constraint firstItem="rMd-L0-hne" firstAttribute="top" secondItem="Ejf-xt-rvu" secondAttribute="bottom" id="5Hr-pE-8jt"/>
<constraint firstItem="rMd-L0-hne" firstAttribute="leading" secondItem="ZR6-x7-9Of" secondAttribute="leading" id="CJI-7Y-hKi"/>
<constraint firstItem="rMd-L0-hne" firstAttribute="top" secondItem="Ejf-xt-rvu" secondAttribute="bottom" id="GKz-jD-5B0"/>
<constraint firstAttribute="trailing" secondItem="Ejf-xt-rvu" secondAttribute="trailing" id="Mne-lj-W93"/>
<constraint firstAttribute="trailing" secondItem="rMd-L0-hne" secondAttribute="trailing" id="OAh-PP-FYs"/>
<constraint firstAttribute="bottom" secondItem="rMd-L0-hne" secondAttribute="bottom" id="fpD-K2-V7D"/>
<constraint firstItem="Ejf-xt-rvu" firstAttribute="leading" secondItem="ZR6-x7-9Of" secondAttribute="leading" id="j4t-9z-J3E"/>
</constraints>
<connections>
<outlet property="accessibilityView0" destination="rMd-L0-hne" id="Cof-pV-laR"/>
<outlet property="accessibilityView1" destination="Ejf-xt-rvu" id="est-B3-rBt"/>
</connections>
</view>
</subviews>
<constraints>
<constraint firstItem="vq4-5M-Tnk" firstAttribute="top" secondItem="Ft6-oW-KC0" secondAttribute="bottom" constant="10" id="2e4-f1-47H"/>
<constraint firstItem="3Uu-vI-59e" firstAttribute="top" relation="greaterThanOrEqual" secondItem="CwR-CM-SDN" secondAttribute="bottom" constant="8" id="4Lb-rh-iGP"/>
<constraint firstItem="ZR6-x7-9Of" firstAttribute="leading" secondItem="S3S-Oj-5AN" secondAttribute="leadingMargin" id="65O-ga-KaL"/>
<constraint firstItem="ZR6-x7-9Of" firstAttribute="centerY" secondItem="S3S-Oj-5AN" secondAttribute="centerY" id="BQN-qh-xVn"/>
<constraint firstItem="FKl-LY-JtV" firstAttribute="top" secondItem="3Uu-vI-59e" secondAttribute="bottom" constant="10" id="V4i-bE-94u"/>
<constraint firstItem="CwR-CM-SDN" firstAttribute="top" secondItem="vq4-5M-Tnk" secondAttribute="bottom" id="YIo-3p-DKd"/>
<constraint firstItem="CwR-CM-SDN" firstAttribute="leading" secondItem="S3S-Oj-5AN" secondAttribute="leadingMargin" id="Yqs-Mz-SgU"/>
<constraint firstItem="3Uu-vI-59e" firstAttribute="leading" secondItem="S3S-Oj-5AN" secondAttribute="leadingMargin" id="YxV-va-6jY"/>
<constraint firstItem="CwR-CM-SDN" firstAttribute="width" secondItem="vq4-5M-Tnk" secondAttribute="width" id="dlu-4c-Js5"/>
<constraint firstAttribute="trailing" secondItem="af3-Uc-bMS" secondAttribute="trailing" constant="15" id="mWN-ky-uf3"/>
<constraint firstItem="vq4-5M-Tnk" firstAttribute="leading" secondItem="S3S-Oj-5AN" secondAttribute="leadingMargin" id="n9q-aN-sgE"/>
<constraint firstItem="ZR6-x7-9Of" firstAttribute="trailing" secondItem="S3S-Oj-5AN" secondAttribute="trailingMargin" id="pFm-6j-Ldv"/>
<constraint firstItem="af3-Uc-bMS" firstAttribute="leading" secondItem="vq4-5M-Tnk" secondAttribute="trailing" constant="8" id="pVG-3w-TAN"/>
<constraint firstItem="af3-Uc-bMS" firstAttribute="centerY" secondItem="S3S-Oj-5AN" secondAttribute="centerY" id="raJ-27-DZf"/>
</constraints>
<connections>
<outlet property="accessibilityView0" destination="vq4-5M-Tnk" id="bMV-lJ-rl5"/>
<outlet property="accessibilityView1" destination="CwR-CM-SDN" id="qPB-vx-063"/>
<outlet property="accessibilityView2" destination="pDO-M3-3su" id="JSB-jk-er5"/>
</connections>
</view>
<extendedEdge key="edgesForExtendedLayout"/>
<nil key="simulatedStatusBarMetrics"/>
<nil key="simulatedTopBarMetrics"/>
<nil key="simulatedBottomBarMetrics"/>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<size key="freeformSize" width="320" height="100"/>
<connections>
<outlet property="daysAgoLabel" destination="pDO-M3-3su" id="eFO-2c-rzZ"/>
<outlet property="daysAgoView" destination="3Uu-vI-59e" id="7LE-mR-ffp"/>
<outlet property="emptyDescriptionLabel" destination="rMd-L0-hne" id="3kP-dD-tib"/>
<outlet property="emptyTitleLabel" destination="Ejf-xt-rvu" id="osb-La-67R"/>
<outlet property="emptyView" destination="ZR6-x7-9Of" id="p6Z-LW-3nv"/>
<outlet property="imageView" destination="af3-Uc-bMS" id="91S-oM-RCp"/>
<outlet property="imageWidthConstraint" destination="tPS-cg-OC1" id="Kg2-hi-pfg"/>
<outlet property="textLabel" destination="CwR-CM-SDN" id="wXQ-Lk-zcc"/>
<outlet property="titleLabel" destination="vq4-5M-Tnk" id="zJ9-bC-g8G"/>
<outlet property="titleLabelTrailingConstraint" destination="pVG-3w-TAN" id="Ap3-Lq-h0z"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="vXp-U4-Rya" userLabel="First Responder" sceneMemberID="firstResponder"/>
<view hidden="YES" contentMode="scaleToFill" id="5yZ-3S-P5X">
<rect key="frame" x="0.0" y="0.0" width="320" height="100"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
</view>
</objects>
<point key="canvasLocation" x="97.599999999999994" y="36.881559220389811"/>
</scene>
</scenes>
</document>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>webcredentials:*.wikipedia.org</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.org.wikimedia.wikipedia</string>
<string>group.org.wikimedia.wikipedia.alpha</string>
<string>group.org.wikimedia.wikipedia.beta</string>
</array>
</dict>
</plist>

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Wikipedia continue reading</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Continue reading</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>7.3.0</string>
<key>CFBundleVersion</key>
<string>0</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widget-extension</string>
</dict>
</dict>
</plist>

View File

@ -0,0 +1,173 @@
import UIKit
import NotificationCenter
import WMF
@available(*, deprecated, message: "TODO: Rework into iOS 14 home screen widget")
class WMFTodayContinueReadingWidgetViewController: ExtensionViewController, NCWidgetProviding {
@IBOutlet weak var imageView: UIImageView!
@IBOutlet weak var daysAgoView: UIView!
@IBOutlet weak var daysAgoLabel: UILabel!
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var textLabel: UILabel!
@IBOutlet weak var emptyView: UIView!
@IBOutlet weak var emptyTitleLabel: UILabel!
@IBOutlet weak var emptyDescriptionLabel: UILabel!
@IBOutlet var imageWidthConstraint: NSLayoutConstraint!
@IBOutlet var titleLabelTrailingConstraint: NSLayoutConstraint!
var articleURL: URL?
override func apply(theme: Theme) {
super.apply(theme: theme)
guard viewIfLoaded != nil else {
return
}
titleLabel.textColor = theme.colors.primaryText
textLabel.textColor = theme.colors.secondaryText
emptyTitleLabel.textColor = theme.colors.primaryText
emptyDescriptionLabel.textColor = theme.colors.secondaryText
daysAgoLabel.textColor = theme.colors.overlayText
daysAgoView.backgroundColor = theme.colors.overlayBackground
}
override func viewDidLoad() {
super.viewDidLoad()
imageView.accessibilityIgnoresInvertColors = true
emptyDescriptionLabel.text = WMFLocalizedString("continue-reading-empty-title", value:"No recently read articles", comment: "No recently read articles")
emptyDescriptionLabel.text = WMFLocalizedString("continue-reading-empty-description", value:"Explore Wikipedia for more articles to read", comment: "Explore Wikipedia for more articles to read")
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.handleTapGestureRecognizer(_:))))
}
@objc func handleTapGestureRecognizer(_ recognizer: UITapGestureRecognizer) {
switch recognizer.state {
case .ended:
continueReading(self)
default:
break
}
}
func widgetPerformUpdate(completionHandler: @escaping (NCUpdateResult) -> Void) {
WidgetController.shared.startWidgetUpdateTask(completionHandler) { (dataStore, completion) in
self.updateView(with: dataStore) { didUpdate in
if didUpdate {
completion(.newData)
} else {
completion(.noData)
}
}
}
}
var emptyViewHidden: Bool = false {
didSet {
emptyView.isHidden = emptyViewHidden
titleLabel.isHidden = !emptyViewHidden
textLabel.isHidden = !emptyViewHidden
imageView.isHidden = !emptyViewHidden
daysAgoView.isHidden = !emptyViewHidden
}
}
var collapseImageAndWidenLabels: Bool = true {
didSet {
imageWidthConstraint.constant = collapseImageAndWidenLabels ? 0 : 86
titleLabelTrailingConstraint.constant = collapseImageAndWidenLabels ? 0 : 10
self.imageView.alpha = self.collapseImageAndWidenLabels ? 0 : 1
self.view.layoutIfNeeded()
}
}
func updateView(with dataStore: MWKDataStore, completion: @escaping (Bool) -> Void) {
let article: WMFArticle
if let openArticleURL = dataStore.viewContext.openArticleURL, let openArticle = dataStore.fetchArticle(with: openArticleURL) {
article = openArticle
} else if let mostRecentHistoryEntry = dataStore.viewContext.mostRecentlyReadArticle {
article = mostRecentHistoryEntry
} else {
completion(false)
return
}
let newArticleURL: URL?
if let fragment = article.viewedFragment {
newArticleURL = article.url?.wmf_URL(withFragment: fragment)
} else {
newArticleURL = article.url
}
guard newArticleURL != nil, newArticleURL?.absoluteString != articleURL?.absoluteString else {
completion(false)
return
}
articleURL = newArticleURL
textLabel.text = nil
titleLabel.text = nil
imageView.image = nil
imageView.isHidden = true
daysAgoLabel.text = nil
daysAgoView.isHidden = true
emptyViewHidden = true
if let subtitle = article.capitalizedWikidataDescriptionOrSnippet {
self.textLabel.text = subtitle
} else {
self.textLabel.text = nil
}
if let date = article.viewedDate {
self.daysAgoView.isHidden = false
self.daysAgoLabel.text = (date as NSDate).wmf_localizedRelativeDateStringFromLocalDateToNow()
} else {
self.daysAgoView.isHidden = true
}
self.titleLabel.attributedText = article.displayTitleHTML.byAttributingHTML(with: .headline, matching: traitCollection)
let combinedCompletion = {
self.updatePreferredContentSize()
completion(true)
}
if let imageURL = article.imageURL(forWidth: self.traitCollection.wmf_nearbyThumbnailWidth) {
self.collapseImageAndWidenLabels = false
self.imageView.wmf_imageController = dataStore.cacheController
self.imageView.wmf_setImage(with: imageURL, detectFaces: true, onGPU: true, failure: { (error) in
self.collapseImageAndWidenLabels = true
combinedCompletion()
}) {
self.collapseImageAndWidenLabels = false
combinedCompletion()
}
} else {
self.collapseImageAndWidenLabels = true
combinedCompletion()
}
}
func updatePreferredContentSize() {
var fitSize = UIView.layoutFittingCompressedSize
fitSize.width = view.bounds.size.width
fitSize = view.systemLayoutSizeFitting(fitSize, withHorizontalFittingPriority: UILayoutPriority.required, verticalFittingPriority: UILayoutPriority.defaultLow)
preferredContentSize = fitSize
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
updatePreferredContentSize()
}
@IBAction func continueReading(_ sender: AnyObject) {
openApp(with: articleURL)
}
}

View File

@ -0,0 +1,4 @@
/* Localized versions of Info.plist keys */
"CFBundleDisplayName" = "Wikipedia continue reading";
"CFBundleName" = "Wikipedia continue reading";

6
Apps/Wikipedia/Gemfile Normal file
View File

@ -0,0 +1,6 @@
source "https://rubygems.org"
group :ci do
gem 'fastlane'
gem 'xcode-install'
end

222
Apps/Wikipedia/Gemfile.lock Normal file
View File

@ -0,0 +1,222 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.6)
rexml
addressable (2.8.1)
public_suffix (>= 2.0.2, < 6.0)
artifactory (3.0.15)
atomos (0.1.3)
aws-eventstream (1.2.0)
aws-partitions (1.710.0)
aws-sdk-core (3.170.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.62.0)
aws-sdk-core (~> 3, >= 3.165.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.119.0)
aws-sdk-core (~> 3, >= 3.165.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
aws-sigv4 (1.5.2)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
declarative (0.0.20)
digest-crc (0.6.4)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.99.0)
faraday (1.10.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.2.6)
fastlane (2.211.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (~> 2.0.0)
naturally (~> 2.2)
optparse (~> 0.1.1)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.3)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (>= 1.4.5, < 2.0.0)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.34.0)
google-apis-core (>= 0.9.1, < 2.a)
google-apis-core (0.11.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
webrick
google-apis-iamcredentials_v1 (0.16.0)
google-apis-core (>= 0.9.1, < 2.a)
google-apis-playcustomapp_v1 (0.12.0)
google-apis-core (>= 0.9.1, < 2.a)
google-apis-storage_v1 (0.19.0)
google-apis-core (>= 0.9.0, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.0)
google-cloud-storage (1.44.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.19.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.3.0)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.5)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.2)
json (2.6.3)
jwt (2.7.0)
memoist (0.16.2)
mini_magick (4.12.0)
mini_mime (1.1.2)
multi_json (1.15.0)
multipart-post (2.0.0)
nanaimo (0.3.0)
naturally (2.2.1)
optparse (0.1.1)
os (1.1.4)
plist (3.6.0)
public_suffix (5.0.1)
rake (13.0.6)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.2.5)
rouge (2.0.7)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
security (0.1.3)
signet (0.17.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
terminal-notifier (2.0.0)
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.1)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode-display_width (1.8.0)
webrick (1.8.1)
word_wrap (1.0.0)
xcode-install (2.8.1)
claide (>= 0.9.1)
fastlane (>= 2.1.0, < 3.0.0)
xcodeproj (1.22.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
ruby
DEPENDENCIES
fastlane
xcode-install
BUNDLED WITH
2.4.6

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 20132020 Wikimedia Foundation et al.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>NotificationServiceExtension</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>7.3.0</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>

View File

@ -0,0 +1,105 @@
import UserNotifications
import WMF
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
private lazy var apiController: RemoteNotificationsAPIController = {
let configuration = Configuration.current
let session = Session(configuration: configuration)
let controller = RemoteNotificationsAPIController(session: session, configuration: configuration)
return controller
}()
private let sharedCache = SharedContainerCache<PushNotificationsCache>.init(fileName: SharedContainerCacheCommonNames.pushNotificationsCache, defaultCache: { PushNotificationsCache(settings: .default, notifications: []) })
private let fallbackPushContent = WMFLocalizedString("notifications-push-fallback-body-text", value: "New activity on Wikipedia", comment: "Fallback body content of a push notification whose content cannot be determined. Could be either due multiple notifications represented or errors.")
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
guard let bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
contentHandler(request.content)
return
}
self.bestAttemptContent = bestAttemptContent
guard bestAttemptContent.body == EchoModelVersion.current else {
bestAttemptContent.body = fallbackPushContent
contentHandler(bestAttemptContent)
return
}
let cache = sharedCache.loadCache()
let project = WikimediaProject.wikipedia(cache.settings.primaryLanguageCode, cache.settings.primaryLocalizedName, nil)
let fallbackPushContent = self.fallbackPushContent
apiController.getUnreadPushNotifications(from: project) { [weak self] fetchedNotifications, error in
DispatchQueue.main.async {
guard let self = self,
error == nil else {
bestAttemptContent.body = fallbackPushContent
contentHandler(bestAttemptContent)
return
}
let finalNotifications = NotificationServiceHelper.determineNotificationsToDisplayAndCache(fetchedNotifications: fetchedNotifications, cachedNotifications: cache.notifications)
let finalNotificationsToDisplay = finalNotifications.notificationsToDisplay
let finalNotificationsToCache = finalNotifications.notificationsToCache
var newCache = cache
newCache.notifications = finalNotificationsToCache
self.sharedCache.saveCache(newCache)
// specific handling for talk page types (New messages title, bundled body)
if let talkPageContent = NotificationServiceHelper.talkPageContent(for: finalNotificationsToDisplay) {
bestAttemptContent.subtitle = talkPageContent.subtitle
bestAttemptContent.body = talkPageContent.body
} else if finalNotificationsToDisplay.count == 1,
let pushContentText = finalNotificationsToDisplay.first?.pushContentText {
bestAttemptContent.body = pushContentText
} else {
bestAttemptContent.body = fallbackPushContent
}
// Assigning interruption level and relevance score only available starting on iOS 15
if #available(iOS 15.0, *) {
if finalNotifications.notificationsToDisplay.count == 1, let notification = finalNotifications.notificationsToDisplay.first {
let priority = RemoteNotification.typeFrom(notification: notification).priority
bestAttemptContent.interruptionLevel = priority.interruptionLevel
bestAttemptContent.relevanceScore = priority.relevanceScore
} else {
if NotificationServiceHelper.allNotificationsAreForSameTalkPage(notifications: finalNotificationsToDisplay) {
bestAttemptContent.interruptionLevel = RemoteNotificationType.mentionInTalkPage.priority.interruptionLevel
bestAttemptContent.relevanceScore = RemoteNotificationType.mentionInTalkPage.priority.relevanceScore
} else {
bestAttemptContent.interruptionLevel = RemoteNotificationType.bulkPriority.interruptionLevel
bestAttemptContent.relevanceScore = RemoteNotificationType.bulkPriority.relevanceScore
}
}
}
let displayContentIdentifiers = finalNotificationsToDisplay.compactMap { PushNotificationContentIdentifier(key: $0.key, date: $0.date) }
PushNotificationContentIdentifier.save(displayContentIdentifiers, to: &bestAttemptContent.userInfo)
bestAttemptContent.badge = NSNumber(value: newCache.currentUnreadCount + finalNotificationsToDisplay.count)
contentHandler(bestAttemptContent)
}
}
}
override func serviceExtensionTimeWillExpire() {
// Called just before the extension will be terminated by the system.
// Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
if let contentHandler = contentHandler,
let bestAttemptContent = bestAttemptContent {
bestAttemptContent.body = fallbackPushContent
contentHandler(bestAttemptContent)
}
}
}

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.org.wikimedia.wikipedia</string>
<string>group.org.wikimedia.wikipedia.alpha</string>
<string>group.org.wikimedia.wikipedia.beta</string>
</array>
</dict>
</plist>

84
Apps/Wikipedia/README.md Normal file
View File

@ -0,0 +1,84 @@
# Wikipedia iOS
The official Wikipedia iOS app.
[![Wikipedia](https://circleci.com/gh/wikimedia/wikipedia-ios.svg?style=shield)](https://github.com/wikimedia/wikipedia-ios)
[![MIT license](https://img.shields.io/badge/license-MIT-lightgrey.svg)](https://raw.githubusercontent.com/wikimedia/wikipedia-ios/main/LICENSE.txt)
* **License**: MIT License
* **Source repo**: https://github.com/wikimedia/wikipedia-ios
* **Planning (bugs & features)**: https://phabricator.wikimedia.org/project/view/782/
* **Team page**: https://www.mediawiki.org/wiki/Wikimedia_Apps/Team/iOS
**Note: The latest `main` branch is set up to build with Xcode 14.2.0.**
## Building and Running
In the directory, run `./scripts/setup`. Note: going to `scripts` directory and running `setup` will not work due to relative paths.
Running `scripts/setup` will setup your computer to build and run the app. The script assumes you have Xcode installed already. It will install [homebrew](https://brew.sh), [SwiftLint](https://github.com/realm/SwiftLint), and [ClangFormat](https://clang.llvm.org/docs/ClangFormat.html). It will also create a pre-commit hook that uses ClangFormat for linting Objective-C code.
After running `scripts/setup`, you should be able to open `Wikipedia.xcodeproj` and run the app on the iOS Simulator (using the **Wikipedia** scheme and target). If you encounter any issues, please don't hesitate to let us know via a [bug report](https://phabricator.wikimedia.org/maniphest/task/edit/form/1/?title=[BUG]&projects=wikipedia-ios-app-product-backlog,ios-app-bugs&description=%3D%3D%3D+How+many+times+were+you+able+to+reproduce+it?%0D%0A%0D%0A%3D%3D%3D+Steps+to+reproduce%0D%0A%23+%0D%0A%23+%0D%0A%23+%0D%0A%0D%0A%3D%3D%3D+Expected+results%0D%0A%0D%0A%3D%3D%3D+Actual+results%0D%0A%0D%0A%3D%3D%3D+Screenshots%0D%0A%0D%0A%3D%3D%3D+Environments+observed%0D%0A**App+version%3A+**+%0D%0A**OS+versions%3A**+%0D%0A**Device+model%3A**+%0D%0A**Device+language%3A**+%0D%0A%0D%0A%3D%3D%3D+Regression?+%0D%0A%0D%0A+Tag++task+with+%23Regression+%0A) or messaging us on IRC in #wikimedia-mobile on Freenode.
### Required Dependencies
If you'd rather install the development prerequisites yourself without our script:
* [**Xcode**](https://itunes.apple.com/us/app/xcode/id497799835) - The easiest way to get Xcode is from the [App Store](https://itunes.apple.com/us/app/xcode/id497799835?mt=12), but you can also download it from [developer.apple.com](https://developer.apple.com/) if you have an Apple ID registered with an Apple Developer account.
* [**SwiftLint**](https://github.com/realm/SwiftLint) - We use this for linting Swift code.
* [**ClangFormat**](https://clang.llvm.org/docs/ClangFormat.html) - We use this for linting Objective-C code.
## Contributing
Covered in the [contributing document](CONTRIBUTING.md).
## Development Guidelines
These are general guidelines rather than hard rules.
### Coding Guidelines
- **Objective-C** - [Apple's Coding Guidelines for Cocoa](https://developer.apple.com/library/content/documentation/Cocoa/Conceptual/CodingGuidelines/CodingGuidelines.html)
- **Swift** - [swift.org API Design Guidelines](https://swift.org/documentation/api-design-guidelines/)
### Formatting
We use Xcode's default 4 space indentation and our `.clang-format` file with the pre-commit hook setup by `scripts/setup`. Where possible, our Swift code is automatically formatted by [SwiftLint](https://github.com/realm/SwiftLint) based on the rules defined in `.swiftlint-autocorrect.yml`.
### Process and Code Review Norms
Covered in the [process document](docs/process.md).
### Logging
When reading logs, note that the log levels are shortened to emoji.
- 🗣️ Verbose
- 💬 Debug
- Info
- ⚠️ Warning
- 🚨 Error
### Testing
The **Wikipedia** scheme is configured to execute the project's iOS unit tests, which can be run using the `Cmd+U` hotkey or the **Product → Test** menu bar action. In order for the tests to pass, the test device's language and region must be set to `en-US` in Settings → General → Language & Region. There is a [ticket filed](https://phabricator.wikimedia.org/T259859) to update the tests to pass regardless of language and region.
### Schemes and Targets
* **Wikipedia** - Points to production servers.
* **Staging** - Pushed to TestFlight as a separate app bundle, and has the ability to toggle different staging environments within the `current` [property](https://github.com/wikimedia/wikipedia-ios/blob/de349525f652ca59c3437cd36fcb13846d737f1e/WMF%20Framework/Configuration.swift#L41) of `Configuration`:
- An option of `appsLabsForPCS` will point to the [Apps team's staging environment](https://mobileapps.wmflabs.org) for page content.
- An option of `deploymentLabsForEventLogging` will point to the [Event Logging](https://wikitech.wikimedia.org/wiki/Analytics/Systems/EventLogging) staging environment. It is for testing analytics events that the app sends to Event Logging.
- An option of `betaCluster` will point to the [MediaWiki beta cluster environment](https://www.mediawiki.org/wiki/Beta_Cluster) for most API calls. This is meant to be a more blanket environment setting, so if this value exists it will also force the beta cluster environment for page content on the article view as well as force the staging environment for event logging. This beta cluster environment is also where developers can test sandbox push notifications triggered across various wikis. This is selected by default.
* **Local Page Content Service and Announcements** - used in Debug mode only, has the ability to toggle different local environments within the `current` [property](https://github.com/wikimedia/wikipedia-ios/blob/de349525f652ca59c3437cd36fcb13846d737f1e/WMF%20Framework/Configuration.swift#L41) of `Configuration`:
- An option of `localPCS` will point to a locally running [mobileapps](https://gerrit.wikimedia.org/r/q/project:mediawiki%252Fservices%252Fmobileapps) repository for page content. This is selected by default.
- An option of `localAnnouncements` will point to a locally running [wikifeeds](https://gerrit.wikimedia.org/r/q/project:mediawiki%252Fservices%252Fwikifeeds) repository for the announcements endpoint. This is selected by default.
- All other endpoints will point to production.
* **RTL** - Launches the app in an RTL locale using the `-AppleLocale` argument.
* **Experimental** - For one off builds. Can point to whatever is needed for the given experiment. Pushed to TestFlight as a separate app bundle.
* **User Testing** - For user testing. Has an alternate configuration so that it can be delivered ad hoc. Pushed to TestFlight as a separate app bundle.
* **WMF** - Bundles up the app logic shared between the main app and the extensions (widgets, notifications).
* **Update Localizations** - Covered in the [localization document](docs/localization.md).
* **Update Languages** - For adding new Wikipedia languages or updating language configurations. Covered in the [languages document](docs/languages.md).
* **{{name}}Widget, {{name}}Notification, {{name}}Stickers** - Extensions for widgets, notifications, and stickers.
### Continuous Integration
Covered in the [CI document](docs/ci.md).
### Event Logging
Covered in the [event logging document](docs/event_logging.md).
### Web Development
The article view and several other components of the app rely on web components. Instructions for working on these components is covered in the [web development document](docs/web_dev.md).
### Contact Us
If you have any questions or comments, you can email us at mobile-ios-wikipedia[at]wikimedia dot org. We'll also gladly accept any [bug reports](https://phabricator.wikimedia.org/maniphest/task/edit/form/1/?title=[BUG]&projects=wikipedia-ios-app-product-backlog,ios-app-bugs&description=%3D%3D%3D+How+many+times+were+you+able+to+reproduce+it?%0D%0A%0D%0A%3D%3D%3D+Steps+to+reproduce%0D%0A%23+%0D%0A%23+%0D%0A%23+%0D%0A%0D%0A%3D%3D%3D+Expected+results%0D%0A%0D%0A%3D%3D%3D+Actual+results%0D%0A%0D%0A%3D%3D%3D+Screenshots%0D%0A%0D%0A%3D%3D%3D+Environments+observed%0D%0A**App+version%3A+**+%0D%0A**OS+versions%3A**+%0D%0A**Device+model%3A**+%0D%0A**Device+language%3A**+%0D%0A**App+language%3A**+%0D%0A%0D%0A%3D%3D%3D+Regression?+%0D%0A%0D%0A+Tag++task+with+%23Regression+%0A).

View File

@ -0,0 +1,123 @@
import Foundation
@objc public protocol ABTestsPersisting: AnyObject {
func libraryValue(for key: String) -> NSCoding?
func setLibraryValue(_ value: NSCoding?, for key: String)
}
@objc(WMFABTestsController)
public class ABTestsController: NSObject {
enum ABTestsError: Error {
case invalidPercentage
}
struct ExperimentConfig {
let experiment: Experiment
let percentageKey: PercentageKey
let bucketKey: BucketKey
let bucketValueControl: BucketValue
let bucketValueTest: BucketValue
}
public enum Experiment {
case articleAsLivingDoc
var config: ExperimentConfig {
switch self {
case .articleAsLivingDoc:
return ABTestsController.articleAsLivingDocConfig
}
}
}
public enum PercentageKey: String {
case articleAsLivingDocPercentKey
}
enum BucketKey: String {
case articleAsLivingDocBucketKey
}
public enum BucketValue: String {
case articleAsLivingDocTest = "LivingDoc_Test"
case articleAsLivingDocControl = "LivingDoc_Control"
}
private static let articleAsLivingDocConfig = ExperimentConfig(experiment: .articleAsLivingDoc, percentageKey: .articleAsLivingDocPercentKey, bucketKey: .articleAsLivingDocBucketKey, bucketValueControl: .articleAsLivingDocControl, bucketValueTest: .articleAsLivingDocTest)
private let persistanceService: ABTestsPersisting
@objc public init(persistanceService: ABTestsPersisting) {
self.persistanceService = persistanceService
super.init()
}
// this will only generate a new bucket as needed (i.e. if the percentage is different than the last time bucket was generated)
@discardableResult
func determineBucketForExperiment(_ experiment: Experiment, withPercentage percentage: NSNumber) throws -> BucketValue {
guard percentage.intValue >= 0 && percentage.intValue <= 100 else {
throw ABTestsError.invalidPercentage
}
// if we have previously generated a bucket with the same percentage, return that value
let maybeOldPercentage = percentageForExperiment(experiment)
let maybeOldBucket = bucketForExperiment(experiment)
if let oldPercentage = maybeOldPercentage,
let oldBucket = maybeOldBucket,
oldPercentage == percentage {
return oldBucket
}
// otherwise generate new bucket
let randomInt = Int.random(in: 1...100)
let isInTest = randomInt <= percentage.intValue
let bucket: BucketValue
switch experiment {
case .articleAsLivingDoc:
bucket = isInTest ? .articleAsLivingDocTest : .articleAsLivingDocControl
}
setBucket(bucket, forExperiment: experiment)
try setPercentage(percentage, forExperiment: experiment)
return bucket
}
// MARK: Persistence setters/getters
func percentageForExperiment(_ experiment: Experiment) -> NSNumber? {
let key = experiment.config.percentageKey.rawValue
return persistanceService.libraryValue(for: key) as? NSNumber
}
func setPercentage(_ percentage: NSNumber, forExperiment experiment: Experiment) throws {
guard percentage.intValue >= 0 && percentage.intValue <= 100 else {
throw ABTestsError.invalidPercentage
}
let key = experiment.config.percentageKey.rawValue
persistanceService.setLibraryValue(percentage, for: key)
}
public func bucketForExperiment(_ experiment: Experiment) -> BucketValue? {
let key = experiment.config.bucketKey.rawValue
guard let rawValue = persistanceService.libraryValue(for: key) as? String else {
return nil
}
return BucketValue(rawValue: rawValue)
}
func setBucket(_ bucket: BucketValue, forExperiment experiment: Experiment) {
let key = experiment.config.bucketKey.rawValue
persistanceService.setLibraryValue((bucket.rawValue as NSString), for: key)
}
}

View File

@ -0,0 +1,44 @@
import UIKit
class ActionButton: SetupButton {
var titleLabelFont = DynamicTextStyle.semiboldSubheadline
override func setup() {
super.setup()
contentEdgeInsets = UIEdgeInsets(top: layoutMargins.top + 1, left: layoutMargins.left + 7, bottom: layoutMargins.bottom + 1, right: layoutMargins.right + 7)
titleLabel?.numberOfLines = 0
updateFonts(with: traitCollection)
}
// MARK: - Dynamic Type
// Only applies new fonts if the content size category changes
override open func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
maybeUpdateFonts(with: traitCollection)
}
var contentSizeCategory: UIContentSizeCategory?
fileprivate func maybeUpdateFonts(with traitCollection: UITraitCollection) {
guard contentSizeCategory == nil || contentSizeCategory != traitCollection.wmf_preferredContentSizeCategory else {
return
}
contentSizeCategory = traitCollection.wmf_preferredContentSizeCategory
updateFonts(with: traitCollection)
}
// Override this method and call super
open func updateFonts(with traitCollection: UITraitCollection) {
titleLabel?.font = UIFont.wmf_font(titleLabelFont, compatibleWithTraitCollection: traitCollection)
}
}
extension ActionButton: Themeable {
func apply(theme: Theme) {
setTitleColor(theme.colors.link, for: .normal)
backgroundColor = theme.colors.cardButtonBackground
layer.cornerRadius = 5
}
}

View File

@ -0,0 +1,19 @@
import Foundation
public enum AnnouncementType: String {
case fundraising
case survey
case announcement
case unknown
}
public extension WMFAnnouncement {
var announcementType: AnnouncementType {
guard let type = type else {
return .unknown
}
return AnnouncementType(rawValue: type) ?? .unknown
}
}

View File

@ -0,0 +1,8 @@
public extension Array {
subscript(safeIndex index: Int) -> Element? {
guard index >= 0, index < endIndex else {
return nil
}
return self[index]
}
}

View File

@ -0,0 +1,208 @@
import Foundation
public final class ArticleCacheController: CacheController {
init(moc: NSManagedObjectContext, imageCacheController: ImageCacheController, session: Session, configuration: Configuration, preferredLanguageDelegate: WMFPreferredLanguageInfoProvider) {
let articleFetcher = ArticleFetcher(session: session, configuration: configuration)
let imageInfoFetcher = MWKImageInfoFetcher(session: session, configuration: configuration)
imageInfoFetcher.preferredLanguageDelegate = preferredLanguageDelegate
let cacheFileWriter = CacheFileWriter(fetcher: articleFetcher)
let articleDBWriter = ArticleCacheDBWriter(articleFetcher: articleFetcher, cacheBackgroundContext: moc, imageController: imageCacheController, imageInfoFetcher: imageInfoFetcher)
super.init(dbWriter: articleDBWriter, fileWriter: cacheFileWriter)
}
enum ArticleCacheControllerError: Error {
case invalidDBWriterType
}
// syncs already cached resources with mobile-html-offline-resources and media-list endpoints (caches new urls, removes old urls)
public func syncCachedResources(url: URL, groupKey: CacheController.GroupKey, groupCompletion: @escaping GroupCompletionBlock) {
guard let articleDBWriter = dbWriter as? ArticleCacheDBWriter else {
groupCompletion(.failure(error: ArticleCacheControllerError.invalidDBWriterType))
return
}
articleDBWriter.syncResources(url: url, groupKey: groupKey) { (result) in
switch result {
case .success(let syncResult):
let group = DispatchGroup()
var successfulAddKeys: [CacheController.UniqueKey] = []
var failedAddKeys: [(CacheController.UniqueKey, Error)] = []
var successfulRemoveKeys: [CacheController.UniqueKey] = []
var failedRemoveKeys: [(CacheController.UniqueKey, Error)] = []
// add new urls in file system
for urlRequest in syncResult.addURLRequests {
guard let uniqueKey = self.fileWriter.uniqueFileNameForURLRequest(urlRequest), urlRequest.url != nil else {
continue
}
group.enter()
self.fileWriter.add(groupKey: groupKey, urlRequest: urlRequest) { (fileWriterResult) in
switch fileWriterResult {
case .success(let response, _):
self.dbWriter.markDownloaded(urlRequest: urlRequest, response: response) { (dbWriterResult) in
defer {
group.leave()
}
switch dbWriterResult {
case .success:
successfulAddKeys.append(uniqueKey)
case .failure(let error):
failedAddKeys.append((uniqueKey, error))
}
}
case .failure(let error):
defer {
group.leave()
}
failedAddKeys.append((uniqueKey, error))
}
}
}
// remove old urls in file system
for key in syncResult.removeItemKeyAndVariants {
guard let uniqueKey = self.fileWriter.uniqueFileNameForItemKey(key.itemKey, variant: key.variant) else {
continue
}
group.enter()
self.fileWriter.remove(itemKey: key.itemKey, variant: key.variant) { (fileWriterResult) in
switch fileWriterResult {
case .success:
self.dbWriter.remove(itemAndVariantKey: key) { (dbWriterResult) in
defer {
group.leave()
}
switch dbWriterResult {
case .success:
successfulRemoveKeys.append(uniqueKey)
case .failure(let error):
failedRemoveKeys.append((uniqueKey, error))
}
}
case .failure(let error):
defer {
group.leave()
}
failedRemoveKeys.append((uniqueKey, error))
}
}
}
group.notify(queue: DispatchQueue.global(qos: .userInitiated)) {
if let error = failedAddKeys.first?.1 ?? failedRemoveKeys.first?.1 {
groupCompletion(.failure(error: CacheControllerError.atLeastOneItemFailedInSync(error)))
return
}
let successKeys = successfulAddKeys + successfulRemoveKeys
groupCompletion(.success(uniqueKeys: successKeys))
}
case .failure(let error):
groupCompletion(.failure(error: error))
}
}
}
public func cacheFromMigration(desktopArticleURL: URL, content: String, completionHandler: @escaping ((Error?) -> Void)) { // articleURL should be desktopURL
guard let articleDBWriter = dbWriter as? ArticleCacheDBWriter else {
completionHandler(ArticleCacheControllerError.invalidDBWriterType)
return
}
cacheBundledResourcesIfNeeded(desktopArticleURL: desktopArticleURL) { (cacheBundledError) in
articleDBWriter.addMobileHtmlURLForMigration(desktopArticleURL: desktopArticleURL, success: { urlRequest in
self.fileWriter.addMobileHtmlContentForMigration(content: content, urlRequest: urlRequest, success: {
articleDBWriter.markDownloaded(urlRequest: urlRequest, response: nil) { (result) in
switch result {
case .success:
if cacheBundledError == nil {
completionHandler(nil)
} else {
completionHandler(cacheBundledError)
}
case .failure(let error):
completionHandler(error)
}
}
}) { (error) in
completionHandler(error)
}
}) { (error) in
completionHandler(error)
}
}
}
private func bundledResourcesAreCached() -> Bool {
guard let articleDBWriter = dbWriter as? ArticleCacheDBWriter else {
return false
}
return articleDBWriter.bundledResourcesAreCached()
}
private func cacheBundledResourcesIfNeeded(desktopArticleURL: URL, completionHandler: @escaping ((Error?) -> Void)) { // articleURL should be desktopURL
guard let articleDBWriter = dbWriter as? ArticleCacheDBWriter else {
completionHandler(ArticleCacheControllerError.invalidDBWriterType)
return
}
if !articleDBWriter.bundledResourcesAreCached() {
articleDBWriter.addBundledResourcesForMigration(desktopArticleURL: desktopArticleURL) { (result) in
switch result {
case .success(let requests):
self.fileWriter.addBundledResourcesForMigration(urlRequests: requests, success: { (_) in
let bulkRequests = requests.map { ArticleCacheDBWriter.BulkMarkDownloadRequest(urlRequest: $0, response: nil) }
articleDBWriter.markDownloaded(requests: bulkRequests) { (result) in
switch result {
case .success:
completionHandler(nil)
case .failure(let error):
completionHandler(error)
}
}
}) { (error) in
completionHandler(error)
}
case .failure(let error):
completionHandler(error)
}
}
} else {
completionHandler(nil)
}
}
}

View File

@ -0,0 +1,244 @@
import Foundation
enum ArticleCacheDBWriterSyncError: Error {
case missingMOC
case cannotFindCacheGroup
case cannotFindCacheItem
case unableToDetermineItemKey
case failureFetchOrCreateCacheItem
case failureToCreateNetworkItem
}
struct CacheDBWritingSyncSuccessResult {
let addURLRequests: [URLRequest]
let removeItemKeyAndVariants: [CacheController.ItemKeyAndVariant]
}
extension ArticleCacheDBWriter {
private struct NetworkItem: Hashable {
let itemKeyAndVariant: CacheController.ItemKeyAndVariant
let url: URL?
let urlRequest: URLRequest?
let cacheItem: CacheItem?
func hash(into hasher: inout Hasher) {
hasher.combine(itemKeyAndVariant)
}
static func ==(lhs: NetworkItem, rhs: NetworkItem) -> Bool {
return lhs.itemKeyAndVariant == rhs.itemKeyAndVariant
}
}
// adds new resources to DB, returns old resources in completion
func syncResources(url: URL, groupKey: CacheController.GroupKey, completion: @escaping (Result<CacheDBWritingSyncSuccessResult, Error>) -> Void) {
let mobileHTMLURL: URL
let mediaListURL: URL
let mobileHTMLRequest: URLRequest
let mediaListRequest: URLRequest
do {
mobileHTMLURL = try articleFetcher.mobileHTMLURL(articleURL: url)
mediaListURL = try articleFetcher.mediaListURL(articleURL: url)
mobileHTMLRequest = try articleFetcher.mobileHTMLRequest(articleURL: url)
mediaListRequest = try articleFetcher.mobileHTMLMediaListRequest(articleURL: url)
} catch let error {
completion(.failure(error))
return
}
let mobileHTMLItemKey = articleFetcher.itemKeyForURL(mobileHTMLURL, type: .article)
let mobileHTMLVariant = articleFetcher.variantForURL(mobileHTMLURL, type: .article)
let mediaListItemKey = articleFetcher.itemKeyForURL(mediaListURL, type: .article)
let mediaListVariant = articleFetcher.variantForURL(mediaListURL, type: .article)
guard let mobileHTMLItemKeyAndVariant = CacheController.ItemKeyAndVariant(itemKey: mobileHTMLItemKey, variant: mobileHTMLVariant),
let mediaListItemKeyAndVariant = CacheController.ItemKeyAndVariant(itemKey: mediaListItemKey, variant: mediaListVariant) else {
completion(.failure(ArticleCacheDBWriterSyncError.failureToCreateNetworkItem))
return
}
let mobileHTMLNetworkItem = NetworkItem(itemKeyAndVariant: mobileHTMLItemKeyAndVariant, url: mobileHTMLURL, urlRequest: mobileHTMLRequest, cacheItem: nil)
let mediaListNetworkItem = NetworkItem(itemKeyAndVariant: mediaListItemKeyAndVariant, url: mediaListURL, urlRequest: mediaListRequest, cacheItem: nil)
fetchImageAndResourceURLsForArticleURL(url, groupKey: groupKey) { [weak self] (result) in
guard let self = self else {
return
}
switch result {
case .success(let urls):
// package offline resource urls into NetworkItems
let offlineResourceItems = urls.offlineResourcesURLs.compactMap { (url) -> NetworkItem? in
guard let itemKey = self.articleFetcher.itemKeyForURL(url, type: .article) else {
return nil
}
let variant = self.articleFetcher.variantForURL(url, type: .article)
guard let itemKeyAndVariant = CacheController.ItemKeyAndVariant(itemKey: itemKey, variant: variant),
let urlRequest = self.articleFetcher.urlRequestFromPersistence(with: url, persistType: .article) else {
return nil
}
return NetworkItem(itemKeyAndVariant: itemKeyAndVariant, url: url, urlRequest: urlRequest, cacheItem: nil)
}
// begin package media list urls into NetworkItems
let mediaListItems = urls.mediaListURLs.compactMap { (url) -> NetworkItem? in
guard let itemKey = self.articleFetcher.itemKeyForURL(url, type: .image) else {
return nil
}
let variant = self.articleFetcher.variantForURL(url, type: .image)
guard let itemKeyAndVariant = CacheController.ItemKeyAndVariant(itemKey: itemKey, variant: variant),
let urlRequest = self.articleFetcher.urlRequestFromPersistence(with: url, persistType: .image) else {
return nil
}
return NetworkItem(itemKeyAndVariant: itemKeyAndVariant, url: url, urlRequest: urlRequest, cacheItem: nil)
}
// group into dictionary of the same itemKey for use in filtering out variants
var similarItemsDictionary: [CacheController.ItemKey: [NetworkItem]] = [:]
for item in mediaListItems {
if var existingItems = similarItemsDictionary[item.itemKeyAndVariant.itemKey] {
existingItems.append(item)
similarItemsDictionary[item.itemKeyAndVariant.itemKey] = existingItems
} else {
similarItemsDictionary[item.itemKeyAndVariant.itemKey] = [item]
}
}
// filter out any variants that image cache controller should not download (i.e. multiple sizes of the same image)
var finalMediaListItems: [NetworkItem] = []
for item in mediaListItems {
guard let similarItems = similarItemsDictionary[item.itemKeyAndVariant.itemKey] else {
continue
}
let allVariantItems = similarItems.map { $0.itemKeyAndVariant }
if self.imageController.shouldDownloadVariantForAllVariantItems(variant: item.itemKeyAndVariant.variant, allVariantItems) {
finalMediaListItems.append(item)
}
}
// end package media list urls into NetworkItems
// begin image info urls into NetworkItems
let imageInfoItems = urls.imageInfoURLs.compactMap { (url) -> NetworkItem? in
guard let itemKey = self.articleFetcher.itemKeyForURL(url, type: .imageInfo) else {
return nil
}
let variant = self.articleFetcher.variantForURL(url, type: .imageInfo)
guard let itemKeyAndVariant = CacheController.ItemKeyAndVariant(itemKey: itemKey, variant: variant),
let urlRequest = self.articleFetcher.urlRequestFromPersistence(with: url, persistType: .imageInfo) else {
return nil
}
return NetworkItem(itemKeyAndVariant: itemKeyAndVariant, url: url, urlRequest: urlRequest, cacheItem: nil)
}
// consolidate list of network items to compare with downloaded cached items
// remove list also contains mobile-html & media-list urls for comparison purposes only, otherwise it will think they should be removed from cache.
let networkItemsForAdd: Set<NetworkItem> = Set(offlineResourceItems + finalMediaListItems + imageInfoItems)
let networkItemsForRemove: Set<NetworkItem> = Set([mobileHTMLNetworkItem] + [mediaListNetworkItem] + offlineResourceItems + finalMediaListItems + imageInfoItems)
self.context.perform { [weak self] in
guard let self = self else {
return
}
guard let group = CacheDBWriterHelper.cacheGroup(with: groupKey, in: self.context) else {
completion(.failure(ArticleCacheDBWriterSyncError.cannotFindCacheGroup))
return
}
guard let cacheItems = group.cacheItems as? Set<CacheItem> else {
completion(.failure(ArticleCacheDBWriterSyncError.cannotFindCacheItem))
return
}
let downloadedCacheItems = cacheItems.compactMap { (cacheItem) -> NetworkItem? in
guard let itemKey = cacheItem.key,
let itemKeyandVariant = CacheController.ItemKeyAndVariant(itemKey: itemKey, variant: cacheItem.variant),
cacheItem.isDownloaded == true else {
return nil
}
return NetworkItem(itemKeyAndVariant: itemKeyandVariant, url: cacheItem.url, urlRequest: nil, cacheItem: cacheItem)
}
let downloadedCacheItemsSet = Set(downloadedCacheItems)
// determine final set of new items that need to be cached
let filteredNewItems = networkItemsForAdd.subtracting(downloadedCacheItemsSet)
let filteredNewURLRequests = filteredNewItems.compactMap { $0.urlRequest }
// determine set of old items that need to be removed
let filteredOldItems = downloadedCacheItemsSet.subtracting(networkItemsForRemove)
// create list of unique item key and variants (those with 1 cache group) to return to CacheController for file deletion. Otherwise delete from group.
var uniqueOldItemKeyAndVariants: [CacheController.ItemKeyAndVariant] = []
for filteredOldItem in filteredOldItems {
if let cacheItem = filteredOldItem.cacheItem {
if cacheItem.cacheGroups?.count == 1 {
uniqueOldItemKeyAndVariants.append(filteredOldItem.itemKeyAndVariant)
} else {
group.removeFromCacheItems(cacheItem)
}
}
}
// cache nonCached urls
self.cacheItems(groupKey: groupKey, items: filteredNewItems) { (result) in
switch result {
case .success:
let result = CacheDBWritingSyncSuccessResult(addURLRequests: filteredNewURLRequests, removeItemKeyAndVariants: uniqueOldItemKeyAndVariants)
completion(.success(result))
case .failure(let error):
completion(.failure(error))
}
}
}
case .failure(let error):
completion(.failure(error))
return
}
}
}
private func cacheItems(groupKey: String, items: Set<NetworkItem>, completion: @escaping ((SaveResult) -> Void)) {
context.perform {
guard let group = CacheDBWriterHelper.fetchOrCreateCacheGroup(with: groupKey, in: self.context) else {
completion(.failure(ArticleCacheDBWriterError.failureFetchOrCreateCacheGroup))
return
}
for item in items {
guard let url = item.url,
item.urlRequest != nil else {
assertionFailure("These need to be populated at this point. They are only optional to be able to compare cleanly with a set of converted CacheItems to NetworkItems")
continue
}
let itemKey = item.itemKeyAndVariant.itemKey
let variant = item.itemKeyAndVariant.variant
guard let item = CacheDBWriterHelper.fetchOrCreateCacheItem(with: url, itemKey: itemKey, variant: variant, in: self.context) else {
completion(.failure(ArticleCacheDBWriterSyncError.failureFetchOrCreateCacheItem))
return
}
group.addToCacheItems(item)
}
CacheDBWriterHelper.save(moc: self.context, completion: completion)
}
}
}

View File

@ -0,0 +1,206 @@
import Foundation
struct ImageAndResourceURLs {
let offlineResourcesURLs: [URL]
let mediaListURLs: [URL]
let imageInfoURLs: [URL]
}
enum ImageAndResourceResult {
case success(ImageAndResourceURLs)
case failure(Error)
}
typealias ImageAndResourceCompletion = (ImageAndResourceResult) -> Void
protocol ArticleCacheResourceDBWriting: CacheDBWriting {
func fetchMediaListURLs(request: URLRequest, groupKey: String, completion: @escaping (Result<[ArticleFetcher.MediaListItem], ArticleCacheDBWriterError>) -> Void)
func fetchOfflineResourceURLs(request: URLRequest, groupKey: String, completion: @escaping (Result<[URL], ArticleCacheDBWriterError>) -> Void)
func cacheURLs(groupKey: String, mustHaveURLRequests: [URLRequest], niceToHaveURLRequests: [URLRequest], completion: @escaping ((SaveResult) -> Void))
var articleFetcher: ArticleFetcher { get }
var imageInfoFetcher: MWKImageInfoFetcher { get }
var context: NSManagedObjectContext { get }
}
extension ArticleCacheResourceDBWriting {
func fetchMediaListURLs(request: URLRequest, groupKey: String, completion: @escaping (Result<[ArticleFetcher.MediaListItem], ArticleCacheDBWriterError>) -> Void) {
guard request.url != nil else {
completion(.failure(.missingListURLInRequest))
return
}
let untrackKey = UUID().uuidString
let task = articleFetcher.fetchMediaListURLs(with: request) { [weak self] (result) in
defer {
self?.untrackTask(untrackKey: untrackKey, from: groupKey)
}
switch result {
case .success(let items):
completion(.success(items))
case .failure(let error):
completion(.failure(.failureFetchingMediaList(error)))
}
}
if let task = task {
trackTask(untrackKey: untrackKey, task: task, to: groupKey)
}
}
func fetchOfflineResourceURLs(request: URLRequest, groupKey: String, completion: @escaping (Result<[URL], ArticleCacheDBWriterError>) -> Void) {
guard request.url != nil else {
completion(.failure(.missingListURLInRequest))
return
}
let untrackKey = UUID().uuidString
let task = articleFetcher.fetchOfflineResourceURLs(with: request) { [weak self] (result) in
defer {
self?.untrackTask(untrackKey: untrackKey, from: groupKey)
}
switch result {
case .success(let urls):
completion(.success(urls))
case .failure(let error):
completion(.failure(.failureFetchingOfflineResourceList(error)))
}
}
if let task = task {
trackTask(untrackKey: untrackKey, task: task, to: groupKey)
}
}
func cacheURLs(groupKey: String, mustHaveURLRequests: [URLRequest], niceToHaveURLRequests: [URLRequest], completion: @escaping ((SaveResult) -> Void)) {
context.perform {
guard let group = CacheDBWriterHelper.fetchOrCreateCacheGroup(with: groupKey, in: self.context) else {
completion(.failure(ArticleCacheDBWriterError.failureFetchOrCreateCacheGroup))
return
}
for urlRequest in mustHaveURLRequests {
guard let url = urlRequest.url,
let itemKey = self.fetcher.itemKeyForURLRequest(urlRequest) else {
completion(.failure(ArticleCacheDBWriterError.unableToDetermineItemKey))
return
}
// note, we purposefully do not set variant here. We need to wait until CacheFileWriter determines if the response varies on language, then set it when we call markDownloaded
guard let item = CacheDBWriterHelper.fetchOrCreateCacheItem(with: url, itemKey: itemKey, variant: nil, in: self.context) else {
completion(.failure(ArticleCacheDBWriterError.failureFetchOrCreateMustHaveCacheItem))
return
}
group.addToCacheItems(item)
group.addToMustHaveCacheItems(item)
}
for urlRequest in niceToHaveURLRequests {
guard let url = urlRequest.url,
let itemKey = self.fetcher.itemKeyForURLRequest(urlRequest) else {
continue
}
guard let item = CacheDBWriterHelper.fetchOrCreateCacheItem(with: url, itemKey: itemKey, variant: nil, in: self.context) else {
continue
}
group.addToCacheItems(item)
}
CacheDBWriterHelper.save(moc: self.context, completion: completion)
}
}
func fetchImageAndResourceURLsForArticleURL(_ articleURL: URL, groupKey: CacheController.GroupKey, completion: @escaping ImageAndResourceCompletion) {
var mobileHTMLOfflineResourcesRequest: URLRequest
var mobileHTMLMediaListRequest: URLRequest
do {
mobileHTMLOfflineResourcesRequest = try articleFetcher.mobileHTMLOfflineResourcesRequest(articleURL: articleURL)
mobileHTMLMediaListRequest = try articleFetcher.mobileHTMLMediaListRequest(articleURL: articleURL)
} catch let error {
completion(.failure(error))
return
}
var mobileHtmlOfflineResourceURLs: [URL] = []
var mediaListURLs: [URL] = []
var imageInfoURLs: [URL] = []
var mediaListError: Error?
var mobileHtmlOfflineResourceError: Error?
let group = DispatchGroup()
group.enter()
fetchOfflineResourceURLs(request: mobileHTMLOfflineResourcesRequest, groupKey: groupKey) { (result) in
defer {
group.leave()
}
switch result {
case .success(let urls):
mobileHtmlOfflineResourceURLs = urls
case .failure(let error):
mobileHtmlOfflineResourceError = error
}
}
group.enter()
fetchMediaListURLs(request: mobileHTMLMediaListRequest, groupKey: groupKey) { (result) in
defer {
group.leave()
}
switch result {
case .success(let items):
mediaListURLs = items.map { $0.imageURL }
let imageTitles = items.map { $0.imageTitle }
let dedupedTitles = Set(imageTitles)
// add imageInfoFetcher's urls for deduped titles (for captions/licensing info in gallery)
for title in dedupedTitles {
if let imageInfoURL = self.imageInfoFetcher.galleryInfoURL(forImageTitles: [title], fromSiteURL: articleURL) {
imageInfoURLs.append(imageInfoURL)
}
}
case .failure(let error):
mediaListError = error
}
}
group.notify(queue: DispatchQueue.global(qos: .default)) {
if let mediaListError = mediaListError {
let result = ImageAndResourceResult.failure(mediaListError)
completion(result)
return
}
if let mobileHtmlOfflineResourceError = mobileHtmlOfflineResourceError {
let result = ImageAndResourceResult.failure(mobileHtmlOfflineResourceError)
completion(result)
return
}
let result = ImageAndResourceURLs(offlineResourcesURLs: mobileHtmlOfflineResourceURLs, mediaListURLs: mediaListURLs, imageInfoURLs: imageInfoURLs)
completion(.success(result))
}
}
}

View File

@ -0,0 +1,32 @@
extension ArticleCollectionViewCell: Themeable {
open func apply(theme: Theme) {
let selected = isBatchEditing ? theme.colors.batchSelectionBackground : theme.colors.midBackground
setBackgroundColors(theme.colors.paperBackground, selected: selected)
imageView.backgroundColor = theme.colors.midBackground
titleLabel.textColor = theme.colors.primaryText
descriptionLabel.textColor = theme.colors.secondaryText
extractLabel?.textColor = theme.colors.primaryText
imageView.alpha = theme.imageOpacity
statusView.backgroundColor = theme.colors.warning
alertButton.tintColor = alertType == .downloading ? theme.colors.warning : theme.colors.error
alertButton.setTitleColor(alertButton.tintColor, for: .normal)
actionsView.apply(theme: theme)
batchEditSelectView?.apply(theme: theme)
updateSelectedOrHighlighted()
}
}
extension ArticleRightAlignedImageCollectionViewCell {
open override func apply(theme: Theme) {
super.apply(theme: theme)
bottomSeparator.backgroundColor = theme.colors.baseBackground
topSeparator.backgroundColor = theme.colors.baseBackground
}
}
extension ArticleFullWidthImageCollectionViewCell {
open override func apply(theme: Theme) {
super.apply(theme: theme)
saveButton.setTitleColor(theme.colors.link, for: .normal)
}
}

View File

@ -0,0 +1,95 @@
import Foundation
/// Represents the response structure of an article summary from the Page Content Service /page/summary endpoint
@objc(WMFArticleSummary)
public class ArticleSummary: NSObject, Codable {
@objc public class Namespace: NSObject, Codable {
let id: Int?
let text: String?
@objc public var number: NSNumber? {
guard let id = id else {
return nil
}
return NSNumber(value: id)
}
}
let id: Int64?
let wikidataID: String?
let revision: String?
let timestamp: String?
let index: Int?
@objc let namespace: Namespace?
let title: String?
let displayTitle: String?
let articleDescription: String?
let extract: String?
let extractHTML: String?
let thumbnail: ArticleSummaryImage?
let original: ArticleSummaryImage?
@objc let coordinates: ArticleSummaryCoordinates?
var languageVariantCode: String?
enum CodingKeys: String, CodingKey {
case id = "pageid"
case revision
case index
case namespace
case title
case timestamp
case displayTitle = "displaytitle"
case articleDescription = "description"
case extract
case extractHTML = "extract_html"
case thumbnail
case original = "originalimage"
case coordinates
case contentURLs = "content_urls"
case wikidataID = "wikibase_item"
}
let contentURLs: ArticleSummaryContentURLs
var articleURL: URL? {
guard let urlString = contentURLs.desktop?.page else {
return nil
}
var articleURL = URL(string: urlString)
articleURL?.wmf_languageVariantCode = languageVariantCode
return articleURL
}
var key: WMFInMemoryURLKey? {
return articleURL?.wmf_inMemoryKey // don't use contentURLs.desktop?.page directly as it needs to be standardized
}
}
@objc(WMFArticleSummaryImage)
class ArticleSummaryImage: NSObject, Codable {
let source: String
let width: Int
let height: Int
var url: URL? {
return URL(string: source)
}
}
@objc(WMFArticleSummaryURLs)
class ArticleSummaryURLs: NSObject, Codable {
let page: String?
let revisions: String?
let edit: String?
let talk: String?
}
@objc(WMFArticleSummaryContentURLs)
class ArticleSummaryContentURLs: NSObject, Codable {
let desktop: ArticleSummaryURLs?
let mobile: ArticleSummaryURLs?
}
@objc(WMFArticleSummaryCoordinates)
class ArticleSummaryCoordinates: NSObject, Codable {
@objc let lat: Double
@objc let lon: Double
}

View File

@ -0,0 +1,122 @@
import Foundation
enum AsyncOperationError: Error {
case cancelled
}
// Adapted from https://gist.github.com/calebd/93fa347397cec5f88233
@objc(WMFAsyncOperation) open class AsyncOperation: Operation {
// MARK: - Operation State
fileprivate let semaphore = DispatchSemaphore(value: 1) // Ensures `state` is thread-safe
@objc public enum State: Int {
case ready
case executing
case finished
var affectedKeyPath: KeyPath<AsyncOperation, Bool> {
switch self {
case .ready:
return \.isReady
case .executing:
return \.isExecuting
case .finished:
return \.isFinished
}
}
}
public var error: Error?
fileprivate var _state = AsyncOperation.State.ready
@objc public var state: AsyncOperation.State {
get {
semaphore.wait()
let state = _state
defer {
semaphore.signal()
}
return state
}
set {
willChangeValue(for: \.state)
let affectedKeyPaths = [_state.affectedKeyPath, newValue.affectedKeyPath]
for keyPath in affectedKeyPaths {
willChangeValue(for: keyPath)
}
semaphore.wait()
_state = newValue
semaphore.signal()
didChangeValue(for: \.state)
for keyPath in affectedKeyPaths {
didChangeValue(for: keyPath)
}
}
}
// MARK: - Operation subclass requirements
public final override var isReady: Bool {
return state == .ready && super.isReady
}
public final override var isExecuting: Bool {
return state == .executing
}
public final override var isFinished: Bool {
return state == .finished
}
public final override var isAsynchronous: Bool {
return true
}
open override func start() {
// From the docs for `start`:
// "Your custom implementation must not call super at any time."
if isCancelled {
finish(with: AsyncOperationError.cancelled)
return
}
state = .executing
execute()
}
// MARK: - Custom behavior
@objc open func finish() {
state = .finished
}
@objc open func finish(with error: Error) {
self.error = error
state = .finished
}
/// Subclasses must implement this to perform their work and they must not
/// call `super`. The default implementation of this function throws an
/// exception.
open func execute() {
fatalError("Subclasses must implement `execute`.")
}
}
@objc(WMFAsyncBlockOperation) open class AsyncBlockOperation: AsyncOperation {
let asyncBlock: (AsyncBlockOperation) -> Void
@objc init(asyncBlock block: @escaping (AsyncBlockOperation) -> Void) {
asyncBlock = block
}
final override public func execute() {
asyncBlock(self)
}
}

View File

@ -0,0 +1,40 @@
import Foundation
@objc(WMFBackgroundFetcher) public protocol BackgroundFetcher: NSObjectProtocol {
func performBackgroundFetch(_ completion: @escaping (UIBackgroundFetchResult) -> Void)
}
@objc(WMFBackgroundFetcherController) public class BackgroundFetcherController: WorkerController {
var fetchers = [BackgroundFetcher]()
@objc public func add(_ worker: BackgroundFetcher) {
fetchers.append(worker)
}
@objc public func performBackgroundFetch(_ completion: @escaping (UIBackgroundFetchResult) -> Void) {
let identifier = UUID().uuidString
delegate?.workerControllerWillStart(self, workWithIdentifier: identifier)
fetchers.asyncMap({ (fetcher, completion) in
fetcher.performBackgroundFetch(completion)
}) { [weak self] (results) in
var combinedResult = UIBackgroundFetchResult.noData
resultLoop: for result in results {
switch result {
case .failed:
combinedResult = .failed
break resultLoop
case .newData:
combinedResult = .newData
default:
break
}
}
completion(combinedResult)
guard let strongSelf = self else {
return
}
strongSelf.delegate?.workerControllerDidEnd(strongSelf, workWithIdentifier: identifier)
}
}
}

View File

@ -0,0 +1,47 @@
import UIKit
public final class BatchEditToolbarViewController: UIViewController {
@IBOutlet private weak var stackView: UIStackView?
@IBOutlet private weak var bottomConstraint: NSLayoutConstraint!
@IBOutlet private weak var separatorView: UIView?
public var items: [UIButton] = []
private var theme: Theme = Theme.standard
public func setItemsEnabled(_ enabled: Bool) {
for item in items {
item.isEnabled = enabled
}
}
public func remove() {
self.willMove(toParent: nil)
view.removeFromSuperview()
self.removeFromParent()
}
public override func didMove(toParent parent: UIViewController?) {
if let parent = parent, let safeAreaOwningView = view.safeAreaLayoutGuide.owningView {
bottomConstraint.constant = max(0, parent.view.safeAreaInsets.bottom - safeAreaOwningView.frame.height)
}
}
public override func viewDidLoad() {
super.viewDidLoad()
for item in items {
stackView?.addArrangedSubview(item)
}
apply(theme: theme)
}
}
extension BatchEditToolbarViewController: Themeable {
public func apply(theme: Theme) {
self.theme = theme
guard viewIfLoaded != nil else {
return
}
view.backgroundColor = theme.colors.midBackground
separatorView?.backgroundColor = theme.colors.border
}
}

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14113" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="BatchEditToolbarViewController" customModule="WMF">
<connections>
<outlet property="bottomConstraint" destination="ctB-SL-ier" id="AHQ-je-Nuc"/>
<outlet property="separatorView" destination="fWi-hH-Agp" id="fQ2-sX-qf2"/>
<outlet property="stackView" destination="gnJ-Br-g7q" id="57k-GK-oCc"/>
<outlet property="view" destination="i5M-Pr-FkT" id="sfx-zR-JGt"/>
</connections>
</placeholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view clearsContextBeforeDrawing="NO" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" id="i5M-Pr-FkT">
<rect key="frame" x="0.0" y="0.0" width="374" height="69"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="fWi-hH-Agp" userLabel="Separator View">
<rect key="frame" x="0.0" y="0.0" width="374" height="0.5"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" constant="0.5" id="cb5-Ls-nDk"/>
</constraints>
</view>
<stackView opaque="NO" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" distribution="fillEqually" alignment="center" translatesAutoresizingMaskIntoConstraints="NO" id="gnJ-Br-g7q">
<rect key="frame" x="0.0" y="0.0" width="374" height="69"/>
<constraints>
<constraint firstAttribute="height" relation="greaterThanOrEqual" constant="44" id="wVI-6W-Xx4"/>
</constraints>
</stackView>
</subviews>
<constraints>
<constraint firstItem="fWi-hH-Agp" firstAttribute="leading" secondItem="i5M-Pr-FkT" secondAttribute="leading" id="I1D-Ye-H4m"/>
<constraint firstItem="gnJ-Br-g7q" firstAttribute="top" secondItem="i5M-Pr-FkT" secondAttribute="top" id="QA2-BJ-6fF"/>
<constraint firstAttribute="trailing" secondItem="fWi-hH-Agp" secondAttribute="trailing" id="UTe-Jg-03m"/>
<constraint firstItem="gnJ-Br-g7q" firstAttribute="leading" secondItem="i5M-Pr-FkT" secondAttribute="leading" id="VgX-Jy-7YZ"/>
<constraint firstItem="RXU-0L-PwE" firstAttribute="bottom" secondItem="gnJ-Br-g7q" secondAttribute="bottom" id="ctB-SL-ier"/>
<constraint firstAttribute="trailing" secondItem="gnJ-Br-g7q" secondAttribute="trailing" id="ieo-Yr-9Um"/>
<constraint firstItem="fWi-hH-Agp" firstAttribute="width" secondItem="i5M-Pr-FkT" secondAttribute="width" id="ksQ-qM-8FW"/>
<constraint firstItem="fWi-hH-Agp" firstAttribute="top" secondItem="i5M-Pr-FkT" secondAttribute="top" id="lhX-uf-Re3"/>
</constraints>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<viewLayoutGuide key="safeArea" id="RXU-0L-PwE"/>
<point key="canvasLocation" x="354" y="-264.5"/>
</view>
</objects>
</document>

View File

@ -0,0 +1,7 @@
import Foundation
extension Bundle {
var isAppExtension: Bool {
return bundleURL.pathExtension.caseInsensitiveCompare("appex") == .orderedSame
}
}

View File

@ -0,0 +1,10 @@
import Foundation
extension Bundle {
@objc public static let wmf: Bundle = Bundle(identifier: "org.wikimedia.WMF")!
@objc(wmf_assetsFolderURL)
public var assetsFolderURL: URL {
return url(forResource: "assets", withExtension: nil)!
}
}

View File

@ -0,0 +1,10 @@
extension CGRect {
// Height required to layout this rect. Returns 0 if rect.height is 0. Returns height plus spacing otherwise.
public func layoutHeight(with spacing: CGFloat) -> CGFloat {
return height > 0 ? height + spacing : 0
}
public var center: CGPoint {
return CGPoint(x: midX, y: midY)
}
}

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>Cache 2.xcdatamodel</string>
</dict>
</plist>

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="15702" systemVersion="19D76" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="CacheGroup" representedClassName="WMFCacheGroup" syncable="YES">
<attribute name="key" attributeType="String"/>
<relationship name="cacheItems" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="CacheItem" inverseName="cacheGroups" inverseEntity="CacheItem"/>
<relationship name="mustHaveCacheItems" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="CacheItem" inverseName="mustHaveCacheGroups" inverseEntity="CacheItem"/>
<fetchIndex name="byKeyIndex">
<fetchIndexElement property="key" type="Binary" order="ascending"/>
</fetchIndex>
</entity>
<entity name="CacheItem" representedClassName="WMFCacheItem" syncable="YES">
<attribute name="date" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="isDownloaded" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="key" attributeType="String"/>
<attribute name="url" optional="YES" attributeType="URI"/>
<attribute name="variant" optional="YES" attributeType="String" customClassName="NSArray"/>
<relationship name="cacheGroups" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="CacheGroup" inverseName="cacheItems" inverseEntity="CacheGroup"/>
<relationship name="mustHaveCacheGroups" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="CacheGroup" inverseName="mustHaveCacheItems" inverseEntity="CacheGroup"/>
<fetchIndex name="byDateIndex">
<fetchIndexElement property="date" type="Binary" order="ascending"/>
</fetchIndex>
<fetchIndex name="compoundIndex">
<fetchIndexElement property="key" type="Binary" order="ascending"/>
<fetchIndexElement property="variant" type="Binary" order="ascending"/>
</fetchIndex>
</entity>
<elements>
<element name="CacheGroup" positionX="-63" positionY="9" width="128" height="88"/>
<element name="CacheItem" positionX="-63" positionY="-18" width="128" height="148"/>
</elements>
</model>

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="12141" systemVersion="16E195" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="CacheGroup" representedClassName="WMFCacheGroup" syncable="YES">
<attribute name="key" attributeType="String" indexed="YES" syncable="YES"/>
<relationship name="cacheItems" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="CacheItem" inverseName="cacheGroups" inverseEntity="CacheItem" syncable="YES"/>
</entity>
<entity name="CacheItem" representedClassName="WMFCacheItem" syncable="YES">
<attribute name="date" attributeType="Date" usesScalarValueType="NO" indexed="YES" syncable="YES"/>
<attribute name="key" attributeType="String" syncable="YES"/>
<attribute name="variant" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES" customClassName="NSArray" syncable="YES"/>
<relationship name="cacheGroups" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="CacheGroup" inverseName="cacheItems" inverseEntity="CacheGroup" syncable="YES"/>
<compoundIndexes>
<compoundIndex>
<index value="key"/>
<index value="variant"/>
</compoundIndex>
</compoundIndexes>
</entity>
<elements>
<element name="CacheGroup" positionX="-63" positionY="9" width="128" height="75"/>
<element name="CacheItem" positionX="-63" positionY="-18" width="128" height="105"/>
</elements>
</model>

View File

@ -0,0 +1,405 @@
import Foundation
public enum CacheControllerError: Error {
case unableToCreateBackgroundCacheContext
case atLeastOneItemFailedInFileWriter(Error)
case failureToGenerateItemResult
case atLeastOneItemFailedInSync(Error)
}
public class CacheController {
#if TEST
public static var temporaryCacheURL: URL? = nil
#endif
static let cacheURL: URL = {
#if TEST
if let temporaryCacheURL = temporaryCacheURL {
return temporaryCacheURL
}
#endif
var url = FileManager.default.wmf_containerURL().appendingPathComponent("Permanent Cache", isDirectory: true)
var values = URLResourceValues()
values.isExcludedFromBackup = true
do {
try url.setResourceValues(values)
} catch {
return url
}
return url
}()
// todo: Settings hook, logout don't sync hook, etc.
public static var totalCacheSizeInBytes: Int64 {
return FileManager.default.sizeOfDirectory(at: cacheURL)
}
/// Performs any necessary migrations on the CacheController's internal storage
static func setupCoreDataStack(_ completion: @escaping (NSManagedObjectContext?, CacheControllerError?) -> Void) {
// Expensive file & db operations happen as a part of this migration, so async it to a non-main queue
DispatchQueue.global(qos: .default).async {
// Instantiating the moc will perform the migrations in CacheItemMigrationPolicy
guard let moc = createCacheContext(cacheURL: cacheURL) else {
completion(nil, .unableToCreateBackgroundCacheContext)
return
}
// do a moc.perform in case anything else needs to be run before the context is ready
moc.perform {
DispatchQueue.main.async {
completion(moc, nil)
}
}
}
}
static func createCacheContext(cacheURL: URL) -> NSManagedObjectContext? {
// create cacheURL directory
do {
try FileManager.default.createDirectory(at: cacheURL, withIntermediateDirectories: true, attributes: nil)
} catch let error {
assertionFailure("Error creating permanent cache: \(error)")
return nil
}
// create ManagedObjectModel based on Cache.momd
guard let modelURL = Bundle.wmf.url(forResource: "Cache", withExtension: "momd"),
let model = NSManagedObjectModel(contentsOf: modelURL) else {
assertionFailure("Failure to create managed object model")
return nil
}
// create persistent store coordinator / persistent store
let dbURL = cacheURL.appendingPathComponent("Cache.sqlite", isDirectory: false)
let persistentStoreCoordinator = NSPersistentStoreCoordinator(managedObjectModel: model)
let options = [
NSMigratePersistentStoresAutomaticallyOption: NSNumber(booleanLiteral: true),
NSInferMappingModelAutomaticallyOption: NSNumber(booleanLiteral: true)
]
do {
try persistentStoreCoordinator.addPersistentStore(ofType: NSSQLiteStoreType, configurationName: nil, at: dbURL, options: options)
} catch {
do {
try FileManager.default.removeItem(at: dbURL)
} catch {
assertionFailure("Failure to remove old db file")
}
do {
try persistentStoreCoordinator.addPersistentStore(ofType: NSSQLiteStoreType, configurationName: nil, at: dbURL, options: options)
} catch {
assertionFailure("Failure to add persistent store to coordinator")
return nil
}
}
let cacheBackgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
cacheBackgroundContext.persistentStoreCoordinator = persistentStoreCoordinator
return cacheBackgroundContext
}
public typealias ItemKey = String
public typealias GroupKey = String
public typealias UniqueKey = String // combo of item key + variant
public typealias IndividualCompletionBlock = (FinalIndividualResult) -> Void
public typealias GroupCompletionBlock = (FinalGroupResult) -> Void
public struct ItemKeyAndVariant: Hashable {
let itemKey: CacheController.ItemKey
let variant: String?
init?(itemKey: CacheController.ItemKey?, variant: String?) {
guard let itemKey = itemKey else {
return nil
}
self.itemKey = itemKey
self.variant = variant
}
}
public enum FinalIndividualResult {
case success(uniqueKey: CacheController.UniqueKey)
case failure(error: Error)
}
public enum FinalGroupResult {
case success(uniqueKeys: [CacheController.UniqueKey])
case failure(error: Error)
}
let dbWriter: CacheDBWriting
let fileWriter: CacheFileWriter
let gatekeeper = CacheGatekeeper()
init(dbWriter: CacheDBWriting, fileWriter: CacheFileWriter) {
self.dbWriter = dbWriter
self.fileWriter = fileWriter
}
public func add(url: URL, groupKey: GroupKey, individualCompletion: @escaping IndividualCompletionBlock, groupCompletion: @escaping GroupCompletionBlock) {
if gatekeeper.shouldQueueAddCompletion(groupKey: groupKey) {
gatekeeper.queueAddCompletion(groupKey: groupKey) {
self.add(url: url, groupKey: groupKey, individualCompletion: individualCompletion, groupCompletion: groupCompletion)
return
}
} else {
gatekeeper.addCurrentlyAddingGroupKey(groupKey)
}
if gatekeeper.numberOfQueuedGroupCompletions(for: groupKey) > 0 {
gatekeeper.queueGroupCompletion(groupKey: groupKey, groupCompletion: groupCompletion)
return
}
gatekeeper.queueGroupCompletion(groupKey: groupKey, groupCompletion: groupCompletion)
dbWriter.add(url: url, groupKey: groupKey) { [weak self] (result) in
self?.finishDBAdd(groupKey: groupKey, individualCompletion: individualCompletion, groupCompletion: groupCompletion, result: result)
}
}
public func cancelTasks(groupKey: String) {
dbWriter.cancelTasks(for: groupKey)
fileWriter.cancelTasks(for: groupKey)
}
public func cancelAllTasks() {
dbWriter.cancelAllTasks()
fileWriter.cancelAllTasks()
}
func shouldDownloadVariantForAllVariantItems(variant: String?, _ allVariantItems: [CacheController.ItemKeyAndVariant]) -> Bool {
return dbWriter.shouldDownloadVariantForAllVariantItems(variant: variant, allVariantItems)
}
func finishDBAdd(groupKey: GroupKey, individualCompletion: @escaping IndividualCompletionBlock, groupCompletion: @escaping GroupCompletionBlock, result: CacheDBWritingResultWithURLRequests) {
let groupCompleteBlock = { (groupResult: FinalGroupResult) in
self.gatekeeper.runAndRemoveGroupCompletions(groupKey: groupKey, groupResult: groupResult)
self.gatekeeper.removeCurrentlyAddingGroupKey(groupKey)
self.gatekeeper.runAndRemoveQueuedRemoves(groupKey: groupKey)
}
switch result {
case .success(let urlRequests):
var successfulKeys: [CacheController.UniqueKey] = []
var failedKeys: [(CacheController.UniqueKey, Error)] = []
let group = DispatchGroup()
for urlRequest in urlRequests {
guard let uniqueKey = fileWriter.uniqueFileNameForURLRequest(urlRequest),
let url = urlRequest.url else {
continue
}
group.enter()
if gatekeeper.numberOfQueuedIndividualCompletions(for: uniqueKey) > 0 {
defer {
group.leave()
}
gatekeeper.queueIndividualCompletion(uniqueKey: uniqueKey, individualCompletion: individualCompletion)
continue
}
gatekeeper.queueIndividualCompletion(uniqueKey: uniqueKey, individualCompletion: individualCompletion)
guard dbWriter.shouldDownloadVariant(urlRequest: urlRequest) else {
group.leave()
continue
}
fileWriter.add(groupKey: groupKey, urlRequest: urlRequest) { [weak self] (result) in
guard let self = self else {
return
}
switch result {
case .success(let response, let data):
self.dbWriter.markDownloaded(urlRequest: urlRequest, response: response) { (result) in
defer {
group.leave()
}
let individualResult: FinalIndividualResult
switch result {
case .success:
successfulKeys.append(uniqueKey)
individualResult = FinalIndividualResult.success(uniqueKey: uniqueKey)
case .failure(let error):
failedKeys.append((uniqueKey, error))
individualResult = FinalIndividualResult.failure(error: error)
}
self.gatekeeper.runAndRemoveIndividualCompletions(uniqueKey: uniqueKey, individualResult: individualResult)
}
self.finishFileSave(data: data, mimeType: response.mimeType, uniqueKey: uniqueKey, url: url)
case .failure(let error):
defer {
group.leave()
}
failedKeys.append((uniqueKey, error))
let individualResult = FinalIndividualResult.failure(error: error)
self.gatekeeper.runAndRemoveIndividualCompletions(uniqueKey: uniqueKey, individualResult: individualResult)
}
}
group.notify(queue: DispatchQueue.global(qos: .userInitiated)) {
let groupResult: FinalGroupResult
if let error = failedKeys.first?.1 {
groupResult = FinalGroupResult.failure(error: CacheControllerError.atLeastOneItemFailedInFileWriter(error))
} else {
groupResult = FinalGroupResult.success(uniqueKeys: successfulKeys)
}
groupCompleteBlock(groupResult)
}
}
case .failure(let error):
let groupResult = FinalGroupResult.failure(error: error)
groupCompleteBlock(groupResult)
}
}
func finishFileSave(data: Data, mimeType: String?, uniqueKey: CacheController.UniqueKey, url: URL) {
// hook to allow subclasses to do any additional work with data
}
public func remove(groupKey: GroupKey, individualCompletion: @escaping IndividualCompletionBlock, groupCompletion: @escaping GroupCompletionBlock) {
if gatekeeper.shouldQueueRemoveCompletion(groupKey: groupKey) {
gatekeeper.queueRemoveCompletion(groupKey: groupKey) {
self.remove(groupKey: groupKey, individualCompletion: individualCompletion, groupCompletion: groupCompletion)
return
}
} else {
gatekeeper.addCurrentlyRemovingGroupKey(groupKey)
}
if gatekeeper.numberOfQueuedGroupCompletions(for: groupKey) > 0 {
gatekeeper.queueGroupCompletion(groupKey: groupKey, groupCompletion: groupCompletion)
return
}
gatekeeper.queueGroupCompletion(groupKey: groupKey, groupCompletion: groupCompletion)
cancelTasks(groupKey: groupKey)
let groupCompleteBlock = { (groupResult: FinalGroupResult) in
self.gatekeeper.runAndRemoveGroupCompletions(groupKey: groupKey, groupResult: groupResult)
self.gatekeeper.removeCurrentlyRemovingGroupKey(groupKey)
self.gatekeeper.runAndRemoveQueuedAdds(groupKey: groupKey)
}
dbWriter.fetchKeysToRemove(for: groupKey) { [weak self] (result) in
guard let self = self else {
return
}
switch result {
case .success(let keys):
var successfulKeys: [CacheController.UniqueKey] = []
var failedKeys: [(CacheController.UniqueKey, Error)] = []
let group = DispatchGroup()
for key in keys {
guard let uniqueKey = self.fileWriter.uniqueFileNameForItemKey(key.itemKey, variant: key.variant) else {
continue
}
group.enter()
if self.gatekeeper.numberOfQueuedIndividualCompletions(for: uniqueKey) > 0 {
defer {
group.leave()
}
self.gatekeeper.queueIndividualCompletion(uniqueKey: uniqueKey, individualCompletion: individualCompletion)
continue
}
self.gatekeeper.queueIndividualCompletion(uniqueKey: uniqueKey, individualCompletion: individualCompletion)
self.fileWriter.remove(itemKey: key.itemKey, variant: key.variant) { (result) in
switch result {
case .success:
self.dbWriter.remove(itemAndVariantKey: key) { (result) in
defer {
group.leave()
}
var individualResult: FinalIndividualResult
switch result {
case .success:
successfulKeys.append(uniqueKey)
individualResult = FinalIndividualResult.success(uniqueKey: uniqueKey)
case .failure(let error):
failedKeys.append((uniqueKey, error))
individualResult = FinalIndividualResult.failure(error: error)
}
self.gatekeeper.runAndRemoveIndividualCompletions(uniqueKey: uniqueKey, individualResult: individualResult)
}
case .failure(let error):
failedKeys.append((uniqueKey, error))
let individualResult = FinalIndividualResult.failure(error: error)
self.gatekeeper.runAndRemoveIndividualCompletions(uniqueKey: uniqueKey, individualResult: individualResult)
group.leave()
}
}
}
group.notify(queue: DispatchQueue.global(qos: .userInitiated)) {
if let error = failedKeys.first?.1 {
let groupResult = FinalGroupResult.failure(error: CacheControllerError.atLeastOneItemFailedInFileWriter(error))
groupCompleteBlock(groupResult)
return
}
self.dbWriter.remove(groupKey: groupKey, completion: { (result) in
var groupResult: FinalGroupResult
switch result {
case .success:
groupResult = FinalGroupResult.success(uniqueKeys: successfulKeys)
case .failure(let error):
groupResult = FinalGroupResult.failure(error: error)
}
groupCompleteBlock(groupResult)
})
}
case .failure(let error):
let groupResult = FinalGroupResult.failure(error: error)
groupCompleteBlock(groupResult)
}
}
}
}

View File

@ -0,0 +1,114 @@
import Foundation
final class CacheDBWriterHelper {
static func fetchOrCreateCacheGroup(with groupKey: String, in moc: NSManagedObjectContext) -> CacheGroup? {
return cacheGroup(with: groupKey, in: moc) ?? createCacheGroup(with: groupKey, in: moc)
}
static func fetchOrCreateCacheItem(with url: URL, itemKey: String, variant: String?, in moc: NSManagedObjectContext) -> CacheItem? {
return cacheItem(with: itemKey, variant: variant, in: moc) ?? createCacheItem(with: url, itemKey: itemKey, variant: variant, in: moc)
}
static func cacheGroup(with key: String, in moc: NSManagedObjectContext) ->
CacheGroup? {
let fetchRequest: NSFetchRequest<CacheGroup> = CacheGroup.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "key == %@", key)
fetchRequest.fetchLimit = 1
do {
guard let group = try moc.fetch(fetchRequest).first else {
return nil
}
return group
} catch let error {
fatalError(error.localizedDescription)
}
}
static func createCacheGroup(with groupKey: String, in moc: NSManagedObjectContext) -> CacheGroup? {
guard let entity = NSEntityDescription.entity(forEntityName: "CacheGroup", in: moc) else {
return nil
}
let group = CacheGroup(entity: entity, insertInto: moc)
group.key = groupKey
return group
}
static func cacheItem(with itemKey: String, variant: String?, in moc: NSManagedObjectContext) -> CacheItem? {
let predicate: NSPredicate
if let variant = variant {
predicate = NSPredicate(format: "key == %@ && variant == %@", itemKey, variant)
} else {
predicate = NSPredicate(format: "key == %@", itemKey)
}
let fetchRequest: NSFetchRequest<CacheItem> = CacheItem.fetchRequest()
fetchRequest.predicate = predicate
fetchRequest.fetchLimit = 1
do {
guard let item = try moc.fetch(fetchRequest).first else {
return nil
}
return item
} catch let error {
fatalError(error.localizedDescription)
}
}
static func createCacheItem(with url: URL, itemKey: String, variant: String?, in moc: NSManagedObjectContext) -> CacheItem? {
guard let entity = NSEntityDescription.entity(forEntityName: "CacheItem", in: moc) else {
return nil
}
let item = CacheItem(entity: entity, insertInto: moc)
item.key = itemKey
item.variant = variant
item.url = url
item.date = Date()
return item
}
static func isCached(itemKey: CacheController.ItemKey, variant: String?, in moc: NSManagedObjectContext, completion: @escaping (Bool) -> Void) {
return moc.perform {
let isCached = CacheDBWriterHelper.cacheItem(with: itemKey, variant: variant, in: moc) != nil
completion(isCached)
}
}
static func allDownloadedVariantItems(itemKey: CacheController.ItemKey, in moc: NSManagedObjectContext) -> [CacheItem] {
let fetchRequest: NSFetchRequest<CacheItem> = CacheItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "key == %@ && isDownloaded == YES", itemKey)
do {
return try moc.fetch(fetchRequest)
} catch {
return []
}
}
static func allVariantItems(itemKey: CacheController.ItemKey, in moc: NSManagedObjectContext) -> [CacheItem] {
let fetchRequest: NSFetchRequest<CacheItem> = CacheItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "key == %@", itemKey)
do {
return try moc.fetch(fetchRequest)
} catch {
return []
}
}
static func save(moc: NSManagedObjectContext, completion: (_ result: SaveResult) -> Void) {
guard moc.hasChanges else {
completion(.success)
return
}
do {
try moc.save()
completion(.success)
} catch let error {
assertionFailure("Error saving cache moc: \(error)")
completion(.failure(error))
}
}
}

View File

@ -0,0 +1,126 @@
import Foundation
enum SaveResult {
case success
case failure(Error)
}
enum CacheDBWritingResultWithURLRequests {
case success([URLRequest])
case failure(Error)
}
enum CacheDBWritingResultWithItemAndVariantKeys {
case success([CacheController.ItemKeyAndVariant])
case failure(Error)
}
enum CacheDBWritingResult {
case success
case failure(Error)
}
enum CacheDBWritingMarkDownloadedError: Error {
case cannotFindCacheGroup
case cannotFindCacheItem
case unableToDetermineItemKey
case missingMOC
}
enum CacheDBWritingRemoveError: Error {
case cannotFindCacheGroup
case cannotFindCacheItem
case missingMOC
}
protocol CacheDBWriting: CacheTaskTracking {
var context: NSManagedObjectContext { get }
typealias CacheDBWritingCompletionWithURLRequests = (CacheDBWritingResultWithURLRequests) -> Void
typealias CacheDBWritingCompletionWithItemAndVariantKeys = (CacheDBWritingResultWithItemAndVariantKeys) -> Void
func add(url: URL, groupKey: CacheController.GroupKey, completion: @escaping CacheDBWritingCompletionWithURLRequests)
func add(urls: [URL], groupKey: CacheController.GroupKey, completion: @escaping CacheDBWritingCompletionWithURLRequests)
func shouldDownloadVariant(itemKey: CacheController.ItemKey, variant: String?) -> Bool
func shouldDownloadVariant(urlRequest: URLRequest) -> Bool
func shouldDownloadVariantForAllVariantItems(variant: String?, _ allVariantItems: [CacheController.ItemKeyAndVariant]) -> Bool
var fetcher: CacheFetching { get }
// default implementations
func remove(itemAndVariantKey: CacheController.ItemKeyAndVariant, completion: @escaping (CacheDBWritingResult) -> Void)
func remove(groupKey: String, completion: @escaping (CacheDBWritingResult) -> Void)
func fetchKeysToRemove(for groupKey: CacheController.GroupKey, completion: @escaping CacheDBWritingCompletionWithItemAndVariantKeys)
func markDownloaded(urlRequest: URLRequest, response: HTTPURLResponse?, completion: @escaping (CacheDBWritingResult) -> Void)
}
extension CacheDBWriting {
func fetchKeysToRemove(for groupKey: CacheController.GroupKey, completion: @escaping CacheDBWritingCompletionWithItemAndVariantKeys) {
context.perform {
guard let group = CacheDBWriterHelper.cacheGroup(with: groupKey, in: self.context) else {
completion(.failure(CacheDBWritingMarkDownloadedError.cannotFindCacheGroup))
return
}
guard let cacheItems = group.cacheItems as? Set<CacheItem> else {
completion(.failure(CacheDBWritingMarkDownloadedError.cannotFindCacheItem))
return
}
let cacheItemsToRemove = cacheItems.filter({ (cacheItem) -> Bool in
return cacheItem.cacheGroups?.count == 1
})
completion(.success(cacheItemsToRemove.compactMap { CacheController.ItemKeyAndVariant(itemKey: $0.key, variant: $0.variant) }))
}
}
func remove(itemAndVariantKey: CacheController.ItemKeyAndVariant, completion: @escaping (CacheDBWritingResult) -> Void) {
context.perform {
guard let cacheItem = CacheDBWriterHelper.cacheItem(with: itemAndVariantKey.itemKey, variant: itemAndVariantKey.variant, in: self.context) else {
completion(.failure(CacheDBWritingRemoveError.cannotFindCacheItem))
return
}
self.context.delete(cacheItem)
CacheDBWriterHelper.save(moc: self.context) { (result) in
switch result {
case .success:
completion(.success)
case .failure(let error):
completion(.failure(error))
}
}
}
}
func remove(groupKey: CacheController.GroupKey, completion: @escaping (CacheDBWritingResult) -> Void) {
context.perform {
guard let cacheGroup = CacheDBWriterHelper.cacheGroup(with: groupKey, in: self.context) else {
completion(.failure(CacheDBWritingRemoveError.cannotFindCacheItem))
return
}
self.context.delete(cacheGroup)
CacheDBWriterHelper.save(moc: self.context) { (result) in
switch result {
case .success:
completion(.success)
case .failure(let error):
completion(.failure(error))
}
}
}
}
func shouldDownloadVariant(urlRequest: URLRequest) -> Bool {
guard let itemKey = fetcher.itemKeyForURLRequest(urlRequest) else {
return false
}
let variant = fetcher.variantForURLRequest(urlRequest)
return shouldDownloadVariant(itemKey: itemKey, variant: variant)
}
}

View File

@ -0,0 +1,145 @@
import Foundation
public struct CacheFetchingResult {
let data: Data
let response: URLResponse
}
enum CacheFetchingError: Error {
case missingDataAndURLResponse
case missingURLResponse
case unableToDetermineURLRequest
}
public protocol CacheFetching {
typealias TemporaryFileURL = URL
typealias MIMEType = String
typealias DownloadCompletion = (Error?, URLRequest?, URLResponse?, TemporaryFileURL?, MIMEType?) -> Void
typealias DataCompletion = (Result<CacheFetchingResult, Error>) -> Void
// internally populates urlRequest with cache header fields
func dataForURL(_ url: URL, persistType: Header.PersistItemType, headers: [String: String], completion: @escaping DataCompletion) -> URLSessionTask?
// assumes urlRequest is already populated with cache header fields
func dataForURLRequest(_ urlRequest: URLRequest, completion: @escaping DataCompletion) -> URLSessionTask?
// Session Passthroughs
func cachedResponseForURL(_ url: URL, type: Header.PersistItemType) -> CachedURLResponse?
func cachedResponseForURLRequest(_ urlRequest: URLRequest) -> CachedURLResponse? // assumes urlRequest is already populated with the proper cache headers
func uniqueKeyForURL(_ url: URL, type: Header.PersistItemType) -> String?
func cacheResponse(httpUrlResponse: HTTPURLResponse, content: CacheResponseContentType, urlRequest: URLRequest, success: @escaping () -> Void, failure: @escaping (Error) -> Void)
func uniqueFileNameForItemKey(_ itemKey: CacheController.ItemKey, variant: String?) -> String?
func uniqueHeaderFileNameForItemKey(_ itemKey: CacheController.ItemKey, variant: String?) -> String?
func uniqueFileNameForURLRequest(_ urlRequest: URLRequest) -> String?
func itemKeyForURLRequest(_ urlRequest: URLRequest) -> String?
func variantForURLRequest(_ urlRequest: URLRequest) -> String?
// Bundled migration only - copies files into cache
func writeBundledFiles(mimeType: String, bundledFileURL: URL, urlRequest: URLRequest, completion: @escaping (Result<Void, Error>) -> Void)
}
extension CacheFetching where Self:Fetcher {
@discardableResult public func dataForURLRequest(_ urlRequest: URLRequest, completion: @escaping DataCompletion) -> URLSessionTask? {
let task = session.dataTask(with: urlRequest) { (data, urlResponse, error) in
if let error = error {
completion(.failure(error))
return
}
guard let unwrappedResponse = urlResponse else {
completion(.failure(CacheFetchingError.missingURLResponse))
return
}
if let httpResponse = unwrappedResponse as? HTTPURLResponse, httpResponse.statusCode != 200 {
completion(.failure(RequestError.unexpectedResponse))
return
}
if let data = data,
let urlResponse = urlResponse {
let result = CacheFetchingResult(data: data, response: urlResponse)
completion(.success(result))
} else {
completion(.failure(CacheFetchingError.missingDataAndURLResponse))
}
}
task?.resume()
return task
}
@discardableResult public func dataForURL(_ url: URL, persistType: Header.PersistItemType, headers: [String: String] = [:], completion: @escaping DataCompletion) -> URLSessionTask? {
guard let urlRequest = session.urlRequestFromPersistence(with: url, persistType: persistType, headers: headers) else {
completion(.failure(CacheFetchingError.unableToDetermineURLRequest))
return nil
}
return dataForURLRequest(urlRequest, completion: completion)
}
}
// MARK: Session Passthroughs
extension CacheFetching where Self:Fetcher {
public func cachedResponseForURL(_ url: URL, type: Header.PersistItemType) -> CachedURLResponse? {
return session.cachedResponseForURL(url, type: type)
}
public func cachedResponseForURLRequest(_ urlRequest: URLRequest) -> CachedURLResponse? {
return session.cachedResponseForURLRequest(urlRequest)
}
public func uniqueFileNameForURLRequest(_ urlRequest: URLRequest) -> String? {
return session.uniqueFileNameForURLRequest(urlRequest)
}
public func uniqueKeyForURL(_ url: URL, type: Header.PersistItemType) -> String? {
return session.uniqueKeyForURL(url, type: type)
}
public func cacheResponse(httpUrlResponse: HTTPURLResponse, content: CacheResponseContentType, urlRequest: URLRequest, success: @escaping () -> Void, failure: @escaping (Error) -> Void) {
session.cacheResponse(httpUrlResponse: httpUrlResponse, content: content, urlRequest: urlRequest, success: success, failure: failure)
}
public func writeBundledFiles(mimeType: String, bundledFileURL: URL, urlRequest: URLRequest, completion: @escaping (Result<Void, Error>) -> Void) {
session.writeBundledFiles(mimeType: mimeType, bundledFileURL: bundledFileURL, urlRequest: urlRequest, completion: completion)
}
public func uniqueFileNameForItemKey(_ itemKey: CacheController.ItemKey, variant: String?) -> String? {
return session.uniqueFileNameForItemKey(itemKey, variant: variant)
}
public func itemKeyForURLRequest(_ urlRequest: URLRequest) -> String? {
return session.itemKeyForURLRequest(urlRequest)
}
public func variantForURLRequest(_ urlRequest: URLRequest) -> String? {
return session.variantForURLRequest(urlRequest)
}
public func itemKeyForURL(_ url: URL, type: Header.PersistItemType) -> String? {
return session.itemKeyForURL(url, type: type)
}
public func variantForURL(_ url: URL, type: Header.PersistItemType) -> String? {
return session.variantForURL(url, type: type)
}
public func urlRequestFromPersistence(with url: URL, persistType: Header.PersistItemType, cachePolicy: WMFCachePolicy? = nil, headers: [String: String] = [:]) -> URLRequest? {
return session.urlRequestFromPersistence(with: url, persistType: persistType, cachePolicy: cachePolicy, headers: headers)
}
public func uniqueHeaderFileNameForItemKey(_ itemKey: CacheController.ItemKey, variant: String?) -> String? {
return session.uniqueHeaderFileNameForItemKey(itemKey, variant: variant)
}
public func isCachedWithURLRequest(_ request: URLRequest, completion: @escaping (Bool) -> Void) {
return session.isCachedWithURLRequest(request, completion: completion)
}
}

View File

@ -0,0 +1,246 @@
import Foundation
import CocoaLumberjackSwift
enum CacheFileWriterError: Error {
case missingTemporaryFileURL
case missingHeaderItemKey
case missingHTTPResponse
case unableToDetermineSiteURLFromMigration
case unexpectedFetcherTypeForBundledMigration
case unableToDetermineBundledOfflineURLS
case failureToSaveBundledFiles
case unableToPullCachedDataFromNotModified
case missingURLInRequest
case unableToGenerateHTTPURLResponse
case unableToDetermineFileNames
}
enum CacheFileWriterAddResult {
case success(response: HTTPURLResponse, data: Data)
case failure(Error)
}
enum CacheFileWriterRemoveResult {
case success
case failure(Error)
}
final class CacheFileWriter: CacheTaskTracking {
private let fetcher: CacheFetching
lazy private var baseCSSFileURL: URL = {
URL(fileURLWithPath: WikipediaAppUtils.assetsPath())
.appendingPathComponent("pcs-html-converter", isDirectory: true)
.appendingPathComponent("base.css", isDirectory: false)
}()
lazy private var pcsCSSFileURL: URL = {
URL(fileURLWithPath: WikipediaAppUtils.assetsPath())
.appendingPathComponent("pcs-html-converter", isDirectory: true)
.appendingPathComponent("pcs.css", isDirectory: false)
}()
lazy private var pcsJSFileURL: URL = {
URL(fileURLWithPath: WikipediaAppUtils.assetsPath())
.appendingPathComponent("pcs-html-converter", isDirectory: true)
.appendingPathComponent("pcs.js", isDirectory: false)
}()
var groupedTasks: [String : [IdentifiedTask]] = [:]
init(fetcher: CacheFetching) {
self.fetcher = fetcher
do {
try FileManager.default.createDirectory(at: CacheController.cacheURL, withIntermediateDirectories: true, attributes: nil)
} catch {
DDLogError("Error creating permanent cache: \(error)")
}
}
func add(groupKey: String, urlRequest: URLRequest, completion: @escaping (CacheFileWriterAddResult) -> Void) {
let untrackKey = UUID().uuidString
let task = fetcher.dataForURLRequest(urlRequest) { [weak self] (response) in
guard let self = self else {
return
}
defer {
self.untrackTask(untrackKey: untrackKey, from: groupKey)
}
switch response {
case .success(let result):
guard let httpUrlResponse = result.response as? HTTPURLResponse else {
completion(.failure(CacheFileWriterError.missingHTTPResponse))
return
}
self.fetcher.cacheResponse(httpUrlResponse: httpUrlResponse, content: .data(result.data), urlRequest: urlRequest, success: {
completion(.success(response: httpUrlResponse, data: result.data))
}) { (error) in
completion(.failure(error))
}
case .failure(let error):
DDLogError("Error downloading data for offline: \(error)\n\(String(describing: response))")
completion(.failure(error))
return
}
}
if let task = task {
trackTask(untrackKey: untrackKey, task: task, to: groupKey)
}
}
func remove(itemKey: String, variant: String?, completion: @escaping (CacheFileWriterRemoveResult) -> Void) {
guard let fileName = self.fetcher.uniqueFileNameForItemKey(itemKey, variant: variant),
let headerFileName = self.fetcher.uniqueHeaderFileNameForItemKey(itemKey, variant: variant) else {
completion(.failure(CacheFileWriterError.unableToDetermineFileNames))
return
}
var responseHeaderRemoveError: Error? = nil
var responseRemoveError: Error? = nil
// remove response from file system
let responseCachedFileURL = CacheFileWriterHelper.fileURL(for: fileName)
do {
try FileManager.default.removeItem(at: responseCachedFileURL)
} catch let error as NSError {
if !(error.code == NSURLErrorFileDoesNotExist || error.code == NSFileNoSuchFileError) {
responseRemoveError = error
}
}
// remove response header from file system
let responseHeaderCachedFileURL = CacheFileWriterHelper.fileURL(for: headerFileName)
do {
try FileManager.default.removeItem(at: responseHeaderCachedFileURL)
} catch let error as NSError {
if !(error.code == NSURLErrorFileDoesNotExist || error.code == NSFileNoSuchFileError) {
responseHeaderRemoveError = error
}
}
if let responseHeaderRemoveError = responseHeaderRemoveError {
completion(.failure(responseHeaderRemoveError))
return
}
if let responseRemoveError = responseRemoveError {
completion(.failure(responseRemoveError))
return
}
completion(.success)
}
func uniqueFileNameForItemKey(_ itemKey: CacheController.ItemKey, variant: String?) -> String? {
return fetcher.uniqueFileNameForItemKey(itemKey, variant: variant)
}
func uniqueFileNameForURLRequest(_ urlRequest: URLRequest) -> String? {
return fetcher.uniqueFileNameForURLRequest(urlRequest)
}
}
// MARK: Migration
extension CacheFileWriter {
// assumes urlRequest is already populated with the right type header
func addMobileHtmlContentForMigration(content: String, urlRequest: URLRequest, success: @escaping () -> Void, failure: @escaping (Error) -> Void) {
guard let url = urlRequest.url else {
failure(CacheFileWriterError.missingURLInRequest)
return
}
// artificially create HTTPURLResponse
guard let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "text/html"]) else {
failure(CacheFileWriterError.unableToGenerateHTTPURLResponse)
return
}
fetcher.cacheResponse(httpUrlResponse: response, content: .string(content), urlRequest: urlRequest, success: {
success()
}) { (error) in
failure(error)
}
}
func addBundledResourcesForMigration(urlRequests:[URLRequest], success: @escaping ([URLRequest]) -> Void, failure: @escaping (Error) -> Void) {
guard let articleFetcher = fetcher as? ArticleFetcher else {
failure(CacheFileWriterError.unexpectedFetcherTypeForBundledMigration)
return
}
guard let bundledOfflineResources = articleFetcher.bundledOfflineResourceURLs() else {
failure(CacheFileWriterError.unableToDetermineBundledOfflineURLS)
return
}
var failedURLRequests: [URLRequest] = []
var succeededURLRequests: [URLRequest] = []
for urlRequest in urlRequests {
guard let urlString = urlRequest.url?.absoluteString else {
continue
}
switch urlString {
case bundledOfflineResources.baseCSS.absoluteString:
fetcher.writeBundledFiles(mimeType: "text/css", bundledFileURL: baseCSSFileURL, urlRequest: urlRequest) { (result) in
switch result {
case .success:
succeededURLRequests.append(urlRequest)
case .failure:
failedURLRequests.append(urlRequest)
}
}
case bundledOfflineResources.pcsCSS.absoluteString:
fetcher.writeBundledFiles(mimeType: "text/css", bundledFileURL: pcsCSSFileURL, urlRequest: urlRequest) { (result) in
switch result {
case .success:
succeededURLRequests.append(urlRequest)
case .failure:
failedURLRequests.append(urlRequest)
}
}
case bundledOfflineResources.pcsJS.absoluteString:
fetcher.writeBundledFiles(mimeType: "application/javascript", bundledFileURL: pcsJSFileURL, urlRequest: urlRequest) { (result) in
switch result {
case .success:
succeededURLRequests.append(urlRequest)
case .failure:
failedURLRequests.append(urlRequest)
}
}
default:
failedURLRequests.append(urlRequest)
}
}
if succeededURLRequests.count == 0 {
failure(CacheFileWriterError.failureToSaveBundledFiles)
return
}
success(succeededURLRequests)
}
}

View File

@ -0,0 +1,139 @@
import Foundation
enum CacheFileWriterHelperError: Error {
case unexpectedHeaderFieldsType
}
final class CacheFileWriterHelper {
static func fileURL(for key: String) -> URL {
return CacheController.cacheURL.appendingPathComponent(key, isDirectory: false)
}
static func saveData(data: Data, toNewFileWithKey key: String, completion: @escaping (FileSaveResult) -> Void) {
do {
let newFileURL = self.fileURL(for: key)
try data.write(to: newFileURL)
completion(.success)
} catch let error as NSError {
if error.domain == NSCocoaErrorDomain, error.code == NSFileWriteFileExistsError {
completion(.exists)
} else {
completion(.failure(error))
}
} catch let error {
completion(.failure(error))
}
}
static func copyFile(from fileURL: URL, toNewFileWithKey key: String, completion: @escaping (FileSaveResult) -> Void) {
do {
let newFileURL = self.fileURL(for: key)
try FileManager.default.copyItem(at: fileURL, to: newFileURL)
completion(.success)
} catch let error as NSError {
if error.domain == NSCocoaErrorDomain, error.code == NSFileWriteFileExistsError {
completion(.exists)
} else {
completion(.failure(error))
}
} catch let error {
completion(.failure(error))
}
}
static func saveResponseHeader(httpUrlResponse: HTTPURLResponse, toNewFileName fileName: String, completion: @escaping (FileSaveResult) -> Void) {
guard let headerFields = httpUrlResponse.allHeaderFields as? [String: String] else {
completion(.failure(CacheFileWriterHelperError.unexpectedHeaderFieldsType))
return
}
saveResponseHeader(headerFields: headerFields, toNewFileName: fileName, completion: completion)
}
static func saveResponseHeader(headerFields: [String: String], toNewFileName fileName: String, completion: (FileSaveResult) -> Void) {
do {
let contentData: Data = try NSKeyedArchiver.archivedData(withRootObject: headerFields, requiringSecureCoding: false)
let newFileURL = self.fileURL(for: fileName)
try contentData.write(to: newFileURL)
completion(.success)
} catch let error as NSError {
if error.domain == NSCocoaErrorDomain, error.code == NSFileWriteFileExistsError {
completion(.exists)
} else {
completion(.failure(error))
}
} catch let error {
completion(.failure(error))
}
}
static func replaceResponseHeaderWithURLResponse(_ httpUrlResponse: HTTPURLResponse, atFileName fileName: String, completion: @escaping (FileSaveResult) -> Void) {
guard let headerFields = httpUrlResponse.allHeaderFields as? [String: String] else {
completion(.failure(CacheFileWriterHelperError.unexpectedHeaderFieldsType))
return
}
replaceResponseHeaderWithHeaderFields(headerFields, atFileName: fileName, completion: completion)
}
static func replaceResponseHeaderWithHeaderFields(_ headerFields:[String: String], atFileName fileName: String, completion: @escaping (FileSaveResult) -> Void) {
do {
let headerData: Data = try NSKeyedArchiver.archivedData(withRootObject: headerFields, requiringSecureCoding:false)
replaceFileWithData(headerData, fileName: fileName, completion: completion)
} catch let error {
completion(.failure(error))
}
}
static func replaceFileWithData(_ data: Data, fileName: String, completion: @escaping (FileSaveResult) -> Void) {
let destinationURL = fileURL(for: fileName)
do {
let temporaryDirectoryURL = try FileManager.default.url(for: .itemReplacementDirectory,
in: .userDomainMask,
appropriateFor: destinationURL,
create: true)
let temporaryFileName = UUID().uuidString
let temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(temporaryFileName)
try data.write(to: temporaryFileURL,
options: .atomic)
_ = try FileManager.default.replaceItemAt(destinationURL, withItemAt: temporaryFileURL)
try FileManager.default.removeItem(at: temporaryDirectoryURL)
completion(.success)
} catch let error {
completion(.failure(error))
}
}
static func saveContent(_ content: String, toNewFileName fileName: String, completion: @escaping (FileSaveResult) -> Void) {
do {
let newFileURL = self.fileURL(for: fileName)
try content.write(to: newFileURL, atomically: true, encoding: .utf8)
completion(.success)
} catch let error as NSError {
if error.domain == NSCocoaErrorDomain, error.code == NSFileWriteFileExistsError {
completion(.exists)
} else {
completion(.failure(error))
}
} catch let error {
completion(.failure(error))
}
}
}
enum FileSaveResult {
case exists
case success
case failure(Error)
}

View File

@ -0,0 +1,214 @@
import Foundation
final class CacheGatekeeper {
private let queue = DispatchQueue(label: "org.wikimedia.cache.gatekeeper")
// Used when adding or removing the same uniqueKey rapidly. Individual completion block is queued here until uniqueKey is determined complete in CacheController. Note this complete can come from another groupKey. Queued completions are then called and cleaned out.
private var individualCompletions: [CacheController.UniqueKey: [CacheController.IndividualCompletionBlock]] = [:]
// Used when adding or removing the same groupKey rapidly. Completion block is queued here until group is determined complete in CacheController. Queued completions are then called and cleaned out.
private var groupCompletions: [CacheController.GroupKey: [CacheController.GroupCompletionBlock]] = [:]
// Used when adding THEN removing (or vice versa) the same group key rapidly. Completion blocks are queued here until an add or remove completes. Queued completions are then called and cleaned out.
private var queuedAddsWhileWaitingOnRemoves: [CacheController.GroupKey: [() -> Void]] = [:]
private var queuedRemovesWhileWaitingOnAdds: [CacheController.GroupKey: [() -> Void]] = [:]
private var currentlyAdding: [CacheController.GroupKey] = []
private var currentlyRemoving: [CacheController.GroupKey] = []
func numberOfQueuedGroupCompletions(for groupKey: CacheController.GroupKey) -> Int {
queue.sync {
return groupCompletions[groupKey]?.count ?? 0
}
}
func numberOfQueuedIndividualCompletions(for uniqueKey: CacheController.UniqueKey) -> Int {
queue.sync {
return individualCompletions[uniqueKey]?.count ?? 0
}
}
func queueGroupCompletion(groupKey: CacheController.GroupKey, groupCompletion: @escaping CacheController.GroupCompletionBlock) {
queue.async { [weak self] in
guard let self = self else {
return
}
var currentCompletions = self.groupCompletions[groupKey] ?? []
currentCompletions.append(groupCompletion)
self.groupCompletions[groupKey] = currentCompletions
}
}
func queueIndividualCompletion(uniqueKey: CacheController.UniqueKey, individualCompletion: @escaping CacheController.IndividualCompletionBlock) {
queue.async { [weak self] in
guard let self = self else {
return
}
var currentCompletions = self.individualCompletions[uniqueKey] ?? []
currentCompletions.append(individualCompletion)
self.individualCompletions[uniqueKey] = currentCompletions
}
}
func runAndRemoveGroupCompletions(groupKey: CacheController.GroupKey, groupResult: CacheController.FinalGroupResult) {
queue.async { [weak self] in
guard let self = self else {
return
}
if let completions = self.groupCompletions[groupKey] {
for completion in completions {
completion(groupResult)
}
}
self.groupCompletions[groupKey]?.removeAll()
}
}
func runAndRemoveIndividualCompletions(uniqueKey: CacheController.UniqueKey, individualResult: CacheController.FinalIndividualResult) {
queue.async { [weak self] in
guard let self = self else {
return
}
if let completions = self.individualCompletions[uniqueKey] {
for completion in completions {
completion(individualResult)
}
}
self.individualCompletions[uniqueKey]?.removeAll()
}
}
func addCurrentlyAddingGroupKey(_ groupKey: CacheController.GroupKey) {
queue.async { [weak self] in
self?.currentlyAdding.append(groupKey)
}
}
func addCurrentlyRemovingGroupKey(_ groupKey: CacheController.GroupKey) {
queue.async { [weak self] in
self?.currentlyRemoving.append(groupKey)
}
}
func removeCurrentlyAddingGroupKey(_ groupKey: CacheController.GroupKey) {
queue.async { [weak self] in
guard let self = self else {
return
}
let filteredCurrentlyAdding = self.currentlyAdding.filter { $0 != groupKey }
self.currentlyAdding = filteredCurrentlyAdding
}
}
func removeCurrentlyRemovingGroupKey(_ groupKey: CacheController.GroupKey) {
queue.async { [weak self] in
guard let self = self else {
return
}
let filteredCurrentlyRemoving = self.currentlyRemoving.filter { $0 != groupKey }
self.currentlyRemoving = filteredCurrentlyRemoving
}
}
func shouldQueueAddCompletion(groupKey: CacheController.GroupKey) -> Bool {
queue.sync {
return currentlyRemoving.contains(groupKey)
}
}
func shouldQueueRemoveCompletion(groupKey: CacheController.GroupKey) -> Bool {
queue.sync {
return currentlyAdding.contains(groupKey)
}
}
func queueAddCompletion(groupKey: CacheController.GroupKey, completion: @escaping () -> Void) {
queue.async { [weak self] in
guard let self = self else {
return
}
var currentCompletions = self.queuedAddsWhileWaitingOnRemoves[groupKey] ?? []
currentCompletions.append(completion)
self.queuedAddsWhileWaitingOnRemoves[groupKey] = currentCompletions
}
}
func queueRemoveCompletion(groupKey: CacheController.GroupKey, completion: @escaping () -> Void) {
queue.async { [weak self] in
guard let self = self else {
return
}
var currentCompletions = self.queuedRemovesWhileWaitingOnAdds[groupKey] ?? []
currentCompletions.append(completion)
self.queuedRemovesWhileWaitingOnAdds[groupKey] = currentCompletions
}
}
func runAndRemoveQueuedRemoves(groupKey: CacheController.GroupKey) {
queue.async { [weak self] in
guard let self = self else {
return
}
if let completion = self.queuedRemovesWhileWaitingOnAdds[groupKey]?.first {
completion()
}
self.queuedRemovesWhileWaitingOnAdds[groupKey]?.removeAll()
}
}
func runAndRemoveQueuedAdds(groupKey: CacheController.GroupKey) {
queue.async { [weak self] in
guard let self = self else {
return
}
if let completion = self.queuedAddsWhileWaitingOnRemoves[groupKey]?.first {
completion()
}
self.queuedAddsWhileWaitingOnRemoves[groupKey]?.removeAll()
}
}
}

View File

@ -0,0 +1,7 @@
import Foundation
import CoreData
@objc(WMFCacheGroup)
public class CacheGroup: NSManagedObject {
}

View File

@ -0,0 +1,49 @@
import Foundation
import CoreData
extension CacheGroup {
@nonobjc public class func fetchRequest() -> NSFetchRequest<CacheGroup> {
return NSFetchRequest<CacheGroup>(entityName: "CacheGroup")
}
@NSManaged public var key: String?
@NSManaged public var cacheItems: NSSet?
@NSManaged public var mustHaveCacheItems: NSSet?
}
// MARK: Generated accessors for cacheItems
extension CacheGroup {
@objc(addCacheItemsObject:)
@NSManaged public func addToCacheItems(_ value: CacheItem)
@objc(removeCacheItemsObject:)
@NSManaged public func removeFromCacheItems(_ value: CacheItem)
@objc(addCacheItems:)
@NSManaged public func addToCacheItems(_ values: NSSet)
@objc(removeCacheItems:)
@NSManaged public func removeFromCacheItems(_ values: NSSet)
}
// MARK: Generated accessors for mustHaveCacheItems
extension CacheGroup {
@objc(addMustHaveCacheItemsObject:)
@NSManaged public func addToMustHaveCacheItems(_ value: CacheItem)
@objc(removeMustHaveCacheItemsObject:)
@NSManaged public func removeFromMustHaveCacheItems(_ value: CacheItem)
@objc(addMustHaveCacheItems:)
@NSManaged public func addToMustHaveCacheItems(_ values: NSSet)
@objc(removeMustHaveCacheItems:)
@NSManaged public func removeFromMustHaveCacheItems(_ values: NSSet)
}

View File

@ -0,0 +1,7 @@
import Foundation
import CoreData
@objc(WMFCacheItem)
public class CacheItem: NSManagedObject {
}

View File

@ -0,0 +1,53 @@
import Foundation
import CoreData
extension CacheItem {
@nonobjc public class func fetchRequest() -> NSFetchRequest<CacheItem> {
return NSFetchRequest<CacheItem>(entityName: "CacheItem")
}
@NSManaged public var date: Date?
@NSManaged public var isDownloaded: Bool
@NSManaged public var key: String?
@NSManaged public var url: URL?
@NSManaged public var variant: String?
@NSManaged public var cacheGroups: NSSet?
@NSManaged public var mustHaveCacheGroups: NSSet?
}
// MARK: Generated accessors for cacheGroups
extension CacheItem {
@objc(addCacheGroupsObject:)
@NSManaged public func addToCacheGroups(_ value: CacheGroup)
@objc(removeCacheGroupsObject:)
@NSManaged public func removeFromCacheGroups(_ value: CacheGroup)
@objc(addCacheGroups:)
@NSManaged public func addToCacheGroups(_ values: NSSet)
@objc(removeCacheGroups:)
@NSManaged public func removeFromCacheGroups(_ values: NSSet)
}
// MARK: Generated accessors for mustHaveCacheGroups
extension CacheItem {
@objc(addMustHaveCacheGroupsObject:)
@NSManaged public func addToMustHaveCacheGroups(_ value: CacheGroup)
@objc(removeMustHaveCacheGroupsObject:)
@NSManaged public func removeFromMustHaveCacheGroups(_ value: CacheGroup)
@objc(addMustHaveCacheGroups:)
@NSManaged public func addToMustHaveCacheGroups(_ values: NSSet)
@objc(removeMustHaveCacheGroups:)
@NSManaged public func removeFromMustHaveCacheGroups(_ values: NSSet)
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,62 @@
import Foundation
enum CacheItemMigrationPolicyError: Error {
case unrecognizedSourceAttributeTypes
}
class CacheItemMigrationPolicy: NSEntityMigrationPolicy {
private let fetcher = ImageFetcher()
override func createDestinationInstances(forSource sInstance: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager) throws {
if sInstance.entity.name == "CacheItem" {
guard let key = sInstance.primitiveValue(forKey: "key") as? String,
let variant = sInstance.primitiveValue(forKey: "variant") as? Int64,
let date = sInstance.primitiveValue(forKey: "date") as? Date else {
throw CacheItemMigrationPolicyError.unrecognizedSourceAttributeTypes
}
let destinationItem = NSEntityDescription.insertNewObject(forEntityName: "CacheItem", into: manager.destinationContext)
destinationItem.setValue(key, forKey: "key")
let newVariant = String(variant)
destinationItem.setValue(newVariant, forKey: "variant")
destinationItem.setValue(date, forKey: "date")
var isDownloaded = false
autoreleasepool { () -> Void in
guard
let fileName = fetcher.uniqueFileNameForItemKey(key, variant: newVariant),
let headerFileName = fetcher.uniqueHeaderFileNameForItemKey(key, variant: newVariant) else {
return
}
let fileURL = CacheFileWriterHelper.fileURL(for: fileName)
let filePath = fileURL.path
// artifically create and save image response header
var headers: [String: String] = [:]
headers["Content-Type"] = FileManager.default.getValueForExtendedFileAttributeNamed(WMFExtendedFileAttributeNameMIMEType, forFileAtPath: filePath)
let values = try? fileURL.resourceValues(forKeys: [URLResourceKey.fileSizeKey])
if let fileSize = values?.fileSize {
headers["Content-Length"] = String(fileSize)
}
guard !headers.isEmpty else {
return
}
CacheFileWriterHelper.saveResponseHeader(headerFields: headers, toNewFileName: headerFileName) { (result) in
switch result {
case .success, .exists:
isDownloaded = true
case .failure:
break
}
}
}
destinationItem.setValue(isDownloaded, forKey: "isDownloaded")
destinationItem.setValue(nil, forKey: "url")
manager.associate(sourceInstance: sInstance, withDestinationInstance: destinationItem, for: mapping)
}
}
}

View File

@ -0,0 +1,54 @@
import Foundation
struct IdentifiedTask {
let untrackKey: String
let task: URLSessionTask
}
// TODO: less of a sledgehammer here
let CacheTaskTrackingSemaphore = DispatchSemaphore(value: 1)
protocol CacheTaskTracking: AnyObject {
var groupedTasks: [String: [IdentifiedTask]] { get set }
func cancelAllTasks()
func cancelTasks(for groupKey: String)
func untrackTask(untrackKey: String, from groupKey: String)
func trackTask(untrackKey: String, task: URLSessionTask, to groupKey: String)
}
extension CacheTaskTracking {
func cancelTasks(for groupKey: String) {
CacheTaskTrackingSemaphore.wait()
if let identifiedTasks = groupedTasks[groupKey] {
for identifiedTask in identifiedTasks {
identifiedTask.task.cancel()
}
}
CacheTaskTrackingSemaphore.signal()
}
func untrackTask(untrackKey: String, from groupKey: String) {
CacheTaskTrackingSemaphore.wait()
if let identifiedTasks = groupedTasks[groupKey] {
groupedTasks[groupKey] = identifiedTasks.filter { $0.untrackKey == untrackKey }
}
CacheTaskTrackingSemaphore.signal()
}
func trackTask(untrackKey: String, task: URLSessionTask, to groupKey: String) {
CacheTaskTrackingSemaphore.wait()
let identifiedTask = IdentifiedTask(untrackKey: untrackKey, task: task)
groupedTasks[groupKey]?.append(identifiedTask)
CacheTaskTrackingSemaphore.signal()
}
func cancelAllTasks() {
CacheTaskTrackingSemaphore.wait()
for group in groupedTasks {
for identifiedTask in group.value {
identifiedTask.task.cancel()
}
}
CacheTaskTrackingSemaphore.signal()
}
}

View File

@ -0,0 +1,20 @@
extension CharacterSet {
public static var encodeURIComponentAllowed: CharacterSet {
return NSCharacterSet.wmf_encodeURIComponentAllowed()
}
public static var relativePathAndFragmentAllowed: CharacterSet {
return NSCharacterSet.wmf_relativePathAndFragmentAllowed()
}
// RFC 3986 reserved + unreserved characters + percent (%)
public static var rfc3986Allowed: CharacterSet {
return CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=%")
}
static let urlQueryComponentAllowed: CharacterSet = {
var characterSet = CharacterSet.urlQueryAllowed
characterSet.remove(charactersIn: "+&=")
return characterSet
}()
}

View File

@ -0,0 +1,81 @@
import Foundation
public extension Sequence {
func asyncMapToDictionary<K,V>( block: (Element, @escaping (K?, V?) -> Void) -> Void, queue: DispatchQueue = DispatchQueue.global(qos: .default), completion: @escaping ([K: V]) -> Void) {
let group = DispatchGroup()
let semaphore = DispatchSemaphore(value: 1)
var results = [K: V](minimumCapacity: underestimatedCount)
for object in self {
group.enter()
block(object, { (key, value) in
defer {
group.leave()
}
guard let key = key, let value = value else {
return
}
semaphore.wait()
results[key] = value
semaphore.signal()
})
}
group.notify(queue: queue) {
completion(results)
}
}
}
public extension Collection {
func asyncMap<R>(_ block: (Element, @escaping (R) -> Void) -> Void, completion: @escaping ([R]) -> Void) {
let group = DispatchGroup()
let semaphore = DispatchSemaphore(value: 1)
var results = [R?](repeating: nil, count: count)
for (index, object) in self.enumerated() {
group.enter()
block(object, { result in
semaphore.wait()
results[index] = result
semaphore.signal()
group.leave()
})
}
group.notify(queue: DispatchQueue.global(qos: .default)) {
completion(results as! [R])
}
}
func asyncCompactMap<R>(_ block: (Element, @escaping (R?) -> Void) -> Void, completion: @escaping ([R]) -> Void) {
let group = DispatchGroup()
let semaphore = DispatchSemaphore(value: 1)
var results = [R?](repeating: nil, count: count)
for (index, object) in self.enumerated() {
group.enter()
block(object, { result in
guard let result = result else {
group.leave()
return
}
semaphore.wait()
results[index] = result
semaphore.signal()
group.leave()
})
}
group.notify(queue: DispatchQueue.global(qos: .default)) {
completion(results.compactMap {$0})
}
}
func asyncForEach(_ block: (Element, @escaping () -> Void) -> Void, completion: @escaping () -> Void) {
let group = DispatchGroup()
for object in self {
group.enter()
block(object, {
group.leave()
})
}
group.notify(queue: DispatchQueue.global(qos: .default)) {
completion()
}
}
}

View File

@ -0,0 +1,189 @@
import Foundation
public class Action: UIAccessibilityCustomAction {
let icon: UIImage?
let confirmationIcon: UIImage?
public let type: ActionType
public let indexPath: IndexPath
public init(accessibilityTitle: String, icon: UIImage?, confirmationIcon: UIImage?, type: ActionType, indexPath: IndexPath, target: Any?, selector: Selector) {
self.icon = icon
self.confirmationIcon = confirmationIcon
self.type = type
self.indexPath = indexPath
super.init(name: accessibilityTitle, target: target, selector: selector)
}
}
@objc public protocol ActionDelegate: NSObjectProtocol {
@objc func availableActions(at indexPath: IndexPath) -> [Action]
@objc func didPerformAction(_ action: Action) -> Bool
@objc func willPerformAction(_ action: Action) -> Bool
@objc optional func didPerformBatchEditToolbarAction(_ action: BatchEditToolbarAction, completion: @escaping (Bool) -> Void)
@objc optional var availableBatchEditToolbarActions: [BatchEditToolbarAction] { get }
}
public enum ActionType {
case delete, save, unsave, share
private struct Icon {
static let delete = UIImage(named: "swipe-action-delete", in: Bundle.wmf, compatibleWith: nil)
static let save = UIImage(named: "swipe-action-save", in: Bundle.wmf, compatibleWith: nil)
static let unsave = UIImage(named: "swipe-action-unsave", in: Bundle.wmf, compatibleWith: nil)
static let share = UIImage(named: "swipe-action-share", in: Bundle.wmf, compatibleWith: nil)
}
public func action(with target: Any?, indexPath: IndexPath) -> Action {
switch self {
case .delete:
return Action(accessibilityTitle: CommonStrings.deleteActionTitle, icon: Icon.delete, confirmationIcon: nil, type: .delete, indexPath: indexPath, target: target, selector: #selector(ActionDelegate.willPerformAction(_:)))
case .save:
return Action(accessibilityTitle: CommonStrings.saveTitle, icon: Icon.save, confirmationIcon: Icon.unsave, type: .save, indexPath: indexPath, target: target, selector: #selector(ActionDelegate.willPerformAction(_:)))
case .unsave:
return Action(accessibilityTitle: CommonStrings.accessibilitySavedTitle, icon: Icon.unsave, confirmationIcon: Icon.save, type: .unsave, indexPath: indexPath, target: target, selector: #selector(ActionDelegate.willPerformAction(_:)))
case .share:
return Action(accessibilityTitle: CommonStrings.shareActionTitle, icon: Icon.share, confirmationIcon: nil, type: .share, indexPath: indexPath, target: target, selector: #selector(ActionDelegate.willPerformAction(_:)))
}
}
}
public class ActionsView: SizeThatFitsView, Themeable {
fileprivate let minButtonWidth: CGFloat = 60
var maximumWidth: CGFloat = 0
var buttonWidth: CGFloat = 0
var buttons: [UIButton] = []
var needsSubviews = true
public var theme = Theme.standard
internal var actions: [Action] = [] {
didSet {
activatedIndex = NSNotFound
needsSubviews = true
}
}
fileprivate var activatedIndex = NSNotFound
func expand(_ action: Action) {
guard let index = actions.firstIndex(of: action) else {
return
}
bringSubviewToFront(buttons[index])
activatedIndex = index
setNeedsLayout()
}
public override var frame: CGRect {
didSet {
setNeedsLayout()
}
}
public override var bounds: CGRect {
didSet {
setNeedsLayout()
}
}
public override func sizeThatFits(_ size: CGSize, apply: Bool) -> CGSize {
let superSize = super.sizeThatFits(size, apply: apply)
if apply {
if size.width > 0 && needsSubviews {
createSubviews(for: actions)
needsSubviews = false
}
let isRTL = effectiveUserInterfaceLayoutDirection == .rightToLeft
if activatedIndex == NSNotFound {
let numberOfButtons = CGFloat(subviews.count)
let buttonDelta = min(size.width, maximumWidth) / numberOfButtons
var x: CGFloat = isRTL ? size.width - buttonWidth : 0
for button in buttons {
button.frame = CGRect(x: x, y: 0, width: buttonWidth, height: size.height)
if isRTL {
x -= buttonDelta
} else {
x += buttonDelta
}
}
} else {
var x: CGFloat = isRTL ? size.width : 0 - (buttonWidth * CGFloat(buttons.count - 1))
for button in buttons {
button.clipsToBounds = true
if button.tag == activatedIndex {
button.frame = CGRect(origin: .zero, size: CGSize(width: size.width, height: size.height))
} else {
button.frame = CGRect(x: x, y: 0, width: buttonWidth, height: size.height)
x += buttonWidth
}
}
}
}
let width = superSize.width == UIView.noIntrinsicMetric ? maximumWidth : superSize.width
let height = superSize.height == UIView.noIntrinsicMetric ? 50 : superSize.height
return CGSize(width: width, height: height)
}
func createSubviews(for actions: [Action]) {
for view in subviews {
view.removeFromSuperview()
}
buttons = []
var maxButtonWidth: CGFloat = 0
for (index, action) in actions.enumerated() {
let button = UIButton(type: .custom)
button.setImage(action.icon, for: .normal)
button.titleLabel?.numberOfLines = 1
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 14, bottom: 0, right: 14)
button.tag = index
switch action.type {
case .delete:
button.backgroundColor = theme.colors.destructive
case .share:
button.backgroundColor = theme.colors.secondaryAction
case .save:
button.backgroundColor = theme.colors.link
case .unsave:
button.backgroundColor = theme.colors.link
}
button.imageView?.tintColor = .white
button.addTarget(self, action: #selector(willPerformAction(_:)), for: .touchUpInside)
maxButtonWidth = max(maxButtonWidth, button.intrinsicContentSize.width)
insertSubview(button, at: 0)
buttons.append(button)
}
backgroundColor = buttons.last?.backgroundColor
buttonWidth = max(minButtonWidth, maxButtonWidth)
maximumWidth = buttonWidth * CGFloat(subviews.count)
setNeedsLayout()
}
public weak var delegate: ActionDelegate?
private var activeSender: UIButton?
@objc func willPerformAction(_ sender: UIButton) {
activeSender = sender
let action = actions[sender.tag]
_ = delegate?.willPerformAction(action)
}
func updateConfirmationImage(for action: Action, completion: @escaping () -> Bool) -> Bool {
if let image = action.confirmationIcon {
activeSender?.setImage(image, for: .normal)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) {
_ = completion()
}
} else {
return completion()
}
return true
}
public func apply(theme: Theme) {
self.theme = theme
backgroundColor = theme.colors.baseBackground
}
}

View File

@ -0,0 +1,652 @@
import Foundation
public enum CollectionViewCellSwipeType {
case primary, secondary, none
}
enum CollectionViewCellState {
case idle, open
}
// wrapper around UIBarButtonItem that lets us access systemItem after button creation
public class SystemBarButton: UIBarButtonItem {
var systemItem: UIBarButtonItem.SystemItem?
public convenience init(with barButtonSystemItem: UIBarButtonItem.SystemItem, target: Any?, action: Selector?) {
self.init(barButtonSystemItem: barButtonSystemItem, target: target, action: action)
self.systemItem = barButtonSystemItem
}
}
public protocol CollectionViewEditControllerNavigationDelegate: AnyObject {
func didChangeEditingState(from oldEditingState: EditingState, to newEditingState: EditingState, rightBarButton: UIBarButtonItem?, leftBarButton: UIBarButtonItem?) // same implementation for 2/3
func didSetBatchEditToolbarHidden(_ batchEditToolbarViewController: BatchEditToolbarViewController, isHidden: Bool, with items: [UIButton]) // has default implementation
func newEditingState(for currentEditingState: EditingState, fromEditBarButtonWithSystemItem systemItem: UIBarButtonItem.SystemItem) -> EditingState
func emptyStateDidChange(_ empty: Bool)
var currentTheme: Theme { get }
}
public class CollectionViewEditController: NSObject, UIGestureRecognizerDelegate, ActionDelegate {
let collectionView: UICollectionView
struct SwipeInfo {
let translation: CGFloat
let velocity: CGFloat
let state: SwipeState
}
var swipeInfoByIndexPath: [IndexPath: SwipeInfo] = [:]
var configuredCellsByIndexPath: [IndexPath: SwipeableCell] = [:]
var activeCell: SwipeableCell? {
guard let indexPath = activeIndexPath else {
return nil
}
return collectionView.cellForItem(at: indexPath) as? SwipeableCell
}
public var isActive: Bool {
return activeIndexPath != nil
}
var activeIndexPath: IndexPath? {
didSet {
if activeIndexPath != nil {
editingState = .swiping
} else {
editingState = isCollectionViewEmpty ? .empty : .none
}
}
}
var isRTL: Bool = false
var initialSwipeTranslation: CGFloat = 0
let maxExtension: CGFloat = 10
let panGestureRecognizer: UIPanGestureRecognizer
let longPressGestureRecognizer: UILongPressGestureRecognizer
public init(collectionView: UICollectionView) {
self.collectionView = collectionView
panGestureRecognizer = UIPanGestureRecognizer()
longPressGestureRecognizer = UILongPressGestureRecognizer()
super.init()
panGestureRecognizer.addTarget(self, action: #selector(handlePanGesture))
longPressGestureRecognizer.addTarget(self, action: #selector(handleLongPressGesture))
if let gestureRecognizers = self.collectionView.gestureRecognizers {
var otherGestureRecognizer: UIGestureRecognizer
for gestureRecognizer in gestureRecognizers {
otherGestureRecognizer = gestureRecognizer is UIPanGestureRecognizer ? panGestureRecognizer : longPressGestureRecognizer
gestureRecognizer.require(toFail: otherGestureRecognizer)
}
}
panGestureRecognizer.delegate = self
self.collectionView.addGestureRecognizer(panGestureRecognizer)
longPressGestureRecognizer.delegate = self
longPressGestureRecognizer.minimumPressDuration = 0.05
longPressGestureRecognizer.require(toFail: panGestureRecognizer)
self.collectionView.addGestureRecognizer(longPressGestureRecognizer)
NotificationCenter.default.addObserver(self, selector: #selector(close), name: UIApplication.willResignActiveNotification, object: nil)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
public func swipeTranslationForItem(at indexPath: IndexPath) -> CGFloat? {
return swipeInfoByIndexPath[indexPath]?.translation
}
public func configureSwipeableCell(_ cell: UICollectionViewCell, forItemAt indexPath: IndexPath, layoutOnly: Bool) {
guard
!layoutOnly,
let cell = cell as? SwipeableCell,
cell.isSwipeEnabled else {
return
}
cell.actions = availableActions(at: indexPath)
configuredCellsByIndexPath[indexPath] = cell
guard let info = swipeInfoByIndexPath[indexPath] else {
return
}
cell.swipeState = info.state
cell.actionsView.delegate = self
cell.swipeTranslation = info.translation
}
public func deconfigureSwipeableCell(_ cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
configuredCellsByIndexPath.removeValue(forKey: indexPath)
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer === panGestureRecognizer {
return panGestureRecognizerShouldBegin(panGestureRecognizer)
}
if gestureRecognizer === longPressGestureRecognizer {
return longPressGestureRecognizerShouldBegin(longPressGestureRecognizer)
}
return false
}
public weak var delegate: ActionDelegate?
public func didPerformAction(_ action: Action) -> Bool {
if let cell = activeCell {
return cell.actionsView.updateConfirmationImage(for: action) {
self.delegatePerformingAction(action)
}
}
return self.delegatePerformingAction(action)
}
private func delegatePerformingAction(_ action: Action) -> Bool {
guard action.indexPath == activeIndexPath else {
return self.delegate?.didPerformAction(action) ?? false
}
let activatedAction = action.type == .delete ? action : nil
closeActionPane(with: activatedAction) { (finished) in
_ = self.delegate?.didPerformAction(action)
}
return true
}
public func willPerformAction(_ action: Action) -> Bool {
return delegate?.willPerformAction(action) ?? didPerformAction(action)
}
public func availableActions(at indexPath: IndexPath) -> [Action] {
return delegate?.availableActions(at: indexPath) ?? []
}
func panGestureRecognizerShouldBegin(_ gestureRecognizer: UIPanGestureRecognizer) -> Bool {
var shouldBegin = false
defer {
if !shouldBegin {
closeActionPane()
}
}
guard delegate != nil else {
return shouldBegin
}
let position = gestureRecognizer.location(in: collectionView)
guard let indexPath = collectionView.indexPathForItem(at: position) else {
return shouldBegin
}
let velocity = gestureRecognizer.velocity(in: collectionView)
// Begin only if there's enough x velocity.
if abs(velocity.y) >= abs(velocity.x) {
return shouldBegin
}
defer {
if let indexPath = activeIndexPath {
initialSwipeTranslation = swipeInfoByIndexPath[indexPath]?.translation ?? 0
}
}
isRTL = collectionView.effectiveUserInterfaceLayoutDirection == .rightToLeft
let isOpenSwipe = isRTL ? velocity.x > 0 : velocity.x < 0
if !isOpenSwipe { // only allow closing swipes on active cells
shouldBegin = indexPath == activeIndexPath
return shouldBegin
}
if activeIndexPath != nil && activeIndexPath != indexPath {
closeActionPane()
}
guard activeIndexPath == nil else {
shouldBegin = true
return shouldBegin
}
activeIndexPath = indexPath
guard let cell = activeCell, !cell.actions.isEmpty && cell.isSwipeEnabled else {
activeIndexPath = nil
return shouldBegin
}
shouldBegin = true
return shouldBegin
}
func longPressGestureRecognizerShouldBegin(_ gestureRecognizer: UILongPressGestureRecognizer) -> Bool {
guard let cell = activeCell else {
return false
}
// Don't allow the cancel gesture to recognize if any of the touches are within the actions view.
let numberOfTouches = gestureRecognizer.numberOfTouches
for touchIndex in 0..<numberOfTouches {
let touchLocation = gestureRecognizer.location(ofTouch: touchIndex, in: cell.actionsView)
let touchedActionsView = cell.actionsView.bounds.contains(touchLocation)
return !touchedActionsView
}
return true
}
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UILongPressGestureRecognizer {
return true
}
if gestureRecognizer is UIPanGestureRecognizer {
return otherGestureRecognizer is UILongPressGestureRecognizer
}
return false
}
private lazy var batchEditToolbarViewController: BatchEditToolbarViewController = {
let batchEditToolbarViewController = BatchEditToolbarViewController(nibName: "BatchEditToolbarViewController", bundle: Bundle.wmf)
batchEditToolbarViewController.items = self.batchEditToolbarItems
return batchEditToolbarViewController
}()
public var batchEditToolbarView: UIView {
return self.batchEditToolbarViewController.view
}
@objc func handlePanGesture(_ sender: UIPanGestureRecognizer) {
guard let indexPath = activeIndexPath, let cell = activeCell, cell.isSwipeEnabled else {
return
}
cell.actionsView.delegate = self
let deltaX = sender.translation(in: collectionView).x
let velocityX = sender.velocity(in: collectionView).x
var swipeTranslation = deltaX + initialSwipeTranslation
let normalizedSwipeTranslation = isRTL ? swipeTranslation : -swipeTranslation
let normalizedMaxSwipeTranslation = abs(cell.swipeTranslationWhenOpen)
switch sender.state {
case .began:
cell.swipeState = .swiping
fallthrough
case .changed:
if normalizedSwipeTranslation < 0 {
let normalizedSqrt = maxExtension * log(abs(normalizedSwipeTranslation))
swipeTranslation = isRTL ? 0 - normalizedSqrt : normalizedSqrt
}
if normalizedSwipeTranslation > normalizedMaxSwipeTranslation {
let maxWidth = normalizedMaxSwipeTranslation
let delta = normalizedSwipeTranslation - maxWidth
swipeTranslation = isRTL ? maxWidth + (maxExtension * log(delta)) : 0 - maxWidth - (maxExtension * log(delta))
}
cell.swipeTranslation = swipeTranslation
swipeInfoByIndexPath[indexPath] = SwipeInfo(translation: swipeTranslation, velocity: velocityX, state: .swiping)
case .cancelled:
fallthrough
case .failed:
fallthrough
case .ended:
let isOpen: Bool
let velocityAdjustment = 0.3 * velocityX
if isRTL {
isOpen = swipeTranslation + velocityAdjustment > 0.5 * cell.swipeTranslationWhenOpen
} else {
isOpen = swipeTranslation + velocityAdjustment < 0.5 * cell.swipeTranslationWhenOpen
}
if isOpen {
openActionPane()
} else {
closeActionPane()
}
fallthrough
default:
break
}
}
@objc func handleLongPressGesture(_ sender: UILongPressGestureRecognizer) {
guard activeIndexPath != nil else {
return
}
switch sender.state {
case .ended:
closeActionPane()
default:
break
}
}
var areSwipeActionsDisabled: Bool = false {
didSet {
longPressGestureRecognizer.isEnabled = !areSwipeActionsDisabled
panGestureRecognizer.isEnabled = !areSwipeActionsDisabled
}
}
// MARK: - States
func openActionPane(_ completion: @escaping (Bool) -> Void = {_ in }) {
collectionView.allowsSelection = false
guard let cell = activeCell, let indexPath = activeIndexPath else {
completion(false)
return
}
let targetTranslation = cell.swipeTranslationWhenOpen
let velocity = swipeInfoByIndexPath[indexPath]?.velocity ?? 0
swipeInfoByIndexPath[indexPath] = SwipeInfo(translation: targetTranslation, velocity: velocity, state: .open)
cell.swipeState = .open
animateActionPane(of: cell, to: targetTranslation, with: velocity, completion: completion)
}
func closeActionPane(with expandedAction: Action? = nil, _ completion: @escaping (Bool) -> Void = {_ in }) {
collectionView.allowsSelection = true
guard let cell = activeCell, let indexPath = activeIndexPath else {
completion(false)
return
}
activeIndexPath = nil
let velocity = swipeInfoByIndexPath[indexPath]?.velocity ?? 0
swipeInfoByIndexPath[indexPath] = nil
if let expandedAction = expandedAction {
let translation = isRTL ? cell.bounds.width : 0 - cell.bounds.width
animateActionPane(of: cell, to: translation, with: velocity, expandedAction: expandedAction, completion: { (finished) in
// don't set isSwiping to false so that the expanded action stays visible through the fade
completion(finished)
})
} else {
animateActionPane(of: cell, to: 0, with: velocity, completion: { (finished: Bool) in
cell.swipeState = self.activeIndexPath == indexPath ? .swiping : .closed
completion(finished)
})
}
}
func animateActionPane(of cell: SwipeableCell, to targetTranslation: CGFloat, with swipeVelocity: CGFloat, expandedAction: Action? = nil, completion: @escaping (Bool) -> Void = {_ in }) {
if let action = expandedAction {
UIView.animate(withDuration: 0.3, delay: 0, options: [.allowUserInteraction, .beginFromCurrentState], animations: {
cell.actionsView.expand(action)
cell.swipeTranslation = targetTranslation
cell.layoutIfNeeded()
}, completion: completion)
return
}
let initialSwipeTranslation = cell.swipeTranslation
let animationTranslation = targetTranslation - initialSwipeTranslation
let animationDuration: TimeInterval = 0.3
let distanceInOneSecond = animationTranslation / CGFloat(animationDuration)
let unitSpeed = distanceInOneSecond == 0 ? 0 : swipeVelocity / distanceInOneSecond
UIView.animate(withDuration: animationDuration, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: unitSpeed, options: [.allowUserInteraction, .beginFromCurrentState], animations: {
cell.swipeTranslation = targetTranslation
cell.layoutIfNeeded()
}, completion: completion)
}
// MARK: - Batch editing
public var isShowingDefaultCellOnly: Bool = false {
didSet {
guard oldValue != isShowingDefaultCellOnly else {
return
}
editingState = isCollectionViewEmpty || isShowingDefaultCellOnly ? .empty : .none
}
}
public weak var navigationDelegate: CollectionViewEditControllerNavigationDelegate? {
willSet {
batchEditToolbarViewController.remove()
}
didSet {
guard oldValue !== navigationDelegate else {
return
}
if navigationDelegate == nil {
editingState = .unknown
} else {
editingState = isCollectionViewEmpty || isShowingDefaultCellOnly ? .empty : .none
}
}
}
private var editableCells: [BatchEditableCell] {
guard let editableCells = collectionView.visibleCells as? [BatchEditableCell] else {
return []
}
return editableCells
}
public var isBatchEditing: Bool {
return editingState == .open
}
private var editingState: EditingState = .unknown {
didSet {
guard editingState != oldValue else {
return
}
editingStateDidChange(from: oldValue, to: editingState)
}
}
private func editingStateDidChange(from oldValue: EditingState, to newValue: EditingState) {
let rightBarButtonSystemItem: UIBarButtonItem.SystemItem?
let leftBarButtonSystemItem: UIBarButtonItem.SystemItem?
var isRightBarButtonEnabled = !(isCollectionViewEmpty || isShowingDefaultCellOnly) || shouldShowEditButtonsForEmptyState
switch newValue {
case .editing:
areSwipeActionsDisabled = true
leftBarButtonSystemItem = .cancel
rightBarButtonSystemItem = .done
isRightBarButtonEnabled = true
if oldValue == .open {
transformBatchEditPane(for: editingState)
}
case .swiping:
leftBarButtonSystemItem = nil
rightBarButtonSystemItem = .edit
case .open:
leftBarButtonSystemItem = nil
rightBarButtonSystemItem = .cancel
transformBatchEditPane(for: editingState)
case .closed:
leftBarButtonSystemItem = nil
rightBarButtonSystemItem = .edit
transformBatchEditPane(for: editingState)
case .empty:
leftBarButtonSystemItem = nil
rightBarButtonSystemItem = shouldShowEditButtonsForEmptyState ? .edit : nil
isBatchEditToolbarHidden = true
default:
leftBarButtonSystemItem = nil
rightBarButtonSystemItem = .edit
}
var rightButton: SystemBarButton?
var leftButton: SystemBarButton?
if let barButtonSystemItem = rightBarButtonSystemItem {
rightButton = SystemBarButton(with: barButtonSystemItem, target: self, action: #selector(barButtonPressed(_:)))
}
if let barButtonSystemItem = leftBarButtonSystemItem {
leftButton = SystemBarButton(with: barButtonSystemItem, target: self, action: #selector(barButtonPressed(_:)))
}
leftButton?.tag = editingState.tag
rightButton?.tag = editingState.tag
rightButton?.isEnabled = isRightBarButtonEnabled
let font = rightBarButtonSystemItem != .edit ? UIFont.wmf_font(.semiboldBody) : UIFont.wmf_font(.body)
let attributes = [NSAttributedString.Key.font: font]
rightButton?.setTitleTextAttributes(attributes, for: .normal)
leftButton?.setTitleTextAttributes(attributes, for: .normal)
navigationDelegate?.didChangeEditingState(from: oldValue, to: editingState, rightBarButton: rightButton, leftBarButton: leftButton)
}
private func transformBatchEditPane(for state: EditingState, animated: Bool = true) {
guard !isCollectionViewEmpty else {
return
}
let willOpen = state == .open
areSwipeActionsDisabled = willOpen
collectionView.allowsMultipleSelection = willOpen
isBatchEditToolbarHidden = !willOpen
for cell in editableCells {
guard cell.isBatchEditable else {
continue
}
if animated {
// ensure layout is in the start anim state
cell.isBatchEditing = !willOpen
cell.layoutIfNeeded()
UIView.animate(withDuration: 0.3, delay: 0.1, options: [.allowUserInteraction, .beginFromCurrentState, .curveEaseInOut], animations: {
cell.isBatchEditing = willOpen
cell.layoutIfNeeded()
})
} else {
cell.isBatchEditing = willOpen
cell.layoutIfNeeded()
}
if let themeableCell = cell as? Themeable, let navigationDelegate = navigationDelegate {
themeableCell.apply(theme: navigationDelegate.currentTheme)
}
}
if !willOpen {
selectedIndexPaths.forEach({ collectionView.deselectItem(at: $0, animated: true) })
batchEditToolbarViewController.setItemsEnabled(false)
}
}
@objc public func close() {
guard editingState == .open || editingState == .swiping else {
return
}
if editingState == .swiping {
editingState = .none
} else {
editingState = .closed
}
closeActionPane()
}
private func emptyStateDidChange() {
if isCollectionViewEmpty || isShowingDefaultCellOnly {
editingState = .empty
} else {
editingState = .none
}
navigationDelegate?.emptyStateDidChange(isCollectionViewEmpty)
}
public var isCollectionViewEmpty: Bool = true {
didSet {
guard oldValue != isCollectionViewEmpty else {
return
}
emptyStateDidChange()
}
}
public var shouldShowEditButtonsForEmptyState: Bool = false
@objc private func barButtonPressed(_ sender: SystemBarButton) {
guard let navigationDelegate = navigationDelegate else {
assertionFailure("Unable to set new editing state - navigationDelegate is nil")
return
}
guard let systemItem = sender.systemItem else {
assertionFailure("Unable to set new editing state - systemItem is nil")
return
}
let currentEditingState = editingState
if currentEditingState == .swiping {
closeActionPane()
}
editingState = navigationDelegate.newEditingState(for: currentEditingState, fromEditBarButtonWithSystemItem: systemItem)
}
public func changeEditingState(to newEditingState: EditingState) {
editingState = newEditingState
}
public var isTextEditing: Bool = false {
didSet {
editingState = isTextEditing ? .editing : .done
}
}
public var isClosed: Bool {
let isClosed = editingState != .open
if !isClosed {
batchEditToolbarViewController.setItemsEnabled(!selectedIndexPaths.isEmpty)
}
return isClosed
}
public func transformBatchEditPaneOnScroll() {
transformBatchEditPane(for: editingState, animated: false)
}
private var selectedIndexPaths: [IndexPath] {
return collectionView.indexPathsForSelectedItems ?? []
}
private var isBatchEditToolbarHidden: Bool = true {
didSet {
self.navigationDelegate?.didSetBatchEditToolbarHidden(batchEditToolbarViewController, isHidden: self.isBatchEditToolbarHidden, with: self.batchEditToolbarItems)
}
}
private var batchEditToolbarActions: [BatchEditToolbarAction] {
guard let delegate = delegate, let actions = delegate.availableBatchEditToolbarActions else {
return []
}
return actions
}
@objc public func didPerformBatchEditToolbarAction(with sender: UIBarButtonItem) {
guard let delegate = delegate else {
assertionFailure("delegate should be set by now")
editingState = .closed
return
}
guard let didPerformBatchEditToolbarAction = delegate.didPerformBatchEditToolbarAction else {
assertionFailure("delegate should implement didPerformBatchEditToolbarAction")
editingState = .closed
return
}
let action = batchEditToolbarActions[sender.tag]
didPerformBatchEditToolbarAction(action) { finished in
if finished {
self.editingState = .closed
}
}
}
private lazy var batchEditToolbarItems: [UIButton] = {
var buttons: [UIButton] = []
for (index, action) in batchEditToolbarActions.enumerated() {
let button = UIButton(type: .system)
button.addTarget(self, action: #selector(didPerformBatchEditToolbarAction(with:)), for: .touchUpInside)
button.tag = index
button.setTitle(action.title, for: UIControl.State.normal)
buttons.append(button)
button.isEnabled = false
}
return buttons
}()
}

View File

@ -0,0 +1,221 @@
class ColumnarCollectionViewLayoutSection {
private class ColumnarCollectionViewLayoutColumn {
var frame: CGRect
init(frame: CGRect) {
self.frame = frame
}
func addItem(_ attributes: ColumnarCollectionViewLayoutAttributes) {
frame.size.height += attributes.frame.height
}
var widthForNextItem: CGFloat {
return frame.width
}
var originForNextItem: CGPoint {
return CGPoint(x: frame.minX, y: frame.maxY)
}
func addSpace(_ space: CGFloat) {
frame.size.height += space
}
}
let sectionIndex: Int
var frame: CGRect = .zero
let metrics: ColumnarCollectionViewLayoutMetrics
var headers: [ColumnarCollectionViewLayoutAttributes] = []
var items: [ColumnarCollectionViewLayoutAttributes] = []
var footers: [ColumnarCollectionViewLayoutAttributes] = []
private let columns: [ColumnarCollectionViewLayoutColumn]
private var columnIndexByItemIndex: [Int: Int] = [:]
private var shortestColumnIndex: Int = 0
init(sectionIndex: Int, frame: CGRect, metrics: ColumnarCollectionViewLayoutMetrics, countOfItems: Int) {
let countOfColumns = metrics.countOfColumns
let columnSpacing = metrics.interColumnSpacing
let columnWidth: CGFloat = floor((frame.size.width - (columnSpacing * CGFloat(countOfColumns - 1))) / CGFloat(countOfColumns))
var columns: [ColumnarCollectionViewLayoutColumn] = []
columns.reserveCapacity(countOfColumns)
var x: CGFloat = frame.origin.x
for _ in 0..<countOfColumns {
columns.append(ColumnarCollectionViewLayoutColumn(frame: CGRect(x: x, y: frame.origin.y, width: columnWidth, height: 0)))
x += columnWidth + columnSpacing
}
self.columns = columns
self.frame = frame
self.sectionIndex = sectionIndex
self.metrics = metrics
items.reserveCapacity(countOfItems)
}
private func columnForItem(at itemIndex: Int) -> ColumnarCollectionViewLayoutColumn? {
guard let columnIndex = columnIndexByItemIndex[itemIndex] else {
return nil
}
return columns[columnIndex]
}
private var columnForNextItem: ColumnarCollectionViewLayoutColumn {
let itemIndex = items.count
let columnIndex = shortestColumnIndex
columnIndexByItemIndex[itemIndex] = columnIndex
return columns[columnIndex]
}
var widthForNextItem: CGFloat {
return columnForNextItem.widthForNextItem
}
var widthForNextSupplementaryView: CGFloat {
return frame.size.width
}
var originForNextSupplementaryView: CGPoint {
return CGPoint(x: frame.minX, y: frame.minY)
}
var originForNextItem: CGPoint {
return columnForNextItem.originForNextItem
}
var widthForSupplementaryViews: CGFloat {
return frame.width
}
func addHeader(_ attributes: ColumnarCollectionViewLayoutAttributes) {
headers.append(attributes)
frame.size.height += attributes.frame.size.height
for column in columns {
column.addSpace(attributes.frame.size.height)
}
}
func addItem(_ attributes: ColumnarCollectionViewLayoutAttributes) {
let column = columnForNextItem
if metrics.interItemSpacing > 0 {
column.addSpace(metrics.interItemSpacing)
}
column.addItem(attributes)
items.append(attributes)
if column.frame.height > frame.height {
frame.size.height = column.frame.height
}
updateShortestColumnIndex()
}
func updateShortestColumnIndex() {
guard columns.count > 1 else {
return
}
var minHeight: CGFloat = CGFloat.greatestFiniteMagnitude
for (index, column) in columns.enumerated() {
guard column.frame.height < minHeight else {
continue
}
minHeight = column.frame.height
shortestColumnIndex = index
}
}
func addFooter(_ attributes: ColumnarCollectionViewLayoutAttributes) {
footers.append(attributes)
frame.size.height += attributes.frame.size.height
}
func updateAttributes(at index: Int, in array: [ColumnarCollectionViewLayoutAttributes], with attributes: ColumnarCollectionViewLayoutAttributes) -> CGFloat {
guard array.indices.contains(index) else {
return 0
}
let oldAttributes = array[index]
let newFrame = CGRect(origin: oldAttributes.frame.origin, size: attributes.frame.size)
let deltaY = newFrame.height - oldAttributes.frame.height
guard !deltaY.isEqual(to: 0) else {
return 0
}
oldAttributes.frame = newFrame
return deltaY
}
func translateAttributesBy(_ deltaY: CGFloat, at index: Int, in array: [ColumnarCollectionViewLayoutAttributes]) -> [IndexPath] {
guard !deltaY.isEqual(to: 0), array.indices.contains(index) else {
return []
}
var invalidatedIndexPaths: [IndexPath] = []
for (index, attributes) in array[index..<array.count].enumerated() {
attributes.frame.origin.y += deltaY
invalidatedIndexPaths.append(IndexPath(item: index, section: sectionIndex))
}
return invalidatedIndexPaths
}
func invalidate(_ originalAttributes: ColumnarCollectionViewLayoutAttributes, with attributes: ColumnarCollectionViewLayoutAttributes) -> ColumnarCollectionViewLayoutSectionInvalidationResults {
let index = originalAttributes.indexPath.item
switch originalAttributes.representedElementCategory {
case UICollectionView.ElementCategory.decorationView:
return ColumnarCollectionViewLayoutSectionInvalidationResults.empty
case UICollectionView.ElementCategory.supplementaryView:
switch originalAttributes.representedElementKind {
case UICollectionView.elementKindSectionHeader:
let deltaY = updateAttributes(at: index, in: headers, with: attributes)
frame.size.height += deltaY
var invalidatedHeaderIndexPaths = translateAttributesBy(deltaY, at: index + 1, in: headers)
invalidatedHeaderIndexPaths.append(originalAttributes.indexPath)
let invalidatedItemIndexPaths = translateAttributesBy(deltaY, at: 0, in: items)
let invalidatedFooterIndexPaths = translateAttributesBy(deltaY, at: 0, in: footers)
return ColumnarCollectionViewLayoutSectionInvalidationResults(invalidatedHeaderIndexPaths: invalidatedHeaderIndexPaths, invalidatedItemIndexPaths: invalidatedItemIndexPaths, invalidatedFooterIndexPaths: invalidatedFooterIndexPaths)
case UICollectionView.elementKindSectionFooter:
let deltaY = updateAttributes(at: index, in: footers, with: attributes)
frame.size.height += deltaY
var invalidatedFooterIndexPaths = translateAttributesBy(deltaY, at: index + 1, in: footers)
invalidatedFooterIndexPaths.append(originalAttributes.indexPath)
return ColumnarCollectionViewLayoutSectionInvalidationResults(invalidatedHeaderIndexPaths: [], invalidatedItemIndexPaths: [], invalidatedFooterIndexPaths: invalidatedFooterIndexPaths)
default:
return ColumnarCollectionViewLayoutSectionInvalidationResults.empty
}
default:
var invalidatedItemIndexPaths: [IndexPath] = [originalAttributes.indexPath]
let deltaY = updateAttributes(at: index, in: items, with: attributes)
guard
let columnIndex = columnIndexByItemIndex[index]
else {
return ColumnarCollectionViewLayoutSectionInvalidationResults.empty
}
let column = columns[columnIndex]
column.frame.size.height += deltaY
if column.frame.height > frame.height {
frame.size.height = column.frame.height
}
let nextIndex = index + 1
if items.indices.contains(nextIndex) {
for affectedIndex in nextIndex..<items.count {
guard columnIndexByItemIndex[affectedIndex] == columnIndex else {
continue
}
items[affectedIndex].frame.origin.y += deltaY
invalidatedItemIndexPaths.append(IndexPath(item: affectedIndex, section: sectionIndex))
}
}
updateShortestColumnIndex()
let invalidatedFooterIndexPaths = translateAttributesBy(deltaY, at: 0, in: footers)
return ColumnarCollectionViewLayoutSectionInvalidationResults(invalidatedHeaderIndexPaths: [], invalidatedItemIndexPaths: invalidatedItemIndexPaths, invalidatedFooterIndexPaths: invalidatedFooterIndexPaths)
}
}
func translate(deltaY: CGFloat) -> ColumnarCollectionViewLayoutSectionInvalidationResults {
let invalidatedHeaderIndexPaths = translateAttributesBy(deltaY, at: 0, in: headers)
let invalidatedItemIndexPaths = translateAttributesBy(deltaY, at: 0, in: items)
let invalidatedFooterIndexPaths = translateAttributesBy(deltaY, at: 0, in: footers)
frame.origin.y += deltaY
return ColumnarCollectionViewLayoutSectionInvalidationResults(invalidatedHeaderIndexPaths: invalidatedHeaderIndexPaths, invalidatedItemIndexPaths: invalidatedItemIndexPaths, invalidatedFooterIndexPaths: invalidatedFooterIndexPaths)
}
}

View File

@ -0,0 +1,318 @@
public protocol ColumnarCollectionViewLayoutDelegate {
func collectionView(_ collectionView: UICollectionView, estimatedHeightForItemAt indexPath: IndexPath, forColumnWidth columnWidth: CGFloat) -> ColumnarCollectionViewLayoutHeightEstimate
func collectionView(_ collectionView: UICollectionView, estimatedHeightForHeaderInSection section: Int, forColumnWidth columnWidth: CGFloat) -> ColumnarCollectionViewLayoutHeightEstimate
func collectionView(_ collectionView: UICollectionView, estimatedHeightForFooterInSection section: Int, forColumnWidth columnWidth: CGFloat) -> ColumnarCollectionViewLayoutHeightEstimate
func collectionView(_ collectionView: UICollectionView, shouldShowFooterForSection section: Int) -> Bool
func metrics(with boundsSize: CGSize, readableWidth: CGFloat, layoutMargins: UIEdgeInsets) -> ColumnarCollectionViewLayoutMetrics
}
public class ColumnarCollectionViewLayout: UICollectionViewLayout {
var info: ColumnarCollectionViewLayoutInfo? {
didSet {
oldInfo = oldValue
}
}
var oldInfo: ColumnarCollectionViewLayoutInfo?
var metrics: ColumnarCollectionViewLayoutMetrics?
var isLayoutValid: Bool = false
let defaultColumnWidth: CGFloat = 315
let maxColumnWidth: CGFloat = 740
public var slideInNewContentFromTheTop: Bool = false
public var animateItems: Bool = false
override public class var layoutAttributesClass: Swift.AnyClass {
return ColumnarCollectionViewLayoutAttributes.self
}
override public class var invalidationContextClass: Swift.AnyClass {
return ColumnarCollectionViewLayoutInvalidationContext.self
}
private var delegate: ColumnarCollectionViewLayoutDelegate? {
return collectionView?.delegate as? ColumnarCollectionViewLayoutDelegate
}
override public func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let sections = info?.sections else {
return []
}
var attributes: [UICollectionViewLayoutAttributes] = []
for section in sections {
guard rect.intersects(section.frame) else {
continue
}
for item in section.headers {
guard rect.intersects(item.frame) else {
continue
}
attributes.append(item)
}
for item in section.items {
guard rect.intersects(item.frame) else {
continue
}
attributes.append(item)
}
for item in section.footers {
guard rect.intersects(item.frame) else {
continue
}
attributes.append(item)
}
}
return attributes
}
override public func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return info?.layoutAttributesForItem(at: indexPath)
}
public override func layoutAttributesForDecorationView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return nil
}
public override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return info?.layoutAttributesForSupplementaryView(ofKind: elementKind, at: indexPath)
}
public var itemLayoutMargins: UIEdgeInsets {
guard let metrics = metrics else {
return .zero
}
return metrics.itemLayoutMargins
}
override public var collectionViewContentSize: CGSize {
guard let info = info else {
return .zero
}
return info.contentSize
}
public func layoutHeight(forWidth width: CGFloat) -> CGFloat {
guard let collectionView = collectionView, let delegate = delegate, width >= 1 else {
return 0
}
let oldMetrics = metrics
let newInfo = ColumnarCollectionViewLayoutInfo()
let newMetrics = delegate.metrics(with: CGSize(width: width, height: 100), readableWidth: width, layoutMargins: .zero)
metrics = newMetrics // needs to be set so that layout margins can be queried. probably not the best solution.
newInfo.layout(with: newMetrics, delegate: delegate, collectionView: collectionView, invalidationContext: nil)
metrics = oldMetrics
return newInfo.contentSize.height
}
override public func prepare() {
defer {
super.prepare()
}
guard let collectionView = collectionView else {
return
}
let size = collectionView.bounds.size
guard size.width > 0 && size.height > 0 else {
return
}
let readableWidth: CGFloat = collectionView.readableContentGuide.layoutFrame.size.width
if let metrics = metrics, !metrics.readableWidth.isEqual(to: readableWidth) {
isLayoutValid = false
}
guard let delegate = delegate, !isLayoutValid else {
return
}
let delegateMetrics = delegate.metrics(with: size, readableWidth: readableWidth, layoutMargins: collectionView.layoutMargins)
metrics = delegateMetrics
let newInfo = ColumnarCollectionViewLayoutInfo()
newInfo.layout(with: delegateMetrics, delegate: delegate, collectionView: collectionView, invalidationContext: nil)
info = newInfo
isLayoutValid = true
}
// MARK: - Invalidation
override public func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
defer {
super.invalidateLayout(with: context)
}
guard let context = context as? ColumnarCollectionViewLayoutInvalidationContext else {
return
}
guard context.invalidateEverything || context.invalidateDataSourceCounts || context.boundsDidChange else {
return
}
isLayoutValid = false
}
override public func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
guard let metrics = metrics else {
return true
}
return !newBounds.size.width.isEqual(to: metrics.boundsSize.width)
}
override public func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
let superContext = super.invalidationContext(forBoundsChange: newBounds)
let context = superContext as? ColumnarCollectionViewLayoutInvalidationContext ?? ColumnarCollectionViewLayoutInvalidationContext()
context.boundsDidChange = true
return context
}
override public func shouldInvalidateLayout(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> Bool {
return !preferredAttributes.frame.equalTo(originalAttributes.frame)
}
override public func invalidationContext(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutInvalidationContext {
let superContext = super.invalidationContext(forPreferredLayoutAttributes: preferredAttributes, withOriginalAttributes: originalAttributes)
let context = superContext as? ColumnarCollectionViewLayoutInvalidationContext ?? ColumnarCollectionViewLayoutInvalidationContext()
context.preferredLayoutAttributes = preferredAttributes
context.originalLayoutAttributes = originalAttributes
if let delegate = delegate, let metrics = metrics, let info = info, let collectionView = collectionView {
info.update(with: metrics, invalidationContext: context, delegate: delegate, collectionView: collectionView)
}
return context
}
// MARK: - Animation
var maxNewSection: Int = -1
var newSectionDeltaY: CGFloat = 0
var appearingIndexPaths: Set<IndexPath> = []
var disappearingIndexPaths: Set<IndexPath> = []
override public func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) {
super.prepare(forCollectionViewUpdates: updateItems)
guard animateItems, let info = info else {
appearingIndexPaths.removeAll(keepingCapacity: true)
disappearingIndexPaths.removeAll(keepingCapacity: true)
maxNewSection = -1
newSectionDeltaY = 0
return
}
if slideInNewContentFromTheTop {
var maxSection = -1
for updateItem in updateItems {
guard let after = updateItem.indexPathAfterUpdate, after.item == NSNotFound, updateItem.indexPathBeforeUpdate == nil else {
continue
}
let section: Int = after.section
guard section == maxSection + 1 else {
continue
}
maxSection = section
}
guard maxSection > -1 && maxSection < info.sections.count else {
maxNewSection = -1
return
}
maxNewSection = maxSection
let sectionFrame = info.sections[maxSection].frame
newSectionDeltaY = 0 - sectionFrame.maxY
appearingIndexPaths.removeAll(keepingCapacity: true)
disappearingIndexPaths.removeAll(keepingCapacity: true)
} else {
appearingIndexPaths.removeAll(keepingCapacity: true)
disappearingIndexPaths.removeAll(keepingCapacity: true)
newSectionDeltaY = 0
maxNewSection = -1
for updateItem in updateItems {
if let after = updateItem.indexPathAfterUpdate, updateItem.indexPathBeforeUpdate == nil {
appearingIndexPaths.insert(after)
} else if let before = updateItem.indexPathBeforeUpdate, updateItem.indexPathAfterUpdate == nil {
disappearingIndexPaths.insert(before)
}
}
}
}
private func adjustAttributesIfNecessary(_ attributes: UICollectionViewLayoutAttributes, forItemOrElementAppearingAtIndexPath indexPath: IndexPath) {
guard indexPath.section <= maxNewSection else {
guard animateItems, appearingIndexPaths.contains(indexPath) else {
return
}
attributes.zIndex = -1
attributes.alpha = 0
return
}
attributes.frame.origin.y += newSectionDeltaY
attributes.alpha = 1
}
public override func initialLayoutAttributesForAppearingSupplementaryElement(ofKind elementKind: String, at elementIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard let attributes = super.initialLayoutAttributesForAppearingSupplementaryElement(ofKind: elementKind, at: elementIndexPath) else {
return nil
}
adjustAttributesIfNecessary(attributes, forItemOrElementAppearingAtIndexPath: elementIndexPath)
return attributes
}
public override func initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard let attributes = super.initialLayoutAttributesForAppearingItem(at: itemIndexPath) else {
return nil
}
adjustAttributesIfNecessary(attributes, forItemOrElementAppearingAtIndexPath: itemIndexPath)
return attributes
}
public override func initialLayoutAttributesForAppearingDecorationElement(ofKind elementKind: String, at decorationIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return super.initialLayoutAttributesForAppearingDecorationElement(ofKind: elementKind, at: decorationIndexPath)
}
public override func finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard let attributes = super.finalLayoutAttributesForDisappearingItem(at: itemIndexPath) else {
return nil
}
guard animateItems, disappearingIndexPaths.contains(itemIndexPath) else {
return attributes
}
attributes.zIndex = -1
attributes.alpha = 0
return attributes
}
// MARK: Scroll View
public var currentSection: Int?
public override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
var superTarget = super.targetContentOffset(forProposedContentOffset: proposedContentOffset)
if let currentSection = currentSection,
let oldInfo = oldInfo,
let info = info,
oldInfo.sections.indices.contains(currentSection),
info.sections.indices.contains(currentSection) {
let oldY = oldInfo.sections[currentSection].frame.origin.y
let newY = info.sections[currentSection].frame.origin.y
let deltaY = newY - oldY
superTarget.y += deltaY
}
return superTarget
}
}
extension ColumnarCollectionViewLayout: NSCopying {
public func copy(with zone: NSZone? = nil) -> Any {
let newLayout = ColumnarCollectionViewLayout()
newLayout.info = info
newLayout.oldInfo = oldInfo
newLayout.metrics = metrics
newLayout.isLayoutValid = isLayoutValid
newLayout.slideInNewContentFromTheTop = slideInNewContentFromTheTop
newLayout.animateItems = animateItems
newLayout.maxNewSection = maxNewSection
newLayout.newSectionDeltaY = newSectionDeltaY
newLayout.appearingIndexPaths = appearingIndexPaths
newLayout.disappearingIndexPaths = disappearingIndexPaths
return newLayout
}
}

View File

@ -0,0 +1,175 @@
struct ColumnarCollectionViewLayoutSectionInvalidationResults {
let invalidatedHeaderIndexPaths: [IndexPath]
let invalidatedItemIndexPaths: [IndexPath]
let invalidatedFooterIndexPaths: [IndexPath]
static let empty: ColumnarCollectionViewLayoutSectionInvalidationResults = ColumnarCollectionViewLayoutSectionInvalidationResults(invalidatedHeaderIndexPaths: [], invalidatedItemIndexPaths: [], invalidatedFooterIndexPaths: [])
}
public class ColumnarCollectionViewLayoutInfo {
var sections: [ColumnarCollectionViewLayoutSection] = []
var contentSize: CGSize = .zero
func layout(with metrics: ColumnarCollectionViewLayoutMetrics, delegate: ColumnarCollectionViewLayoutDelegate, collectionView: UICollectionView, invalidationContext context: ColumnarCollectionViewLayoutInvalidationContext?) {
guard let dataSource = collectionView.dataSource else {
return
}
guard let countOfSections = dataSource.numberOfSections?(in: collectionView), countOfSections > 0 else {
return
}
sections.reserveCapacity(countOfSections)
let x = metrics.layoutMargins.left
var y = metrics.layoutMargins.top
let width = metrics.boundsSize.width - metrics.layoutMargins.left - metrics.layoutMargins.right
for sectionIndex in 0..<countOfSections {
let countOfItems = dataSource.collectionView(collectionView, numberOfItemsInSection: sectionIndex)
let section = ColumnarCollectionViewLayoutSection(sectionIndex: sectionIndex, frame: CGRect(x: x, y: y, width: width, height: 0), metrics: metrics, countOfItems: countOfItems)
sections.append(section)
let headerWidth = section.widthForSupplementaryViews
let headerHeightEstimate = delegate.collectionView(collectionView, estimatedHeightForHeaderInSection: sectionIndex, forColumnWidth: headerWidth)
if !headerHeightEstimate.height.isEqual(to: 0) {
let headerAttributes = ColumnarCollectionViewLayoutAttributes(forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, with: IndexPath(item: 0, section: sectionIndex))
headerAttributes.layoutMargins = metrics.itemLayoutMargins
headerAttributes.precalculated = headerHeightEstimate.precalculated
headerAttributes.frame = CGRect(origin: section.originForNextSupplementaryView, size: CGSize(width: headerWidth, height: headerHeightEstimate.height))
headerAttributes.zIndex = -10
section.addHeader(headerAttributes)
}
for itemIndex in 0..<countOfItems {
let indexPath = IndexPath(item: itemIndex, section: sectionIndex)
let itemWidth = section.widthForNextItem
let itemSizeEstimate = delegate.collectionView(collectionView, estimatedHeightForItemAt: indexPath, forColumnWidth: itemWidth)
let itemAttributes = ColumnarCollectionViewLayoutAttributes(forCellWith: indexPath)
itemAttributes.precalculated = itemSizeEstimate.precalculated
itemAttributes.layoutMargins = metrics.itemLayoutMargins
itemAttributes.zIndex = 0
itemAttributes.frame = CGRect(origin: section.originForNextItem, size: CGSize(width: itemWidth, height: itemSizeEstimate.height))
section.addItem(itemAttributes)
}
let footerWidth = section.widthForSupplementaryViews
let footerHeightEstimate = delegate.collectionView(collectionView, estimatedHeightForFooterInSection: sectionIndex, forColumnWidth: footerWidth)
if delegate.collectionView(collectionView, shouldShowFooterForSection: sectionIndex), !footerHeightEstimate.height.isEqual(to: 0) {
let footerAttributes = ColumnarCollectionViewLayoutAttributes(forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, with: IndexPath(item: 0, section: sectionIndex))
footerAttributes.layoutMargins = metrics.itemLayoutMargins
footerAttributes.precalculated = footerHeightEstimate.precalculated
let footerOrigin = CGPoint(x: 0, y: y + section.frame.height)
footerAttributes.frame = CGRect(origin: footerOrigin, size: CGSize(width: width, height: footerHeightEstimate.height))
footerAttributes.zIndex = -10
section.addFooter(footerAttributes)
}
y += section.frame.size.height + metrics.interSectionSpacing
}
y += metrics.layoutMargins.bottom
contentSize = CGSize(width: metrics.boundsSize.width, height: y)
}
func update(with metrics: ColumnarCollectionViewLayoutMetrics, invalidationContext context: ColumnarCollectionViewLayoutInvalidationContext, delegate: ColumnarCollectionViewLayoutDelegate, collectionView: UICollectionView) {
guard let originalAttributes = context.originalLayoutAttributes as? ColumnarCollectionViewLayoutAttributes, let preferredAttributes = context.preferredLayoutAttributes as? ColumnarCollectionViewLayoutAttributes else {
assert(false)
return
}
let indexPath = originalAttributes.indexPath
let sectionIndex = indexPath.section
guard sections.indices.contains(sectionIndex) else {
assert(false)
return
}
let section = sections[sectionIndex]
let oldHeight = section.frame.height
let result = section.invalidate(originalAttributes, with: preferredAttributes)
let newHeight = section.frame.height
let deltaY = newHeight - oldHeight
var invalidatedHeaderIndexPaths: [IndexPath] = result.invalidatedHeaderIndexPaths
var invalidatedItemIndexPaths: [IndexPath] = result.invalidatedItemIndexPaths
var invalidatedFooterIndexPaths: [IndexPath] = result.invalidatedFooterIndexPaths
if !deltaY.isEqual(to: 0) {
contentSize.height += deltaY
let nextSectionIndex = sectionIndex + 1
if nextSectionIndex < sections.count {
for section in sections[nextSectionIndex..<sections.count] {
let result = section.translate(deltaY: deltaY)
invalidatedHeaderIndexPaths.append(contentsOf: result.invalidatedHeaderIndexPaths)
invalidatedItemIndexPaths.append(contentsOf: result.invalidatedItemIndexPaths)
invalidatedFooterIndexPaths.append(contentsOf: result.invalidatedFooterIndexPaths)
}
}
}
if !invalidatedHeaderIndexPaths.isEmpty {
context.invalidateSupplementaryElements(ofKind: UICollectionView.elementKindSectionHeader, at: invalidatedHeaderIndexPaths)
}
if !invalidatedItemIndexPaths.isEmpty {
context.invalidateItems(at: invalidatedItemIndexPaths)
}
if !invalidatedFooterIndexPaths.isEmpty {
context.invalidateSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter, at: invalidatedFooterIndexPaths)
}
}
func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard sections.indices.contains(indexPath.section) else {
return nil
}
let section = sections[indexPath.section]
guard section.items.indices.contains(indexPath.item) else {
return nil
}
return section.items[indexPath.item]
}
public func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard sections.indices.contains(indexPath.section) else {
return nil
}
let section = sections[indexPath.section]
switch elementKind {
case UICollectionView.elementKindSectionHeader:
guard section.headers.indices.contains(indexPath.item) else {
return nil
}
return section.headers[indexPath.item]
case UICollectionView.elementKindSectionFooter:
guard section.footers.indices.contains(indexPath.item) else {
return nil
}
return section.footers[indexPath.item]
default:
return nil
}
}
}
class ColumnarCollectionViewLayoutInvalidationContext: UICollectionViewLayoutInvalidationContext {
var originalLayoutAttributes: UICollectionViewLayoutAttributes?
var preferredLayoutAttributes: UICollectionViewLayoutAttributes?
var boundsDidChange: Bool = false
}
public class ColumnarCollectionViewLayoutAttributes: UICollectionViewLayoutAttributes {
public var precalculated: Bool = false
public var layoutMargins: UIEdgeInsets = .zero
override public func copy(with zone: NSZone? = nil) -> Any {
let copy = super.copy(with: zone)
guard let la = copy as? ColumnarCollectionViewLayoutAttributes else {
return copy
}
la.precalculated = precalculated
la.layoutMargins = layoutMargins
return la
}
}
public struct ColumnarCollectionViewLayoutHeightEstimate {
public var precalculated: Bool
public var height: CGFloat
public init(precalculated: Bool, height: CGFloat) {
self.precalculated = precalculated
self.height = height
}
}

View File

@ -0,0 +1,52 @@
public struct ColumnarCollectionViewLayoutMetrics {
public static let defaultItemLayoutMargins = UIEdgeInsets(top: 10, left: 15, bottom: 10, right: 15) // individual cells on each explore card
public static let defaultExploreItemLayoutMargins = UIEdgeInsets(top: 15, left: 15, bottom: 15, right: 15) // explore card cells
let boundsSize: CGSize
let layoutMargins: UIEdgeInsets
let countOfColumns: Int
let itemLayoutMargins: UIEdgeInsets
let readableWidth: CGFloat
let interSectionSpacing: CGFloat
let interColumnSpacing: CGFloat
let interItemSpacing: CGFloat
var shouldMatchColumnHeights = false
public static func exploreViewMetrics(with boundsSize: CGSize, readableWidth: CGFloat, layoutMargins: UIEdgeInsets) -> ColumnarCollectionViewLayoutMetrics {
let useTwoColumns = boundsSize.width >= 600 || (boundsSize.width > boundsSize.height && readableWidth >= 500)
let countOfColumns = useTwoColumns ? 2 : 1
let interColumnSpacing: CGFloat = useTwoColumns ? 20 : 0
let interItemSpacing: CGFloat = 35
let interSectionSpacing: CGFloat = useTwoColumns ? 20 : 0
let layoutMarginsForMetrics: UIEdgeInsets
let itemLayoutMargins: UIEdgeInsets
let defaultItemMargins = ColumnarCollectionViewLayoutMetrics.defaultExploreItemLayoutMargins
let topAndBottomMargin: CGFloat = 30 // space between top of navigation bar and first section
if useTwoColumns {
let itemMarginWidth = max(defaultItemMargins.left, defaultItemMargins.right)
let marginWidth = max(max(max(layoutMargins.left, layoutMargins.right), round(0.5 * (boundsSize.width - (readableWidth * CGFloat(countOfColumns))))), itemMarginWidth)
layoutMarginsForMetrics = UIEdgeInsets(top: topAndBottomMargin, left: marginWidth - itemMarginWidth, bottom: topAndBottomMargin, right: marginWidth - itemMarginWidth)
itemLayoutMargins = UIEdgeInsets(top: defaultItemMargins.top, left: itemMarginWidth, bottom: defaultItemMargins.bottom, right: itemMarginWidth)
} else {
let marginWidth = max(layoutMargins.left, layoutMargins.right)
itemLayoutMargins = UIEdgeInsets(top: defaultItemMargins.top, left: marginWidth, bottom: defaultItemMargins.bottom, right: marginWidth)
layoutMarginsForMetrics = UIEdgeInsets(top: topAndBottomMargin, left: 0, bottom: topAndBottomMargin, right: 0)
}
return ColumnarCollectionViewLayoutMetrics(boundsSize: boundsSize, layoutMargins: layoutMarginsForMetrics, countOfColumns: countOfColumns, itemLayoutMargins: itemLayoutMargins, readableWidth: readableWidth, interSectionSpacing: interSectionSpacing, interColumnSpacing: interColumnSpacing, interItemSpacing: interItemSpacing, shouldMatchColumnHeights: false)
}
public static func tableViewMetrics(with boundsSize: CGSize, readableWidth: CGFloat, layoutMargins: UIEdgeInsets, interSectionSpacing: CGFloat = 0, interItemSpacing: CGFloat = 0) -> ColumnarCollectionViewLayoutMetrics {
let marginWidth = max(max(layoutMargins.left, layoutMargins.right), round(0.5 * (boundsSize.width - readableWidth)))
var itemLayoutMargins = ColumnarCollectionViewLayoutMetrics.defaultItemLayoutMargins
itemLayoutMargins.left = max(marginWidth, itemLayoutMargins.left)
itemLayoutMargins.right = max(marginWidth, itemLayoutMargins.right)
return ColumnarCollectionViewLayoutMetrics(boundsSize: boundsSize, layoutMargins: .zero, countOfColumns: 1, itemLayoutMargins: itemLayoutMargins, readableWidth: readableWidth, interSectionSpacing: interSectionSpacing, interColumnSpacing: 0, interItemSpacing: interItemSpacing, shouldMatchColumnHeights: false)
}
public static func exploreCardMetrics(with boundsSize: CGSize, readableWidth: CGFloat, layoutMargins: UIEdgeInsets) -> ColumnarCollectionViewLayoutMetrics {
let itemLayoutMargins = ColumnarCollectionViewLayoutMetrics.defaultItemLayoutMargins
return ColumnarCollectionViewLayoutMetrics(boundsSize: boundsSize, layoutMargins: layoutMargins, countOfColumns: 1, itemLayoutMargins: itemLayoutMargins, readableWidth: readableWidth, interSectionSpacing: 0, interColumnSpacing: 0, interItemSpacing: 0, shouldMatchColumnHeights: false)
}
}

View File

@ -0,0 +1,470 @@
import Foundation
// Utilize this class to define localized strings that are used in multiple places in similar contexts.
// There should only be one WMF Localized String function in code for every localization key.
// If the same string value is used in different contexts, use different localization keys.
@objc(WMFCommonStrings)
public class CommonStrings: NSObject {
@objc public static let plainWikipediaName = CommonStrings.plainWikipediaName()
@objc public static func plainWikipediaName(with languageCode: String? = nil) -> String {
WMFLocalizedString("about-wikipedia", languageCode: languageCode, value:"Wikipedia", comment: "Wikipedia {{Identical|Wikipedia}}")
}
@objc public static let articleCountFormat = WMFLocalizedString("places-filter-top-articles-count", value:"{{PLURAL:%1$d|%1$d article|%1$d articles}}", comment: "Describes how many top articles are found in the top articles filter - %1$d is replaced with the number of articles")
@objc public static let readingListCountFormat = WMFLocalizedString("reading-lists-count", value:"{{PLURAL:%1$d|%1$d reading list|%1$d reading lists}}", comment: "Describes the number of reading lists - %1$d is replaced with the number of reading lists")
@objc public static let shortSavedTitle = WMFLocalizedString("action-saved", value: "Saved", comment: "Short title for the save button in the 'Saved' state - Indicates the article is saved. Please use the shortest translation possible. {{Identical|Saved}}")
@objc public static let accessibilitySavedTitle = WMFLocalizedString("action-saved-accessibility", value: "Saved. Activate to unsave.", comment: "Accessibility title for the 'Unsave' action {{Identical|Saved}}")
@objc public static let shortUnsaveTitle = WMFLocalizedString("action-unsave", value: "Unsave", comment: "Short title for the 'Unsave' action. Please use the shortest translation possible. {{Identical|Saved}}")
@objc public static let accessibilityBackTitle = WMFLocalizedString("back-button-accessibility-label", value: "Back", comment: "Accessibility label for a button to navigate back. {{Identical|Back}}")
@objc public static let accessibilitySavedNotification = WMFLocalizedString("action-saved-accessibility-notification", value: "Article saved for later", comment: "Notification spoken after user saves an article for later.")
@objc public static let accessibilityUnsavedNotification = WMFLocalizedString("action-unsaved-accessibility-notification", value: "Article unsaved", comment: "Notification spoken after user removes an article from Saved articles.")
@objc public static func articleDeletedNotification(articleCount: Int) -> String {
return String.localizedStringWithFormat(WMFLocalizedString("article-deleted-accessibility-notification", value: "{{PLURAL:%1$d|article|articles}} deleted", comment: "Notification spoken after user deletes an article from the list. %1$d will be replaced with the number of deleted articles."), articleCount)
}
@objc public static func unsaveArticleAndRemoveFromListsTitle(articleCount: Int) -> String {
return String.localizedStringWithFormat(WMFLocalizedString("saved-unsave-article-and-remove-from-reading-lists-title", value: "Unsave {{PLURAL:%1$d|article|articles}}?", comment: "Title of the alert action that unsaves a selected article and removes it from all associated reading lists. %1$d will be replaced with the number of articles to be unsaved."), articleCount)
}
@objc public static func unsaveArticleAndRemoveFromListsMessage(articleCount: Int) -> String {
return String.localizedStringWithFormat(WMFLocalizedString("saved-unsave-article-and-remove-from-reading-lists-message", value: "Unsaving {{PLURAL:%1$d|this article will remove it|these articles will remove them}} from all associated reading lists", comment: "Message of the alert action that unsaves a selected article and removes it from all associated reading lists. %1$d will be replaced with the number of articles being unsaved."), articleCount)
}
@objc public static let shortSaveTitle = WMFLocalizedString("action-save", value: "Save", comment: "Title for the 'Save' action {{Identical|Save}}")
@objc public static let savedTitle:String = CommonStrings.savedTitle(languageCode: nil)
@objc public static let saveTitle:String = CommonStrings.saveTitle(languageCode: nil)
@objc public static let dimImagesTitle = WMFLocalizedString("dim-images", value: "Dim images", comment: "Label for image dimming setting")
@objc public static let searchTitle = WMFLocalizedString("search-title", value: "Search", comment: "Title for search interface. {{Identical|Search}}")
@objc public static let settingsTitle = WMFLocalizedString("settings-title", value: "Settings", comment: "Title of the view where app settings are displayed. {{Identical|Settings}}")
@objc public static let placesTabTitle = WMFLocalizedString("places-title", value: "Places", comment: "Title of the Places screen shown on the places tab.")
@objc public static let historyTabTitle = WMFLocalizedString("history-title", value: "History", comment: "Title of the history screen shown on history tab {{Identical|History}}")
@objc public static let exploreTabTitle = WMFLocalizedString("home-title", value: "Explore", comment: "Title for home interface. {{Identical|Explore}}")
@objc public static let savedTabTitle = WMFLocalizedString("saved-title", value: "Saved", comment: "Title of the saved screen shown on the saved tab {{Identical|Saved}}")
@objc public static let notificationsCenterTitle = WMFLocalizedString("notifications-center-title", value: "Notifications", comment: "Title for Notifications Center interface, as well as the accessibility label for the button that navigates to Notifications Center.")
@objc public static let notificationsCenterBadgeTitle = WMFLocalizedString("notifications-center-badge-button-accessibility-label", value: "Notifications with unread badge", comment: "Accessibility label for a button that navigates to Notifications Center. This button has a badge indicating there are unread notifications.")
public static let notificationsCenterMarkAsRead = WMFLocalizedString("notifications-center-mark-as-read", value: "Mark as Read", comment: "Button text in Notifications Center to mark a notification as read.")
public static let notificationsCenterMarkAsReadSwipe = WMFLocalizedString("notifications-center-swipe-mark-as-read", value: "Mark as read", comment: "Button text in Notifications Center swipe actions to mark a notification as read.")
public static let notificationsCenterMarkAsUnread = WMFLocalizedString("notifications-center-mark-as-unread", value: "Mark as Unread", comment: "Button text in Notifications Center to mark a notification as unread.")
public static let notificationsCenterMarkAsUnreadSwipe = WMFLocalizedString("notifications-center-swipe-mark-as-unread", value: "Mark as unread", comment: "Button text in Notifications Center swipe actions to mark a notification as unread.")
public static let notificationsCenterAllNotificationsStatus = WMFLocalizedString("notifications-center-status-all", value: "All", comment: "Text to indicate all notifications in Notifications Center.")
public static let notificationsCenterReadNotificationsStatus = WMFLocalizedString("notifications-center-status-read", value: "Read", comment: "Text to indicate a read notification in Notifications Center.")
public static let notificationsCenterUnreadNotificationsStatus = WMFLocalizedString("notifications-center-status-unread", value: "Unread", comment: "Text to indicate an unread notification in Notifications Center.")
public static let notificationsCenterAgentDescriptionFromFormat = WMFLocalizedString("notifications-center-agent-description-from-format", value: "From %1$@", comment: "Text indicating who triggered a notification in notifications center. %1$@ will be replaced with the origin agent of the notification, which could be a username.")
public static let notificationsCenterAlert = WMFLocalizedString("notifications-center-alert", value: "Alert", comment: "Description of various \"alert\" notification types, used on the notifications cell and detail views.")
public static let notificationsCenterNotice = WMFLocalizedString("notifications-center-type-item-description-notice", value: "Notice", comment: "Description of \"notice\" notification types, used on the notification cell and detail views.")
public static let notificationsChangePassword = WMFLocalizedString("notifications-center-change-password", value: "Change password", comment: "Button text in Notifications Center that routes user to change password screen.")
public static let notificationsCenterDestinationWeb = WMFLocalizedString("notifications-center-destination-web", value: "On web", comment: "Informational text next to each notification center action on the detail screen, informing the user that the action will take them to a web view or outside of the app.")
public static let notificationsCenterDestinationApp = WMFLocalizedString("notifications-center-destination-app", value: "In app", comment: "Informational text next to each notification center action on the detail screen, informing the user that the action will take them to a native view within the app.")
public static let notificationsCenterLoginSuccessDescription = WMFLocalizedString("notifications-center-subheader-login-success-unknown-device", value: "Login from an unfamiliar device", comment: "Subtitle text for 'Successful login from an unknown device' notifications in Notifications Center and filters.")
public static let notificationsCenterUserTalkPageMessage = WMFLocalizedString("notifications-center-type-title-user-talk-page-messsage", value: "Talk page message", comment: "Title of \"user talk page message\" notification type. Used on filters view toggles and the notification detail view.")
public static let notificationsCenterPageReviewed = WMFLocalizedString("notifications-center-type-title-page-review", value: "Page review", comment: "Title of \"page review\" notification type. Used on filters view toggles and the notification detail view.")
public static let notificationsCenterPageLinked =
WMFLocalizedString("notifications-center-type-title-page-link", value: "Page link", comment: "Title of \"page link\" notification type. Used on filters view toggles and the notification detail view.")
public static let notificationsCenterConnectionWithWikidata = WMFLocalizedString("notifications-center-type-title-connection-with-wikidata", value: "Connection with Wikidata", comment: "Title of \"connection with Wikidata\" notification type. Used on filters view toggles and the notification detail view.")
public static let notificationsCenterEmailFromOtherUser = WMFLocalizedString("notifications-center-type-title-email-from-other-user", value: "Email from other user", comment: "Title of \"email from other user\" notification type. Used on filters view toggles and the notification detail view.")
public static let notificationsCenterMentionInTalkPage = WMFLocalizedString("notifications-center-type-title-talk-page-mention", value: "Talk page mention", comment: "Title of \"talk page mention\" notification type. Used on filters view toggles and the notification detail view.")
public static let notificationsCenterMentionInEditSummary = WMFLocalizedString("notifications-center-type-title-edit-summary-mention", value: "Edit summary mention", comment: "Title of \"edit summary mention\" notification type. Used on filters view toggles and the notification detail view.")
public static let notificationsCenterSuccessfulMention = WMFLocalizedString("notifications-center-type-title-sent-mention-success", value: "Sent mention success", comment: "Title of \"sent mention success\" notification type. Used on filters view toggles and the notification detail view.")
public static let notificationsCenterFailedMention = WMFLocalizedString("notifications-center-type-title-sent-mention-failure", value: "Sent mention failure", comment: "Title of \"sent mention failure\" notification type. Used on filters view toggles and the notification detail view.")
public static let notificationsCenterUserRightsChange = WMFLocalizedString("notifications-center-type-title-user-rights-change", value: "User rights change", comment: "Title of \"user rights change\" notification type. Used on filters view toggles and the notification detail view.")
public static let notificationsCenterEditReverted = WMFLocalizedString("notifications-center-type-title-edit-reverted", value: "Edit reverted", comment: "Title of \"edit reverted\" notification type. Used on filters view toggles and the notification detail view.")
public static let notificationsCenterLoginAttempts = WMFLocalizedString("notifications-center-type-title-login-attempts", value: "Login attempts", comment: "Title of \"Login attempts\" notification type. Used on filters view toggles and the notification detail view. Represents failed logins from both a known and unknown device.")
public static let notificationsCenterLoginSuccess = WMFLocalizedString("notifications-center-type-title-login-success", value: "Login success", comment: "Title of \"login success\" notification type. Used on filters view toggles and the notification detail view. Represents successful logins from an unknown device.")
public static let notificationsCenterEditMilestone = WMFLocalizedString("notifications-center-type-title-edit-milestone", value: "Edit milestone", comment: "Title of \"edit milestone\" notification type. Used on filters view toggles and the notification detail view.")
public static let notificationsCenterTranslationMilestone = WMFLocalizedString("notifications-center-type-title-translation-milestone", value: "Translation milestone", comment: "Title of \"translation milestone\" notification type. Used on filters view toggles and the notification detail view.")
public static let notificationsCenterThanks = WMFLocalizedString("notifications-center-type-title-thanks", value: "Thanks", comment: "Title of \"thanks\" notification type. Used on filters view toggles and the notification detail view.")
public static let notificationsCenterWelcome = WMFLocalizedString("notifications-center-type-title-welcome", value: "Welcome", comment: "Title of \"welcome\" notification type. Used on filters view toggles and the notification detail view.")
public static let notificationsCenterOtherFilter = WMFLocalizedString("notifications-center-type-title-other", value: "Other", comment: "Title of \"other\" notifications filter. Used on filter toggles.")
@objc public static let exploreFeedTitle = WMFLocalizedString("welcome-exploration-explore-feed-title", value:"Explore feed", comment:"Title for Explore feed")
@objc public static let featuredArticleTitle = WMFLocalizedString("explore-featured-article-heading", value: "Featured article", comment: "Text for 'Featured article' header")
@objc public static let onThisDayTitle = CommonStrings.onThisDayTitle()
@objc public static func onThisDayTitle(with languageCode: String? = nil) -> String {
WMFLocalizedString("on-this-day-title", languageCode: languageCode, value: "On this day", comment: "Title for the 'On this day' feed section")
}
@objc public static let topReadTitle = WMFLocalizedString("places-filter-top-articles", value:"Top read", comment: "Title of places search filter that searches top articles")
@objc public static let pictureOfTheDayTitle = WMFLocalizedString("explore-potd-heading", value: "Picture of the day", comment: "Text for 'Picture of the day' header")
@objc public static let randomizerTitle = WMFLocalizedString("explore-randomizer", value: "Randomizer", comment: "Displayed on a button that loads another random article - it's a 'Randomizer'")
@objc public static let languagesTitle = WMFLocalizedString("languages-settings-title", value: "Languages", comment: "Title for the 'Languages' section in Settings")
@objc public static let relatedPagesTitle = WMFLocalizedString("explore-because-you-read", value: "Because you read", comment: "Text for 'Because you read' header")
@objc public static let continueReadingTitle = WMFLocalizedString("explore-continue-reading-heading", value: "Continue reading", comment: "Text for 'Continue Reading' header")
@objc public static let hideCardTitle = WMFLocalizedString("explore-hide-card-prompt", value: "Hide this card", comment: "Title of button shown for users to confirm the hiding of a suggestion in the explore feed")
@objc static public func savedTitle(languageCode: String?) -> String {
return WMFLocalizedString("button-saved-for-later", languageCode: languageCode, value: "Saved for later", comment: "Longer button text for already saved button used in various places.")
}
@objc static public func saveTitle(languageCode: String?) -> String {
return WMFLocalizedString("button-save-for-later", languageCode: languageCode, value: "Save for later", comment: "Longer button text for save button used in various places.")
}
@objc public static let shortShareTitle = WMFLocalizedString("action-share", value: "Share", comment: "Short title for the 'Share' action. Please use the shortest translation possible. {{Identical|Share}}")
@objc public static let accessibilityShareTitle = WMFLocalizedString("action-share-accessibility", value: "Share", comment: "Accessibility title for the 'Share' action")
@objc public static let accessibilityLanguagesTitle = WMFLocalizedString("action-language-accessibility", value: "Change language", comment: "Accessibility title for the 'Language' toolbar button on articles and talk pages.")
@objc public static let shortReadTitle = WMFLocalizedString("action-read", value: "Read", comment: "Title for the 'Read' action\n{{Identical|Read}}")
@objc public static let dismissButtonTitle = WMFLocalizedString("announcements-dismiss", value: "Dismiss", comment: "Button text indicating a user wants to dismiss an announcement {{Identical|No thanks}}")
@objc public static let textSizeSliderAccessibilityLabel = WMFLocalizedString("reading-themes-controls-accessibility-text-size-slider", value: "Text size slider", comment: "Accessibility label for the text size slider that adjusts article text size.")
@objc public static let deleteActionTitle = WMFLocalizedString("article-delete", value: "Delete", comment: "Title of the action that deletes the selected articles article.")
@objc public static let removeActionTitle = WMFLocalizedString("action-remove", value: "Remove", comment: "Title of the action that removes the selection from the current context.")
@objc public static let createNewListTitle = WMFLocalizedString("reading-list-create-new-list-title", value: "Create a new list", comment: "Title for the view in charge of creating a new reading list.")
@objc public static let moveToReadingListActionTitle = WMFLocalizedString("action-move-to-reading-list", value: "Move to reading list", comment: "Title of the action that moves the selected articles to another reading list")
@objc public static let addToReadingListActionTitle = WMFLocalizedString("action-add-to-reading-list", value: "Add to reading list", comment: "Title of the action that adds selected articles to a reading list")
@objc public static let addToReadingListShortActionTitle = WMFLocalizedString("action-add-to-reading-list-short", value: "Add to list", comment: "Shorter title for the action that adds selected articles to a reading list")
@objc public static let moveToActionTitle = WMFLocalizedString("action-move-to", value: "Move to…", comment: "Title of the action that moves the selection elsewhere.")
@objc public static let addToActionTitle = WMFLocalizedString("action-add-to", value: "Add to…", comment: "Title of the action that adds the selection to something else.")
@objc public static let shareActionTitle = WMFLocalizedString("article-share", value: "Share", comment: "Text of the article list row action shown on swipe which allows the user to choose the sharing option")
public static let shareMenuTitle = WMFLocalizedString("share-menu-item", value: "Share…", comment:"'Share…' menu item with ellipsis to indicate further actions are required.")
@objc public static let updateActionTitle = WMFLocalizedString("action-update", value: "Update", comment: "Title of the update action.")
@objc public static let cancelActionTitle = WMFLocalizedString("action-cancel", value: "Cancel", comment: "Title of the cancel action.")
@objc public static let retryActionTitle = WMFLocalizedString("action-retry", value: "Retry", comment: "Title of the retry action.")
@objc public static let discardEditsActionTitle = WMFLocalizedString("action-discard-edits", value: "Discard edits", comment: "Title of the discard edits action.")
@objc public static let sortActionTitle = WMFLocalizedString("action-sort", value: "Sort", comment: "Title of the sort action.")
@objc public static let sortAlertTitle = WMFLocalizedString("reading-lists-sort-saved-articles", value: "Sort saved articles", comment: "Title of the alert that allows sorting saved articles.")
@objc public static let nextTitle = WMFLocalizedString("button-next", value: "Next", comment: "Button text for next button used in various places. {{Identical|Next}}")
@objc public static let skipTitle = WMFLocalizedString("button-skip", value: "Skip", comment: "Button text for skip button used in various places.")
@objc public static let okTitle = WMFLocalizedString("button-ok", value: "OK", comment: "Button text for ok button used in various places {{Identical|OK}}")
@objc public static let doneTitle = WMFLocalizedString("description-published-button-title", value: "Done", comment: "Title for description panel done button.")
public static let goBackTitle = WMFLocalizedString("button-go-back", value: "Go back", comment: "Button text for Go back button used in various places")
public static let publishAnywayTitle = WMFLocalizedString("button-publish-anyway", value: "Publish anyway", comment: "Button text for publish button used when first warned against publishing.")
@objc public static let editNotices = WMFLocalizedString("edit-notices", value: "Edit notices", comment: "Title text and accessibility label for edit notices button.")
@objc public static let undo = WMFLocalizedString("action-undo", value: "Undo", comment: "Title text and accessibility label for undo action on buttons or info sheets.")
@objc public static let redo = WMFLocalizedString("action-redo", value: "Redo", comment: "Title text and accessibility label for redo action on buttons or info sheets.")
@objc public static let findInPage = WMFLocalizedString("action-find-in-page", value: "Find in page", comment: "Title text and accessibility label for find in page action on buttons or info sheets.")
@objc public static let readingThemesControls = WMFLocalizedString("article-toolbar-reading-themes-controls-toolbar-item", value: "Reading Themes Controls", comment: "Accessibility label for the Reading Themes Controls article toolbar item")
public static let welcomePromiseTitle = WMFLocalizedString("description-welcome-promise-title", value:"By starting, I promise not to misuse this feature", comment:"Title text asking user to edit descriptions responsibly")
@objc public static let gotItButtonTitle = WMFLocalizedString("welcome-explore-tell-me-more-done-button", value: "Got it", comment:"Text for button dismissing detailed explanation of new features")
public static let getStartedTitle = WMFLocalizedString("welcome-explore-continue-button", value:"Get started", comment:"Text for button for dismissing welcome screens {{Identical|Get started}}")
@objc public static let privacyPolicyURLString = "https://foundation.m.wikimedia.org/wiki/Privacy_policy"
@objc public static let account = WMFLocalizedString("settings-account", value: "Account", comment: "Title for button and page letting user view their account page.")
@objc public static let myLanguages = WMFLocalizedString("settings-my-languages", value: "My languages", comment: "Title for list of user's preferred languages")
@objc public static let readingPreferences = WMFLocalizedString("settings-appearance", value: "Reading preferences", comment: "Title of the reading preferences screen.")
@objc public static let pushNotifications = WMFLocalizedString("settings-notifications", value: "Push notifications", comment: "Title for view and button letting users change their push notifications settings.")
public static let tryAgain = WMFLocalizedString("settings-notifications-echo-failure-try-again", value: "Try again", comment: "Text alerting the user to try action again after error")
@objc public static let settingsStorageAndSyncing = WMFLocalizedString("settings-storage-and-syncing-title", value: "Article storage and syncing", comment: "Title of the saved articles storage and syncing settings screen")
@objc public static let inTheNewsTitle = WMFLocalizedString("in-the-news-title", value:"In the news", comment:"Title for the 'In the news' notification & feed section")
@objc public static let wikipediaLanguages = WMFLocalizedString("languages-wikipedia", value: "Wikipedia languages", comment: "Title for list of Wikipedia languages")
@objc public static let unknownError = WMFLocalizedString("error-unknown", value: "An unknown error occurred", comment: "Message displayed when an unknown error occurred")
@objc public static let readingListsDefaultListTitle = WMFLocalizedString("reading-lists-default-list-title", value: "Saved", comment: "The title of the default saved pages list {{Identical|Saved}}")
@objc public static let localizedEnableLocationTitle = WMFLocalizedString("places-enable-location-title", value:"Explore articles near your location by enabling Location Access", comment:"Explains that you can explore articles near you by enabling location access. \"Location\" should be the same term, which is used in the device settings, under \"Privacy\".")
@objc public static let localizedEnableLocationExploreTitle = WMFLocalizedString("explore-enable-location-title", value:"Explore articles near your current location", comment:"Explains that you can explore articles near your current location. \"Location\" should be the same term, which is used in the device settings, under \"Privacy\".")
@objc public static let localizedEnableLocationDescription = WMFLocalizedString("places-enable-location-description", value:"Access to your location is available only when the app or one of its features is visible on your screen.", comment:"Describes that access to your location is only used when the app or one of its features is on the screen")
@objc public static let localizedEnableLocationButtonTitle = WMFLocalizedString("places-enable-location-action-button-title", value:"Enable location", comment:"Button title to enable location access")
@objc public static let nearbyFooterTitle = WMFLocalizedString("home-nearby-footer", value: "More places near your location", comment: "Footer for presenting user option to see longer list of nearby articles.")
@objc public static let readingListLoginSubtitle = WMFLocalizedString("reading-list-login-subtitle", value:"Log in or create an account to allow your saved articles and reading lists to be synced across devices and saved to your user preferences.", comment:"Subtitle explaining that saved articles and reading lists can be synced across Wikipedia apps.")
@objc public static let readingListLoginButtonTitle = WMFLocalizedString("reading-list-login-button-title", value:"Log in to sync your saved articles", comment:"Title for button to login to sync saved articles and reading lists.")
@objc public static let readingListDoNotKeepSubtitle = WMFLocalizedString("reading-list-do-not-keep-button-title", value:"No, delete articles from device", comment:"Title for button to remove saved articles from device.")
@objc public static let readingListsDefaultListDescription = WMFLocalizedString("reading-lists-default-list-description", value: "Default list for your saved articles", comment: "The description of the default saved pages list.")
@objc public static let readingListsEntryLimitReachedFormat = WMFLocalizedString("reading-list-entry-limit-reached", value: "{{PLURAL:%1$d|Article|Articles}} cannot be added to this list. You have reached the limit of %2$d articles per reading list for %3$@", comment: "Informs the user that adding the selected articles to their reading list would put them over the limit. %1$d will be replaced with the number of articles the user is trying to add. %2$d will be replaced with the maximum number of articles allowed per list. %3$@ will be replaced with the name of the list.")
@objc public static let readingListsListLimitReachedFormat = WMFLocalizedString("reading-list-list-limit-reached", value: "You have reached the limit of %1$d reading lists per account", comment: "Informs the user that they have reached the allowed limit of reading lists per account. %1$d will be replaced with the maximum number of allowed reading lists")
@objc public static let eraseAllSavedArticles = WMFLocalizedString("settings-storage-and-syncing-erase-saved-articles-title", value: "Erase saved articles", comment: "Title of the settings option that enables erasing saved articles")
@objc public static let keepSavedArticlesOnDeviceMessage = WMFLocalizedString("reading-list-keep-subtitle", value: "There are articles synced to your Wikipedia account. Would you like to keep them on this device after you log out?", comment: "Subtitle asking if synced articles should be kept on device after logout.")
@objc public static let closeButtonAccessibilityLabel = WMFLocalizedString("close-button-accessibility-label", value: "Close", comment: "Accessibility label for a button that closes a dialog. {{Identical|Close}}")
@objc public static let onTitle = WMFLocalizedString("explore-feed-preferences-feed-card-visibility-global-cards-on", value: "On", comment: "Text for Explore feed card setting indicating that the global feed card is active")
@objc public static let onAllTitle = WMFLocalizedString("explore-feed-preferences-feed-card-visibility-all-languages-on", value: "On all", comment: "Text for Explore feed card setting indicating that the feed card is active in all preferred languages")
@objc public static let offTitle = WMFLocalizedString("explore-feed-preferences-feed-card-visibility-all-languages-off", value: "Off", comment: "Text for Explore feed card setting indicating that the feed card is hidden in all preferred languages")
@objc public static func onTitle(_ count: Int) -> String {
return String.localizedStringWithFormat(WMFLocalizedString("explore-feed-preferences-feed-card-visibility-languages-count", value:"On %1$d", comment: "Text for Explore feed card setting indicating the number of languages it's visible in - %1$d is replaced with the number of languages"), count)
}
@objc public static let turnOnExploreTabTitle = WMFLocalizedString("explore-feed-preferences-turn-on-explore-tab-title", value: "Turn on the Explore tab?", comment: "Title for alert that allows users to turn on the Explore tab")
@objc public static let turnOnExploreActionTitle = WMFLocalizedString("explore-feed-preferences-turn-on-explore-tab-action-title", value: "Turn on Explore", comment: "Title for action that allows users to turn on the Explore tab")
@objc public static let customizeExploreFeedTitle = WMFLocalizedString("explore-feed-preferences-customize-explore-feed-action-title", value: "Customize Explore feed", comment: "Title for action that allows users to go to the Explore feed settings screen")
@objc public static let revertedEditTitle = WMFLocalizedString("reverted-edit-title", value: "Reverted edit", comment: "Title for notification informing user that their edit was reverted.")
@objc public static let noInternetConnection = WMFLocalizedString("no-internet-connection", value: "No internet connection", comment: "String used in various places to indicate no internet connection")
@objc public static let noEmailClient = WMFLocalizedString("no-email-account-alert", value: "Please setup an email account on your device and try again.", comment: "Displayed to the user when they try to send a feedback email, but they have never set up an account on their device")
@objc public static let vanishAccount = WMFLocalizedString("account-request-vanishing", value: "Vanish account", comment: "This will initiate the process of requesting your account to be vanished ")
@objc public static var usernameFieldTitle = WMFLocalizedString("vanish-account-username-field", value: "Username and user page", comment: "Title for the username and userpage form field")
@objc public static let learnMoreButtonText = WMFLocalizedString("vanish-account-learn-more-text", value: "Learn more", comment: "Text for button on vanish account request screen that redirects to the meta page about the process")
// REMINDER: do not delete the app store strings below. We're not using them anywhere within the app itself but we need them to remain so they get upstreamed into TWN. ("localizations.swift copies the non-EN translations of these strings into respective Fastlane "Localized Metadata" files. See: https://docs.fastlane.tools/actions/deliver/)
@objc public static let appStoreSubtitle = WMFLocalizedString("app-store-subtitle", value: "The free encyclopedia", comment: "Subtitle describing the app for the app store")
@objc public static let appStoreShortDescription = WMFLocalizedString("app-store-short-description", value: "Download the Wikipedia app to explore places near you, sync articles to read offline and customize your reading experience.", comment: "Short description of the app for the app store")
@objc public static let appStoreReleaseNotes = WMFLocalizedString("app-store-release-notes", value: "Fully customizable and easier to read Explore feed. Localization, performance improvements and bug fixes.", comment: "Short summary of what is new in this version of the app for the app store")
@objc public static let appStoreKeywords = WMFLocalizedString("app-store-keywords", value: "Wikipedia, reference, wiki, encyclopedia, info, knowledge, research, information, explore, learn", comment: "Short list of keywords describing the app for the app store. It is required that these are individual words, not phrases, and are comma separated.")
@objc public static let editAttribution = WMFLocalizedString("wikitext-upload-save-anonymously-warning", value: "Edits will be attributed to the IP address of your device. If you %1$@ you will have more privacy.", comment: "Button sub-text informing user or draw-backs of not signing in before saving wikitext. Parameters:\n* %1$@ - sign in button text")
@objc public static let editSignIn = WMFLocalizedString("wikitext-upload-save-sign-in", value: "Log in", comment: "{{Identical|Log in}}")
public static let genericErrorDescription = WMFLocalizedString("fetcher-error-generic", value: "Something went wrong. Please try again later.", comment: "Error shown to the user for generic errors with no clear recovery steps for the user.")
public static let insertMediaTitle = WMFLocalizedString("insert-media-title", value: "Insert media", comment: "Title for the view in charge of inserting media into an article")
public static let publishTitle = WMFLocalizedString("button-publish", value: "Publish", comment: "Button text for publish button used in various places. Please prioritize for de, ar and zh wikis. {{Identical|Publish}}")
public static let logoutTitle = WMFLocalizedString("main-menu-account-logout", value: "Log out", comment: "Button text for logging out.")
public static let insertLinkTitle = WMFLocalizedString("insert-link-title", value: "Insert link", comment: "Title for the Insert link screen")
public static let editLinkTitle = WMFLocalizedString("edit-link-title", value: "Edit link", comment: "Title for the Edit link screen")
public static let readStatusAccessibilityLabel = WMFLocalizedString("talk-page-discussion-read-accessibility-label", value: "Read", comment: "Accessibility text for indicating that some content have been read.")
public static let unreadStatusAccessibilityLabel = WMFLocalizedString("talk-page-discussion-unread-accessibility-label", value: "Unread", comment: "Accessibility text for indicating that some content have not been read.")
public static let talkPageNewBannerTitle = WMFLocalizedString("talk-page-new-banner-title", value: "Please be kind", comment: "Title text on banner that appears once user posts a new reply or discussion topic on their talk page.")
public static let talkPageNewBannerSubtitle = WMFLocalizedString("talk-page-new-banner-subtitle", value: "Remember, we are all humans here", comment: "Subtitle text on banner that appears once user posts a new reply or discussion topic on their talk page.")
public static func talkPageTitleUserTalk(languageCode: String?) -> String {
WMFLocalizedString("talk-page-title-user-talk", languageCode: languageCode, value: "User Talk", comment: "This title label is displayed at the top of a talk page topic list, if the talk page type is a user talk page. Please prioritize for de, ar and zh wikis.")
}
public static func talkPageTitleArticleTalk(languageCode: String?) -> String {
WMFLocalizedString("talk-page-title-article-talk", languageCode: languageCode, value: "Article Talk", comment: "This title label is displayed at the top of a talk page topic list, if the talk page type is an article talk page. Please prioritize for de, ar and zh wikis.")
}
public static let accessibilityClearTitle = WMFLocalizedString("clear-title-accessibility-label", value: "Clear", comment: "Accessibility label title for action that clears text")
public static let successfullyPublishedDiscussion = WMFLocalizedString("talk-page-new-topic-success-text", value: "Your discussion was successfully published", comment: "Banner text that appears after a new discussion was successfully published on a talk page.")
public static let successfullyPublishedReply = WMFLocalizedString("talk-page-new-reply-success-text", value: "Your reply was successfully published", comment: "Banner text that appears after a new reply was successfully published on a talk page discussion.")
public static func talkPageReply(languageCode: String?) -> String {
WMFLocalizedString("talk-page-reply-button", languageCode: languageCode, value: "Reply", comment: "Text used on button to reply to talk page messages. Please prioritize for de, ar and zh wikis.")
}
@objc public static let talkPageReplyAccessibilityText = WMFLocalizedString("talk-page-reply-button-accessibility-label", value: "Reply to %@", comment: "Accessibility text for reply button. The %@ will be replaced with the name of the user whose comment is being responded")
public static let revisionHistory = WMFLocalizedString("talk-page-revision-history", value: "Revision history", comment: "Title for option that leads to talk pages revision history. Please prioritize for de, ar and zh wikis.")
public static let defaultThemeDisplayName = WMFLocalizedString("theme-default-display-name", value: "Default", comment: "Default theme name presented to the user")
public static let diffSingleLineFormat = WMFLocalizedString("diff-single-line-format", value:"Line %1$d", comment:"Label in diff to indicate how many lines a change section encompases. This format is for a single change line. %1$d is replaced by the change line number.")
public static let diffMultiLineFormat = WMFLocalizedString("diff-multi-line-format", value:"Lines %1$d - %2$d", comment:"Label in diff to indicate how many lines a change section encompases. This format is for multiple change lines. %1$d is replaced by the starting line number and %2$d is replaced by the ending line number.")
public static let compareTitle = WMFLocalizedString("page-history-compare-title", value: "Compare", comment: "Title for action button that allows users to contrast different items")
public static let maxRevisionsSelectedWarningTitle = WMFLocalizedString("page-history-revisions-comparison-warning", value: "Only two revisions can be selected", comment: "Text telling the user how many revisions can be selected for comparison")
public static let loginOrCreateAccountTitle = WMFLocalizedString("reading-list-login-or-create-account-button-title", value:"Log in or create account", comment:"Title for button to login or create account.")
@objc public static let diffErrorTitle = WMFLocalizedString("diff-revision-error-title", value: "Unable to load revision", comment: "Text for placeholder label visible when there has been an error while fetching the diff.")
@objc public static let minorEditTitle = WMFLocalizedString("page-history-revision-minor-edit-accessibility-label", value: "Minor edit", comment: "Accessibility label text used if edit was minor")
@objc public static let authorTitle = WMFLocalizedString("page-history-revision-author-accessibility-label", value: "Author: %@", comment: "Accessibility label text telling the user who authored a revision. %@ is replaced with the author.")
@objc public static let unknownTitle = WMFLocalizedString("unknown-generic-text", value: "Unknown", comment: "Default text used in places where no contextual information is provided")
public static func aboutThisArticleTitle(with languageCode: String) -> String {
return WMFLocalizedString("article-about-title", languageCode: languageCode, value: "About this article", comment: "The text that is displayed before the 'about' section at the bottom of an article")
}
public static func readMoreTitle(with languageCode: String) -> String {
return WMFLocalizedString("article-read-more-title", languageCode: languageCode, value: "Read more", comment: "The text that is displayed before the read more section at the bottom of an article {{Identical|Read more}}")
}
public static let revisionMadeFormat = WMFLocalizedString("page-history-revision-time-accessibility-label", value: "Revision made %@", comment: "Label text telling the user what time revision was made - %@ is replaced with the time")
public static let compareRevisionsTitle = WMFLocalizedString("diff-compare-header-heading", value: "Compare Revisions", comment: "Heading label in header when comparing two revisions.")
// Article As A Living Doucment Strings - for some reason build script doesn't auto generate these when used directly in SignificantEventsViewModels.swift
public static let viewFullHistoryText = WMFLocalizedString("aaald-view-full-history-button", value: "View full article history", comment: "Text displayed in a button for pushing to the full article history view on the article as a living document screen.")
static let smallChangeDescription = WMFLocalizedString("aaald-small-change-description",
value:"{{PLURAL:%1$d|0=No small changes made|%1$d small change made|%1$d small changes made}}",
comment:"Describes how many small changes are batched together in the article as a living document timeline view. %1$d is replaced with the number of small changes.")
static let newTalkTopicDescriptionFormat = WMFLocalizedString("aaald-new-talk-topic-description-format", value: "%1$@ about this article", comment: "Title displayed in an article as a living document timeline cell and content insert explaining that a new article talk page topic has been posted. %1$@ is replaced by `New discussion` text.")
static let newTalkTopicDiscussion = WMFLocalizedString("aaald-new-discussion", value: "New discussion", comment: "Portion of title displayed in article as a living document timeline cell and content insert explaining that a new article talk page topic has been posted.")
static let vandalismRevertDescription = WMFLocalizedString("aaald-vandalism-revert-description", value: "Suspected Vandalism reverted", comment: "Title displayed in an article as a living document timeline cell explaining that a vandalism revision was reverted.")
static let multipleChangesMadeDescription = WMFLocalizedString("aaald-multiple-changes-description", value: "Multiple changes made", comment: "Title displayed in article as a living document content insert explaining that multiple changes were made in a revision.")
static let addedTextDescription = WMFLocalizedString("aaald-added-text-description-2", value:"%1$@ added", comment:"Title displayed in an article as a living document cell explaining that a revision has a certain number of characters added. %1$@ is replaced by a formatted string representing characters added.")
static let deletedTextDescription = WMFLocalizedString("aaald-deleted-text-description-2", value:"%1$@ deleted", comment:"Title displayed in an article as a living document cell explaining that a revision has a certain number of characters deleted. %1$@ is replaced by a formatted string representing characters deleted.")
static let charactersTextDescription = WMFLocalizedString("aaald-characters-text-description", value:"{{PLURAL:%1$d|0=characters|character|characters}}",
comment:"Displayed in an article as a living document cell explaining that a revision has a certain number of characters added or deleted. %1$d is the number of characters added or deleted.")
static let articleDescriptionUpdatedDescription = WMFLocalizedString("aaald-article-description-updated-description", value:"Article description updated",
comment:"Title displayed in an article as a living document cell explaining that an article's description was updated in a revision.")
static let singleReferenceAddedDescription = WMFLocalizedString("aaald-single-reference-added-description", value:"Reference added",
comment:"Title displayed in an article as a living document timeline cell when a reference was added (and no other changes) to a revision.")
static let multipleReferencesAddedDescription = WMFLocalizedString("aaald-multiple-references-added-description", value:"Multiple references added",
comment:"Title displayed in an article as a living document cell when multiple references were added (and no other changes) to a revision.")
static let numericalMultipleReferencesAddedDescription = WMFLocalizedString("aaald-numerical-multiple-references-added-description", value:"{{PLURAL:%1$d|0=0 references|%1$d reference|%1$d references}} added",
comment:"Title displayed in an article as a living document cell explaining that multiple references were added to a revision. This string is used alongside other changes types like added characters. %1$d is replaced with the number of references.")
static let oneSectionDescription = WMFLocalizedString("aaald-one-section-description", value: "in the %1$@ section", comment: "Text explaining what section an article as a living document event change occurred in, if occurred in only one section. %1$@ is replaced with the section name.")
static let twoSectionsDescription = WMFLocalizedString("aaald-two-sections-description", value: "in the %1$@ and %2$@ sections", comment: "Text explaining what sections an article as a living document event change occurred in, if occurred in two sections. %1$@ is replaced with the first section name, %2$@ with the second.")
static let manySectionsDescription = WMFLocalizedString("aaald-many-sections-description", value: "in %1$d sections", comment: "Text explaining what sections an article as a living document change occurred in, if occurred in 3+ sections. %1$d is replaced with the number of sections.")
static let newBookReferenceTitle = WMFLocalizedString("aaald-new-book-reference-title",
value:"Book", comment: "Header text for a new book reference type that was added in an article as a living document cell.")
static let newJournalReferenceTitle = WMFLocalizedString("aaald-new-journal-reference-title",
value:"Journal", comment: "Header text for a new journal reference type that was added in an article as a living document cell.")
static let newNewsReferenceTitle = WMFLocalizedString("aaald-new-news-reference-title",
value:"News", comment: "Header text for a new news reference type that was added in an article as a living document cell.")
static let newWebsiteReferenceTitle = WMFLocalizedString("aaald-new-website-reference-title",
value:"Website", comment: "Header text for a new website reference type that was added in an article as an living document cell.")
static let newJournalReferenceVolume = WMFLocalizedString("aaald-new-journal-reference-volume",
value:"Volume %1$@:", comment: "Volume text for a new journal reference type that was added in an article as a living document cell. %1$@ is replaced by the journal volume number of the reference.")
static let newJournalReferenceDatabase = WMFLocalizedString("aaald-new-journal-reference-database",
value:"via %1$@", comment: "Database text for a new journal reference type that was added in an article as a living document cell. %1$@ is replaced by the database volume number of the reference.")
static let newWebsiteReferenceArchiveUrlText = WMFLocalizedString("aaald-new-website-reference-archive-url-text",
value:"Archive.org URL", comment: "Archive.org URL text for a new website reference type that was added in an article as a living document cell. This will be turned into a link that goes to the reference's Archive.org URL.")
static let newWebsiteReferenceArchiveDateText = WMFLocalizedString("aaald-new-website-reference-archive-date-text",
value:"from the original on %1$@", comment: "Text in a new website reference in an article as a living document cell that describes when the reference was retrieved for Archive.org. %1$@ is replaced with the reference's archive date.")
static let newNewsReferenceRetrievedDate = WMFLocalizedString("aaald-new-news-reference-retrieved-date",
value:"Retrieved %1$@", comment: "Retrieved date text for a new news reference type that was added in an article as a living document cell. %1$@ is replaced by the reference's retrieved date.")
// tonitodo: this fails with EXC_BADACCESS when I try to use plural edits
static let revisionUserInfo = WMFLocalizedString(
"aaald-revision-userInfo",
value:"Edit by %1$@ (%2$@ edits)", comment: "Text describing details about the user that made a significant revision in the article as a living document view. %1$@ is replaced by the editor name and %2$d is replaced by the number of edits they have made.")
static let revisionUserInfoAnonymous = WMFLocalizedString("aaald-revision-by-anonymous",
value:"Edit by anonymous user", comment: "Text describing the anonymous user that made a significant revision in the article as a living document view.")
static let articleAsLivingDocSummaryTitle = WMFLocalizedString(
"aaald-summary-title",
value:"{{PLURAL:%1$d|0=0 changes|%1$d change|%1$d changes}} by {{PLURAL:%2$d|0=0 editors|%2$d editor|%2$d editors}} in {{PLURAL:%3$d|0=0 days|%3$d day|%3$d days}}",
comment:"Describes how many small changes are batched together in the article as a living document timeline view. %1$d is replaced by the number of accumulated changes editors made, %2$d is replaced by the number of editors that made that change and %3$d is replaced with relative timeframe date that the edit counting started (e.g. 10 days).")
@objc public static func onThisDayAdditionalEventsMessage(with languageCode: String?, locale: Locale, eventsCount: Int) -> String {
return String(format: WMFLocalizedString("on-this-day-detail-header-title", languageCode: languageCode, value:"{{PLURAL:%1$d|%1$d historical event|%1$d historical events}}", comment:"Title for 'On this day' detail view - %1$d is replaced with the number of historical events which occurred on the given day"), locale: locale, eventsCount)
}
@objc public static func onThisDayHeaderDateRangeMessage(with languageCode: String?, locale: Locale, lastEvent: String, firstEvent: String) -> String {
return String(format: WMFLocalizedString("on-this-day-detail-header-date-range", languageCode: languageCode, value:"from %1$@ - %2$@", comment:"Text for 'On this day' detail view events 'year range' label - %1$@ is replaced with string version of the oldest event year - i.e. '300 BC', %2$@ is replaced with string version of the most recent event year - i.e. '2006', "), locale: locale, lastEvent, firstEvent)
}
public static func onThisDayFooterWith(with eventCount: Int, languageCode: String? = nil, locale: Locale = Locale.autoupdatingCurrent) -> String {
return String(format: WMFLocalizedString("on-this-day-footer-showing-event-count", languageCode: languageCode, value: "{{PLURAL:%1$d|%1$d more historical event|%1$d more historical events}} on this day", comment: "Footer for presenting user option to see longer list of 'On this day' articles. %1$@ will be substituted with the number of events"), locale: locale, eventCount)
}
public static let articleAsLivingDocErrorTitle = WMFLocalizedString("aaald-error-title", value: "Unable to load inline article history", comment: "Title of error banner that appears at the bottom of an article when significant events fail to load.")
public static let articleAsLivingDocErrorSubtitle = WMFLocalizedString("aaald-error-subitle", value: "Refresh to try again", comment: "Subtitle of error banner that appears at the bottom of an article when significant events fail to load.")
public static let editorExitConfirmationTitle = WMFLocalizedString("editor-exit-confirmation-title", value: "Dismiss the editing mode?", comment: "Title text of editing mode confirmation alert. Presented to the user when they they are about to be navigated away from the editor flow.")
public static let editorExitConfirmationBody = WMFLocalizedString("editor-exit-confirmation-body", value: "Are you sure you want to leave editing mode without publishing first?", comment: "Body text of editing mode confirmation alert. Presented to the user when they they are about to be navigated away from the editor flow.")
public static let talkPageCloseConfirmationKeepEditing = WMFLocalizedString("talk-pages-compose-close-confirmation-keep", value: "Keep Editing", comment: "Title of keep editing action, displayed within a confirmation alert to user when they attempt to close the new topic view or new reply after entering text. Please prioritize for de, ar and zh wikis.")
}
// Language variant strings
public extension CommonStrings {
// General
static let variantsAlertPreferencesButton = WMFLocalizedString("variants-alert-preferences-button", value: "Review your preferences", comment: "Action button on alert used to inform users about variant support.")
static let variantsAlertDismissButton = WMFLocalizedString("variants-alert-dismiss-button", value: "No thanks", comment: "Dismiss button on alert used to inform users about variant support.")
// Chinese (zh)
static let chineseVariantsAlertTitle = WMFLocalizedString("chinese-variants-alert-title", value: "Updates to Chinese variant support", comment: "Title of alert used to inform users about Chinese variant support.")
static let chineseVariantsAlertBody = WMFLocalizedString("chinese-variants-alert-body", value: "The Wikipedia app now supports the following Chinese variants as primary or secondary languages within the app, making it easier to read, search and edit in your preferred variants:\n\n简体 Chinese, Simplified (zh-hans)\n香港繁體 Hong Kong Traditional (zh-hk)\n澳門繁體 Macau Traditional (zh-mo)\n大马简体 Malaysia Simplified (zh-my)\n新加坡简体 Singapore Simplified (zh-sg)\n臺灣正體 Taiwanese Traditional (zh-tw)", comment: "Body text of alert used to inform users about Chinese variant support. Please do not translate the newlines (\n) or Chinese characters (简体, 繁體, etc.).")
// Crimean Tatar (crh)
static let crimeanTatarVariantsAlertTitle = WMFLocalizedString("crimean-tatar-variants-alert-title", value: "Updates to Crimean Tatar variant support", comment: "Title of alert used to inform users about Crimean Tatar variant support.")
static let crimeanTatarVariantsAlertBody = WMFLocalizedString("crimean-tatar-variants-alert-body", value: "The Wikipedia app now supports the following Crimean Tatar variants as primary or secondary languages within the app, making it easier to read, search and edit in your preferred variants:\n\nQırımtatarca, Latin Crimean Tatar Latin (chr-latn)\nкъырымтатарджа, Кирил Crimean Tatar Cyrillic (crh-cyrl)", comment: "Body text of alert used to inform users about Crimean Tatar variant support. Please do not translate the newlines (\n) or Crimean Tatar characters (къырымтатарджа, etc.).")
// Gan (gan)
static let ganVariantsAlertTitle = WMFLocalizedString("gan-variants-alert-title", value: "Updates to Gan variant support", comment: "Title of alert used to inform users about Gan variant support.")
static let ganVariantsAlertBody = WMFLocalizedString("gan-variants-alert-body", value: "The Wikipedia app now supports the following Gan variants as primary or secondary languages within the app, making it easier to read, search and edit in your preferred variants:\n\n贛語 原文 Gan (gan)\n赣语 简体 Gan, Simplified (gan-hans)\n贛語 繁體 Gan, Traditional (gan-hant)", comment: "Body text of alert used to inform users about Gan variant support. Please do not translate the newlines (\n) or Gan characters (贛語 原文, etc.).")
// Inuktitut (iu)
static let inuktitutVariantsAlertTitle = WMFLocalizedString("inuktitut-variants-alert-title", value: "Updates to Inuktitut variant support", comment: "Title of alert used to inform users about Inuktitut variant support.")
static let inuktitutVariantsAlertBody = WMFLocalizedString("inuktitut-variants-alert-body", value: "The Wikipedia app now supports the following Inuktitut variants as primary or secondary languages within the app, making it easier to read, search and edit in your preferred variants:\n\nᐃᓄᒃᑎᑐᑦ ᑎᑎᕋᐅᓯᖅ ᓄᑖᖅ Inuktitut, Syllabics (ike-cans)\nInuktitut ilisautik, Inuktitut, Latin (ike-latn)", comment: "Body text of alert used to inform users about Inuktitut variant support. Please do not translate the newlines (\n) or Inuktitut characters (ᐃᓄᒃᑎᑐᑦ ᑎᑎᕋᐅᓯᖅ ᓄᑖᖅ, etc.).")
// Kazakh (kk)
static let kazakhVariantsAlertTitle = WMFLocalizedString("kazakh-variants-alert-title", value: "Updates to Kazakh variant support", comment: "Title of alert used to inform users about Kazakh variant support.")
static let kazakhVariantsAlertBody = WMFLocalizedString("kazakh-variants-alert-body", value: "The Wikipedia app now supports the following Kazakh variants as primary or secondary languages within the app, making it easier to read, search and edit in your preferred variants:\n\nҚазақша Kazakh (kk)\nҚазақша Кирил Kazakh, Cyrillic (kk-cyrl)\nqazaqşa latin Kazakh, Latin (kk-latn)\nتوتە قازاقشا Kazakh, Arabic (kk-arab)", comment: "Body text of alert used to inform users about Kazakh variant support. Please do not translate the newlines (\n) or Kazakh characters (Қазақша, etc.).")
// Kurdish (ku)
static let kurdishVariantsAlertTitle = WMFLocalizedString("kurdish-variants-alert-title", value: "Updates to Kurdish variant support", comment: "Title of alert used to inform users about Kurdish variant support.")
static let kurdishVariantsAlertBody = WMFLocalizedString("kurdish-variants-alert-body", value: "The Wikipedia app now supports the following Kurdish variants as primary or secondary languages within the app, making it easier to read, search and edit in your preferred variants:\n\nKurdî Latînî Kurdish, Latin (ku-latn)\nكوردی Kurdish, Arabic (kk-arab)", comment: "Body text of alert used to inform users about Kurdish variant support. Please do not translate the newlines (\n) or Kurdish characters (كوردی, etc.).")
// Serbian (sr)
static let serbianVariantsAlertTitle = WMFLocalizedString("serbian-variants-alert-title", value: "Updates to Serbian variant support", comment: "Title of alert used to inform users about Serbian variant support.")
static let serbianVariantsAlertBody = WMFLocalizedString("serbian-variants-alert-body", value: "The Wikipedia app now supports the following Serbian variants as primary or secondary languages within the app, making it easier to read, search and edit in your preferred variants:\n\nсрпски ћирилица Serbian, Cyrillic (sr-ec)\nsrpski latinica Serbian, Latin (sr-el)", comment: "Body text of alert used to inform users about Serbian variant support. Please do not translate the newlines (\n) or Serbian characters (nсрпски ћирилица, etc.).")
// Tajik (tg)
static let tajikVariantsAlertTitle = WMFLocalizedString("tajik-variants-alert-title", value: "Updates to Tajik variant support", comment: "Title of alert used to inform users about Tajik variant support.")
static let tajikVariantsAlertBody = WMFLocalizedString("tajik-variants-alert-body", value: "The Wikipedia app now supports the following Tajik variants as primary or secondary languages within the app, making it easier to read, search and edit in your preferred variants:\n\nтоҷикӣ кирилликӣ Tajik, Cyrillic (tg-cyrl)\ntojikī lotinī Tajik, Latin (tg-latn)", comment: "Body text of alert used to inform users about Tajik variant support. Please do not translate the newlines (\n) or Tajik characters (тоҷикӣ кирилликӣ, etc.).")
// Uzbek (uz)
static let uzbekVariantsAlertTitle = WMFLocalizedString("uzbek-variants-alert-title", value: "Updates to Uzbek variant support", comment: "Title of alert used to inform users about Uzbek variant support.")
static let uzbekVariantsAlertBody = WMFLocalizedString("uzbek-variants-alert-body", value: "The Wikipedia app now supports the following Uzbek variants as primary or secondary languages within the app, making it easier to read, search and edit in your preferred variants:\n\noʻzbekcha lotin Uzbek, Latin (uz-latin)\nўзбекча кирилл Uzbek, Cyrillic (uz-cyrl)", comment: "Body text of alert used to inform users about Uzbek variant support. Please do not translate the newlines (\n) or Uzbek characters (ўзбекча кирилл, etc.).")
// Tachelhit
static let tachelhitVariantsAlertTitle = WMFLocalizedString("tachelhit-variants-alert-title", value: "Updates to Tachelhit variant support", comment: "Title of alert used to inform users about Tachelhit variant support.")
static let tachelhitVariantsAlertBody = WMFLocalizedString("tachelhit-variants-alert-body", value: "The Wikipedia app now supports the following Tachelhit variants as primary or secondary languages within the app, making it easier to read, search and edit in your preferred variants:\n\nⵜⴰⵛⵍⵃⵉⵜ Tachelhit, Tifinagh (shi-tfng)\nTaclḥit Tachelhit, Latin (shi-latn)", comment: "Body text of alert used to inform users about Tachelhit variant support. Please do not translate the newlines (\n) or Tachelhit characters (ⵜⴰⵛⵍⵃⵉⵜ, etc.).")
}

View File

@ -0,0 +1,413 @@
import Foundation
/// Configuration handles the current environment - production, beta, staging, labs
/// It has the functions that build URLs for the various APIs utilized by the app.
/// It also maintains the list of relevant domains - default domain, domains that require the CentralAuth cookies to be copied, etc.
@objc(WMFConfiguration)
public class Configuration: NSObject {
public struct StagingOptions: OptionSet {
public let rawValue: Int
public static let appsLabsforPCS = StagingOptions(rawValue: 1 << 0)
public static let deploymentLabsForEventLogging = StagingOptions(rawValue: 1 << 1)
public static let betaCluster = StagingOptions(rawValue: 1 << 2) // note, this will force beta cluster for PCS (thus ignoring an appsLabsforPCS value if also set) and force deploymentLabsForEventLogging
public init(rawValue: Int) {
self.rawValue = rawValue
}
}
public struct LocalOptions: OptionSet {
public let rawValue: Int
public static let localAnnouncements = LocalOptions(rawValue: 1 << 0)
public static let localPCS = LocalOptions(rawValue: 1 << 1)
public init(rawValue: Int) {
self.rawValue = rawValue
}
}
public enum Environment {
case production
case staging(StagingOptions)
case local(LocalOptions)
}
public let environment: Environment
@objc public static let current: Configuration = {
#if WMF_LOCAL
return Configuration.local(options: [.localPCS, .localAnnouncements])
#elseif WMF_STAGING
/* NOTE: .betaCluster attempts to point to the MediaWiki beta cluster for all possible endpoints.
Change this to .appsLabsForPCS and/or .deploymentLabsForEventLogging for alternative staging environments.
Example: Configuration.staging(options: [.appsLabsForPCS, .deploymentLabsForEventLogging])
.appsLabsForPCS = Product Infrastructure team's labs instance for PCS endpoints
.deploymentLabsForEventLogging = labs instance for testing event logging endpoints
All other endpoints would point to production */
return Configuration.staging(options: [.deploymentLabsForEventLogging])
#else
return .production
#endif
}()
private let pageContentServiceAPIType: APIURLComponentsBuilder.RESTBase.BuilderType
private let feedContentAPIType: APIURLComponentsBuilder.RESTBase.BuilderType
private let announcementsAPIType: APIURLComponentsBuilder.RESTBase.BuilderType
private let eventLoggingAPIType: APIURLComponentsBuilder.EventLogging.BuilderType
private let mediaWikiRestAPIType = APIURLComponentsBuilder.MediaWiki.BuilderType.productionRest
private let mediaWikiAPIType = APIURLComponentsBuilder.MediaWiki.BuilderType.production
private let wikidataAPIType: APIURLComponentsBuilder.Wikidata.BuilderType
private let commonsAPIType: APIURLComponentsBuilder.Commons.BuilderType
private let metricsAPIType = APIURLComponentsBuilder.RESTBase.BuilderType.production
// MARK: Configurations
private static var commonProductionCentralAuthCookieTargetDomains = [
Domain.mediaWiki.withDotPrefix,
Domain.wikimedia.withDotPrefix,
Domain.wiktionary.withDotPrefix,
Domain.wikiquote.withDotPrefix,
Domain.wikibooks.withDotPrefix,
Domain.wikisource.withDotPrefix,
Domain.wikinews.withDotPrefix,
Domain.wikiversity.withDotPrefix,
Domain.wikispecies.withDotPrefix,
Domain.wikivoyage.withDotPrefix
]
public static let production: Configuration = {
let centralAuthCookieTargetDomains = commonProductionCentralAuthCookieTargetDomains + [Domain.wikidata.withDotPrefix, Domain.commons.withDotPrefix]
return Configuration(
environment: .production,
defaultSiteDomain: Domain.wikipedia,
wikipediaCookieDomain: Domain.wikipedia.withDotPrefix,
centralAuthCookieTargetDomains: centralAuthCookieTargetDomains,
pageContentServiceAPIType: .production,
feedContentAPIType: .production,
announcementsAPIType: .production,
wikidataAPIType: .production,
commonsAPIType: .production,
eventLoggingAPIType: .production)
}()
private static func staging(options: StagingOptions) -> Configuration {
let defaultSiteDomain = options.contains(.betaCluster) ? Domain.wikipediaBetaLabs : Domain.wikipedia
let wikipediaCookieDomain = options.contains(.betaCluster) ? Domain.wikipediaBetaLabs.withDotPrefix : Domain.wikipedia.withDotPrefix
let wikidataCookieDomain = options.contains(.betaCluster) ? Domain.wikidataBetaLabs.withDotPrefix : Domain.wikidata.withDotPrefix
let commonsCookieDomain = options.contains(.betaCluster) ? Domain.commonsBetaLabs.withDotPrefix : Domain.commons.withDotPrefix
let centralAuthCookieTargetDomains = commonProductionCentralAuthCookieTargetDomains + [wikidataCookieDomain, commonsCookieDomain]
let pcsApiType: APIURLComponentsBuilder.RESTBase.BuilderType = options.contains(.appsLabsforPCS) && !options.contains(.betaCluster) ? .stagingAppsLabsPCS : .production
let wikidataApiType: APIURLComponentsBuilder.Wikidata.BuilderType = options.contains(.betaCluster) ? .betaLabs : .production
let commonsApiType: APIURLComponentsBuilder.Commons.BuilderType = options.contains(.betaCluster) ? .betaLabs : .production
let eventLoggingApiType: APIURLComponentsBuilder.EventLogging
.BuilderType = options.contains(.deploymentLabsForEventLogging) || options.contains(.betaCluster) ? .staging : .production
return Configuration(
environment: .staging(options),
defaultSiteDomain: defaultSiteDomain,
wikipediaCookieDomain: wikipediaCookieDomain,
centralAuthCookieTargetDomains: centralAuthCookieTargetDomains,
pageContentServiceAPIType: pcsApiType,
feedContentAPIType: .production,
announcementsAPIType: .production,
wikidataAPIType: wikidataApiType,
commonsAPIType: commonsApiType,
eventLoggingAPIType: eventLoggingApiType
)
}
private static func local(options: LocalOptions) -> Configuration {
let pcsApiType: APIURLComponentsBuilder.RESTBase.BuilderType = options.contains(.localPCS) ? .localPCS : .production
let announcementsApiType: APIURLComponentsBuilder.RESTBase.BuilderType = options.contains(.localAnnouncements) ? .localAnnouncements : .production
let centralAuthCookieTargetDomains = commonProductionCentralAuthCookieTargetDomains + [Domain.wikidata.withDotPrefix, Domain.commons.withDotPrefix]
return Configuration(
environment: .local(options),
defaultSiteDomain: Domain.wikipedia,
wikipediaCookieDomain: Domain.wikipedia.withDotPrefix,
centralAuthCookieTargetDomains: centralAuthCookieTargetDomains,
pageContentServiceAPIType: pcsApiType,
feedContentAPIType: .production,
announcementsAPIType: announcementsApiType,
wikidataAPIType: .production,
commonsAPIType: .production,
eventLoggingAPIType: .production)
}
// MARK: Constants
struct Scheme {
static let http = "http"
static let https = "https"
}
public struct Domain {
public static let wikipedia = "wikipedia.org"
public static let wikipediaBetaLabs = "wikipedia.beta.wmflabs.org"
public static let wikidata = "wikidata.org"
public static let wikidataBetaLabs = "wikidata.beta.wmflabs.org"
public static let commons = "commons.wikimedia.org"
public static let commonsBetaLabs = "commons.wikimedia.beta.wmflabs.org"
public static let mediaWiki = "www.mediawiki.org"
public static let wikispecies = "species.wikimedia.org"
public static let appsLabs = "mobileapps.wmflabs.org" // Product Infrastructure team's labs instance
public static let localhost = "localhost"
public static let englishWikipedia = "en.wikipedia.org"
public static let testWikipedia = "test.wikipedia.org"
public static let wikimedia = "wikimedia.org"
public static let metaWiki = "meta.wikimedia.org"
public static let wikimediafoundation = "wikimediafoundation.org"
public static let uploads = "upload.wikimedia.org"
public static let wikibooks = "wikibooks.org"
public static let wiktionary = "wiktionary.org"
public static let wikiquote = "wikiquote.org"
public static let wikisource = "wikisource.org"
public static let wikinews = "wikinews.org"
public static let wikiversity = "wikiversity.org"
public static let wikivoyage = "wikivoyage.org"
}
struct Path {
static let wikiResourceComponent = ["wiki"]
static let restBaseAPIComponents = ["api", "rest_v1"]
static let mediaWikiAPIComponents = ["w", "api.php"]
static let mediaWikiRestAPIComponents = ["w", "rest.php"]
static let expandedWikiResourceComponents = ["w", "index.php"]
}
// MARK: State
@objc public let defaultSiteDomain: String
public let defaultSiteURL: URL
public let wikipediaCookieDomain: String
public let centralAuthCookieSourceDomain: String // copy cookies from
public let centralAuthCookieTargetDomains: [String] // copy cookies to
// Wikipedia Domains
public let wikipediaDomains: [String]
// Domains that can fall back to in-app web view
public let inAppWebViewRoutingDomains: [String]
@objc public lazy var router: Router = {
return Router(configuration: self)
}()
required init(environment: Environment, defaultSiteDomain: String,
wikipediaCookieDomain: String,
centralAuthCookieTargetDomains: [String] = [],
pageContentServiceAPIType: APIURLComponentsBuilder.RESTBase.BuilderType,
feedContentAPIType: APIURLComponentsBuilder.RESTBase.BuilderType,
announcementsAPIType: APIURLComponentsBuilder.RESTBase.BuilderType,
wikidataAPIType: APIURLComponentsBuilder.Wikidata.BuilderType,
commonsAPIType: APIURLComponentsBuilder.Commons.BuilderType,
eventLoggingAPIType: APIURLComponentsBuilder.EventLogging.BuilderType) {
self.environment = environment
self.defaultSiteDomain = defaultSiteDomain
var components = URLComponents()
components.scheme = "https"
components.host = defaultSiteDomain
self.defaultSiteURL = components.url!
self.wikipediaCookieDomain = wikipediaCookieDomain
self.centralAuthCookieSourceDomain = self.wikipediaCookieDomain
self.centralAuthCookieTargetDomains = centralAuthCookieTargetDomains
self.wikipediaDomains = [Domain.wikipedia, Domain.wikipediaBetaLabs, Domain.appsLabs]
self.inAppWebViewRoutingDomains = wikipediaDomains + [Domain.mediaWiki, Domain.wikidata, Domain.wikimedia, Domain.wikimediafoundation]
self.pageContentServiceAPIType = pageContentServiceAPIType
self.feedContentAPIType = feedContentAPIType
self.announcementsAPIType = announcementsAPIType
self.wikidataAPIType = wikidataAPIType
self.commonsAPIType = commonsAPIType
self.eventLoggingAPIType = eventLoggingAPIType
}
// MARK: Page Content Service
public func pageContentServiceBuilder(withWikiHost wikiHost: String? = nil) -> APIURLComponentsBuilder {
let builder = pageContentServiceAPIType.builder(withWikiHost: wikiHost)
return builder
}
/// The Page Content Service includes mobile-html and the associated endpoints. It can be run locally with this repository: https://gerrit.wikimedia.org/r/admin/projects/mediawiki/services/mobileapps
/// On production, it is run through RESTBase at https://en.wikipedia.org/api/rest_v1/ (works for all language wikis)
@objc(pageContentServiceAPIURLForURL:appendingPathComponents:)
public func pageContentServiceAPIURLForURL(_ url: URL? = nil, appending pathComponents: [String] = [""]) -> URL? {
let builder = pageContentServiceAPIType.builder(withWikiHost: url?.host)
let components = builder.components(byAppending: pathComponents)
return components.wmf_URLWithLanguageVariantCode(url?.wmf_languageVariantCode)
}
/// Returns the default request headers for Page Content Service API requests
public func pageContentServiceHeaders(for url: URL) -> [String: String] {
// If the language supports variants, only send a single code with variant for that language.
// This is a workaround for an issue with server-side Accept-Language header handling and
// can be removed when https://phabricator.wikimedia.org/T256491 is fixed.
// NOTE: In general it does not seem that most sites process multi-language Accept-Language headers.
// For variants, sending a single Accept-Language header is sufficient and seems the least error-prone.
if let languageVariantCode = url.wmf_languageVariantCode {
return ["Accept-Language": languageVariantCode]
} else {
return [:]
}
}
// MARK: Metrics
/// The metrics API lives only on wikimedia.org: https://wikimedia.org/api/rest_v1/
@objc(metricsAPIURLComponentsAppendingPathComponents:)
public func metricsAPIURLComponents(appending pathComponents: [String] = [""]) -> URLComponents {
let builder = metricsAPIType.builder(withWikiHost: Domain.wikimedia)
return builder.components(byAppending: ["metrics"] + pathComponents)
}
// MARK: Wikifeeds (Feed Content and Announcements)
/// Feed content is located in the wikifeeds repository. It can be run locally with: https://gerrit.wikimedia.org/r/admin/projects/mediawiki/services/wikifeeds
/// On production, it is run through RESTBase at https://en.wikipedia.org/api/rest_v1/ (works for all language wikis)
@objc(feedContentAPIURLForURL:appendingPathComponents:)
public func feedContentAPIURLForURL(_ url: URL?, appending pathComponents: [String] = [""]) -> URL? {
let builder = feedContentAPIType.builder(withWikiHost: url?.host)
let components = builder.components(byAppending: pathComponents)
return components.wmf_URLWithLanguageVariantCode(url?.wmf_languageVariantCode)
}
/// Announcements are located in the wikifeeds repository. It can be run locally with: https://gerrit.wikimedia.org/r/admin/projects/mediawiki/services/wikifeeds
/// On production, it is run through RESTBase at https://en.wikipedia.org/api/rest_v1/ (works for all language wikis)
@objc(announcementsAPIURLForURL:appendingPathComponents:)
public func announcementsAPIURLForURL(_ url: URL?, appending pathComponents: [String] = [""]) -> URL? {
let builder = announcementsAPIType.builder(withWikiHost: url?.host)
let components = builder.components(byAppending: pathComponents)
return components.wmf_URLWithLanguageVariantCode(url?.wmf_languageVariantCode)
}
// MARK: Event Logging
@objc(eventLoggingAPIURLWithPayload:)
public func eventLoggingAPIURL(with payload: NSObject) -> URL? {
let builder = eventLoggingAPIType.builder()
let components = try? builder.components(byAssigningPayloadToPercentEncodedQuery: payload)
return components?.url
}
// MARK: MediaWiki Rest
public func mediaWikiRestAPIURLForURL(_ url: URL? = nil, appending pathComponents: [String] = [""], queryParameters: [String: Any]? = nil) -> URL? {
let builder = mediaWikiRestAPIType.builder(withWikiHost: url?.host)
let components = builder.components(byAppending: pathComponents, queryParameters: queryParameters)
return components.wmf_URLWithLanguageVariantCode(url?.wmf_languageVariantCode)
}
// MARK: MediaWiki
@objc(mediaWikiAPIURLForURL:withQueryParameters:)
public func mediaWikiAPIURLForURL(_ url: URL?, with queryParameters: [String: Any]? = nil) -> URL? {
let components = mediaWikiAPIURLForHost(url?.host, with: queryParameters)
return components.wmf_URLWithLanguageVariantCode(url?.wmf_languageVariantCode)
}
public func mediaWikiAPIURLForHost(_ host: String? = nil, with queryParameters: [String: Any]? = nil) -> URLComponents {
let builder = mediaWikiAPIType.builder(withWikiHost: host)
guard let queryParameters = queryParameters else {
return builder.components()
}
return builder.components(queryParameters: queryParameters)
}
public func mediaWikiAPIURLForLanguageCode(_ languageCode: String, siteDomain: String? = nil, queryParameters: [String: Any]?) -> URLComponents {
let domain = siteDomain ?? defaultSiteDomain
let host = "\(languageCode).\(domain)"
return mediaWikiAPIURLForHost(host, with: queryParameters)
}
// MARK: Wikidata
public func wikidataAPIURLComponents(with queryParameters: [String: Any]?) -> URLComponents {
let builder = wikidataAPIType.builder()
return builder.components(queryParameters: queryParameters)
}
// MARK: Commons
@objc(commonsAPIURLComponentsWithQueryParameters:)
public func commonsAPIURLComponents(with queryParameters: [String: Any]?) -> URLComponents {
let builder = commonsAPIType.builder()
return builder.components(queryParameters: queryParameters)
}
// MARK: Article URLs
func articleURLComponentsBuilder(for host: String) -> APIURLComponentsBuilder {
var components = URLComponents()
components.host = host
components.scheme = Scheme.https
return APIURLComponentsBuilder(hostComponents: components, basePathComponents: Path.wikiResourceComponent)
}
func expandedArticleURLComponentsBuilder(for host: String) -> APIURLComponentsBuilder {
var components = URLComponents()
components.host = host
components.scheme = Scheme.https
return APIURLComponentsBuilder(hostComponents: components, basePathComponents: Path.expandedWikiResourceComponents)
}
public func articleURLForHost(_ host: String, languageVariantCode: String?, appending pathComponents: [String]) -> URL? {
let builder = articleURLComponentsBuilder(for: host)
let components = builder.components(byAppending: pathComponents)
return components.wmf_URLWithLanguageVariantCode(languageVariantCode)
}
// Uses format https://en.wikipedia.org/w/index.php?title=Main_Page
// As opposed to https://en.wikipedia.org/wiki/Main_Page
public func expandedArticleURLForHost(_ host: String, languageVariantCode: String?, queryParameters: [String: Any]?) -> URL? {
let builder = expandedArticleURLComponentsBuilder(for: host)
let components = builder.components(byAppending: [], queryParameters: queryParameters)
return components.wmf_URLWithLanguageVariantCode(languageVariantCode)
}
// MARK: Routing Helpers
public func isWikipediaHost(_ host: String?) -> Bool {
guard let host = host else {
return false
}
for domain in wikipediaDomains {
if host.isDomainOrSubDomainOf(domain) {
return true
}
}
return false
}
/// Indicates if a url should fall back to an in-app web view or not
/// Please inspect url namespace first and confirm url cannot display natively before using this method.
/// - Parameter host: url host that you are trying to route
/// - Returns: true = host should fall back to app web view, route to in-app web view. false = host should fall back to external Safari web browser (business logic for parental controls).
public func hostCanRouteToInAppWebView(_ host: String?) -> Bool {
guard let host = host else {
return false
}
for domain in inAppWebViewRoutingDomains {
if host.isDomainOrSubDomainOf(domain) {
return true
}
}
return false
}
}

View File

@ -0,0 +1,81 @@
import Foundation
extension DateFormatter {
// Returns year string - i.e. '1000' or '200 BC'. (Negative years are 'BC')
class func wmf_yearString(for year: Int, with wikipediaLanguageCode: String?) -> String? {
var components = DateComponents()
components.year = year
let calendar = NSCalendar.wmf_utcGregorian()
guard let date = calendar?.date(from: components) else {
return nil
}
let formatter = year < 0 ? DateFormatter.wmf_yearWithEraGMTDateFormatter(for: wikipediaLanguageCode) : DateFormatter.wmf_yearGMTDateFormatter(for: wikipediaLanguageCode)
return formatter.string(from: date)
}
private static var wmf_yearGMTDateFormatterCache: [String: DateFormatter] = [:]
public static func wmf_yearGMTDateFormatter(for wikipediaLanguageCode: String?) -> DateFormatter {
let wikipediaLanguageCode = wikipediaLanguageCode ?? "en"
if let formatter = wmf_yearGMTDateFormatterCache[wikipediaLanguageCode] {
return formatter
}
let dateFormatter = DateFormatter()
dateFormatter.locale = NSLocale.wmf_locale(for: wikipediaLanguageCode)
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
dateFormatter.setLocalizedDateFormatFromTemplate("y")
wmf_yearGMTDateFormatterCache[wikipediaLanguageCode] = dateFormatter
return dateFormatter
}
private static var wmf_yearWithEraGMTDateFormatterCache: [String: DateFormatter] = [:]
public static func wmf_yearWithEraGMTDateFormatter(for wikipediaLanguageCode: String?) -> DateFormatter {
let wikipediaLanguageCode = wikipediaLanguageCode ?? "en"
if let formatter = wmf_yearWithEraGMTDateFormatterCache[wikipediaLanguageCode] {
return formatter
}
let dateFormatter = DateFormatter()
dateFormatter.locale = NSLocale.wmf_locale(for: wikipediaLanguageCode)
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
dateFormatter.setLocalizedDateFormatFromTemplate("y G")
wmf_yearWithEraGMTDateFormatterCache[wikipediaLanguageCode] = dateFormatter
return dateFormatter
}
private static var wmf_monthNameDayNumberGMTFormatterCache: [String: DateFormatter] = [:]
public static func wmf_monthNameDayNumberGMTFormatter(for wikipediaLanguageCode: String?) -> DateFormatter {
let wikipediaLanguageCode = wikipediaLanguageCode ?? "en"
if let formatter = wmf_monthNameDayNumberGMTFormatterCache[wikipediaLanguageCode] {
return formatter
}
let dateFormatter = DateFormatter()
dateFormatter.locale = NSLocale.wmf_locale(for: wikipediaLanguageCode)
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
dateFormatter.setLocalizedDateFormatFromTemplate("MMMM d")
wmf_monthNameDayNumberGMTFormatterCache[wikipediaLanguageCode] = dateFormatter
return dateFormatter
}
private static var wmf_longDateGMTFormatterCache: [String: DateFormatter] = [:]
public static func wmf_longDateGMTFormatter(for wikipediaLanguageCode: String?) -> DateFormatter {
let wikipediaLanguageCode = wikipediaLanguageCode ?? "en"
if let formatter = wmf_longDateGMTFormatterCache[wikipediaLanguageCode] {
return formatter
}
let dateFormatter = DateFormatter()
dateFormatter.locale = NSLocale.wmf_locale(for: wikipediaLanguageCode)
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
dateFormatter.timeStyle = .none
dateFormatter.dateStyle = .long
wmf_longDateGMTFormatterCache[wikipediaLanguageCode] = dateFormatter
return dateFormatter
}
}

View File

@ -0,0 +1,34 @@
import Foundation
class DeviceInfo {
static let shared = {
return DeviceInfo()
}()
lazy var model: String? = {
var size : Int = 0
sysctlbyname("hw.machine", nil, &size, nil, 0)
var machine = [CChar](repeating: 0, count: size)
sysctlbyname("hw.machine", &machine, &size, nil, 0)
return String(cString: machine)
}()
// Only includes iOS 11 compatible older devices
static let olderDevicePrefixes: Set<String> = [
"iPad4", // iPad Air
"iPad5", // iPad Air 2
"iPhone6", // iPhone 5s
"iPhone7", // iPhone 6
"iPod7" // iPod Touch (6th gen)
]
lazy var isOlderDevice: Bool = {
guard let model = model else {
return true
}
guard let substring = model.components(separatedBy: ",").first else {
return true
}
return DeviceInfo.olderDevicePrefixes.contains(substring)
}()
}

View File

@ -0,0 +1,12 @@
import Foundation
public extension Dictionary where Key: Equatable {
func wmf_isEqualTo<C: Collection>(_ dictionary: Dictionary, excluding excludedKeys: C? = nil) -> Bool where C.Element == Key {
guard let excludedKeys = excludedKeys else {
return (self as NSDictionary).isEqual(to: dictionary)
}
let left = filter({ !excludedKeys.contains($0.key) })
let right = dictionary.filter({ !excludedKeys.contains($0.key) })
return (left as NSDictionary).isEqual(to: right)
}
}

View File

@ -0,0 +1,7 @@
import Foundation
import CoreData
@objc(WMFEPEventRecord)
public class EPEventRecord: NSManagedObject {
}

View File

@ -0,0 +1,15 @@
import Foundation
import CoreData
extension EPEventRecord {
@nonobjc public class func fetchRequest() -> NSFetchRequest<EPEventRecord> {
return NSFetchRequest<EPEventRecord>(entityName: "WMFEPEventRecord")
}
@NSManaged public var data: Data
@NSManaged public var stream: String
@NSManaged public var recorded: Date?
@NSManaged public var purgeable: Bool
}

View File

@ -0,0 +1,730 @@
/*
* Event Platform Client (EPC)
*
* DESCRIPTION
* Collects events in an input buffer, adds some metadata, places them in an
* ouput buffer where they are periodically bursted to a remote endpoint via
* HTTP POST.
*
* Designed for use with Wikipedia iOS application producing events to a
* stream intake service.
*
* LICENSE NOTICE
* Copyright 2020 Wikimedia Foundation
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
* IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
* THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
import Foundation
import CocoaLumberjackSwift
/**
* Event Platform Client (EPC)
*
* Use `EPC.shared?.submit(stream, event, domain?, date?)` to submit ("log") events to
* streams.
*
* iOS schemas will always include the following fields which are managed by EPC
* and which will be assigned automatically by the library:
* - `dt`: client-side timestamp of when event was originally submitted
* - `app_install_id`: app install ID as in legacy EventLoggingService
* - `app_session_id`: the ID of the session at the time of the event when it was
* originally submitted
*/
public class EventPlatformClient: NSObject, SamplingControllerDelegate {
// MARK: - Properties
public static let shared: EventPlatformClient = {
return EventPlatformClient()
}()
// SINGLETONTODO
/// Session for requesting data
let session = MWKDataStore.shared().session
let samplingController: SamplingController
let storageManager: StorageManager?
/**
* Store events until the library is finished initializing
*
* The EPC library makes an HTTP request to a remote stream configuration service for information
* about how to evaluate incoming event data. Until this initialization is complete, we store any incoming
* events in this buffer.
*
* Only modify (append events to, remove events from) asynchronously via `queue.async`
*/
private var inputBuffer: [(Data, Stream)] = []
/**
* Maximum number of events allowed in the input buffer
*/
private let inbutBufferLimit = 128
/**
* Streams are the event stream identifiers that can be utilized with the EventPlatformClientLibrary. They should
* correspond to the `$id` of a schema in
* [this repository](https://gerrit.wikimedia.org/g/schemas/event/secondary/).
*/
public enum Stream: String, Codable {
case editHistoryCompare = "ios.edit_history_compare"
case remoteNotificationsInteraction = "ios.notification_interaction"
case talkPagesInteraction = "ios.talk_page_interaction"
}
/**
* Schema specifies which schema (and specifically which version of that schema)
* a given event conforms to. Analytics schemas can be found in the jsonschema directory of
* [secondary repo](https://gerrit.wikimedia.org/g/schemas/event/secondary/).
* As an example, if instrumenting client-side error logging, a possible
* `$schema` would be `/mediawiki/client/error/1.0.0`. For the most part, the
* `$schema` will start with `/analytics`, since there's where
* analytics-related schemas are collected.
*/
public enum Schema: String, Codable {
case editHistoryCompare = "/analytics/mobile_apps/ios_edit_history_compare/2.1.0"
case remoteNotificationsInteraction = "/analytics/mobile_apps/ios_notification_interaction/2.1.0"
case talkPages = "/analytics/mobile_apps/ios_talk_page_interaction/1.0.0"
}
/**
* Serial dispatch queue that enables working with properties in a thread-safe
* way
*/
private let queue = DispatchQueue(label: "EventPlatformClient-" + UUID().uuidString)
/**
* Serial dispatch queue for encoding data on a background thread
*/
private let encodeQueue = DispatchQueue(label: "EventPlatformClientEncode-" + UUID().uuidString, qos: .background)
/**
* Where to send events to for intake
*
* See [wikitech:Event Platform/EventGate](https://wikitech.wikimedia.org/wiki/Event_Platform/EventGate)
* for more information. Specifically, the section on
* **eventgate-analytics-external**. This service uses the stream
* configurations from Meta wiki as its source of truth.
*/
private static let eventIntakeURI = URL(string: "https://intake-analytics.wikimedia.org/v1/events")!
/**
* MediaWiki API endpoint which returns stream configurations as JSON
*
* Streams are configured via [mediawiki-config/wmf-config/InitialiseSettings.php](https://gerrit.wikimedia.org/g/operations/mediawiki-config/+/master/wmf-config/InitialiseSettings.php)
*
* The config changes are deployed in [backport windows](https://wikitech.wikimedia.org/wiki/Backport_windows)
* by scheduling on the [Deployments](https://wikitech.wikimedia.org/wiki/Deployments)
* page. Stream configurations are made available for external consumption via
* MediaWiki API via [Extension:EventStreamConfig](https://gerrit.wikimedia.org/g/mediawiki/extensions/EventStreamConfig/)
*
* In production, we use [Meta wiki](https://meta.wikimedia.org/wiki/Main_Page)
* [streamconfigs endpoint](https://meta.wikimedia.org/w/api.php?action=help&modules=streamconfigs)
* with the constraint that the `destination_event_service` is configured to
* be "eventgate-analytics-external" (to filter out irrelevant streams from
* the returned list of stream configurations).
*/
private static let streamConfigsURI = URL(string: "https://meta.wikimedia.org/w/api.php?action=streamconfigs&format=json&constraints=destination_event_service=eventgate-analytics-external")!
/**
* An individual stream's configuration.
*/
struct StreamConfiguration: Codable {
let sampling: Sampling?
struct Sampling: Codable {
let rate: Double?
let identifier: String?
}
}
/**
* Holds each stream's configuration.
*/
private var streamConfigurations: [Stream: StreamConfiguration]? {
get {
queue.sync {
return _streamConfigurations
}
}
set {
queue.async {
self._streamConfigurations = newValue
}
}
}
private var _streamConfigurations: [Stream: StreamConfiguration]? = nil
/**
* Updated when app enters background, used for determining if the session has
* expired.
*/
private var lastTimestamp: Date = Date()
/**
* Return a session identifier
* - Returns: session ID
*
* The identifier is a string of 20 zero-padded hexadecimal digits
* representing a uniformly random 80-bit integer.
*/
internal var sessionID: String {
queue.sync {
guard let sID = _sessionID else {
let newID = generateID()
_sessionID = newID
return newID
}
return sID
}
}
private var _sessionID: String?
// MARK: - Methods
public override init() {
self.storageManager = StorageManager.shared
self.samplingController = SamplingController()
super.init()
self.samplingController.delegate = self
guard self.storageManager != nil else {
DDLogError("EPC: Error initializing the storage manager. Event intake and submission will be disabled.")
return
}
self.fetchStreamConfiguration(retries: 10, retryDelay: 30)
}
/**
* This method is called by the application delegate in
* `applicationWillResignActive()` and disables event logging.
*/
public func appInBackground() {
lastTimestamp = Date()
}
/**
* This method is called by the application delegate in
* `applicationDidBecomeActive()` and re-enables event logging.
*
* If it has been more than 15 minutes since the app entered background state,
* a new session is started.
*/
public func appInForeground() {
if sessionTimedOut() {
resetSession()
}
}
/**
* This method is called by the application delegate in
* `applicationWillTerminate()`
*
* We do not persist session ID on app close because we have decided that a
* session ends when the user (or the OS) has closed the app or when 15
* minutes of inactivity have passed.
*/
public func appWillClose() {
// Placeholder for any onTerminate logic
}
/**
* Generates a new identifier using the same algorithm as EPC libraries for
* web and Android
*/
private func generateID() -> String {
var id: String = ""
for _ in 1...5 {
id += String(format: "%04x", arc4random_uniform(65535))
}
return id
}
/**
* Called when user toggles logging permissions in Settings
*
* This assumes storageManager's deviceID will be reset separately by a
* different owner (EventLoggingService's `reset()` method)
*/
public func reset() {
resetSession()
}
/**
* Unset the session
*/
private func resetSession() {
queue.async {
self._sessionID = nil
}
samplingController.removeAllSamplingCache()
}
/**
* Check if session expired, based on last active timestamp
*
* A new session ID is required if it has been more than 15 minutes since the
* user was last active (e.g. when app entered background).
*/
private func sessionTimedOut() -> Bool {
/*
* A TimeInterval value is always specified in seconds.
*/
return lastTimestamp.timeIntervalSinceNow < -900
}
/**
* Fetch stream configuration from stream configuration service
* - Parameters:
* - retries: number of retries remaining
* - retryDelay: seconds between each attempt, increasing by 50% after
* every failed attempt
*/
private func fetchStreamConfiguration(retries: Int, retryDelay: TimeInterval) {
self.httpGet(url: EventPlatformClient.streamConfigsURI, completion: { (data, response, error) in
guard let httpResponse = response as? HTTPURLResponse, let data = data, httpResponse.statusCode == 200 else {
DDLogWarn("EPC: Server did not respond adequately, will try \(EventPlatformClient.streamConfigsURI.absoluteString) again")
if retries > 0 {
dispatchOnMainQueueAfterDelayInSeconds(retryDelay) {
self.fetchStreamConfiguration(retries: retries - 1, retryDelay: retryDelay * 1.5)
}
} else {
DDLogWarn("EPC: Ran out of retries when attempting to download stream configs")
}
return
}
self.loadStreamConfiguration(data)
})
}
/**
* Processes fetched stream config
* - Parameter data: JSON-serialized stream configuration
*
* Example of a retrieved config:
* ``` js
* {
* "streams": {
* "test.instrumentation.sampled": {
* "sampling": {
* "rate":0.1
* }
* },
* "test.instrumentation": {},
* }
* }
* ```
*/
private func loadStreamConfiguration(_ data: Data) {
#if DEBUG
if let raw = String.init(data: data, encoding: String.Encoding.utf8) {
DDLogDebug("EPC: Downloaded stream configs (raw): \(raw)")
}
#endif
guard let storageManager = self.storageManager else {
DDLogError("Storage manager not initialized; this shouldn't happen!")
return
}
struct StreamConfigurationsJSON: Codable {
let streams: [String: StreamConfiguration]
}
do {
let json = try JSONDecoder().decode(StreamConfigurationsJSON.self, from: data)
// Make them available to any newly logged events before flushing
// buffer (this is set using serial queue but asynchronously)
streamConfigurations = json.streams.reduce(into: [:], { (result, kv) in
guard let stream = Stream(rawValue: kv.key) else {
return
}
result?[stream] = kv.value
})
// Process event buffer after making stream configs available
// NOTE: If any event is re-submitted while streamConfigurations
// is still being set (asynchronously), they will just go back to
// input buffer.
while let (data, stream) = inputBufferPopFirst() {
guard let config = streamConfigurations?[stream] else {
continue
}
guard samplingController.inSample(stream: stream, config: config) else {
continue
}
storageManager.push(data: data, stream: stream)
}
} catch let error {
DDLogError("EPC: Problem processing JSON payload from response: \(error)")
}
}
/**
* Flush the queue of outgoing requests in a first-in-first-out,
* fire-and-forget fashion
*/
func postAllScheduled(_ completion: (() -> Void)? = nil) {
guard let storageManager = self.storageManager else {
completion?()
return
}
let events = storageManager.popAll()
if events.count == 0 {
// DDLogDebug("EPC: Nothing to send.")
completion?()
return
}
DDLogDebug("EPC: Processing all scheduled requests")
let group = DispatchGroup()
for event in events {
group.enter()
httpPost(url: EventPlatformClient.eventIntakeURI, body: event.data) { result in
switch result {
case .success:
storageManager.markPurgeable(event: event)
break
case .failure(let error):
switch error {
case .networkingLibraryError:
/// Leave unmarked to retry on networking library failure
break
default:
/// Give up on events rejected by the server
DDLogError("EPC: The analytics service failed to process an event. A response code of 400 could indicate that the event didn't conform to provided schema. Check the error for more information.: \(error)")
storageManager.markPurgeable(event: event)
break
}
}
group.leave()
}
}
group.notify(queue: queue) {
completion?()
}
}
/// EventBody is used to encode event data into the POST body of a request to the Modern Event Platform
struct EventBody<E>: Encodable where E: EventInterface {
/// EventGate needs to know which version of the schema to validate against
var meta: Meta
struct Meta: Codable {
let stream: Stream
/**
* meta.id is *optional* and should only be done in case the client is
* known to send duplicates of events, otherwise we don't need to
* make the payload any heavier than it already is
*/
let id: UUID
let domain: String?
}
let appInstallID: String
/**
* Generated events have the session ID attached to them before stream
* config is available (in case they're generated offline) and before
* they're cc'd to any other streams (once config is available).
*/
let appSessionID: String
/**
* The top-level field `dt` is for recording the time the event
* was generated. EventGate sets `meta.dt` during ingestion, so for
* analytics events that field is used as "timestamp of reception" and
* is used for partitioning the events in the database. See Phab:T240460
* for more information.
*/
let dt: Date
/**
* Event represents the client-provided event data.
* The event is encoded at the top level of the resulting structure.
* If any of the `CodingKeys` conflict with keys defined by `EventBody`,
* the values from `event` will be used.
*/
let event: E
enum CodingKeys: String, CodingKey {
case schema = "$schema"
case meta
case appInstallID = "app_install_id"
case appSessionID = "app_session_id"
case dt
case event
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
do {
try container.encode(meta, forKey: .meta)
try container.encode(appInstallID, forKey: .appInstallID)
try container.encode(appSessionID, forKey: .appSessionID)
try container.encode(dt, forKey: .dt)
try container.encode(E.schema, forKey: .schema)
try event.encode(to: encoder)
} catch let error {
DDLogError("EPC: Error encoding event body: \(error)")
}
}
}
/**
* Submit an event according to the given stream's configuration.
* - Parameters:
* - stream: The stream to submit the event to
* - event: The event data
* - domain: Optional domain to include for the event (without protocol)
*
* An example call:
* ```
* struct TestEvent: EventInterface {
* static let schema = "/analytics/mobile_apps/test/1.0.0"
* let test_string: String
* let test_map: SourceInfo
* struct SourceInfo: Codable {
* let file: String
* let method: String
* }
* }
*
* let sourceInfo = TestEvent.SourceInfo(file: "Features/Feed/ExploreViewController.swift", method: "refreshControlActivated")
* let event = TestEvent(test_string: "Explore Feed refreshed", test_map: sourceInfo)
*
* EventPlatformClient.shared?.submit(
* stream: .test, // Defined in `EPC.Stream`
* event: event
* )
* ```
*
* Regarding `domain`: this is *optional* and should be used when event needs
* to be attrributed to a particular wiki (Wikidata, Wikimedia Commons, a
* specific edition of Wikipedia, etc.). If the language is NOT relevant in
* the context, `domain` can be safely omitted. Using "domain" rather than
* "language" is consistent with the other platforms and allows for the
* possibility of setting a non-Wikipedia domain like "commons.wikimedia.org"
* and "wikidata.org" for multimedia/metadata-related in-app analytics.
* Instrumentation code should use the `host` property of a `URL` as the value
* for this parameter.
*
* Cases where instrumentation would set a `domain`:
* - reading or editing an article
* - managing watchlist
* - interacting with feed
* - searching
*
* Cases where it might not be necessary for the instrument to set a `domain`:
* - changing settings
* - managing reading lists
* - navigating map of nearby articles
* - multi-lingual features like Suggested Edits
* - marking session start/end; in which case schema and `data` should have a
* `languages` field where user's list of languages can be stored, although
* it might make sense to set it to the domain associated with the user's
* 1st preferred language in which case use
* `MWKLanguageLinkController.sharedInstance().appLanguage.siteURL().host`
*/
public func submit<E: EventInterface>(stream: Stream, event: E, domain: String? = nil) {
let date = Date() // Record the date synchronously so there's no delay
encodeQueue.async {
self._submit(stream: stream, event: event, date: date, domain: domain)
}
}
/// Private, synchronous version of `submit`.
private func _submit<E: EventInterface>(stream: Stream, event: E, date: Date, domain: String? = nil) {
guard let storageManager = self.storageManager else {
return
}
let userDefaults = UserDefaults.standard
if !userDefaults.wmf_sendUsageReports {
return
}
guard let appInstallID = userDefaults.wmf_appInstallId else {
DDLogWarn("EPC: App install ID is unset. This shouldn't happen.")
return
}
let meta = EventBody<E>.Meta(stream: stream, id: UUID(), domain: domain)
let eventPayload = EventBody(meta: meta, appInstallID: appInstallID, appSessionID: sessionID, dt: date, event: event)
do {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
#if DEBUG
encoder.outputFormatting = .prettyPrinted
#endif
let data = try encoder.encode(eventPayload)
#if DEBUG
let jsonString = String(data: data, encoding: .utf8)!
DDLogDebug("EPC: Scheduling event to be sent to \(EventPlatformClient.eventIntakeURI) with POST body:\n\(jsonString)")
#endif
guard let streamConfigs = streamConfigurations else {
appendEventToInputBuffer(data: data, stream: stream)
return
}
guard let config = streamConfigs[stream] else {
DDLogDebug("EPC: Event submitted to '\(stream)' but only the following streams are configured: \(streamConfigs.keys.map(\.rawValue).joined(separator: ", "))")
return
}
guard samplingController.inSample(stream: stream, config: config) else {
DDLogDebug("EPC: Stream '\(stream.rawValue)' is not in sample")
return
}
storageManager.push(data: data, stream: stream)
} catch let error {
DDLogError("EPC: \(error.localizedDescription)")
}
}
}
// MARK: Thread-safe accessors for collection properties
private extension EventPlatformClient {
/**
* Thread-safe synchronous retrieval of buffered events
*/
func getInputBuffer() -> [(Data, Stream)] {
queue.sync {
return self.inputBuffer
}
}
/**
* Thread-safe synchronous buffering of an event
* - Parameter event: event to be buffered
*/
func appendEventToInputBuffer(data: Data, stream: Stream) {
queue.sync {
/*
* Check if input buffer has reached maximum allowed size. Practically
* speaking, there should not have been over a hundred events
* generated when the user first launches the app and before the
* stream configuration has been downloaded and becomes available. In
* such a case we're just going to start clearing out the oldest
* events to make room for new ones.
*/
if self.inputBuffer.count == self.inbutBufferLimit {
_ = self.inputBuffer.remove(at: 0)
}
self.inputBuffer.append((data, stream))
}
}
/**
* Thread-safe synchronous removal of first buffered event
* - Returns: a previously buffered event
*/
func inputBufferPopFirst() -> (Data, Stream)? {
queue.sync {
if self.inputBuffer.isEmpty {
return nil
}
return self.inputBuffer.remove(at: 0)
}
}
}
// MARK: NetworkIntegration
private extension EventPlatformClient {
/// PostEventError describes the possible failure cases when POSTing an event
enum PostEventError: Error {
case networkingLibraryError(_ error: Error)
case missingResponse
case unexepectedResponse(_ httpCode: Int)
}
/**
* HTTP POST
* - Parameter body: Body of the POST request
* - Parameter completion: callback invoked upon receiving the server response
*/
private func httpPost(url: URL, body: Data? = nil, completion: @escaping ((Result<Void, PostEventError>) -> Void)) {
DDLogDebug("EPC: Attempting to POST events")
let request = session.request(with: url, method: .post, bodyData: body, bodyEncoding: .json)
let task = session.dataTask(with: request, completionHandler: { (_, response, error) in
let fail: (PostEventError) -> Void = { error in
DDLogDebug("EPC: An error occurred sending the request: \(error)")
completion(.failure(error))
}
if let error = error {
fail(PostEventError.networkingLibraryError(error))
return
}
guard let httpResponse = response as? HTTPURLResponse else {
fail(PostEventError.missingResponse)
return
}
guard httpResponse.statusCode == 201 else {
fail(PostEventError.unexepectedResponse(httpResponse.statusCode))
return
}
completion(.success(()))
})
task?.resume()
}
/**
* HTTP GET
* - Parameter url: Where to GET data from
* - Parameter completion: What to do with gotten data
*/
private func httpGet(url: URL, completion: @escaping (Data?, URLResponse?, Error?) -> Void) {
DDLogDebug("EPC: Attempting to GET data from \(url.absoluteString)")
var request = URLRequest.init(url: url) // httpMethod = "GET" by default
request.setValue(WikipediaAppUtils.versionedUserAgent(), forHTTPHeaderField: "User-Agent")
let task = session.dataTask(with: request, completionHandler: completion)
task?.resume()
}
}
// MARK: EventInterface
/**
* Protocol for event data.
* Currently only requires conformance to Codable.
*/
public protocol EventInterface: Codable {
/**
* Defines which schema this event conforms to.
* Check the documentation for `EPC.Schema` for more information.
*/
static var schema: EventPlatformClient.Schema { get }
}

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20C69" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="WMFEPEventRecord" representedClassName="WMFEPEventRecord" syncable="YES">
<attribute name="data" attributeType="Binary"/>
<attribute name="purgeable" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="recorded" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="stream" attributeType="String"/>
<fetchIndex name="byRecordedIndex">
<fetchIndexElement property="recorded" type="Binary" order="ascending"/>
</fetchIndex>
</entity>
<elements>
<element name="WMFEPEventRecord" positionX="-63" positionY="-18" width="128" height="89"/>
</elements>
</model>

View File

@ -0,0 +1,53 @@
import Foundation
@objc(WMFMetricsClientBridge)
public class MetricsClientBridge: NSObject {
let client = EventPlatformClient.shared
@objc(sharedInstance) public static let shared: MetricsClientBridge = {
return MetricsClientBridge()
}()
@objc public func appInBackground() {
client.appInBackground()
}
@objc public func appInForeground() {
client.appInForeground()
}
@objc public func appWillClose() {
client.appWillClose()
}
@objc public func reset() {
client.reset()
}
}
// MARK: PeriodicWorker
extension MetricsClientBridge: PeriodicWorker {
public func doPeriodicWork(_ completion: @escaping () -> Void) {
guard let storageManager = self.client.storageManager else {
return
}
storageManager.pruneStaleEvents(completion: {
self.client.postAllScheduled(completion)
})
}
}
// MARK: BackgroundFetcher
extension MetricsClientBridge: BackgroundFetcher {
public func performBackgroundFetch(_ completion: @escaping (UIBackgroundFetchResult) -> Void) {
doPeriodicWork {
completion(.noData)
}
}
}

View File

@ -0,0 +1,138 @@
import Foundation
import CocoaLumberjackSwift
protocol SamplingControllerDelegate: AnyObject {
var sessionID: String { get }
}
class SamplingController: NSObject {
/**
* Serial dispatch queue that enables working with properties in a thread-safe
* way
*/
private let queue = DispatchQueue(label: "EventPlatformClientSampling-" + UUID().uuidString)
/**
* Cache of "in sample" / "out of sample" determination for each stream
*
* The process of determining only has to happen the first time an event is
* logged to a stream for which stream configuration is available. All other
* times `in_sample` simply returns the cached determination.
*
* Only cache determinations asynchronously via `queue.async`
*/
private var samplingCache: [EventPlatformClient.Stream: Bool] = [:]
weak var delegate: SamplingControllerDelegate?
/**
* Compute a boolean function on a random identifier
* - Parameter stream: name of the stream
* - Parameter config: stream configuration for the provided stream name
* - Returns: `true` if in sample or `false` otherwise
*
* The determinations are lazy and cached, so each stream's in-sample vs
* out-of-sample determination is computed only once, the first time an event
* is logged to that stream.ß
*
* Refer to sampling settings section in
* [mw:Wikimedia Product/Analytics Infrastructure/Stream configuration](https://www.mediawiki.org/wiki/Wikimedia_Product/Analytics_Infrastructure/Stream_configuration)
* for more information.
*/
func inSample(stream: EventPlatformClient.Stream, config: EventPlatformClient.StreamConfiguration) -> Bool {
if let cachedValue = getSamplingForStream(stream) {
return cachedValue
}
guard let rate = config.sampling?.rate else {
/*
* If stream is present in streamConfigurations but doesn't have
* sampling settings, it is always in-sample.
*/
cacheSamplingForStream(stream, inSample: true)
return true
}
/*
* All platforms use session ID as the default identifier for determining
* in- vs out-of-sample of events sent to streams. On the web, streams can
* be set to use pageview token instead. On the apps, streams can be set
* to use device token instead.
*/
let sessionIdentifierType = "session"
let deviceIdentifierType = "device"
let identifierType = config.sampling?.identifier ?? sessionIdentifierType
let appInstallID = UserDefaults.standard.wmf_appInstallId
guard identifierType == sessionIdentifierType || identifierType == deviceIdentifierType else {
DDLogDebug("EPC: Logged to stream which is not configured for sampling based on \(sessionIdentifierType) or \(deviceIdentifierType) identifier")
cacheSamplingForStream(stream, inSample: false)
return false
}
guard let identifier = identifierType == sessionIdentifierType ? delegate?.sessionID : appInstallID else {
DDLogError("EPC: Missing token for determining in- vs out-of-sample. Falling back to out-of-sample.")
cacheSamplingForStream(stream, inSample: false)
return false
}
let result = determine(identifier, rate)
cacheSamplingForStream(stream, inSample: result)
return result
}
/**
* Yields a deterministic (not stochastic) determination of whether the
* provided `id` is in-sample or out-of-sample according to the `acceptance`
* rate
* - Parameter id: identifier to use for determining sampling
* - Parameter acceptance: the desired proportion of many `token`-s being
* accepted
*
* The algorithm works in a "widen the net on frozen fish" fashion -- tokens
* continue evaluating to true as the acceptance rate increases. For example,
* a device determined to be in-sample for a stream "A" having rate 0.1 will
* be determined to be in-sample for a stream "B" having rate 0.2, and its
* events will show up in tables "A" and "B".
*/
private func determine(_ id: String, _ acceptance: Double) -> Bool {
guard let token = UInt32(id.prefix(8), radix: 16) else {
return false
}
return (Double(token) / Double(UInt32.max)) < acceptance
}
/**
* Thread-safe asynchronous caching of a stream's in-vs-out-of-sample
* determination
* - Parameter stream: name of stream to cache determination for
* - Parameter inSample: whether the stream was determined to be in-sample
* this session
*/
func cacheSamplingForStream(_ stream: EventPlatformClient.Stream, inSample: Bool) {
queue.async {
self.samplingCache[stream] = inSample
}
}
/**
* Thread-safe synchronous retrieval of a stream's cached in-vs-out-of-sample determination
* - Parameter stream: name of stream to retrieve determination for from the cache
* - Returns: `true` if stream was determined to be in-sample this session, `false` otherwise
*/
func getSamplingForStream(_ stream: EventPlatformClient.Stream) -> Bool? {
queue.sync {
return self.samplingCache[stream]
}
}
/**
* Thread-safe asynchronous clearance of cached stream in-vs-out-of-sample determinations
*/
func removeAllSamplingCache() {
queue.async {
self.samplingCache.removeAll()
}
}
}

View File

@ -0,0 +1,201 @@
import Foundation
import CocoaLumberjackSwift
@objc (WMFEPCStorageManager)
public class StorageManager: NSObject {
private let managedObjectContext: NSManagedObjectContext
private let pruningAge: TimeInterval = 60*60*24*30 // 30 days
@objc(sharedInstance) public static let shared: StorageManager? = {
let fileManager = FileManager.default
var storageDirectory = fileManager.wmf_containerURL().appendingPathComponent("Event Platform", isDirectory: true)
do {
try fileManager.createDirectory(at: storageDirectory, withIntermediateDirectories: true, attributes: nil)
var values = URLResourceValues()
values.isExcludedFromBackup = true
try storageDirectory.setResourceValues(values)
} catch let error {
DDLogError("EPCStorageManager: Error creating Event Platform Client directory: \(error)")
}
let storageURL = storageDirectory.appendingPathComponent("EventPlatformEvents.sqlite")
DDLogDebug("EPC StorageManager: Events persistent store: \(storageURL)")
return StorageManager(storageURL: storageURL)
}()
private init?(storageURL: URL) {
guard let modelURL = Bundle.wmf.url(forResource: "EventPlatformEvents", withExtension: "momd"), let model = NSManagedObjectModel(contentsOf: modelURL) else {
return nil
}
let psc = NSPersistentStoreCoordinator(managedObjectModel: model)
let options = [NSMigratePersistentStoresAutomaticallyOption: NSNumber(booleanLiteral: true), NSInferMappingModelAutomaticallyOption: NSNumber(booleanLiteral: true)]
do {
try psc.addPersistentStore(ofType: NSSQLiteStoreType, configurationName: nil, at: storageURL, options: options)
} catch {
do {
try FileManager.default.removeItem(at: storageURL)
} catch {
}
do {
try psc.addPersistentStore(ofType: NSSQLiteStoreType, configurationName: nil, at: storageURL, options: options)
} catch {
DDLogError("EPC: Event Platform StorageManager: adding persistent store to coordinator: \(error)")
return nil
}
}
let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
managedObjectContext.persistentStoreCoordinator = psc
self.managedObjectContext = managedObjectContext
}
func push(data: Data, stream: EventPlatformClient.Stream) {
let now = Date()
perform { moc in
if let record = NSEntityDescription.insertNewObject(forEntityName: "WMFEPEventRecord", into: moc) as? EPEventRecord {
record.data = data
record.stream = stream.rawValue
record.recorded = now
DDLogDebug("EPC StorageManager: \(record.objectID) recorded!")
self.save(moc)
}
}
}
func popAll() -> [PersistedEvent] {
var events: [PersistedEvent] = []
performAndWait { moc in
let fetch: NSFetchRequest<EPEventRecord> = EPEventRecord.fetchRequest()
fetch.sortDescriptors = [NSSortDescriptor(keyPath: \EPEventRecord.recorded, ascending: true)]
fetch.predicate = NSPredicate(format: "(purgeable == FALSE)")
do {
var count = 0
let records = try moc.fetch(fetch)
for record in records {
guard let stream = EventPlatformClient.Stream(rawValue: record.stream) else {
continue
}
events.append(PersistedEvent(data: record.data, stream: stream, managedObjectURI: record.objectID.uriRepresentation()))
count += 1
}
if count > 0 {
DDLogDebug("EPC: Found \(count) events awaiting submission")
}
} catch let error {
DDLogError(error.localizedDescription)
}
}
return events
}
func markPurgeable(event: PersistedEvent) {
perform { moc in
do {
guard let psc = moc.persistentStoreCoordinator else {
DDLogWarn("EPC: Error getting persistent store coordinator")
return
}
guard let moid = psc.managedObjectID(forURIRepresentation: event.managedObjectURI) else {
DDLogWarn("EPC: Error getting managed object ID for URI \(event.managedObjectURI)")
return
}
guard let record = try moc.existingObject(with: moid) as? EPEventRecord else {
DDLogWarn("EPC: Tried to mark managed object \(moid) as purgeable, but it was not found")
return
}
record.purgeable = true
self.save(moc)
} catch let error {
DDLogError(error.localizedDescription)
}
}
}
func pruneStaleEvents(completion: @escaping (() -> Void)) {
perform { moc in
defer {
completion()
}
let pruneFetch = NSFetchRequest<NSFetchRequestResult>(entityName: "WMFEPEventRecord")
pruneFetch.returnsObjectsAsFaults = false
let pruneDate = Date().addingTimeInterval(-(self.pruningAge)) as NSDate
pruneFetch.predicate = NSPredicate(format: "(recorded < %@) OR (purgeable == TRUE)", pruneDate)
let delete = NSBatchDeleteRequest(fetchRequest: pruneFetch)
delete.resultType = .resultTypeCount
do {
let result = try moc.execute(delete)
guard let deleteResult = result as? NSBatchDeleteResult else {
DDLogError("EPC StorageManager: Could not read NSBatchDeleteResult")
return
}
guard let count = deleteResult.result as? Int else {
DDLogError("EPC StorageManager: Could not read NSBatchDeleteResult count")
return
}
if count > 0 {
DDLogInfo("EPC StorageManager: Pruned \(count) events")
}
} catch let error {
DDLogError("EPC StorageManager: Error pruning events: \(error.localizedDescription)")
}
}
}
private func save(_ moc: NSManagedObjectContext) {
guard moc.hasChanges else {
return
}
do {
try moc.save()
} catch let error {
DDLogError("EPC: Error saving StorageManager managedObjectContext: \(error)")
}
}
private func performAndWait(_ block: (_ moc: NSManagedObjectContext) -> Void) {
let moc = self.managedObjectContext
moc.performAndWait {
block(moc)
}
}
private func perform(_ block: @escaping (_ moc: NSManagedObjectContext) -> Void) {
let moc = self.managedObjectContext
moc.perform {
block(moc)
}
}
}
struct PersistedEvent: Codable {
let data: Data
let stream: EventPlatformClient.Stream
let managedObjectURI: URL
}
#if TEST
extension StorageManager {
var managedObjectContextToTest: NSManagedObjectContext { return managedObjectContext }
func testSave(_ moc: NSManagedObjectContext) {
save(moc)
}
}
#endif

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>EventLogging 2.xcdatamodel</string>
</dict>
</plist>

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="16119" systemVersion="19G73" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="WMFEventRecord" representedClassName="WMFEventRecord" syncable="YES">
<attribute name="event" attributeType="Transformable" valueTransformerName="WMFSecureUnarchiveFromDataTransformer"/>
<attribute name="failed" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="postAttempts" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="posted" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="recorded" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userAgent" optional="YES" attributeType="String"/>
<fetchIndex name="byRecordedIndex">
<fetchIndexElement property="recorded" type="Binary" order="ascending"/>
</fetchIndex>
</entity>
<entity name="WMFKeyValue" representedClassName="WMFKeyValue" syncable="YES">
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="group" optional="YES" attributeType="String"/>
<attribute name="key" attributeType="String"/>
<attribute name="value" optional="YES" attributeType="Transformable" valueTransformerName="WMFSecureUnarchiveFromDataTransformer"/>
<fetchIndex name="byKeyIndex">
<fetchIndexElement property="key" type="Binary" order="ascending"/>
</fetchIndex>
</entity>
<elements>
<element name="WMFEventRecord" positionX="-63" positionY="-18" width="128" height="133"/>
<element name="WMFKeyValue" positionX="-45" positionY="36" width="128" height="103"/>
</elements>
</model>

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="16119" systemVersion="19G73" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="WMFEventRecord" representedClassName="WMFEventRecord" syncable="YES">
<attribute name="event" attributeType="Transformable" valueTransformerName=""/>
<attribute name="failed" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="postAttempts" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="posted" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="recorded" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userAgent" optional="YES" attributeType="String"/>
<fetchIndex name="byRecordedIndex">
<fetchIndexElement property="recorded" type="Binary" order="ascending"/>
</fetchIndex>
</entity>
<entity name="WMFKeyValue" representedClassName="WMFKeyValue" syncable="YES">
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="group" optional="YES" attributeType="String"/>
<attribute name="key" attributeType="String"/>
<attribute name="value" optional="YES" attributeType="Transformable" valueTransformerName=""/>
<fetchIndex name="byKeyIndex">
<fetchIndexElement property="key" type="Binary" order="ascending"/>
</fetchIndex>
</entity>
<elements>
<element name="WMFEventRecord" positionX="-63" positionY="-18" width="128" height="133"/>
<element name="WMFKeyValue" positionX="-45" positionY="36" width="128" height="103"/>
</elements>
</model>

View File

@ -0,0 +1,468 @@
import Foundation
import CocoaLumberjackSwift
enum EventLoggingError {
case generic
case network
}
@objc(WMFEventLoggingService)
public class EventLoggingService : NSObject, URLSessionDelegate {
private struct Key {
static let isEnabled = "SendUsageReports"
static let appInstallID = "WMFAppInstallID"
static let lastLoggedSnapshot = "WMFLastLoggedSnapshot"
static let appInstallDate = "AppInstallDate"
static let loggedDaysInstalled = "DailyLoggingStatsDaysInstalled"
static let lastSuccessfulPost = "LastSuccessfulPost"
}
private var pruningAge: TimeInterval = 60*60*24*30 // 30 days
private var sendOnWWANThreshold: TimeInterval = 24 * 60 * 60
private var postBatchSize = 32
private var postTimeout: TimeInterval = 60*2 // 2 minutes
private var postInterval: TimeInterval = 60*10 // 10 minutes
private var debugDisableImmediateSend = false
private let session: Session
private let persistentStoreCoordinator: NSPersistentStoreCoordinator
private let managedObjectContext: NSManagedObjectContext
private let operationQueue: OperationQueue
@objc(sharedInstance) public static let shared: EventLoggingService? = {
let fileManager = FileManager.default
var permanentStorageDirectory = fileManager.wmf_containerURL().appendingPathComponent("Event Logging", isDirectory: true)
var didGetDirectoryExistsError = false
do {
try fileManager.createDirectory(at: permanentStorageDirectory, withIntermediateDirectories: true, attributes: nil)
} catch let error {
DDLogError("EventLoggingService: Error creating permanent cache: \(error)")
}
do {
var values = URLResourceValues()
values.isExcludedFromBackup = true
try permanentStorageDirectory.setResourceValues(values)
} catch let error {
DDLogError("EventLoggingService: Error excluding from backup: \(error)")
}
let permanentStorageURL = permanentStorageDirectory.appendingPathComponent("Events.sqlite")
DDLogDebug("EventLoggingService: Events persistent store: \(permanentStorageURL)")
// SINGLETONTODO
let eventLoggingService = EventLoggingService(session: MWKDataStore.shared().session, permanentStorageURL: permanentStorageURL)
if let eventLoggingService = eventLoggingService {
MWKDataStore.shared().setupAbTestsController(withPersistenceService: eventLoggingService)
}
return eventLoggingService
}()
@objc
public func log(event: [String: Any], schema: String, revision: Int, wiki: String) {
let event: NSDictionary = ["event": event, "schema": schema, "revision": revision, "wiki": wiki]
logEvent(event)
}
public init?(session: Session, permanentStorageURL: URL?) {
let bundle = Bundle.wmf
let modelURL = bundle.url(forResource: "EventLogging", withExtension: "momd")!
let model = NSManagedObjectModel(contentsOf: modelURL)!
let psc = NSPersistentStoreCoordinator(managedObjectModel: model)
let options = [NSMigratePersistentStoresAutomaticallyOption: NSNumber(booleanLiteral: true), NSInferMappingModelAutomaticallyOption: NSNumber(booleanLiteral: true)]
operationQueue = OperationQueue()
operationQueue.maxConcurrentOperationCount = 1
self.session = session
if let storeURL = permanentStorageURL {
do {
try psc.addPersistentStore(ofType: NSSQLiteStoreType, configurationName: nil, at: storeURL, options: options)
} catch {
do {
try FileManager.default.removeItem(at: storeURL)
} catch {
}
do {
try psc.addPersistentStore(ofType: NSSQLiteStoreType, configurationName: nil, at: storeURL, options: options)
} catch {
return nil
}
}
} else {
do {
try psc.addPersistentStore(ofType: NSInMemoryStoreType, configurationName: nil, at: nil, options: options)
} catch {
return nil
}
}
self.persistentStoreCoordinator = psc
self.managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
self.managedObjectContext.persistentStoreCoordinator = psc
super.init()
}
@objc
public func reset() {
self.resetSession()
self.resetInstall()
}
@objc
func migrateShareUsageAndInstallIDToUserDefaults() {
let enabledNumber = libraryValue(for: Key.isEnabled) as? NSNumber
if enabledNumber != nil {
UserDefaults.standard.wmf_sendUsageReports = enabledNumber!.boolValue
} else {
UserDefaults.standard.wmf_sendUsageReports = false
}
UserDefaults.standard.wmf_appInstallId = libraryValue(for: Key.appInstallID) as? String
}
@objc
private func logEvent(_ event: NSDictionary) {
let now = NSDate()
perform { moc in
let record = NSEntityDescription.insertNewObject(forEntityName: "WMFEventRecord", into: self.managedObjectContext) as! EventRecord
record.event = event
record.recorded = now
record.userAgent = WikipediaAppUtils.versionedUserAgent()
DDLogDebug("EventLoggingService: \(record.objectID) recorded!")
self.save(moc)
}
}
@objc
private func tryPostEvents(_ completion: (() -> Void)? = nil) {
let operation = AsyncBlockOperation { (operation) in
self.perform { moc in
let pruneFetch = NSFetchRequest<NSFetchRequestResult>(entityName: "WMFEventRecord")
pruneFetch.returnsObjectsAsFaults = false
let pruneDate = Date().addingTimeInterval(-(self.pruningAge)) as NSDate
pruneFetch.predicate = NSPredicate(format: "(recorded < %@) OR (posted != nil) OR (failed == TRUE)", pruneDate)
let delete = NSBatchDeleteRequest(fetchRequest: pruneFetch)
delete.resultType = .resultTypeCount
do {
let result = try self.managedObjectContext.execute(delete)
guard let deleteResult = result as? NSBatchDeleteResult else {
DDLogError("EventLoggingService: Could not read NSBatchDeleteResult")
return
}
guard let count = deleteResult.result as? Int else {
DDLogError("EventLoggingService: Could not read NSBatchDeleteResult count")
return
}
if count > 0 {
DDLogInfo("EventLoggingService: Pruned \(count) events")
}
} catch let error {
DDLogError("EventLoggingService: Error pruning events: \(error.localizedDescription)")
}
let fetch: NSFetchRequest<EventRecord> = EventRecord.fetchRequest()
fetch.sortDescriptors = [NSSortDescriptor(keyPath: \EventRecord.recorded, ascending: true)]
fetch.predicate = NSPredicate(format: "(posted == nil) AND (failed != TRUE)")
fetch.fetchLimit = self.postBatchSize
var eventRecords: [EventRecord] = []
do {
eventRecords = try moc.fetch(fetch)
} catch let error {
DDLogError(error.localizedDescription)
}
var wifiOnly = true
if let lastSuccessNumber = moc.wmf_keyValue(forKey: Key.lastSuccessfulPost)?.value as? NSNumber {
let now = CFAbsoluteTimeGetCurrent()
let interval = now - CFAbsoluteTime(lastSuccessNumber.doubleValue)
if interval > self.sendOnWWANThreshold {
wifiOnly = false
}
}
if !eventRecords.isEmpty {
self.postEvents(eventRecords, onlyWiFi: wifiOnly, completion: {
operation.finish()
})
} else {
operation.finish()
}
}
}
operationQueue.addOperation(operation)
guard let completion = completion else {
return
}
let completionBlockOp = BlockOperation(block: completion)
completionBlockOp.addDependency(operation)
operationQueue.addOperation(completion)
}
private func perform(_ block: @escaping (_ moc: NSManagedObjectContext) -> Void) {
let moc = self.managedObjectContext
moc.perform {
block(moc)
}
}
private func performAndWait(_ block: (_ moc: NSManagedObjectContext) -> Void) {
let moc = self.managedObjectContext
moc.performAndWait {
block(moc)
}
}
private func asyncSave() {
perform { (moc) in
self.save(moc)
}
}
private func postEvents(_ eventRecords: [EventRecord], onlyWiFi: Bool, completion: @escaping () -> Void) {
DDLogDebug("EventLoggingService: Posting \(eventRecords.count) events!")
let taskGroup = WMFTaskGroup()
var completedRecordIDs = Set<NSManagedObjectID>()
var failedRecordIDs = Set<NSManagedObjectID>()
for record in eventRecords {
let moid = record.objectID
guard let payload = record.event else {
failedRecordIDs.insert(moid)
continue
}
taskGroup.enter()
let userAgent = record.userAgent ?? WikipediaAppUtils.versionedUserAgent()
submit(payload: payload, userAgent: userAgent, onlyWiFi: onlyWiFi) { (error) in
if let error = error {
if error != .network {
failedRecordIDs.insert(moid)
}
} else {
completedRecordIDs.insert(moid)
}
taskGroup.leave()
}
}
taskGroup.waitInBackground {
self.perform { moc in
let postDate = NSDate()
for moid in completedRecordIDs {
let mo = try? self.managedObjectContext.existingObject(with: moid)
guard let record = mo as? EventRecord else {
continue
}
record.posted = postDate
}
for moid in failedRecordIDs {
let mo = try? self.managedObjectContext.existingObject(with: moid)
guard let record = mo as? EventRecord else {
continue
}
record.failed = true
}
if completedRecordIDs.count == eventRecords.count {
self.managedObjectContext.wmf_setValue(NSNumber(value: CFAbsoluteTimeGetCurrent()), forKey: Key.lastSuccessfulPost)
DDLogDebug("EventLoggingService: All records succeeded")
} else {
DDLogDebug("EventLoggingService: Some records failed")
}
self.save(moc)
completion()
}
}
}
private func submit(payload: NSObject, userAgent: String, onlyWiFi: Bool, completion: @escaping (EventLoggingError?) -> Void) {
guard let url = Configuration.current.eventLoggingAPIURL(with: payload) else {
DDLogError("EventLoggingService: Could not create URL")
completion(EventLoggingError.generic)
return
}
var request = URLRequest(url: url)
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
let session = onlyWiFi ? self.session.wifiOnlyURLSession : self.session.defaultURLSession
let task = session.dataTask(with: request, completionHandler: { (_, response, error) in
guard error == nil,
let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode / 100 == 2 else {
if let error = error as NSError?, error.domain == NSURLErrorDomain {
completion(EventLoggingError.network)
} else {
completion(EventLoggingError.generic)
}
return
}
completion(nil)
// DDLogDebug("EventLoggingService: event \(eventRecord.objectID) posted!")
})
task.resume()
}
// mark stored values
private func save(_ moc: NSManagedObjectContext) {
guard moc.hasChanges else {
return
}
do {
try moc.save()
} catch let error {
DDLogError("Error saving EventLoggingService managedObjectContext: \(error)")
}
}
private var semaphore = DispatchSemaphore(value: 1)
private var libraryValueCache: [String: NSCoding] = [:]
public func libraryValue(for key: String) -> NSCoding? {
semaphore.wait()
defer {
semaphore.signal()
}
var value = libraryValueCache[key]
if value != nil {
return value
}
performAndWait { moc in
value = managedObjectContext.wmf_keyValue(forKey: key)?.value
if value != nil {
libraryValueCache[key] = value
return
}
if let legacyValue = UserDefaults.standard.object(forKey: key) as? NSCoding {
value = legacyValue
libraryValueCache[key] = legacyValue
managedObjectContext.wmf_setValue(legacyValue, forKey: key)
UserDefaults.standard.removeObject(forKey: key)
save(moc)
}
}
return value
}
public func setLibraryValue(_ value: NSCoding?, for key: String) {
semaphore.wait()
defer {
semaphore.signal()
}
libraryValueCache[key] = value
perform { moc in
self.managedObjectContext.wmf_setValue(value, forKey: key)
self.save(moc)
}
}
@objc public var isEnabled: Bool {
return UserDefaults.standard.wmf_sendUsageReports
}
@objc public var lastLoggedSnapshot: NSCoding? {
get {
return libraryValue(for: Key.lastLoggedSnapshot)
}
set {
setLibraryValue(newValue, for: Key.lastLoggedSnapshot)
}
}
@objc public var appInstallDate: Date? {
get {
var value = libraryValue(for: Key.appInstallDate) as? Date
if value == nil {
value = Date()
setLibraryValue(value as NSDate?, for: Key.appInstallDate)
}
return value
}
set {
setLibraryValue(newValue as NSDate?, for: Key.appInstallDate)
}
}
@objc public var loggedDaysInstalled: NSNumber? {
get {
return libraryValue(for: Key.loggedDaysInstalled) as? NSNumber
}
set {
setLibraryValue(newValue, for: Key.loggedDaysInstalled)
}
}
private var _sessionID: String?
@objc public var sessionID: String? {
semaphore.wait()
defer {
semaphore.signal()
}
if _sessionID == nil {
_sessionID = UUID().uuidString
}
return _sessionID
}
private var _sessionStartDate: Date?
@objc public var sessionStartDate: Date? {
semaphore.wait()
defer {
semaphore.signal()
}
if _sessionStartDate == nil {
_sessionStartDate = Date()
}
return _sessionStartDate
}
@objc public func resetSession() {
semaphore.wait()
defer {
semaphore.signal()
}
_sessionID = nil
_sessionStartDate = Date()
}
private func resetInstall() {
UserDefaults.standard.wmf_appInstallId = nil
lastLoggedSnapshot = nil
loggedDaysInstalled = nil
appInstallDate = nil
}
}
extension EventLoggingService: PeriodicWorker {
public func doPeriodicWork(_ completion: @escaping () -> Void) {
tryPostEvents(completion)
}
}
extension EventLoggingService: BackgroundFetcher {
public func performBackgroundFetch(_ completion: @escaping (UIBackgroundFetchResult) -> Void) {
doPeriodicWork {
completion(.noData)
}
}
}
extension EventLoggingService: ABTestsPersisting {
}

View File

@ -0,0 +1,29 @@
@objc public protocol EventLoggingSearchSourceProviding {
var searchSource: String { get }
}
@objc public protocol EventLoggingEventValuesProviding {
var eventLoggingCategory: EventLoggingCategory { get }
var eventLoggingLabel: EventLoggingLabel? { get }
}
public protocol EventLoggingStandardEventProviding {
var standardEvent: [String: Any] { get }
}
public extension EventLoggingStandardEventProviding where Self: EventLoggingFunnel {
var standardEvent: [String: Any] {
guard let aii = appInstallID, let si = sessionID else {
return ["event_dt": timestamp]
}
return ["app_install_id": aii, "session_id": si, "event_dt": timestamp]
}
func wholeEvent(with event: [AnyHashable: Any]) -> [String: Any] {
guard let event = event as? [String: Any] else {
assertionFailure("Expected dictionary with keys of type String")
return [:]
}
return standardEvent.merging(event, uniquingKeysWith: { (first, _) in first })
}
}

View File

@ -0,0 +1,7 @@
import Foundation
import CoreData
@objc(WMFEventRecord)
public class EventRecord: NSManagedObject {
}

View File

@ -0,0 +1,19 @@
import Foundation
import CoreData
extension EventRecord {
@nonobjc public class func fetchRequest() -> NSFetchRequest<EventRecord> {
return NSFetchRequest<EventRecord>(entityName: "WMFEventRecord")
}
@NSManaged public var event: NSObject?
@NSManaged public var userAgent: String?
@NSManaged public var recorded: NSDate?
@NSManaged public var posted: NSDate?
@NSManaged public var postAttempts: Int16
@NSManaged public var failed: Bool
}

View File

@ -0,0 +1,391 @@
import UIKit
public protocol CardContent {
var view: UIView! { get }
func contentHeight(forWidth: CGFloat) -> CGFloat
}
// Allows the card background view to communicate with the cell to detect taps in the title area
// A Random article card navigates to different destinations depending on whether the area
// above or below the card content is tapped.
fileprivate protocol CardBackgroundViewDelegate: UIView {
func titleAreaYThreshold(for cardBackgroundView: CardBackgroundView) -> CGFloat // Return value in the coordinate system of the card background view
var titleAreaTapped: Bool { get set }
}
private class CardBackgroundView: UIView {
fileprivate weak var delegate: CardBackgroundViewDelegate?
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if let delegate = delegate {
let yThreshold = delegate.titleAreaYThreshold(for: self)
delegate.titleAreaTapped = point.y < yThreshold
}
return super.hitTest(point, with: event)
}
}
public protocol ExploreCardCollectionViewCellDelegate: AnyObject {
func exploreCardCollectionViewCellWantsCustomization(_ cell: ExploreCardCollectionViewCell)
func exploreCardCollectionViewCellWantsToUndoCustomization(_ cell: ExploreCardCollectionViewCell)
}
public class ExploreCardCollectionViewCell: CollectionViewCell, CardBackgroundViewDelegate, Themeable {
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
public let customizationButton = UIButton()
private let undoButton = UIButton()
private let undoLabel = UILabel()
private let footerButton = AlignedImageButton()
public weak var delegate: ExploreCardCollectionViewCellDelegate?
private let cardBackgroundView = CardBackgroundView()
private let cardCornerRadius = Theme.exploreCardCornerRadius
private let cardShadowRadius = CGFloat(10)
private let cardShadowOffset = CGSize(width: 0, height: 2)
public var titleAreaTapped: Bool = false
static let overflowImage = UIImage(named: "overflow")
public override func setup() {
super.setup()
titleLabel.numberOfLines = 0
contentView.addSubview(titleLabel)
subtitleLabel.numberOfLines = 0
contentView.addSubview(subtitleLabel)
customizationButton.setImage(ExploreCardCollectionViewCell.overflowImage, for: .normal)
customizationButton.contentEdgeInsets = .zero
customizationButton.imageEdgeInsets = .zero
customizationButton.titleEdgeInsets = .zero
customizationButton.titleLabel?.textAlignment = .center
customizationButton.addTarget(self, action: #selector(customizationButtonPressed), for: .touchUpInside)
cardBackgroundView.layer.cornerRadius = cardCornerRadius
cardBackgroundView.layer.shadowOffset = cardShadowOffset
cardBackgroundView.layer.shadowRadius = cardShadowRadius
cardBackgroundView.layer.shadowColor = cardShadowColor.cgColor
cardBackgroundView.layer.shadowOpacity = cardShadowOpacity
cardBackgroundView.layer.masksToBounds = false
cardBackgroundView.isOpaque = true
cardBackgroundView.delegate = self
contentView.addSubview(cardBackgroundView)
contentView.addSubview(customizationButton)
footerButton.imageIsRightAligned = true
let image = #imageLiteral(resourceName: "places-more").imageFlippedForRightToLeftLayoutDirection()
footerButton.setImage(image, for: .normal)
footerButton.isUserInteractionEnabled = false
footerButton.titleLabel?.numberOfLines = 0
footerButton.titleLabel?.textAlignment = .right
contentView.addSubview(footerButton)
undoLabel.numberOfLines = 0
contentView.addSubview(undoLabel)
undoButton.titleLabel?.numberOfLines = 0
undoButton.setTitle(CommonStrings.undo, for: .normal)
undoButton.addTarget(self, action: #selector(undoButtonPressed), for: .touchUpInside)
undoButton.isUserInteractionEnabled = true
undoButton.titleLabel?.textAlignment = .right
contentView.addSubview(undoButton)
}
// This method is called to reset the cell to the default configuration. It is called on initial setup and prepareForReuse. Subclassers should call super.
override open func reset() {
super.reset()
layoutMargins = UIEdgeInsets(top: 15, left: 13, bottom: 15, right: 13)
footerButton.isHidden = true
undoButton.isHidden = true
undoLabel.isHidden = true
}
public var cardContent: (CardContent & Themeable)? = nil {
didSet {
oldValue?.view?.removeFromSuperview()
guard let view = cardContent?.view else {
return
}
view.layer.cornerRadius = cardCornerRadius
contentView.addSubview(view)
}
}
fileprivate func titleAreaYThreshold(for cardBackgroundView: CardBackgroundView) -> CGFloat {
// The title area is defined to include card background from its top down to the bottom of the card content
// This registers taps on the side margins of the card content as in the title area
let yThreshold = cardContent?.view?.frame.maxY ?? 0.0
let convertedPoint = convert(CGPoint(x: 0.0, y: yThreshold), to: cardBackgroundView)
return convertedPoint.y
}
private var undoTitle: String? {
didSet {
undoLabel.text = undoTitle
}
}
public var footerTitle: String? {
get {
return footerButton.title(for: .normal)
}
set {
footerButton.setTitle(newValue, for: .normal)
footerButton.isHidden = newValue == nil
setNeedsLayout()
}
}
public var title: String? {
get {
return titleLabel.text
}
set {
titleLabel.text = newValue
setNeedsLayout()
}
}
public var subtitle: String? {
get {
return subtitleLabel.text
}
set {
subtitleLabel.text = newValue
setNeedsLayout()
}
}
public var isCustomizationButtonHidden: Bool {
get {
return customizationButton.isHidden
}
set {
customizationButton.isHidden = newValue
setNeedsLayout()
}
}
public var undoType: WMFContentGroupUndoType = .none {
didSet {
switch undoType {
case .contentGroup:
undoTitle = WMFLocalizedString("explore-feed-preferences-card-hidden-title", value: "Card hidden", comment: "Title for button that appears in place of feed card hidden by user via the overflow button")
isCollapsed = true
case .contentGroupKind:
guard let title = title else {
return
}
undoTitle = String.localizedStringWithFormat(WMFLocalizedString("explore-feed-preferences-feed-cards-hidden-title", value: "All %@ cards hidden", comment: "Title for cell that appears in place of feed card hidden by user via the overflow button - %@ is replaced with feed card type"), title)
isCollapsed = true
default:
isCollapsed = false
}
}
}
private var isCollapsed: Bool = false {
didSet {
if isCollapsed {
undoLabel.isHidden = false
customizationButton.isHidden = true
undoButton.isHidden = false
cardContent?.view.isHidden = true
titleLabel.isHidden = true
subtitleLabel.isHidden = true
footerButton.isHidden = true
UIAccessibility.post(notification: UIAccessibility.Notification.layoutChanged, argument: undoButton)
} else {
cardContent?.view.isHidden = false
undoLabel.isHidden = true
undoButton.isHidden = true
titleLabel.isHidden = title == nil
subtitleLabel.isHidden = subtitle == nil
footerButton.isHidden = footerTitle == nil
}
setNeedsLayout()
}
}
override public func sizeThatFits(_ size: CGSize, apply: Bool) -> CGSize {
let size = super.sizeThatFits(size, apply: apply) // intentionally shade size
var origin = CGPoint(x: layoutMargins.left, y: layoutMargins.top)
let widthMinusMargins = size.width - layoutMargins.left - layoutMargins.right
let isRTL = traitCollection.layoutDirection == .rightToLeft
let labelHorizontalAlignment: HorizontalAlignment = isRTL ? .right : .left
let buttonHorizontalAlignment: HorizontalAlignment = isRTL ? .left : .right
var customizationButtonDeltaWidthMinusMargins: CGFloat = 0
if !customizationButton.isHidden {
let customizationButtonSize = CGSize(width: 50, height: 50)
let customizationButtonNudgeWidth = round(0.55 * customizationButtonSize.width)
customizationButtonDeltaWidthMinusMargins = customizationButtonNudgeWidth
if apply {
let originX = isRTL ? layoutMargins.left - customizationButtonSize.width + customizationButtonNudgeWidth : size.width - layoutMargins.right - customizationButtonNudgeWidth
let originY = origin.y - round(0.25 * customizationButtonSize.height)
let customizationButtonOrigin = CGPoint(x: originX, y: originY)
customizationButton.frame = CGRect(origin: customizationButtonOrigin, size: customizationButtonSize)
}
}
var labelOrigin = origin
if isRTL {
labelOrigin.x += customizationButtonDeltaWidthMinusMargins
}
if !titleLabel.isHidden {
origin.y += titleLabel.wmf_preferredHeight(at: labelOrigin, maximumWidth: widthMinusMargins - customizationButtonDeltaWidthMinusMargins, horizontalAlignment: labelHorizontalAlignment, spacing: 4, apply: apply)
labelOrigin.y = origin.y
}
if !subtitleLabel.isHidden {
origin.y += subtitleLabel.wmf_preferredHeight(at: labelOrigin, maximumWidth: widthMinusMargins - customizationButtonDeltaWidthMinusMargins, horizontalAlignment: labelHorizontalAlignment, spacing: 20, apply: apply)
}
if let cardContent = cardContent, !cardContent.view.isHidden {
let view = cardContent.view
let height = cardContent.contentHeight(forWidth: widthMinusMargins)
let cardContentViewFrame = CGRect(origin: origin, size: CGSize(width: widthMinusMargins, height: height))
if apply {
view?.frame = cardContentViewFrame
cardBackgroundView.frame = cardContentViewFrame.insetBy(dx: -cardBorderWidth, dy: -cardBorderWidth)
}
origin.y += cardContentViewFrame.height
}
if isCollapsed, !undoLabel.isHidden, !undoButton.isHidden {
let undoOffset: UIOffset = UIOffset(horizontal: 15, vertical: 16)
labelOrigin.x += undoOffset.horizontal
labelOrigin.y += undoOffset.vertical
let undoButtonMaxWidthPercentage: CGFloat = 0.25
let undoLabelMaxWidth = widthMinusMargins - (widthMinusMargins * undoButtonMaxWidthPercentage)
let undoLabelMinWidth = widthMinusMargins * 0.5
let undoLabelX = isRTL ? widthMinusMargins - undoLabelMaxWidth : labelOrigin.x
let undoLabelFrameHeight = undoLabel.wmf_preferredHeight(at: CGPoint(x: undoLabelX, y: labelOrigin.y), maximumWidth: undoLabelMaxWidth, minimumWidth: undoLabelMinWidth, horizontalAlignment: labelHorizontalAlignment, spacing: 0, apply: apply)
let undoButtonMaxWidth = widthMinusMargins * undoButtonMaxWidthPercentage
let undoButtonX = isRTL ? labelOrigin.x : widthMinusMargins - undoButtonMaxWidth
let undoButtonMinSize = CGSize(width: UIView.noIntrinsicMetric, height: undoLabelFrameHeight)
let undoButtonMaxSize = CGSize(width: undoButtonMaxWidth, height: UIView.noIntrinsicMetric)
let undoButtonFrame = undoButton.wmf_preferredFrame(at: CGPoint(x: undoButtonX, y: labelOrigin.y), maximumSize: undoButtonMaxSize, minimumSize: undoButtonMinSize, horizontalAlignment: buttonHorizontalAlignment, apply: apply)
let undoHeight = max(undoLabelFrameHeight, undoButtonFrame.height)
// If cardBackgroundView metrics change double check the hitTest() override in CardBackgroundView
let cardBackgroundViewHeight = undoHeight + undoOffset.vertical * 2
let cardBackgroundViewFrame = CGRect(x: layoutMargins.left, y: layoutMargins.top, width: widthMinusMargins, height: cardBackgroundViewHeight)
if apply {
cardBackgroundView.frame = cardBackgroundViewFrame
}
origin.y += cardBackgroundViewFrame.height
}
if !footerButton.isHidden {
origin.y += layoutMargins.bottom
origin.y += footerButton.wmf_preferredHeight(at: origin, maximumWidth: widthMinusMargins, horizontalAlignment: buttonHorizontalAlignment, spacing: 0, apply: apply)
}
origin.y += layoutMargins.bottom
let totalSize = CGSize(width: size.width, height: ceil(origin.y))
if apply {
cardBackgroundView.layer.shadowPath = UIBezierPath(roundedRect: cardBackgroundView.bounds, cornerRadius: cardBackgroundView.layer.cornerRadius).cgPath
}
return totalSize
}
public override func updateFonts(with traitCollection: UITraitCollection) {
super.updateFonts(with: traitCollection)
titleLabel.font = UIFont.wmf_font(.semiboldSubheadline, compatibleWithTraitCollection: traitCollection)
subtitleLabel.font = UIFont.wmf_font(.subheadline, compatibleWithTraitCollection: traitCollection)
footerButton.titleLabel?.font = UIFont.wmf_font(.semiboldSubheadline, compatibleWithTraitCollection: traitCollection)
undoLabel.font = UIFont.wmf_font(.subheadline, compatibleWithTraitCollection: traitCollection)
undoButton.titleLabel?.font = UIFont.wmf_font(.semiboldSubheadline, compatibleWithTraitCollection: traitCollection)
customizationButton.titleLabel?.font = UIFont.wmf_font(.boldTitle1, compatibleWithTraitCollection: traitCollection)
}
private var cardShadowColor: UIColor = .black {
didSet {
cardBackgroundView.layer.shadowColor = cardShadowColor.cgColor
}
}
private var cardShadowOpacity: Float = 0 {
didSet {
guard cardBackgroundView.layer.shadowOpacity != cardShadowOpacity else {
return
}
cardBackgroundView.layer.shadowOpacity = cardShadowOpacity
}
}
private var cardBorderWidth: CGFloat = 1 {
didSet {
cardBackgroundView.layer.borderWidth = cardBorderWidth
}
}
public override func updateBackgroundColorOfLabels() {
super.updateBackgroundColorOfLabels()
titleLabel.backgroundColor = labelBackgroundColor
subtitleLabel.backgroundColor = labelBackgroundColor
footerButton.backgroundColor = labelBackgroundColor
undoLabel.backgroundColor = labelBackgroundColor
undoButton.backgroundColor = labelBackgroundColor
customizationButton.backgroundColor = labelBackgroundColor
}
public func apply(theme: Theme) {
contentView.tintColor = theme.colors.link
let backgroundColor = isCollapsed ? theme.colors.cardButtonBackground : theme.colors.paperBackground
let selectedBackgroundColor = isCollapsed ? theme.colors.cardButtonBackground : theme.colors.midBackground
let cardBackgroundViewBorderColor = isCollapsed ? backgroundColor.cgColor : theme.colors.cardBorder.cgColor
cardBackgroundView.layer.borderColor = cardBackgroundViewBorderColor
setBackgroundColors(.clear, selected: selectedBackgroundColor)
titleLabel.textColor = theme.colors.primaryText
subtitleLabel.textColor = theme.colors.secondaryText
customizationButton.setTitleColor(theme.colors.link, for: .normal)
footerButton.setTitleColor(theme.colors.link, for: .normal)
undoLabel.textColor = theme.colors.primaryText
undoButton.setTitleColor(theme.colors.link, for: .normal)
updateSelectedOrHighlighted()
cardBackgroundView.backgroundColor = backgroundColor
cardShadowOpacity = theme.cardShadowOpacity
cardShadowColor = theme.colors.cardShadow
cardContent?.apply(theme: theme)
let displayScale = max(1, traitCollection.displayScale)
cardBorderWidth = CGFloat(theme.cardBorderWidthInPixels) / displayScale
}
@objc func customizationButtonPressed() {
delegate?.exploreCardCollectionViewCellWantsCustomization(self)
}
@objc func undoButtonPressed() {
delegate?.exploreCardCollectionViewCellWantsToUndoCustomization(self)
}
// MARK: - Accessibility
override open func updateAccessibilityElements() {
var updatedAccessibilityElements: [Any] = []
if isCollapsed {
updatedAccessibilityElements.append(undoLabel)
updatedAccessibilityElements.append(undoButton)
} else {
let groupedLabels = [titleLabel, subtitleLabel]
let customizeActionTitle = WMFLocalizedString("explore-feed-customize-accessibility-title", value: "Customize", comment: "Accessibility title for feed customization")
let customizeAction = UIAccessibilityCustomAction(name: customizeActionTitle, target: self, selector: #selector(customizationButtonPressed))
updatedAccessibilityElements.append(LabelGroupAccessibilityElement(view: self, labels: groupedLabels, actions: [customizeAction]))
if let contentView = cardContent?.view {
updatedAccessibilityElements.append(contentView)
}
if !footerButton.isHidden, let label = footerButton.titleLabel {
let footerElement = UIAccessibilityElement(accessibilityContainer: self)
footerElement.isAccessibilityElement = true
footerElement.accessibilityLabel = label.text
footerElement.accessibilityTraits = UIAccessibilityTraits.link
footerElement.accessibilityFrameInContainerSpace = footerButton.frame
updatedAccessibilityElements.append(footerElement)
}
}
accessibilityElements = updatedAccessibilityElements
}
}

View File

@ -0,0 +1,110 @@
@objc public class ExploreFeedPreferencesUpdateCoordinator: NSObject {
private unowned let feedContentController: WMFExploreFeedContentController
private var oldExploreFeedPreferences = [String: Any]()
private var newExploreFeedPreferences = [String: Any]()
private var willTurnOnContentGroupOrLanguage = false
private var updateFeed: Bool = true
@objc public init(feedContentController: WMFExploreFeedContentController) {
self.feedContentController = feedContentController
}
@objc public func configure(oldExploreFeedPreferences: [String: Any], newExploreFeedPreferences: [String: Any], willTurnOnContentGroupOrLanguage: Bool, updateFeed: Bool) {
self.oldExploreFeedPreferences = oldExploreFeedPreferences
self.newExploreFeedPreferences = newExploreFeedPreferences
self.willTurnOnContentGroupOrLanguage = willTurnOnContentGroupOrLanguage
self.updateFeed = updateFeed
}
@objc public func coordinateUpdate(from viewController: UIViewController) {
if willTurnOnContentGroupOrLanguage {
guard UserDefaults.standard.defaultTabType == .settings else {
feedContentController.saveNewExploreFeedPreferences(newExploreFeedPreferences, apply: true, updateFeed: updateFeed)
return
}
guard areAllLanguagesTurnedOff(in: oldExploreFeedPreferences) else {
feedContentController.saveNewExploreFeedPreferences(newExploreFeedPreferences, apply: true, updateFeed: updateFeed)
return
}
guard areGlobalCardsTurnedOff(in: oldExploreFeedPreferences) else {
feedContentController.saveNewExploreFeedPreferences(newExploreFeedPreferences, apply: true, updateFeed: updateFeed)
return
}
present(turnOnExploreAlertController, from: viewController)
} else {
guard UserDefaults.standard.defaultTabType == .explore else {
feedContentController.saveNewExploreFeedPreferences(newExploreFeedPreferences, apply: true, updateFeed: updateFeed)
return
}
guard areAllLanguagesTurnedOff(in: newExploreFeedPreferences) else {
feedContentController.saveNewExploreFeedPreferences(newExploreFeedPreferences, apply: true, updateFeed: updateFeed)
return
}
guard areGlobalCardsTurnedOff(in: newExploreFeedPreferences) else {
feedContentController.saveNewExploreFeedPreferences(newExploreFeedPreferences, apply: true, updateFeed: updateFeed)
return
}
present(turnOffExploreAlertController, from: viewController)
}
}
private lazy var turnOffExploreAlertController: UIAlertController = {
let alertController = UIAlertController(title: WMFLocalizedString("explore-feed-preferences-turn-off-explore-feed-alert-title", value: "Turn off Explore feed?", comment: "Title for alert that allows user to decide whether they want to turn off Explore feed"), message: WMFLocalizedString("explore-feed-preferences-turn-off-explore-feed-alert-message", value: "Hiding all Explore feed cards will turn off the Explore tab and replace it with a Settings tab", comment: "Message for alert that allows user to decide whether they want to turn off Explore feed"), preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: WMFLocalizedString("explore-feed-preferences-turn-off-explore-feed-alert-action-title", value: "Turn off Explore feed", comment: "Title for action alert that allows user to turn off Explore feed"), style: .destructive, handler: { _ in
UserDefaults.standard.defaultTabType = .settings
self.feedContentController.saveNewExploreFeedPreferences(self.newExploreFeedPreferences, apply: true, updateFeed: self.updateFeed)
}))
alertController.addAction(UIAlertAction(title: CommonStrings.cancelActionTitle, style: .cancel, handler: { _ in
self.feedContentController.rejectNewExploreFeedPreferences()
}))
return alertController
}()
private lazy var turnOnExploreAlertController: UIAlertController = {
let alertController = UIAlertController(title: CommonStrings.turnOnExploreTabTitle, message: WMFLocalizedString("explore-feed-preferences-turn-on-explore-feed-alert-message", value: "By choosing to show Explore feed cards you are turning on the Explore tab", comment: "Message for alert that allows user to decide whether they want to turn on Explore feed"), preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: CommonStrings.turnOnExploreActionTitle, style: .default, handler: { _ in
self.feedContentController.saveNewExploreFeedPreferences(self.newExploreFeedPreferences, apply: true, updateFeed: self.updateFeed)
UserDefaults.standard.defaultTabType = .explore
}))
alertController.addAction(UIAlertAction(title: CommonStrings.cancelActionTitle, style: .cancel, handler: { _ in
self.feedContentController.rejectNewExploreFeedPreferences()
}))
return alertController
}()
private func present(_ alertController: UIAlertController, from presenter: UIViewController) {
if let presenter = presenter.presentedViewController {
if presenter is UINavigationController {
presenter.present(alertController, animated: true)
}
} else {
presenter.present(alertController, animated: true)
}
}
private func areAllLanguagesTurnedOff(in exploreFeedPreferences: [String: Any]) -> Bool {
guard exploreFeedPreferences.count == 1 else {
return false
}
guard exploreFeedPreferences.first?.key == WMFExploreFeedPreferencesGlobalCardsKey else {
assertionFailure("Expected value with key WMFExploreFeedPreferencesGlobalCardsKey")
return false
}
return true
}
private func globalCardPreferences(in exploreFeedPreferences: [String: Any]) -> [NSNumber: NSNumber]? {
guard let globalCardPreferences = exploreFeedPreferences[WMFExploreFeedPreferencesGlobalCardsKey] as? [NSNumber: NSNumber] else {
assertionFailure("Expected value of type Dictionary<NSNumber, NSNumber>")
return nil
}
return globalCardPreferences
}
private func areGlobalCardsTurnedOff(in exploreFeedPreferences: [String: Any]) -> Bool {
guard let globalCardPreferences = globalCardPreferences(in: exploreFeedPreferences) else {
return false
}
return globalCardPreferences.values.filter { $0.boolValue == true }.isEmpty
}
}

View File

@ -0,0 +1,45 @@
import UIKit
open class ExtensionViewController: UIViewController, Themeable {
public final var theme: Theme = Theme.widgetLight
open func apply(theme: Theme) {
self.theme = theme
}
var isFirstLayout = true
open override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
updateThemeFromTraitCollection(force: isFirstLayout)
isFirstLayout = false
}
private func updateThemeFromTraitCollection(force: Bool = false) {
let compatibleTheme = Theme.widgetThemeCompatible(with: traitCollection)
guard theme !== compatibleTheme else {
if force {
apply(theme: theme)
}
return
}
apply(theme: compatibleTheme)
}
override open func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
updateThemeFromTraitCollection()
}
public func openAppInActivity(with activityType: WMFUserActivityType) {
self.extensionContext?.open(NSUserActivity.wmf_baseURLForActivity(of: activityType))
}
public func openApp(with url: URL?, fallback fallbackURL: URL? = nil) {
guard let wikipediaSchemeURL = url?.replacingSchemeWithWikipediaScheme ?? fallbackURL else {
openAppInActivity(with: .explore)
return
}
self.extensionContext?.open(wikipediaSchemeURL)
}
}

View File

@ -0,0 +1,76 @@
import UIKit
public protocol FakeProgressReceiving {
var progress: Float { get }
func setProgress(_ progress: Float, animated: Bool)
}
public protocol FakeProgressDelegate: AnyObject {
func setProgressHidden(_ hidden: Bool, animated: Bool)
}
public class FakeProgressController: NSObject {
private let progress: FakeProgressReceiving
weak var delegate: FakeProgressDelegate?
public var minVisibleDuration: TimeInterval = 0.7
public var delay: TimeInterval = 1.0
public init(progress: FakeProgressReceiving, delegate: FakeProgressDelegate?) {
self.progress = progress
self.delegate = delegate
}
deinit {
NSObject.cancelPreviousPerformRequests(withTarget: self)
}
// MARK: - Progress
@objc private func incrementProgress() {
guard !isProgressHidden && progress.progress <= 0.69 else {
return
}
let rand = 0.15 + Float(arc4random_uniform(15))/100
progress.setProgress(progress.progress + rand, animated: true)
perform(#selector(incrementProgress), with: nil, afterDelay: 0.3)
}
fileprivate var isProgressHidden: Bool = true
@objc private func hideProgress() {
isProgressHidden = true
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(incrementProgress), object: nil)
self.delegate?.setProgressHidden(true, animated: true)
}
@objc private func showProgress() {
isProgressHidden = false
self.delegate?.setProgressHidden(false, animated: false)
}
private func cancelPreviousShowsAndHides() {
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(showProgress), object: nil)
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(hideProgress), object: nil)
}
public func start() {
assert(Thread.isMainThread)
cancelPreviousShowsAndHides()
perform(#selector(showProgress), with: nil, afterDelay: delay)
progress.setProgress(0, animated: false)
perform(#selector(incrementProgress), with: nil, afterDelay: 0.3)
}
public func stop() {
assert(Thread.isMainThread)
cancelPreviousShowsAndHides()
perform(#selector(hideProgress), with: nil, afterDelay: minVisibleDuration)
}
public func finish() {
progress.setProgress(1.0, animated: true)
stop()
}
}

View File

@ -0,0 +1,9 @@
import Foundation
public struct FeatureFlags {
public static var needsNewTalkPage: Bool {
return true
}
}

View File

@ -0,0 +1,514 @@
import Foundation
// Base class for combining a Session and Configuration to make network requests
// Session handles constructing and making requests, Configuration handles url structure for the current target
// TODO: Standardize on returning CancellationKey or URLSessionTask
// TODO: Centralize cancellation and remove other cancellation implementations (ReadingListsAPI)
// TODO: Think about utilizing a request buildler instead of so many separate functions
// TODO: Utilize Result type where possible (Swift only)
@objc(WMFFetcher)
open class Fetcher: NSObject {
@objc public let configuration: Configuration
@objc public let session: Session
public typealias CancellationKey = String
private var tasks = [String: URLSessionTask]()
private let semaphore = DispatchSemaphore.init(value: 1)
@objc override public convenience init() {
let dataStore = MWKDataStore.shared()
self.init(session: dataStore.session, configuration: dataStore.configuration)
}
@objc required public init(session: Session, configuration: Configuration) {
self.session = session
self.configuration = configuration
}
@discardableResult public func requestMediaWikiAPIAuthToken(for URL: URL?, type: TokenType, cancellationKey: CancellationKey? = nil, completionHandler: @escaping (Result<Token, Error>) -> Swift.Void) -> URLSessionTask? {
let parameters = [
"action": "query",
"meta": "tokens",
"type": type.stringValue,
"format": "json"
]
return performMediaWikiAPIPOST(for: URL, with: parameters) { (result, response, error) in
if let error = error {
completionHandler(Result.failure(error))
return
}
guard
let query = result?["query"] as? [String: Any],
let tokens = query["tokens"] as? [String: Any],
let tokenValue = tokens[type.stringValue + "token"] as? String
else {
completionHandler(Result.failure(RequestError.unexpectedResponse))
return
}
guard !tokenValue.isEmpty else {
completionHandler(Result.failure(RequestError.unexpectedResponse))
return
}
completionHandler(Result.success(Token(value: tokenValue, type: type)))
}
}
@objc(requestMediaWikiAPIAuthToken:withType:cancellationKey:completionHandler:)
@discardableResult public func requestMediaWikiAPIAuthToken(for URL: URL?, with type: TokenType, cancellationKey: CancellationKey? = nil, completionHandler: @escaping (Token?, Error?) -> Swift.Void) -> URLSessionTask? {
return requestMediaWikiAPIAuthToken(for: URL, type: type, cancellationKey: cancellationKey) { (result) in
switch result {
case .failure(let error):
completionHandler(nil, error)
case .success(let token):
completionHandler(token, nil)
}
}
}
@objc(performTokenizedMediaWikiAPIPOSTWithTokenType:toURL:withBodyParameters:cancellationKey:reattemptLoginOn401Response:completionHandler:)
@discardableResult public func performTokenizedMediaWikiAPIPOST(tokenType: TokenType = .csrf, to URL: URL?, with bodyParameters: [String: String]?, cancellationKey: CancellationKey? = nil, reattemptLoginOn401Response: Bool = true, completionHandler: @escaping ([String: Any]?, HTTPURLResponse?, Error?) -> Swift.Void) -> CancellationKey? {
let key = cancellationKey ?? UUID().uuidString
let task = requestMediaWikiAPIAuthToken(for: URL, type: tokenType, cancellationKey: key) { (result) in
switch result {
case .failure(let error):
completionHandler(nil, nil, error)
self.untrack(taskFor: key)
case .success(let token):
var mutableBodyParameters = bodyParameters ?? [:]
mutableBodyParameters[tokenType.parameterName] = token.value
self.performMediaWikiAPIPOST(for: URL, with: mutableBodyParameters, cancellationKey: key, reattemptLoginOn401Response: reattemptLoginOn401Response, completionHandler: completionHandler)
}
}
track(task: task, for: key)
return key
}
@objc(performMediaWikiAPIPOSTForURL:withBodyParameters:cancellationKey:reattemptLoginOn401Response:completionHandler:)
@discardableResult public func performMediaWikiAPIPOST(for URL: URL?, with bodyParameters: [String: String]?, cancellationKey: CancellationKey? = nil, reattemptLoginOn401Response: Bool = true, completionHandler: @escaping ([String: Any]?, HTTPURLResponse?, Error?) -> Swift.Void) -> URLSessionTask? {
let url = configuration.mediaWikiAPIURLForURL(URL, with: nil)
let key = cancellationKey ?? UUID().uuidString
let task = session.postFormEncodedBodyParametersToURL(to: url, bodyParameters: bodyParameters, reattemptLoginOn401Response:reattemptLoginOn401Response) { (result, response, error) in
completionHandler(result, response, error)
self.untrack(taskFor: key)
}
track(task: task, for: key)
return task
}
@objc(performMediaWikiAPIGETForURL:withQueryParameters:cancellationKey:completionHandler:)
@discardableResult public func performMediaWikiAPIGET(for URL: URL?, with queryParameters: [String: Any]?, cancellationKey: CancellationKey?, completionHandler: @escaping ([String: Any]?, HTTPURLResponse?, Error?) -> Swift.Void) -> URLSessionTask? {
let url = configuration.mediaWikiAPIURLForURL(URL, with: queryParameters)
let key = cancellationKey ?? UUID().uuidString
let task = session.getJSONDictionary(from: url) { (result, response, error) in
let returnError = error ?? RequestError.from(result?["error"] as? [String : Any])
completionHandler(result, response, returnError)
self.untrack(taskFor: key)
}
track(task: task, for: key)
return task
}
@objc(performMediaWikiAPIGETForURLRequest:cancellationKey:completionHandler:)
@discardableResult public func performMediaWikiAPIGET(for urlRequest: URLRequest, cancellationKey: CancellationKey?, completionHandler: @escaping ([String: Any]?, HTTPURLResponse?, Error?) -> Swift.Void) -> URLSessionTask? {
let key = cancellationKey ?? UUID().uuidString
let task = session.getJSONDictionary(from: urlRequest) { (result, response, error) in
let returnError = error ?? RequestError.from(result?["error"] as? [String : Any])
completionHandler(result, response, returnError)
self.untrack(taskFor: key)
}
track(task: task, for: key)
return task
}
// MARK: Resolving MediaWiki Errors For Display
/// Chain from MediaWiki API response if you want to resolve a set of error messages into a full html string for display. Use this method for raw dictionary responses. For Swift Codable responses, use resolveMediaWikiBlockedError(from apiErrors: [MediaWikiAPIError]...).
/// - Parameters:
/// - result: Serialized dictionary from MediaWiki API response
/// - completionHandler: Completion handler called when full html is determined, which is packaged up in a MediaWikiAPIDisplayError object.
@objc(resolveMediaWikiApiErrorFromResult:siteURL:completionHandler:)
func resolveMediaWikiApiErrorFromResult(_ result: [String: Any], siteURL: URL, completionHandler: @escaping (MediaWikiAPIDisplayError?) -> Void) {
var apiErrors: [MediaWikiAPIError] = []
guard let errorsDict = result["errors"] as? [[String: Any]] else {
completionHandler(nil)
return
}
for errorDict in errorsDict {
if let error = MediaWikiAPIError(dict: errorDict) {
apiErrors.append(error)
}
}
resolveMediaWikiError(from: apiErrors, siteURL: siteURL, completion: completionHandler)
}
/// Chain from MediaWiki API response if you want to resolve a set of error messages into a full html string for display. Use from Swift Codable responses that capture a collection of [MediaWikiAPIError] items.
/// - Parameters:
/// - apiErrors: Decoded MediaWikiAPIError items from API response
/// - completion: Called when full html is determined, which is packaged up in a MediaWikiAPIDisplayError object.
public func resolveMediaWikiError(from apiErrors: [MediaWikiAPIError], siteURL: URL, completion: @escaping (MediaWikiAPIDisplayError?) -> Void) {
let protectedPageError = apiErrors.filter { $0.code.contains("protectedpage") }
let blockedApiErrors = apiErrors.filter { $0.code.contains("block") }
let firstBlockedApiErrorWithInfo = blockedApiErrors.first(where: { $0.data?.blockInfo != nil })
let fallbackBlockedApiError = blockedApiErrors.first(where: { !$0.html.isEmpty })
let firstAbuseFilterError = apiErrors.first(where: { $0.code.contains("abusefilter") && !$0.html.isEmpty })
let fallbackCompletion: () -> Void = {
guard let fallbackBlockedApiError else {
guard let firstAbuseFilterError else {
completion(nil)
return
}
let displayError = MediaWikiAPIDisplayError(messageHtml: firstAbuseFilterError.html, linkBaseURL: siteURL, code: firstAbuseFilterError.code)
completion(displayError)
return
}
let displayError = MediaWikiAPIDisplayError(messageHtml: fallbackBlockedApiError.html, linkBaseURL: siteURL, code: fallbackBlockedApiError.code)
completion(displayError)
return
}
if let blockedApiError = firstBlockedApiErrorWithInfo,
let blockedApiInfo = blockedApiError.data?.blockInfo {
resolveMediaWikiApiBlockError(siteURL: siteURL, code: blockedApiError.code, html: blockedApiError.html, blockInfo: blockedApiInfo) { displayError in
guard let displayError = displayError else {
fallbackCompletion()
return
}
completion(displayError)
}
} else if let protectedPageError = protectedPageError.first(where: {!$0.html.isEmpty}) {
let displayError = MediaWikiAPIDisplayError(messageHtml: protectedPageError.html, linkBaseURL: siteURL, code: protectedPageError.code)
completion(displayError)
} else {
fallbackCompletion()
}
}
private func resolveMediaWikiApiBlockError(siteURL: URL, code: String, html: String, blockInfo: MediaWikiAPIError.Data.BlockInfo, completionHandler: @escaping (MediaWikiAPIDisplayError?) -> Void) {
// First turn blockReason into html, if needed
let group = DispatchGroup()
var blockReasonHtml: String?
var templateHtml: String?
var templateSiteURL: URL?
group.enter()
parseBlockReason(siteURL: siteURL, blockReason: blockInfo.blockReason) { text in
blockReasonHtml = text
group.leave()
}
group.enter()
fetchBlockedTextTemplate(isPartial: blockInfo.blockPartial, siteURL: siteURL) { text, siteURL in
templateHtml = text
templateSiteURL = siteURL
group.leave()
}
group.notify(queue: DispatchQueue.global(qos: .default)) {
guard var templateHtml = templateHtml else {
completionHandler(nil)
return
}
let linkBaseURL = templateSiteURL ?? siteURL
// Replace encoded placeholders first, before replacing them with blocked text.
templateHtml = templateHtml.replacingOccurrences(of: "%241", with: "$1")
templateHtml = templateHtml.replacingOccurrences(of: "%242", with: "$2")
templateHtml = templateHtml.replacingOccurrences(of: "%243", with: "") // stripped out below
templateHtml = templateHtml.replacingOccurrences(of: "%244", with: "") // stripped out below
templateHtml = templateHtml.replacingOccurrences(of: "%245", with: "$5")
templateHtml = templateHtml.replacingOccurrences(of: "%246", with: "$6")
templateHtml = templateHtml.replacingOccurrences(of: "%247", with: "$7")
templateHtml = templateHtml.replacingOccurrences(of: "%248", with: "$8")
// Replace placeholders with blocked text
templateHtml = templateHtml.replacingOccurrences(of: "$1", with: blockInfo.blockedBy)
if let blockReasonHtml {
templateHtml = templateHtml.replacingOccurrences(of: "$2", with: blockReasonHtml)
}
templateHtml = templateHtml.replacingOccurrences(of: "$3", with: "") // IP Address
templateHtml = templateHtml.replacingOccurrences(of: "$4", with: "") // unknown parameter (unused?)
templateHtml = templateHtml.replacingOccurrences(of: "$5", with: String(blockInfo.blockID))
let blockExpiryDisplayDate = self.blockedDateForDisplay(iso8601DateString: blockInfo.blockExpiry, siteURL: linkBaseURL)
templateHtml = templateHtml.replacingOccurrences(of: "$6", with: blockExpiryDisplayDate)
let username = MWKDataStore.shared().authenticationManager.loggedInUsername ?? ""
templateHtml = templateHtml.replacingOccurrences(of: "$7", with: username)
let blockedTimestampDisplayDate = self.blockedDateForDisplay(iso8601DateString: blockInfo.blockedTimestamp, siteURL: linkBaseURL)
templateHtml = templateHtml.replacingOccurrences(of: "$8", with: blockedTimestampDisplayDate)
let displayError = MediaWikiAPIDisplayError(messageHtml: templateHtml, linkBaseURL: linkBaseURL, code: code)
completionHandler(displayError)
}
}
private func blockedDateForDisplay(iso8601DateString: String, siteURL: URL) -> String {
var formattedDateString: String? = nil
if let date = (iso8601DateString as NSString).wmf_iso8601Date() {
let dateFormatter = DateFormatter.wmf_localCustomShortDateFormatterWithTime(for: NSLocale.wmf_locale(for: siteURL.wmf_languageCode))
formattedDateString = dateFormatter?.string(from: date)
}
return formattedDateString ?? ""
}
private func parseBlockReason(attempt: Int = 1, siteURL: URL, blockReason: String, completion: @escaping (String?) -> Void) {
let params: [String: Any] = [
"action": "parse",
"prop": "text",
"mobileformat": 1,
"text": blockReason,
"errorformat": "html",
"erroruselocal": 1,
"format": "json",
"formatversion": 2
]
performMediaWikiAPIGET(for: siteURL, with: params, cancellationKey: nil) { [weak self] result, response, error in
guard let parse = result?["parse"] as? [String: Any],
let text = parse["text"] as? String else {
// If unable to find, try app language once. Otherwise return nil.
guard attempt == 1 else {
completion(nil)
return
}
guard let appLangSiteURL = MWKDataStore.shared().languageLinkController.appLanguage?.siteURL else {
completion(nil)
return
}
self?.parseBlockReason(attempt: attempt + 1, siteURL: appLangSiteURL, blockReason: blockReason, completion: completion)
return
}
completion(text)
}
}
private func fetchBlockedTextTemplate(isPartial: Bool = false, attempt: Int = 1, siteURL: URL, completion: @escaping (String?, URL) -> Void) {
// Note: Not enough languages seem to have MediaWiki:Blockedtext-partial, so forcing MediaWiki:Blockedtext for now.
let templateName = "MediaWiki:Blockedtext"
if let parseText = templateName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) {
let params: [String: Any] = [
"action": "parse",
"prop": "text",
"mobileformat": 1,
"page": parseText,
"errorformat": "html",
"erroruselocal": 1,
"format": "json",
"formatversion": 2
]
performMediaWikiAPIGET(for: siteURL, with: params, cancellationKey: nil) { [weak self] result, response, error in
guard let parse = result?["parse"] as? [String: Any],
let text = parse["text"] as? String else {
// If unable to find, try app language once. Otherwise return nil.
guard attempt == 1 else {
completion(nil, siteURL)
return
}
guard let appLangSiteURL = MWKDataStore.shared().languageLinkController.appLanguage?.siteURL else {
completion(nil, siteURL)
return
}
self?.fetchBlockedTextTemplate(isPartial: isPartial, attempt: attempt + 1, siteURL: appLangSiteURL, completion: completion)
return
}
completion(text, siteURL)
}
}
}
// MARK: Decodable
@discardableResult public func performDecodableMediaWikiAPIGET<T: Decodable>(for URL: URL?, with queryParameters: [String: Any]?, cancellationKey: CancellationKey? = nil, completionHandler: @escaping (Result<T, Error>) -> Swift.Void) -> CancellationKey? {
let url = configuration.mediaWikiAPIURLForURL(URL, with: queryParameters)
let key = cancellationKey ?? UUID().uuidString
let task = session.jsonDecodableTask(with: url) { (result: T?, response: URLResponse?, error: Error?) in
guard let result = result else {
let error = error ?? RequestError.unexpectedResponse
completionHandler(.failure(error))
return
}
completionHandler(.success(result))
self.untrack(taskFor: key)
}
track(task: task, for: key)
return key
}
/// Creates and kicks off a URLSessionTask, tracking it in case the session needs to cancel it later. From fetchers, prefer using this method over calling session.jsonDecodableTask directly as it ensures the task is tracked and uses the result type
@discardableResult public func trackedJSONDecodableTask<T: Decodable>(with urlRequest: URLRequest, completionHandler: @escaping (Result<T, Error>, HTTPURLResponse?) -> Swift.Void) -> URLSessionTask? {
let key = UUID().uuidString
let task = session.jsonDecodableTask(with: urlRequest) { (result: T?, response: URLResponse?, error: Error?) in
defer {
self.untrack(taskFor: key)
}
guard let result = result else {
completionHandler(.failure(error ?? RequestError.unexpectedResponse), response as? HTTPURLResponse)
return
}
completionHandler(.success(result), response as? HTTPURLResponse)
}
track(task: task, for: key)
return task
}
// MARK: Tracking
@objc(trackTask:forKey:)
public func track(task: URLSessionTask?, for key: String) {
guard let task = task else {
return
}
semaphore.wait()
tasks[key] = task
semaphore.signal()
}
@objc(untrackTaskForKey:)
public func untrack(taskFor key: String) {
semaphore.wait()
tasks.removeValue(forKey: key)
semaphore.signal()
}
@objc(cancelTaskForKey:)
public func cancel(taskFor key: String) {
semaphore.wait()
tasks[key]?.cancel()
tasks.removeValue(forKey: key)
semaphore.signal()
}
@objc(cancelAllTasks)
public func cancelAllTasks() {
semaphore.wait()
for (_, task) in tasks {
task.cancel()
}
tasks.removeAll(keepingCapacity: true)
semaphore.signal()
}
}
// MARK: Modern Swift Concurrency APIs
extension Fetcher {
public func performDecodableMediaWikiAPIGet<T: Decodable>(for URL: URL, with queryParameters: [String: Any]?) async throws -> T {
guard let url = configuration.mediaWikiAPIURLForURL(URL, with: queryParameters) else {
throw RequestError.invalidParameters
}
let (data, response) = try await session.data(for: url)
guard let httpResponse = (response as? HTTPURLResponse) else {
throw RequestError.unexpectedResponse
}
guard HTTPStatusCode.isSuccessful(httpResponse.statusCode) else {
throw RequestError.http(httpResponse.statusCode)
}
return try JSONDecoder().decode(T.self, from: data)
}
}
// These are for bridging to Obj-C only
@objc public extension Fetcher {
@objc class var unexpectedResponseError: NSError {
return RequestError.unexpectedResponse as NSError
}
@objc class var invalidParametersError: NSError {
return RequestError.invalidParameters as NSError
}
@objc class var noNewDataError: NSError {
return RequestError.noNewData as NSError
}
@objc class var cancelledError: NSError {
return NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: [NSLocalizedDescriptionKey: RequestError.unexpectedResponse.localizedDescription])
}
}
@objc(WMFTokenType)
public enum TokenType: Int {
case csrf, login, createAccount
var stringValue: String {
switch self {
case .login:
return "login"
case .createAccount:
return "createaccount"
case .csrf:
return "csrf"
}
}
var parameterName: String {
switch self {
case .login:
return "logintoken"
case .createAccount:
return "createtoken"
default:
return "token"
}
}
}
@objc(WMFToken)
public class Token: NSObject {
@objc public var value: String
@objc public var type: TokenType
public var isAuthorized: Bool
@objc init(value: String, type: TokenType) {
self.value = value
self.type = type
self.isAuthorized = value != "+\\"
}
}

View File

@ -0,0 +1,21 @@
import Foundation
extension FileManager {
/// DEPRECATED: This should only be used for migrating legacy data to the new cache format. The app should no longer need to store information in extended file attributes. Previously, this was used to store the MIME type of a cached file so the approrpriate Content-Type could be set when serving it as a cached response. Now, the response headers are cached in a separate file, removing the need to attach MIME type (or any other info) to the file itself. This function can be removed when the corresponding migration is removed.
/// - Parameter attributeName: Extended file attribute name
/// - Parameter path: Path of the file
/// - Returns: the attribute value for the given attributeName or nil if no such attribute exists on the file
func getValueForExtendedFileAttributeNamed(_ attributeName: String, forFileAtPath path: String) -> String? {
let name = (attributeName as NSString).utf8String
let path = (path as NSString).fileSystemRepresentation
let bufferLength = getxattr(path, name, nil, 0, 0, 0)
guard bufferLength != -1, let buffer = malloc(bufferLength) else {
return nil
}
let readLen = getxattr(path, name, buffer, bufferLength, 0, 0)
return String(bytesNoCopy: buffer, length: readLen, encoding: .utf8, freeWhenDone: true)
}
}

View File

@ -0,0 +1,29 @@
import UIKit
@objc(WMFGradient)
public class Gradient: NSObject {
fileprivate var r1: CGFloat = 0
fileprivate var g1: CGFloat = 0
fileprivate var b1: CGFloat = 0
fileprivate var a1: CGFloat = 0
fileprivate var r2: CGFloat = 0
fileprivate var g2: CGFloat = 0
fileprivate var b2: CGFloat = 0
fileprivate var a2: CGFloat = 0
init(startColor: UIColor, endColor: UIColor) {
startColor.getRed(&r1, green: &g1, blue: &b1, alpha: &a1)
endColor.getRed(&r2, green: &g2, blue: &b2, alpha: &a2)
super.init()
}
@objc(colorAtPercentage:)
public final func color(at percentage: CGFloat) -> UIColor {
let r = r1 + percentage * (r2 - r1)
let g = g1 + percentage * (g2 - g1)
let b = b1 + percentage * (b2 - b1)
let a = a1 + percentage * (a2 - a1)
return UIColor(red: r, green: g, blue: b, alpha: a)
}
}

Some files were not shown because too many files have changed in this diff Show More