2020-02-10 17:30:11 -08:00
|
|
|
|
//
|
|
|
|
|
|
// AppIDsViewController.swift
|
|
|
|
|
|
// AltStore
|
|
|
|
|
|
//
|
|
|
|
|
|
// Created by Riley Testut on 1/27/20.
|
|
|
|
|
|
// Copyright © 2020 Riley Testut. All rights reserved.
|
|
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
|
|
import UIKit
|
|
|
|
|
|
|
2023-03-01 00:48:36 -05:00
|
|
|
|
import SideStoreCore
|
2023-03-01 14:36:52 -05:00
|
|
|
|
import RoxasUIKit
|
|
|
|
|
|
import Down
|
2020-02-10 17:30:11 -08:00
|
|
|
|
|
2023-03-01 00:48:36 -05:00
|
|
|
|
final class AppIDsViewController: UICollectionViewController {
|
2020-02-10 17:30:11 -08:00
|
|
|
|
private lazy var dataSource = self.makeDataSource()
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-02-10 17:30:11 -08:00
|
|
|
|
private var didInitialFetch = false
|
|
|
|
|
|
private var isLoading = false {
|
|
|
|
|
|
didSet {
|
2023-03-01 00:48:36 -05:00
|
|
|
|
update()
|
2020-02-10 17:30:11 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-02-10 17:30:11 -08:00
|
|
|
|
@IBOutlet var activityIndicatorBarButtonItem: UIBarButtonItem!
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
|
|
|
|
|
override func viewDidLoad() {
|
2020-02-10 17:30:11 -08:00
|
|
|
|
super.viewDidLoad()
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
|
|
|
|
|
collectionView.dataSource = dataSource
|
|
|
|
|
|
|
|
|
|
|
|
activityIndicatorBarButtonItem.isIndicatingActivity = true
|
|
|
|
|
|
|
2020-02-10 17:30:11 -08:00
|
|
|
|
let refreshControl = UIRefreshControl()
|
|
|
|
|
|
refreshControl.addTarget(self, action: #selector(AppIDsViewController.fetchAppIDs), for: .primaryActionTriggered)
|
2023-03-01 00:48:36 -05:00
|
|
|
|
collectionView.refreshControl = refreshControl
|
2020-02-10 17:30:11 -08:00
|
|
|
|
}
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
|
|
|
|
|
override func viewWillAppear(_ animated: Bool) {
|
2020-02-10 17:30:11 -08:00
|
|
|
|
super.viewWillAppear(animated)
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
|
|
|
|
|
if !didInitialFetch {
|
|
|
|
|
|
fetchAppIDs()
|
2020-02-10 17:30:11 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2023-03-01 00:48:36 -05:00
|
|
|
|
private extension AppIDsViewController {
|
|
|
|
|
|
func makeDataSource() -> RSTFetchedResultsCollectionViewDataSource<AppID> {
|
2020-02-10 17:30:11 -08:00
|
|
|
|
let fetchRequest = AppID.fetchRequest() as NSFetchRequest<AppID>
|
|
|
|
|
|
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \AppID.name, ascending: true),
|
|
|
|
|
|
NSSortDescriptor(keyPath: \AppID.bundleIdentifier, ascending: true),
|
|
|
|
|
|
NSSortDescriptor(keyPath: \AppID.expirationDate, ascending: true)]
|
|
|
|
|
|
fetchRequest.returnsObjectsAsFaults = false
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
|
|
|
|
|
if let team = DatabaseManager.shared.activeTeam() {
|
2020-02-10 17:30:11 -08:00
|
|
|
|
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(AppID.team), team)
|
2023-03-01 00:48:36 -05:00
|
|
|
|
} else {
|
2020-02-10 17:30:11 -08:00
|
|
|
|
fetchRequest.predicate = NSPredicate(value: false)
|
|
|
|
|
|
}
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-02-10 17:30:11 -08:00
|
|
|
|
let dataSource = RSTFetchedResultsCollectionViewDataSource<AppID>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
|
|
|
|
|
dataSource.proxy = self
|
2023-03-01 00:48:36 -05:00
|
|
|
|
dataSource.cellConfigurationHandler = { cell, appID, _ in
|
2020-02-10 17:30:11 -08:00
|
|
|
|
let tintColor = UIColor.altPrimary
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-03-24 13:27:44 -07:00
|
|
|
|
let cell = cell as! BannerCollectionViewCell
|
2020-02-10 17:30:11 -08:00
|
|
|
|
cell.layoutMargins.left = self.view.layoutMargins.left
|
|
|
|
|
|
cell.layoutMargins.right = self.view.layoutMargins.right
|
|
|
|
|
|
cell.tintColor = tintColor
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-02-10 17:30:11 -08:00
|
|
|
|
cell.bannerView.iconImageView.isHidden = true
|
|
|
|
|
|
cell.bannerView.button.isIndicatingActivity = false
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-03-30 13:40:14 -07:00
|
|
|
|
cell.bannerView.buttonLabel.text = NSLocalizedString("Expires in", comment: "")
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-08-27 15:23:21 -07:00
|
|
|
|
let attributedAccessibilityLabel = NSMutableAttributedString(string: appID.name + ". ")
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
|
|
|
|
|
if let expirationDate = appID.expirationDate {
|
2020-02-10 17:30:11 -08:00
|
|
|
|
cell.bannerView.button.isHidden = false
|
|
|
|
|
|
cell.bannerView.button.isUserInteractionEnabled = false
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-02-10 17:30:11 -08:00
|
|
|
|
cell.bannerView.buttonLabel.isHidden = false
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-02-10 17:30:11 -08:00
|
|
|
|
let currentDate = Date()
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-02-10 17:30:11 -08:00
|
|
|
|
let numberOfDays = expirationDate.numberOfCalendarDays(since: currentDate)
|
2020-08-27 15:23:21 -07:00
|
|
|
|
let numberOfDaysText = (numberOfDays == 1) ? NSLocalizedString("1 day", comment: "") : String(format: NSLocalizedString("%@ days", comment: ""), NSNumber(value: numberOfDays))
|
|
|
|
|
|
cell.bannerView.button.setTitle(numberOfDaysText.uppercased(), for: .normal)
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-08-27 15:23:21 -07:00
|
|
|
|
attributedAccessibilityLabel.mutableString.append(String(format: NSLocalizedString("Expires in %@.", comment: ""), numberOfDaysText) + " ")
|
2023-03-01 00:48:36 -05:00
|
|
|
|
} else {
|
2020-02-10 17:30:11 -08:00
|
|
|
|
cell.bannerView.button.isHidden = true
|
2020-08-27 15:23:21 -07:00
|
|
|
|
cell.bannerView.button.isUserInteractionEnabled = true
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-02-10 17:30:11 -08:00
|
|
|
|
cell.bannerView.buttonLabel.isHidden = true
|
|
|
|
|
|
}
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-02-10 17:30:11 -08:00
|
|
|
|
cell.bannerView.titleLabel.text = appID.name
|
|
|
|
|
|
cell.bannerView.subtitleLabel.text = appID.bundleIdentifier
|
|
|
|
|
|
cell.bannerView.subtitleLabel.numberOfLines = 2
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-08-27 15:23:21 -07:00
|
|
|
|
let attributedBundleIdentifier = NSMutableAttributedString(string: appID.bundleIdentifier.lowercased(), attributes: [.accessibilitySpeechPunctuation: true])
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
|
|
|
|
|
if let team = appID.team, let range = attributedBundleIdentifier.string.range(of: team.identifier.lowercased()), #available(iOS 13, *) {
|
2020-08-27 15:23:21 -07:00
|
|
|
|
// Prefer to speak the team ID one character at a time.
|
|
|
|
|
|
let nsRange = NSRange(range, in: attributedBundleIdentifier.string)
|
|
|
|
|
|
attributedBundleIdentifier.addAttributes([.accessibilitySpeechSpellOut: true], range: nsRange)
|
|
|
|
|
|
}
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-08-27 15:23:21 -07:00
|
|
|
|
attributedAccessibilityLabel.append(attributedBundleIdentifier)
|
|
|
|
|
|
cell.bannerView.accessibilityAttributedLabel = attributedAccessibilityLabel
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-02-10 17:30:11 -08:00
|
|
|
|
// Make sure refresh button is correct size.
|
|
|
|
|
|
cell.layoutIfNeeded()
|
|
|
|
|
|
}
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-02-10 17:30:11 -08:00
|
|
|
|
return dataSource
|
|
|
|
|
|
}
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
|
|
|
|
|
@objc func fetchAppIDs() {
|
|
|
|
|
|
guard !isLoading else { return }
|
|
|
|
|
|
isLoading = true
|
|
|
|
|
|
|
|
|
|
|
|
AppManager.shared.fetchAppIDs { result in
|
|
|
|
|
|
do {
|
2020-02-10 17:30:11 -08:00
|
|
|
|
let (_, context) = try result.get()
|
|
|
|
|
|
try context.save()
|
2023-03-01 00:48:36 -05:00
|
|
|
|
} catch {
|
2020-02-10 17:30:11 -08:00
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
|
|
let toastView = ToastView(error: error)
|
|
|
|
|
|
toastView.show(in: self)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-02-10 17:30:11 -08:00
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
|
|
self.isLoading = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
|
|
|
|
|
func update() {
|
|
|
|
|
|
if !isLoading {
|
|
|
|
|
|
collectionView.refreshControl?.endRefreshing()
|
|
|
|
|
|
activityIndicatorBarButtonItem.isIndicatingActivity = false
|
2020-02-10 17:30:11 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2023-03-01 00:48:36 -05:00
|
|
|
|
extension AppIDsViewController: UICollectionViewDelegateFlowLayout {
|
|
|
|
|
|
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, sizeForItemAt _: IndexPath) -> CGSize {
|
|
|
|
|
|
CGSize(width: collectionView.bounds.width, height: 80)
|
2020-02-10 17:30:11 -08:00
|
|
|
|
}
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
|
|
|
|
|
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
|
2020-02-10 17:30:11 -08:00
|
|
|
|
let indexPath = IndexPath(row: 0, section: section)
|
|
|
|
|
|
let headerView = self.collectionView(collectionView, viewForSupplementaryElementOfKind: UICollectionView.elementKindSectionHeader, at: indexPath)
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-02-10 17:30:11 -08:00
|
|
|
|
// Use this view to calculate the optimal size based on the collection view's width
|
|
|
|
|
|
let size = headerView.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingExpandedSize.height),
|
|
|
|
|
|
withHorizontalFittingPriority: .required, // Width is fixed
|
|
|
|
|
|
verticalFittingPriority: .fittingSizeLevel) // Height can be as large as needed
|
|
|
|
|
|
return size
|
|
|
|
|
|
}
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
|
|
|
|
|
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, referenceSizeForFooterInSection _: Int) -> CGSize {
|
|
|
|
|
|
CGSize(width: collectionView.bounds.width, height: 50)
|
2020-02-10 17:30:11 -08:00
|
|
|
|
}
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
|
|
|
|
|
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
|
|
|
|
|
|
switch kind {
|
2020-02-10 17:30:11 -08:00
|
|
|
|
case UICollectionView.elementKindSectionHeader:
|
2020-03-24 13:27:44 -07:00
|
|
|
|
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Header", for: indexPath) as! TextCollectionReusableView
|
2023-03-01 00:48:36 -05:00
|
|
|
|
headerView.layoutMargins.left = view.layoutMargins.left
|
|
|
|
|
|
headerView.layoutMargins.right = view.layoutMargins.right
|
|
|
|
|
|
|
|
|
|
|
|
if let activeTeam = DatabaseManager.shared.activeTeam(), activeTeam.type == .free {
|
2023-03-01 14:36:52 -05:00
|
|
|
|
let text = NSLocalizedString(
|
|
|
|
|
|
"""
|
2022-11-05 23:50:07 -07:00
|
|
|
|
Each app and app extension installed with SideStore must register an App ID with Apple. Apple limits non-developer Apple IDs to 10 App IDs at a time.
|
2020-02-10 17:30:11 -08:00
|
|
|
|
|
2022-11-05 23:50:07 -07:00
|
|
|
|
**App IDs can't be deleted**, but they do expire after one week. SideStore will automatically renew App IDs for all active apps once they've expired.
|
2020-03-23 11:33:06 -07:00
|
|
|
|
""", comment: "")
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2023-03-01 14:36:52 -05:00
|
|
|
|
let mdown = Down(markdownString: text)
|
|
|
|
|
|
let labelFont: DownFont = headerView.textLabel.font
|
|
|
|
|
|
let fonts: FontCollection = StaticFontCollection(
|
|
|
|
|
|
heading1: labelFont,
|
|
|
|
|
|
heading2: labelFont,
|
|
|
|
|
|
heading3: labelFont,
|
|
|
|
|
|
heading4: labelFont,
|
|
|
|
|
|
heading5: labelFont,
|
|
|
|
|
|
heading6: labelFont,
|
|
|
|
|
|
body: labelFont,
|
|
|
|
|
|
code: labelFont,
|
|
|
|
|
|
listItemPrefix: labelFont)
|
|
|
|
|
|
let config: DownStylerConfiguration = .init(fonts: fonts)
|
|
|
|
|
|
let styler: Styler = DownStyler.init(configuration: config)
|
|
|
|
|
|
let options: DownOptions = .default
|
|
|
|
|
|
headerView.textLabel.attributedText = try? mdown.toAttributedString(options,
|
|
|
|
|
|
styler: styler) ?? NSAttributedString(string: text)
|
|
|
|
|
|
} else {
|
2020-03-23 11:33:06 -07:00
|
|
|
|
headerView.textLabel.text = NSLocalizedString("""
|
2022-11-05 23:50:07 -07:00
|
|
|
|
Each app and app extension installed with SideStore must register an App ID with Apple.
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-02-10 17:30:11 -08:00
|
|
|
|
App IDs for paid developer accounts never expire, and there is no limit to how many you can create.
|
2020-03-23 11:33:06 -07:00
|
|
|
|
""", comment: "")
|
2020-02-10 17:30:11 -08:00
|
|
|
|
}
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-02-10 17:30:11 -08:00
|
|
|
|
return headerView
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-02-10 17:30:11 -08:00
|
|
|
|
case UICollectionView.elementKindSectionFooter:
|
2020-03-24 13:27:44 -07:00
|
|
|
|
let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Footer", for: indexPath) as! TextCollectionReusableView
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
|
|
|
|
|
let count = dataSource.itemCount
|
|
|
|
|
|
if count == 1 {
|
2020-02-10 17:30:11 -08:00
|
|
|
|
footerView.textLabel.text = NSLocalizedString("1 App ID", comment: "")
|
2023-03-01 00:48:36 -05:00
|
|
|
|
} else {
|
2020-02-10 17:30:11 -08:00
|
|
|
|
footerView.textLabel.text = String(format: NSLocalizedString("%@ App IDs", comment: ""), NSNumber(value: count))
|
|
|
|
|
|
}
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-02-10 17:30:11 -08:00
|
|
|
|
return footerView
|
2023-03-01 00:48:36 -05:00
|
|
|
|
|
2020-02-10 17:30:11 -08:00
|
|
|
|
default: fatalError()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2023-03-01 14:36:52 -05:00
|
|
|
|
|
|
|
|
|
|
fileprivate enum MarkdownStyledBlock: Equatable {
|
|
|
|
|
|
case generic
|
|
|
|
|
|
case headline(Int)
|
|
|
|
|
|
case paragraph
|
|
|
|
|
|
case unorderedListElement
|
|
|
|
|
|
case orderedListElement(Int)
|
|
|
|
|
|
case blockquote
|
|
|
|
|
|
case code(String?)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: -
|
|
|
|
|
|
@available(iOS 15, *)
|
|
|
|
|
|
extension AttributedString {
|
|
|
|
|
|
|
|
|
|
|
|
init(styledMarkdown markdownString: String, fontSize: CGFloat = UIFont.preferredFont(forTextStyle: .body).pointSize) throws {
|
|
|
|
|
|
|
|
|
|
|
|
var s = try AttributedString(markdown: markdownString, options: .init(allowsExtendedAttributes: true, interpretedSyntax: .full, failurePolicy: .returnPartiallyParsedIfPossible, languageCode: "en"), baseURL: nil)
|
|
|
|
|
|
|
|
|
|
|
|
// Looking at the AttributedString’s raw structure helps with understanding the following code.
|
|
|
|
|
|
print(s)
|
|
|
|
|
|
|
|
|
|
|
|
// Set base font and paragraph style for the whole string
|
|
|
|
|
|
s.font = .systemFont(ofSize: fontSize)
|
|
|
|
|
|
s.paragraphStyle = defaultParagraphStyle
|
|
|
|
|
|
|
|
|
|
|
|
// Will respect dark mode automatically
|
|
|
|
|
|
s.foregroundColor = .label
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: Inline Intents
|
|
|
|
|
|
let inlineIntents: [InlinePresentationIntent] = [.emphasized, .stronglyEmphasized, .code, .strikethrough, .softBreak, .lineBreak, .inlineHTML, .blockHTML]
|
|
|
|
|
|
|
|
|
|
|
|
for inlineIntent in inlineIntents {
|
|
|
|
|
|
|
|
|
|
|
|
var sourceAttributeContainer = AttributeContainer()
|
|
|
|
|
|
sourceAttributeContainer.inlinePresentationIntent = inlineIntent
|
|
|
|
|
|
|
|
|
|
|
|
var targetAttributeContainer = AttributeContainer()
|
|
|
|
|
|
switch inlineIntent {
|
|
|
|
|
|
case .emphasized:
|
|
|
|
|
|
targetAttributeContainer.font = .italicSystemFont(ofSize: fontSize)
|
|
|
|
|
|
case .stronglyEmphasized:
|
|
|
|
|
|
targetAttributeContainer.font = .systemFont(ofSize: fontSize, weight: .bold)
|
|
|
|
|
|
case .code:
|
|
|
|
|
|
targetAttributeContainer.font = .monospacedSystemFont(ofSize: fontSize, weight: .regular)
|
|
|
|
|
|
targetAttributeContainer.backgroundColor = .secondarySystemBackground
|
|
|
|
|
|
case .strikethrough:
|
|
|
|
|
|
targetAttributeContainer.strikethroughStyle = .single
|
|
|
|
|
|
case .softBreak:
|
|
|
|
|
|
break // TODO: Implement
|
|
|
|
|
|
case .lineBreak:
|
|
|
|
|
|
break // TODO: Implement
|
|
|
|
|
|
case .inlineHTML:
|
|
|
|
|
|
break // TODO: Implement
|
|
|
|
|
|
case .blockHTML:
|
|
|
|
|
|
break // TODO: Implement
|
|
|
|
|
|
default:
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
s = s.replacingAttributes(sourceAttributeContainer, with: targetAttributeContainer)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: Blocks
|
|
|
|
|
|
// Accessing via dynamic lookup key path (\.presentationIntent) triggers a warning on Xcode 13.1, so we use the verbose way: AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self
|
|
|
|
|
|
// We use .reversed() iteration to be able to add characters to the string without breaking ranges.
|
|
|
|
|
|
var previousListID = 0
|
|
|
|
|
|
|
|
|
|
|
|
for (intentBlock, intentRange) in s.runs[AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self].reversed() {
|
|
|
|
|
|
guard let intentBlock = intentBlock else { continue }
|
|
|
|
|
|
|
|
|
|
|
|
var block: MarkdownStyledBlock = .generic
|
|
|
|
|
|
var currentElementOrdinal: Int = 0
|
|
|
|
|
|
|
|
|
|
|
|
var currentListID = 0
|
|
|
|
|
|
|
|
|
|
|
|
for intent in intentBlock.components {
|
|
|
|
|
|
switch intent.kind {
|
|
|
|
|
|
case .paragraph:
|
|
|
|
|
|
if block == .generic {
|
|
|
|
|
|
block = .paragraph
|
|
|
|
|
|
}
|
|
|
|
|
|
case .header(level: let level):
|
|
|
|
|
|
block = .headline(level)
|
|
|
|
|
|
case .orderedList:
|
|
|
|
|
|
block = .orderedListElement(currentElementOrdinal)
|
|
|
|
|
|
currentListID = intent.identity
|
|
|
|
|
|
case .unorderedList:
|
|
|
|
|
|
block = .unorderedListElement
|
|
|
|
|
|
currentListID = intent.identity
|
|
|
|
|
|
case .listItem(ordinal: let ordinal):
|
|
|
|
|
|
currentElementOrdinal = ordinal
|
|
|
|
|
|
if block != .unorderedListElement {
|
|
|
|
|
|
block = .orderedListElement(ordinal)
|
|
|
|
|
|
}
|
|
|
|
|
|
case .codeBlock(languageHint: let languageHint):
|
|
|
|
|
|
block = .code(languageHint)
|
|
|
|
|
|
case .blockQuote:
|
|
|
|
|
|
block = .blockquote
|
|
|
|
|
|
case .thematicBreak:
|
|
|
|
|
|
break // This is ---- in Markdown.
|
|
|
|
|
|
case .table(columns: _):
|
|
|
|
|
|
break
|
|
|
|
|
|
case .tableHeaderRow:
|
|
|
|
|
|
break
|
|
|
|
|
|
case .tableRow(rowIndex: _):
|
|
|
|
|
|
break
|
|
|
|
|
|
case .tableCell(columnIndex: _):
|
|
|
|
|
|
break
|
|
|
|
|
|
@unknown default:
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
switch block {
|
|
|
|
|
|
case .generic:
|
|
|
|
|
|
assertionFailure(intentBlock.debugDescription)
|
|
|
|
|
|
case .headline(let level):
|
|
|
|
|
|
switch level {
|
|
|
|
|
|
case 1:
|
|
|
|
|
|
s[intentRange].font = .systemFont(ofSize: 30, weight: .heavy)
|
|
|
|
|
|
case 2:
|
|
|
|
|
|
s[intentRange].font = .systemFont(ofSize: 20, weight: .heavy)
|
|
|
|
|
|
case 3:
|
|
|
|
|
|
s[intentRange].font = .systemFont(ofSize: 15, weight: .heavy)
|
|
|
|
|
|
default:
|
|
|
|
|
|
// TODO: Handle H4 to H6
|
|
|
|
|
|
s[intentRange].font = .systemFont(ofSize: 15, weight: .heavy)
|
|
|
|
|
|
}
|
|
|
|
|
|
case .paragraph:
|
|
|
|
|
|
break
|
|
|
|
|
|
case .unorderedListElement:
|
|
|
|
|
|
s.characters.insert(contentsOf: "•\t", at: intentRange.lowerBound)
|
|
|
|
|
|
s[intentRange].paragraphStyle = previousListID == currentListID ? listParagraphStyle : lastElementListParagraphStyle
|
|
|
|
|
|
case .orderedListElement(let ordinal):
|
|
|
|
|
|
s.characters.insert(contentsOf: "\(ordinal).\t", at: intentRange.lowerBound)
|
|
|
|
|
|
s[intentRange].paragraphStyle = previousListID == currentListID ? listParagraphStyle : lastElementListParagraphStyle
|
|
|
|
|
|
case .blockquote:
|
|
|
|
|
|
s[intentRange].paragraphStyle = defaultParagraphStyle
|
|
|
|
|
|
s[intentRange].foregroundColor = .secondaryLabel
|
|
|
|
|
|
case .code:
|
|
|
|
|
|
s[intentRange].font = .monospacedSystemFont(ofSize: 13, weight: .regular)
|
|
|
|
|
|
s[intentRange].paragraphStyle = codeParagraphStyle
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Remember the list ID so we can check if it’s identical in the next block
|
|
|
|
|
|
previousListID = currentListID
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: Add line breaks to separate blocks
|
|
|
|
|
|
if intentRange.lowerBound != s.startIndex {
|
|
|
|
|
|
s.characters.insert(contentsOf: "\n", at: intentRange.lowerBound)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
self = s
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fileprivate let defaultParagraphStyle: NSParagraphStyle = {
|
|
|
|
|
|
var paragraphStyle = NSMutableParagraphStyle()
|
|
|
|
|
|
paragraphStyle.paragraphSpacing = 10.0
|
|
|
|
|
|
paragraphStyle.minimumLineHeight = 20.0
|
|
|
|
|
|
return paragraphStyle
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fileprivate let listParagraphStyle: NSMutableParagraphStyle = {
|
|
|
|
|
|
var paragraphStyle = NSMutableParagraphStyle()
|
|
|
|
|
|
paragraphStyle.tabStops = [NSTextTab(textAlignment: .left, location: 20)]
|
|
|
|
|
|
paragraphStyle.headIndent = 20
|
|
|
|
|
|
paragraphStyle.minimumLineHeight = 20.0
|
|
|
|
|
|
return paragraphStyle
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
|
|
fileprivate let lastElementListParagraphStyle: NSMutableParagraphStyle = {
|
|
|
|
|
|
var paragraphStyle = NSMutableParagraphStyle()
|
|
|
|
|
|
paragraphStyle.tabStops = [NSTextTab(textAlignment: .left, location: 20)]
|
|
|
|
|
|
paragraphStyle.headIndent = 20
|
|
|
|
|
|
paragraphStyle.minimumLineHeight = 20.0
|
|
|
|
|
|
paragraphStyle.paragraphSpacing = 20.0 // The last element in a list needs extra paragraph spacing
|
|
|
|
|
|
return paragraphStyle
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fileprivate let codeParagraphStyle: NSParagraphStyle = {
|
|
|
|
|
|
var paragraphStyle = NSMutableParagraphStyle()
|
|
|
|
|
|
paragraphStyle.minimumLineHeight = 20.0
|
|
|
|
|
|
paragraphStyle.firstLineHeadIndent = 20
|
|
|
|
|
|
paragraphStyle.headIndent = 20
|
|
|
|
|
|
return paragraphStyle
|
|
|
|
|
|
}()
|