Compare commits
8 Commits
main
...
setup/proj
Author | SHA1 | Date | |
---|---|---|---|
9835846d8e | |||
4be21af7c3 | |||
e95062119f | |||
4d4529028b | |||
f3c2dde953 | |||
66aa840ec3 | |||
f670ad3e37 | |||
0bee38d195 |
124
.gitignore
vendored
124
.gitignore
vendored
@ -1,28 +1,44 @@
|
||||
# ---> Swift
|
||||
# Xcode
|
||||
#
|
||||
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
|
||||
### OSX ###
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
### Xcode ###
|
||||
*.xcodeproj/*
|
||||
!*.xcodeproj/project.pbxproj
|
||||
!*.xcodeproj/xcshareddata/
|
||||
!*.xcodeproj/project.xcworkspace/
|
||||
!*.xcworkspace/contents.xcworkspacedata
|
||||
/*.gcno
|
||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||
|
||||
## User settings
|
||||
xcuserdata/
|
||||
|
||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||
*.xcscmblueprint
|
||||
*.xccheckout
|
||||
|
||||
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
|
||||
build/
|
||||
DerivedData/
|
||||
*.moved-aside
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
*.mode1v3
|
||||
!default.mode1v3
|
||||
*.mode2v3
|
||||
!default.mode2v3
|
||||
*.perspectivev3
|
||||
!default.perspectivev3
|
||||
|
||||
## Obj-C/Swift specific
|
||||
*.hmap
|
||||
|
||||
@ -31,70 +47,18 @@ DerivedData/
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
|
||||
## Playgrounds
|
||||
timeline.xctimeline
|
||||
playground.xcworkspace
|
||||
|
||||
# Swift Package Manager
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
|
||||
# Packages/
|
||||
# Package.pins
|
||||
# Package.resolved
|
||||
# *.xcodeproj
|
||||
#
|
||||
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
|
||||
# hence it is not needed unless you have added a package configuration file to your project
|
||||
# .swiftpm
|
||||
|
||||
.build/
|
||||
|
||||
# CocoaPods
|
||||
#
|
||||
# We recommend against adding the Pods directory to your .gitignore. However
|
||||
# you should judge for yourself, the pros and cons are mentioned at:
|
||||
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
|
||||
#
|
||||
# Pods/
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from the Xcode workspace
|
||||
# *.xcworkspace
|
||||
|
||||
# Carthage
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from Carthage dependencies.
|
||||
# Carthage/Checkouts
|
||||
|
||||
Carthage/Build/
|
||||
|
||||
# Accio dependency management
|
||||
Dependencies/
|
||||
.accio/
|
||||
|
||||
# fastlane
|
||||
#
|
||||
# It is recommended to not store the screenshots in the git repo.
|
||||
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
|
||||
# For more information about the recommended setup visit:
|
||||
# https://docs.fastlane.tools/best-practices/source-control/#source-control
|
||||
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
|
||||
# Code Injection
|
||||
#
|
||||
# After new code Injection tools there's a generated folder /iOSInjectionProject
|
||||
# https://github.com/johnno1962/injectionforxcode
|
||||
### Swift ###
|
||||
|
||||
iOSInjectionProject/
|
||||
|
||||
# ---> Xcode
|
||||
## User settings
|
||||
xcuserdata/
|
||||
|
||||
## Xcode 8 and earlier
|
||||
*.xcscmblueprint
|
||||
*.xccheckout
|
||||
## Playgrounds
|
||||
timeline.xctimeline
|
||||
playground.xcworkspace
|
||||
|
||||
### SwiftPM ###
|
||||
Packages
|
||||
.build/
|
28
Apps/Locations/Libraries/Package.swift
Normal file
28
Apps/Locations/Libraries/Package.swift
Normal file
@ -0,0 +1,28 @@
|
||||
// swift-tools-version: 5.8
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Libraries",
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
.library(
|
||||
name: "Libraries",
|
||||
targets: ["Libraries"]),
|
||||
],
|
||||
dependencies: [
|
||||
// Dependencies declare other packages that this package depends on.
|
||||
// .package(url: /* package url */, from: "1.0.0"),
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||
.target(
|
||||
name: "Libraries",
|
||||
dependencies: []),
|
||||
.testTarget(
|
||||
name: "LibrariesTests",
|
||||
dependencies: ["Libraries"]),
|
||||
]
|
||||
)
|
@ -0,0 +1,6 @@
|
||||
public struct Libraries {
|
||||
public private(set) var text = "Hello, World!"
|
||||
|
||||
public init() {
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
import XCTest
|
||||
@testable import Libraries
|
||||
|
||||
final class LibrariesTests: XCTestCase {
|
||||
func testExample() throws {
|
||||
// This is an example of a functional test case.
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct
|
||||
// results.
|
||||
XCTAssertEqual(Libraries().text, "Hello, World!")
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
6
Apps/Locations/Resources/Assets.xcassets/Contents.json
Normal file
6
Apps/Locations/Resources/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
25
Apps/Locations/Resources/Base.lproj/LaunchScreen.storyboard
Normal file
25
Apps/Locations/Resources/Base.lproj/LaunchScreen.storyboard
Normal file
@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
24
Apps/Locations/Resources/Base.lproj/Main.storyboard
Normal file
24
Apps/Locations/Resources/Base.lproj/Main.storyboard
Normal file
@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="ViewController" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
25
Apps/Locations/Resources/Info.plist
Normal file
25
Apps/Locations/Resources/Info.plist
Normal file
@ -0,0 +1,25 @@
|
||||
<?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>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>Default Configuration</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
@ -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>Locations.xcdatamodel</string>
|
||||
</dict>
|
||||
</plist>
|
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="1" systemVersion="11A491" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="false" userDefinedModelVersionIdentifier="">
|
||||
<elements/>
|
||||
</model>
|
82
Apps/Locations/Sources/AppDelegate.swift
Normal file
82
Apps/Locations/Sources/AppDelegate.swift
Normal file
@ -0,0 +1,82 @@
|
||||
//
|
||||
// AppDelegate.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 08/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import CoreData
|
||||
|
||||
@main
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: UISceneSession Lifecycle
|
||||
|
||||
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
// Called when a new scene session is being created.
|
||||
// Use this method to select a configuration to create the new scene with.
|
||||
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
|
||||
// Called when the user discards a scene session.
|
||||
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
|
||||
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
|
||||
}
|
||||
|
||||
// MARK: - Core Data stack
|
||||
|
||||
lazy var persistentContainer: NSPersistentContainer = {
|
||||
/*
|
||||
The persistent container for the application. This implementation
|
||||
creates and returns a container, having loaded the store for the
|
||||
application to it. This property is optional since there are legitimate
|
||||
error conditions that could cause the creation of the store to fail.
|
||||
*/
|
||||
let container = NSPersistentContainer(name: "Locations")
|
||||
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
|
||||
if let error = error as NSError? {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
||||
|
||||
/*
|
||||
Typical reasons for an error here include:
|
||||
* The parent directory does not exist, cannot be created, or disallows writing.
|
||||
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
|
||||
* The device is out of space.
|
||||
* The store could not be migrated to the current model version.
|
||||
Check the error message to determine what the actual problem was.
|
||||
*/
|
||||
fatalError("Unresolved error \(error), \(error.userInfo)")
|
||||
}
|
||||
})
|
||||
return container
|
||||
}()
|
||||
|
||||
// MARK: - Core Data Saving support
|
||||
|
||||
func saveContext () {
|
||||
let context = persistentContainer.viewContext
|
||||
if context.hasChanges {
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
||||
let nserror = error as NSError
|
||||
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
56
Apps/Locations/Sources/SceneDelegate.swift
Normal file
56
Apps/Locations/Sources/SceneDelegate.swift
Normal file
@ -0,0 +1,56 @@
|
||||
//
|
||||
// SceneDelegate.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 08/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
|
||||
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
|
||||
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
|
||||
guard let _ = (scene as? UIWindowScene) else { return }
|
||||
}
|
||||
|
||||
func sceneDidDisconnect(_ scene: UIScene) {
|
||||
// Called as the scene is being released by the system.
|
||||
// This occurs shortly after the scene enters the background, or when its session is discarded.
|
||||
// Release any resources associated with this scene that can be re-created the next time the scene connects.
|
||||
// The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
|
||||
}
|
||||
|
||||
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||
// Called when the scene has moved from an inactive state to an active state.
|
||||
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
|
||||
}
|
||||
|
||||
func sceneWillResignActive(_ scene: UIScene) {
|
||||
// Called when the scene will move from an active state to an inactive state.
|
||||
// This may occur due to temporary interruptions (ex. an incoming phone call).
|
||||
}
|
||||
|
||||
func sceneWillEnterForeground(_ scene: UIScene) {
|
||||
// Called as the scene transitions from the background to the foreground.
|
||||
// Use this method to undo the changes made on entering the background.
|
||||
}
|
||||
|
||||
func sceneDidEnterBackground(_ scene: UIScene) {
|
||||
// Called as the scene transitions from the foreground to the background.
|
||||
// Use this method to save data, release shared resources, and store enough scene-specific state information
|
||||
// to restore the scene back to its current state.
|
||||
|
||||
// Save changes in the application's managed object context when the application transitions to the background.
|
||||
(UIApplication.shared.delegate as? AppDelegate)?.saveContext()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
20
Apps/Locations/Sources/ViewController.swift
Normal file
20
Apps/Locations/Sources/ViewController.swift
Normal file
@ -0,0 +1,20 @@
|
||||
//
|
||||
// ViewController.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 08/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class ViewController: UIViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
// Do any additional setup after loading the view.
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
44
Apps/Wikipedia/.circleci/config.yml
Normal file
44
Apps/Wikipedia/.circleci/config.yml
Normal 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
|
61
Apps/Wikipedia/.clang-format
Normal file
61
Apps/Wikipedia/.clang-format
Normal 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
1
Apps/Wikipedia/.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1 @@
|
||||
custom: ['https://donate.wikimedia.org/?utm_medium=githubRepo']
|
10
Apps/Wikipedia/.github/pull_request_template.md
vendored
Normal file
10
Apps/Wikipedia/.github/pull_request_template.md
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
**Phabricator:**
|
||||
|
||||
### Notes
|
||||
*
|
||||
|
||||
### Test Steps
|
||||
1.
|
||||
|
||||
### Screenshots/Videos
|
||||
|
29
Apps/Wikipedia/.github/workflows/localization.yml
vendored
Normal file
29
Apps/Wikipedia/.github/workflows/localization.yml
vendored
Normal 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
|
3
Apps/Wikipedia/.jshintignore
Normal file
3
Apps/Wikipedia/.jshintignore
Normal file
@ -0,0 +1,3 @@
|
||||
Wikipedia/assets/**
|
||||
fastlane/**
|
||||
www/node_modules/**
|
1
Apps/Wikipedia/.node-version
Normal file
1
Apps/Wikipedia/.node-version
Normal file
@ -0,0 +1 @@
|
||||
10.16.0
|
1
Apps/Wikipedia/.ruby-version
Normal file
1
Apps/Wikipedia/.ruby-version
Normal file
@ -0,0 +1 @@
|
||||
3.0.5
|
37
Apps/Wikipedia/.swiftlint-autocorrect.yml
Normal file
37
Apps/Wikipedia/.swiftlint-autocorrect.yml
Normal 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
|
37
Apps/Wikipedia/.swiftlint.yml
Normal file
37
Apps/Wikipedia/.swiftlint.yml
Normal 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
|
7
Apps/Wikipedia/.xctool-args
Normal file
7
Apps/Wikipedia/.xctool-args
Normal file
@ -0,0 +1,7 @@
|
||||
[
|
||||
"-workspace", "Wikipedia.xcworkspace",
|
||||
"-scheme", "Wikipedia",
|
||||
"-configuration", "Debug",
|
||||
"-sdk", "iphonesimulator",
|
||||
"-destination", "platform=iOS Simulator,name=iPhone 6,OS=10.0"
|
||||
]
|
1
Apps/Wikipedia/.xcversion
Normal file
1
Apps/Wikipedia/.xcversion
Normal file
@ -0,0 +1 @@
|
||||
14.2.0
|
1
Apps/Wikipedia/CODE_OF_CONDUCT.md
Normal file
1
Apps/Wikipedia/CODE_OF_CONDUCT.md
Normal file
@ -0,0 +1 @@
|
||||
The development of this software is covered by a [Code of Conduct](https://www.mediawiki.org/wiki/Code_of_Conduct).
|
32
Apps/Wikipedia/CONTRIBUTING.md
Normal file
32
Apps/Wikipedia/CONTRIBUTING.md
Normal 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)
|
||||
|
||||
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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()
|
@ -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 { }
|
||||
// }
|
@ -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)
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
/* Localized versions of Info.plist keys */
|
||||
|
||||
"CFBundleDisplayName" = "Wikipedia continue reading";
|
||||
"CFBundleName" = "Wikipedia continue reading";
|
@ -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>
|
@ -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>
|
31
Apps/Wikipedia/ContinueReadingWidget/Info.plist
Normal file
31
Apps/Wikipedia/ContinueReadingWidget/Info.plist
Normal 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>
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,4 @@
|
||||
/* Localized versions of Info.plist keys */
|
||||
|
||||
"CFBundleDisplayName" = "Wikipedia continue reading";
|
||||
"CFBundleName" = "Wikipedia continue reading";
|
6
Apps/Wikipedia/Gemfile
Normal file
6
Apps/Wikipedia/Gemfile
Normal 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
222
Apps/Wikipedia/Gemfile.lock
Normal 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
|
21
Apps/Wikipedia/LICENSE.txt
Normal file
21
Apps/Wikipedia/LICENSE.txt
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2013–2020 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.
|
31
Apps/Wikipedia/NotificationServiceExtension/Info.plist
Normal file
31
Apps/Wikipedia/NotificationServiceExtension/Info.plist
Normal 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>
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
84
Apps/Wikipedia/README.md
Normal file
@ -0,0 +1,84 @@
|
||||
# Wikipedia iOS
|
||||
The official Wikipedia iOS app.
|
||||
|
||||
[](https://github.com/wikimedia/wikipedia-ios)
|
||||
[](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).
|
123
Apps/Wikipedia/WMF Framework/ABTestsController.swift
Normal file
123
Apps/Wikipedia/WMF Framework/ABTestsController.swift
Normal 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)
|
||||
}
|
||||
}
|
44
Apps/Wikipedia/WMF Framework/ActionButton.swift
Normal file
44
Apps/Wikipedia/WMF Framework/ActionButton.swift
Normal 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
|
||||
}
|
||||
|
||||
}
|
19
Apps/Wikipedia/WMF Framework/AnnouncementType.swift
Normal file
19
Apps/Wikipedia/WMF Framework/AnnouncementType.swift
Normal 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
|
||||
}
|
||||
}
|
8
Apps/Wikipedia/WMF Framework/Array+SafeIndex.swift
Normal file
8
Apps/Wikipedia/WMF Framework/Array+SafeIndex.swift
Normal file
@ -0,0 +1,8 @@
|
||||
public extension Array {
|
||||
subscript(safeIndex index: Int) -> Element? {
|
||||
guard index >= 0, index < endIndex else {
|
||||
return nil
|
||||
}
|
||||
return self[index]
|
||||
}
|
||||
}
|
208
Apps/Wikipedia/WMF Framework/ArticleCacheController.swift
Normal file
208
Apps/Wikipedia/WMF Framework/ArticleCacheController.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
206
Apps/Wikipedia/WMF Framework/ArticleCacheResourceDBWriting.swift
Normal file
206
Apps/Wikipedia/WMF Framework/ArticleCacheResourceDBWriting.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
95
Apps/Wikipedia/WMF Framework/ArticleSummary.swift
Normal file
95
Apps/Wikipedia/WMF Framework/ArticleSummary.swift
Normal 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
|
||||
}
|
122
Apps/Wikipedia/WMF Framework/AsyncOperation.swift
Normal file
122
Apps/Wikipedia/WMF Framework/AsyncOperation.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
40
Apps/Wikipedia/WMF Framework/BackgroundFetcher.swift
Normal file
40
Apps/Wikipedia/WMF Framework/BackgroundFetcher.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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>
|
7
Apps/Wikipedia/WMF Framework/Bundle+IsAppExtension.swift
Normal file
7
Apps/Wikipedia/WMF Framework/Bundle+IsAppExtension.swift
Normal file
@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
extension Bundle {
|
||||
var isAppExtension: Bool {
|
||||
return bundleURL.pathExtension.caseInsensitiveCompare("appex") == .orderedSame
|
||||
}
|
||||
}
|
10
Apps/Wikipedia/WMF Framework/Bundle.swift
Normal file
10
Apps/Wikipedia/WMF Framework/Bundle.swift
Normal 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)!
|
||||
}
|
||||
}
|
10
Apps/Wikipedia/WMF Framework/CGRect+Layout.swift
Normal file
10
Apps/Wikipedia/WMF Framework/CGRect+Layout.swift
Normal 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)
|
||||
}
|
||||
}
|
@ -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>
|
@ -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>
|
@ -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>
|
405
Apps/Wikipedia/WMF Framework/CacheController.swift
Normal file
405
Apps/Wikipedia/WMF Framework/CacheController.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
114
Apps/Wikipedia/WMF Framework/CacheDBWriterHelper.swift
Normal file
114
Apps/Wikipedia/WMF Framework/CacheDBWriterHelper.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
126
Apps/Wikipedia/WMF Framework/CacheDBWriting.swift
Normal file
126
Apps/Wikipedia/WMF Framework/CacheDBWriting.swift
Normal 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)
|
||||
}
|
||||
}
|
145
Apps/Wikipedia/WMF Framework/CacheFetching.swift
Normal file
145
Apps/Wikipedia/WMF Framework/CacheFetching.swift
Normal 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)
|
||||
}
|
||||
}
|
246
Apps/Wikipedia/WMF Framework/CacheFileWriter.swift
Normal file
246
Apps/Wikipedia/WMF Framework/CacheFileWriter.swift
Normal 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)
|
||||
}
|
||||
}
|
139
Apps/Wikipedia/WMF Framework/CacheFileWriterHelper.swift
Normal file
139
Apps/Wikipedia/WMF Framework/CacheFileWriterHelper.swift
Normal 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)
|
||||
}
|
214
Apps/Wikipedia/WMF Framework/CacheGatekeeper.swift
Normal file
214
Apps/Wikipedia/WMF Framework/CacheGatekeeper.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(WMFCacheGroup)
|
||||
public class CacheGroup: NSManagedObject {
|
||||
|
||||
}
|
@ -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)
|
||||
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(WMFCacheItem)
|
||||
public class CacheItem: NSManagedObject {
|
||||
|
||||
}
|
@ -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
62
Apps/Wikipedia/WMF Framework/CacheItemMigrationPolicy.swift
Normal file
62
Apps/Wikipedia/WMF Framework/CacheItemMigrationPolicy.swift
Normal 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)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
54
Apps/Wikipedia/WMF Framework/CacheTaskTracking.swift
Normal file
54
Apps/Wikipedia/WMF Framework/CacheTaskTracking.swift
Normal 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()
|
||||
}
|
||||
}
|
20
Apps/Wikipedia/WMF Framework/CharacterSet+LinkParsing.swift
Normal file
20
Apps/Wikipedia/WMF Framework/CharacterSet+LinkParsing.swift
Normal 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
|
||||
}()
|
||||
}
|
81
Apps/Wikipedia/WMF Framework/Collection+AsyncMap.swift
Normal file
81
Apps/Wikipedia/WMF Framework/Collection+AsyncMap.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
189
Apps/Wikipedia/WMF Framework/CollectionViewCellActionsView.swift
Normal file
189
Apps/Wikipedia/WMF Framework/CollectionViewCellActionsView.swift
Normal 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
|
||||
}
|
||||
}
|
652
Apps/Wikipedia/WMF Framework/CollectionViewEditController.swift
Normal file
652
Apps/Wikipedia/WMF Framework/CollectionViewEditController.swift
Normal 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
|
||||
}()
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
318
Apps/Wikipedia/WMF Framework/ColumnarCollectionViewLayout.swift
Normal file
318
Apps/Wikipedia/WMF Framework/ColumnarCollectionViewLayout.swift
Normal 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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
470
Apps/Wikipedia/WMF Framework/CommonStrings.swift
Normal file
470
Apps/Wikipedia/WMF Framework/CommonStrings.swift
Normal 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.).")
|
||||
|
||||
}
|
413
Apps/Wikipedia/WMF Framework/Configuration.swift
Normal file
413
Apps/Wikipedia/WMF Framework/Configuration.swift
Normal 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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
}
|
34
Apps/Wikipedia/WMF Framework/DeviceInfo.swift
Normal file
34
Apps/Wikipedia/WMF Framework/DeviceInfo.swift
Normal 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)
|
||||
}()
|
||||
}
|
12
Apps/Wikipedia/WMF Framework/Dictionary+Equality.swift
Normal file
12
Apps/Wikipedia/WMF Framework/Dictionary+Equality.swift
Normal 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)
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(WMFEPEventRecord)
|
||||
public class EPEventRecord: NSManagedObject {
|
||||
|
||||
}
|
@ -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
|
||||
|
||||
}
|
@ -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 }
|
||||
}
|
@ -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>
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
201
Apps/Wikipedia/WMF Framework/Event Platform/StorageManager.swift
Normal file
201
Apps/Wikipedia/WMF Framework/Event Platform/StorageManager.swift
Normal 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
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user