1615 lines
77 KiB
Swift

import Foundation
// tonitodo: It makes more sense for this to live in the app. Can we move out of WMF?
public struct ArticleAsLivingDocViewModel {
public let nextRvStartId: UInt?
public let sha: String?
public let sections: [SectionHeader]
public let articleInsertHtmlSnippets: [String]
public let lastUpdatedTimestamp: String?
public let summaryText: String?
private let isoDateFormatter = ISO8601DateFormatter()
public init(nextRvStartId: UInt?, sha: String?, sections: [SectionHeader], summaryText: String?, articleInsertHtmlSnippets: [String], lastUpdatedTimestamp: String?) {
self.nextRvStartId = nextRvStartId
self.sha = sha
self.sections = sections
self.summaryText = summaryText
self.articleInsertHtmlSnippets = articleInsertHtmlSnippets
self.lastUpdatedTimestamp = lastUpdatedTimestamp
}
public init?(significantEvents: SignificantEvents, traitCollection: UITraitCollection, theme: Theme) {
guard let dayMonthNumberYearDateFormatter = DateFormatter.wmf_monthNameDayOfMonthNumberYear() else {
assertionFailure("Unable to generate date formatters for Significant Events View Models")
return nil
}
self.nextRvStartId = significantEvents.nextRvStartId
self.sha = significantEvents.sha
// initialize summary text
var summaryText: String? = nil
if let earliestDate = isoDateFormatter.date(from: significantEvents.summary.earliestTimestampString) {
let currentDate = Date()
let calendar = NSCalendar.current
let unitFlags:Set<Calendar.Component> = [.day]
let components = calendar.dateComponents(unitFlags, from: earliestDate, to: currentDate)
if let numberOfDays = components.day {
summaryText = String.localizedStringWithFormat(CommonStrings.articleAsLivingDocSummaryTitle,
significantEvents.summary.numChanges,
significantEvents.summary.numUsers,
numberOfDays)
}
}
self.summaryText = summaryText
// loop through typed events, turn into view models and segment off into sections
var currentSectionEvents: [TypedEvent] = []
var sections: [SectionHeader] = []
var maybeCurrentTimestamp: Date?
var maybePreviousTimestamp: Date?
for originalEvent in significantEvents.typedEvents {
var maybeEvent: TypedEvent? = nil
if let smallEventViewModel = Event.Small(typedEvents: [originalEvent]) {
maybeEvent = .small(smallEventViewModel)
} else if let largeEventViewModel = Event.Large(typedEvent: originalEvent) {
// this is just an optimization to have collection view height calculations sooner so it doesn't happen while the user is scrolling
largeEventViewModel.calculateSideScrollingCollectionViewHeightForTraitCollection(traitCollection, theme: theme)
maybeEvent = .large(largeEventViewModel)
}
guard let event = maybeEvent else {
assertionFailure("Unable to instantiate event view model, skipping event")
continue
}
switch originalEvent {
case .large(let largeChange):
maybeCurrentTimestamp = isoDateFormatter.date(from: largeChange.timestampString)
case .newTalkPageTopic(let newTalkPageTopic):
maybeCurrentTimestamp = isoDateFormatter.date(from: newTalkPageTopic.timestampString)
case .vandalismRevert(let vandalismRevert):
maybeCurrentTimestamp = isoDateFormatter.date(from: vandalismRevert.timestampString)
case .small(let smallChange):
maybeCurrentTimestamp = isoDateFormatter.date(from: smallChange.timestampString)
}
guard let currentTimestamp = maybeCurrentTimestamp else {
assertionFailure("Significant Events - Unable to determine event timestamp, skipping event.")
continue
}
if let previousTimestamp = maybePreviousTimestamp {
let calendar = NSCalendar.current
if !calendar.isDate(previousTimestamp, inSameDayAs: currentTimestamp) {
// multiple days have passed since last event, package up current sections into new section
let section = SectionHeader(timestamp: previousTimestamp, typedEvents: currentSectionEvents, subtitleDateFormatter: dayMonthNumberYearDateFormatter)
sections.append(section)
currentSectionEvents.removeAll()
currentSectionEvents.append(event)
maybePreviousTimestamp = currentTimestamp
} else {
currentSectionEvents.append(event)
maybePreviousTimestamp = currentTimestamp
}
} else {
currentSectionEvents.append(event)
maybePreviousTimestamp = currentTimestamp
}
}
// capture any final currentSectionEvents into new section
if let currentTimestamp = maybeCurrentTimestamp {
let section = SectionHeader(timestamp: currentTimestamp, typedEvents: currentSectionEvents, subtitleDateFormatter: dayMonthNumberYearDateFormatter)
sections.append(section)
currentSectionEvents.removeAll()
}
// collapse sibling small event view models
var finalSections: [SectionHeader] = []
for section in sections {
var collapsedEventViewModels: [TypedEvent] = []
var currentSmallChanges: [SignificantEvents.Event.Small] = []
for event in section.typedEvents {
switch event {
case .small(let smallEventViewModel):
currentSmallChanges.append(contentsOf: smallEventViewModel.smallChanges)
default:
if currentSmallChanges.count > 0 {
collapsedEventViewModels.append(.small(Event.Small(smallChanges: currentSmallChanges)))
currentSmallChanges.removeAll()
}
collapsedEventViewModels.append(event)
continue
}
}
// add any final small changes
if currentSmallChanges.count > 0 {
collapsedEventViewModels.append(.small(Event.Small(smallChanges: currentSmallChanges)))
currentSmallChanges.removeAll()
}
let collapsedSection = SectionHeader(timestamp: section.timestamp, typedEvents: collapsedEventViewModels, subtitleDateFormatter: dayMonthNumberYearDateFormatter)
finalSections.append(collapsedSection)
}
finalSections = ArticleAsLivingDocViewModel.collapseSmallEvents(from: finalSections)
self.sections = finalSections
// grab first 3 large event html snippets
var articleInsertHtmlSnippets: [String] = []
var lastUpdatedTimestamp: String?
let htmlSnippetCountMax = 3
outerLoop: for (sectionIndex, section) in finalSections.enumerated() {
for (itemIndex, event) in section.typedEvents.enumerated() {
switch event {
case .small(let smallEvent):
if lastUpdatedTimestamp == nil {
lastUpdatedTimestamp = smallEvent.timestampForDisplay()
}
case .large(let largeEvent):
if lastUpdatedTimestamp == nil {
lastUpdatedTimestamp = largeEvent.fullyRelativeTimestampForDisplay()
}
let indexPath = IndexPath(item: itemIndex, section: sectionIndex)
if let htmlSnippet = largeEvent.articleInsertHtmlSnippet(isFirst: articleInsertHtmlSnippets.count == 0, isLast: articleInsertHtmlSnippets.count == htmlSnippetCountMax - 1, indexPath: indexPath) {
if articleInsertHtmlSnippets.count < htmlSnippetCountMax {
articleInsertHtmlSnippets.append(htmlSnippet)
} else {
if lastUpdatedTimestamp != nil {
break outerLoop
}
}
}
}
}
}
self.articleInsertHtmlSnippets = articleInsertHtmlSnippets
self.lastUpdatedTimestamp = lastUpdatedTimestamp
}
/// Collapses sequential sections that contain only small events into one section, including a date range that represents the collected events
static func collapseSmallEvents(from sections: [SectionHeader]) -> [SectionHeader] {
guard let dayMonthNumberYearDateFormatter = DateFormatter.wmf_monthNameDayOfMonthNumberYear(), let isoDateFormatter = DateFormatter.wmf_iso8601() else {
return sections
}
let enumeratedSections = sections.enumerated()
var mutatedSections: [SectionHeader] = []
var rangesToCollapse: [ClosedRange<Int>] = []
for (outerIndex, outerSection) in enumeratedSections {
let startIndex = outerIndex
var endIndex = outerIndex
if outerSection.containsOnlySmallEvents {
for (innerIndex, innerSection) in enumeratedSections {
guard innerIndex >= startIndex + 1, !rangesToCollapse.contains(where: {$0.contains(innerIndex) }) else {
continue
}
if innerSection.containsOnlySmallEvents {
endIndex = innerIndex
} else {
break
}
}
}
if startIndex != endIndex {
// This range is eligible to be collapsed
rangesToCollapse.append(startIndex...endIndex)
}
}
var typedEvents: [TypedEvent] = []
typealias CollapsedSection = (section: SectionHeader, sectionHashes: [Int])
var collapsedSections: [CollapsedSection] = []
var collapsedSectionHashes: [Int] = []
// Create new sections for each collapsed range
for range in rangesToCollapse {
for sectionElement in sections[range] {
typedEvents.append(contentsOf: sectionElement.typedEvents)
}
collapsedSectionHashes.append(contentsOf: sections[range].compactMap { $0.hashValue })
if let startIndex = range.first {
let smallChanges = typedEvents.flatMap { $0.smallChanges }
let collapsedSmallEvent = Event.Small(smallChanges: smallChanges)
let smallTypedEvent = TypedEvent.small(collapsedSmallEvent)
let smallChangeDates = smallChanges.compactMap { isoDateFormatter.date(from: $0.timestampString) }
var dateRange: DateInterval?
if let minDate = smallChangeDates.min(), let maxDate = smallChangeDates.max() {
dateRange = DateInterval(start: minDate, end: maxDate)
}
let section = SectionHeader(timestamp: sections[startIndex].timestamp, typedEvents: [smallTypedEvent], subtitleDateFormatter: dayMonthNumberYearDateFormatter, dateRange: dateRange)
collapsedSections.append((section, collapsedSectionHashes))
}
typedEvents = []
collapsedSectionHashes = []
}
var newlyCollapsedSectionHashes: [Int] = []
// Returns the small event collapsed section that represents the `sectionHash`, if one exists
func firstCollapsedSectionContaining(sectionHash: Int) -> CollapsedSection? {
return collapsedSections
.first { collapsedSection in collapsedSection.sectionHashes.contains(sectionHash) }
}
// Reconstruct sections with newly eligible small event sections collapsed in proper order
for section in sections {
if let collapsedSection = firstCollapsedSectionContaining(sectionHash: section.hashValue), !newlyCollapsedSectionHashes.contains(section.hashValue) {
mutatedSections.append(collapsedSection.section)
newlyCollapsedSectionHashes.append(contentsOf: collapsedSection.sectionHashes)
}
if !newlyCollapsedSectionHashes.contains(section.hashValue) {
mutatedSections.append(section)
}
}
return mutatedSections
}
static func eventDisplayTimestamp(timestampString: String) -> String? {
let isoDateFormatter = ISO8601DateFormatter()
guard
let shortFormatter = DateFormatter.wmf_24hshortTimeWithUTCTimeZone(),
let date = isoDateFormatter.date(from: timestampString) else {
return nil
}
let calendar = NSCalendar.current
let components = calendar.dateComponents([.hour, .minute], from: date, to: Date())
if let hours = components.hour, let minutes = components.minute {
switch hours {
case ..<1:
return String.localizedStringWithFormat(WMFLocalizedDateFormatStrings.minutesAgo(), minutes)
case ..<24:
return String.localizedStringWithFormat(WMFLocalizedDateFormatStrings.hoursAgo(), hours)
default:
break
}
}
return shortFormatter.string(from: date)
}
static func displayTimestamp(timestampString: String, fullyRelative: Bool) -> String? {
if let isoDateFormatter = DateFormatter.wmf_iso8601(),
let timeDateFormatter = DateFormatter.wmf_24hshortTimeWithUTCTimeZone(),
let date = isoDateFormatter.date(from: timestampString) {
if fullyRelative {
let relativeTime = (date as NSDate).wmf_fullyLocalizedRelativeDateStringFromLocalDateToNow()
return relativeTime
} else {
let calendar = NSCalendar.current
let unitFlags:Set<Calendar.Component> = [.day]
let components = calendar.dateComponents(unitFlags, from: date, to: Date())
if let numberOfDays = components.day {
switch numberOfDays {
case 0:
let relativeTime = (date as NSDate).wmf_fullyLocalizedRelativeDateStringFromLocalDateToNow()
return relativeTime
default:
let shortTime = timeDateFormatter.string(from: date)
return shortTime
}
}
}
}
return nil
}
}
// MARK: SectionHeader
public extension ArticleAsLivingDocViewModel {
class SectionHeader: Hashable {
public let title: String
public let subtitleTimestampDisplay: String
public let timestamp: Date
public let dateRange: DateInterval?
public var typedEvents: [TypedEvent]
private let sectionTimestampIdentifier: String
private static let calendar: Calendar = Calendar.current
private static let relativeDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.doesRelativeDateFormatting = true
formatter.timeStyle = .none
formatter.dateStyle = .short
return formatter
}()
private static let dayMonthYearFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.setLocalizedDateFormatFromTemplate("MMMM d, yyyy")
return formatter
}()
private static let dayMonthFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.setLocalizedDateFormatFromTemplate("MMMM d")
return formatter
}()
public init(timestamp: Date, typedEvents: [TypedEvent], subtitleDateFormatter: DateFormatter, dateRange: DateInterval? = nil) {
// If today or yesterday, show friendly localized relative format ("Today", "Yesterday")
func relativelyFormat(date: Date) -> String {
let nsTimestamp = timestamp as NSDate
if SectionHeader.calendar.isDateInToday(date) || SectionHeader.calendar.isDateInYesterday(date) {
return SectionHeader.relativeDateFormatter.string(from: date)
} else {
return nsTimestamp.wmf_fullyLocalizedRelativeDateStringFromLocalDateToNow()
}
}
self.title = relativelyFormat(date: timestamp)
if let dateRange = dateRange {
var dateRangeStrings: [String] = []
let startDate = dateRange.start
let endDate = dateRange.end
if SectionHeader.calendar.isDateInToday(endDate) || SectionHeader.calendar.isDateInYesterday(endDate) {
let endString = relativelyFormat(date: endDate)
let startString = SectionHeader.dayMonthYearFormatter.string(from: startDate)
dateRangeStrings.append(contentsOf: [endString, startString])
} else {
let endString = SectionHeader.dayMonthFormatter.string(from: endDate)
let startString = SectionHeader.dayMonthYearFormatter.string(from: startDate)
dateRangeStrings.append(contentsOf: [endString, startString])
}
self.subtitleTimestampDisplay = dateRangeStrings.joined(separator: " - ")
} else {
self.subtitleTimestampDisplay = SectionHeader.dayMonthYearFormatter.string(from: timestamp)
}
self.dateRange = dateRange
self.timestamp = timestamp
self.typedEvents = typedEvents
self.sectionTimestampIdentifier = subtitleDateFormatter.string(from: timestamp)
}
public static func == (lhs: ArticleAsLivingDocViewModel.SectionHeader, rhs: ArticleAsLivingDocViewModel.SectionHeader) -> Bool {
return lhs.sectionTimestampIdentifier == rhs.sectionTimestampIdentifier
}
public func hash(into hasher: inout Hasher) {
hasher.combine(sectionTimestampIdentifier)
hasher.combine(typedEvents)
}
// MARK: - Helpers
public var containsOnlySmallEvents: Bool {
typedEvents.count == 1 && typedEvents.allSatisfy { event in event.isSmall }
}
}
}
// MARK: Events
public extension ArticleAsLivingDocViewModel {
enum TypedEvent: Hashable {
case small(Event.Small)
case large(Event.Large)
public static func == (lhs: ArticleAsLivingDocViewModel.TypedEvent, rhs: ArticleAsLivingDocViewModel.TypedEvent) -> Bool {
switch lhs {
case .large(let leftLargeEvent):
switch rhs {
case .large(let rightLargeEvent):
return leftLargeEvent == rightLargeEvent
default:
return false
}
case .small(let leftSmallEvent):
switch rhs {
case .small(let rightSmallEvent):
return leftSmallEvent == rightSmallEvent
default:
return false
}
}
}
public func hash(into hasher: inout Hasher) {
switch self {
case .small(let smallEvent):
smallEvent.smallChanges.forEach { hasher.combine($0.revId) }
case .large(let largeEvent):
hasher.combine(largeEvent.revId)
hasher.combine(largeEvent.wereThanksSent)
}
}
// MARK: - Helpers
public var isSmall: Bool {
switch self {
case .small:
return true
default:
return false
}
}
public var smallChanges: [SignificantEvents.Event.Small] {
switch self {
case .small(let small):
return small.smallChanges
default:
return []
}
}
}
}
public extension ArticleAsLivingDocViewModel {
struct Event {
public class Small: Equatable {
private var lastTraitCollection: UITraitCollection?
private var lastTheme: Theme?
public lazy var eventDescription = {
return String.localizedStringWithFormat(
CommonStrings.smallChangeDescription,
smallChanges.count)
}()
public let smallChanges: [SignificantEvents.Event.Small]
public var loggingPosition = 0
public init?(typedEvents: [SignificantEvents.TypedEvent]) {
var smallChanges: [SignificantEvents.Event.Small] = []
for event in typedEvents {
switch event {
case .small(let smallChange):
smallChanges.append(smallChange)
default:
return nil
}
}
guard smallChanges.count > 0 else {
return nil
}
self.smallChanges = smallChanges
}
public init(smallChanges: [SignificantEvents.Event.Small]) {
self.smallChanges = smallChanges
}
public static func == (lhs: ArticleAsLivingDocViewModel.Event.Small, rhs: ArticleAsLivingDocViewModel.Event.Small) -> Bool {
return lhs.smallChanges == rhs.smallChanges
}
// Only used in the html portion of the feature
func timestampForDisplay() -> String? {
guard let timestampString = smallChanges.first?.timestampString else {
return nil
}
let displayTimestamp = ArticleAsLivingDocViewModel.displayTimestamp(timestampString: timestampString, fullyRelative: true)
return displayTimestamp
}
}
public class Large: Equatable {
public enum ChangeDetail {
case snippet(Snippet) // use for a basic horizontally scrolling snippet cell (will contain talk page topic snippets, added text snippets, article description updated snippets)
case reference(Reference)
}
public struct Snippet {
public let description: NSAttributedString
}
public struct Reference {
public let type: String
public let description: NSAttributedString
public let accessDateYearDisplay: String?
init?(type: String, description: NSAttributedString?, accessDateYearDisplay: String?) {
guard let description = description else {
return nil
}
self.type = type
self.description = description
self.accessDateYearDisplay = accessDateYearDisplay
}
}
public enum UserType {
case standard
case anonymous
case bot
}
public enum ButtonsToDisplay {
case thankAndViewChanges(userId: UInt, revisionId: UInt)
case viewDiscussion(sectionName: String?)
}
public let typedEvent: SignificantEvents.TypedEvent
private var lastTraitCollection: UITraitCollection?
private var lastTheme: Theme?
private(set) var eventDescription: NSAttributedString?
private(set) var sideScrollingCollectionViewHeight: CGFloat?
private(set) var changeDetails: [ChangeDetail]?
private(set) var displayTimestamp: String?
private(set) var userInfo: NSAttributedString?
let userId: UInt
public let userType: UserType
public let buttonsToDisplay: ButtonsToDisplay
public let revId: UInt
public let parentId: UInt
public var wereThanksSent = false
public var loggingPosition = 0
init?(typedEvent: SignificantEvents.TypedEvent) {
let userGroups: [String]?
switch typedEvent {
case .newTalkPageTopic(let newTalkPageTopic):
self.userId = newTalkPageTopic.userId
userGroups = newTalkPageTopic.userGroups
if let talkPageSection = newTalkPageTopic.section {
self.buttonsToDisplay = .viewDiscussion(sectionName: Self.sectionTitleWithWikitextAndHtmlStripped(originalTitle: talkPageSection))
} else {
self.buttonsToDisplay = .viewDiscussion(sectionName: nil)
}
case .large(let largeChange):
self.userId = largeChange.userId
userGroups = largeChange.userGroups
self.buttonsToDisplay = .thankAndViewChanges(userId: largeChange.userId, revisionId: largeChange.revId)
case .vandalismRevert(let vandalismRevert):
self.userId = vandalismRevert.userId
userGroups = vandalismRevert.userGroups
self.buttonsToDisplay = .thankAndViewChanges(userId: vandalismRevert.userId, revisionId: vandalismRevert.revId)
case .small:
return nil
}
if let userGroups = userGroups,
userGroups.contains("bot") {
userType = .bot
} else if self.userId == 0 {
userType = .anonymous
} else {
userType = .standard
}
self.typedEvent = typedEvent
switch typedEvent {
case .large(let largeChange):
revId = largeChange.revId
parentId = largeChange.parentId
case .newTalkPageTopic(let newTalkPageTopic):
revId = newTalkPageTopic.revId
parentId = newTalkPageTopic.parentId
case .vandalismRevert(let vandalismRevert):
revId = vandalismRevert.revId
parentId = vandalismRevert.parentId
default:
assertionFailure("Shouldn't happen")
revId = 0
parentId = 0
}
}
public static func == (lhs: ArticleAsLivingDocViewModel.Event.Large, rhs: ArticleAsLivingDocViewModel.Event.Large) -> Bool {
return lhs.revId == rhs.revId && lhs.wereThanksSent == rhs.wereThanksSent
}
}
}
}
// MARK: Large Event Type Helper methods
public extension ArticleAsLivingDocViewModel.Event.Large {
func articleInsertHtmlSnippet(isFirst: Bool = false, isLast: Bool = false, indexPath: IndexPath) -> String? {
guard let timestampForDisplay = self.fullyRelativeTimestampForDisplay(),
let userInfo = userInfoHtmlSnippet() else {
return nil
}
let liElementIdName = isFirst ? "significant-changes-first-list" : isLast ? "significant-changes-last-list" : "significant-changes-list"
let lastUserInfoIdAdditions = isLast ? " id='significant-changes-userInfo-last'" : ""
return "<li id='\(liElementIdName)'><p class='significant-changes-timestamp'>\(timestampForDisplay)</p><p class='significant-changes-description'>\(eventDescription ?? NSAttributedString(string: ""))</p><p class='significant-changes-userInfo'\(lastUserInfoIdAdditions)>\(userInfo)</p></li>"
}
private var htmlSignificantEventsLinkEndingTag: String {
return "</a>"
}
// if trait collection or theme is different from the last time attributed strings were generated,
// reset to nil to trigger generation again the next time it's requested
func resetAttributedStringsIfNeededWithTraitCollection(_ traitCollection: UITraitCollection, theme: Theme) {
if let lastTraitCollection = lastTraitCollection,
let lastTheme = lastTheme,
lastTraitCollection != traitCollection || lastTheme != theme {
eventDescription = nil
sideScrollingCollectionViewHeight = nil
changeDetails = nil
userInfo = nil
Self.heightForThreeLineSnippet = nil
Self.heightForReferenceTitle = nil
}
lastTraitCollection = traitCollection
lastTheme = theme
}
struct IndividualDescription {
let priority: Int // used for sorting
let text: String
}
private func individualDescriptionsForTypedChanges(_ typedChanges: [SignificantEvents.TypedChange]) -> [IndividualDescription] {
var descriptions: [IndividualDescription] = []
var numReferences = 0
for typedChange in typedChanges {
switch typedChange {
case .addedText(let addedText):
let countNumber = NSNumber(value: addedText.characterCount)
let characterCount = "\(NumberFormatter.localizedThousandsStringFromNumber(countNumber).localizedLowercase) " + String.localizedStringWithFormat(CommonStrings.charactersTextDescription, addedText.characterCount)
let description = String.localizedStringWithFormat(CommonStrings.addedTextDescription, characterCount)
descriptions.append(IndividualDescription(priority: 1, text: description))
case .deletedText(let deletedText):
let countNumber = NSNumber(value: deletedText.characterCount)
let characterCount = "\(NumberFormatter.localizedThousandsStringFromNumber(countNumber).localizedLowercase) " + String.localizedStringWithFormat(CommonStrings.charactersTextDescription, deletedText.characterCount)
let description = String.localizedStringWithFormat(CommonStrings.deletedTextDescription, characterCount)
descriptions.append(IndividualDescription(priority: 2, text: description))
case .newTemplate(let newTemplate):
var numArticleDescriptions = 0
for template in newTemplate.typedTemplates {
switch template {
case .articleDescription:
numArticleDescriptions += 1
case .bookCitation,
.journalCitation,
.newsCitation,
.websiteCitation:
numReferences += 1
}
}
if numArticleDescriptions > 0 {
let description = CommonStrings.articleDescriptionUpdatedDescription
descriptions.append(IndividualDescription(priority: 3, text: description))
}
}
}
if descriptions.count == 0 {
switch numReferences {
case 0:
break
case 1:
let description = CommonStrings.singleReferenceAddedDescription
descriptions.append(IndividualDescription(priority: 0, text: description))
default:
let description = CommonStrings.multipleReferencesAddedDescription
descriptions.append(IndividualDescription(priority: 0, text: description))
}
} else {
if numReferences > 0 {
let description = String.localizedStringWithFormat(CommonStrings.numericalMultipleReferencesAddedDescription, numReferences)
descriptions.append(IndividualDescription(priority: 0, text: description))
}
}
return descriptions
}
private func sectionsSet() -> Set<String> {
let set: Set<String>
switch typedEvent {
case .newTalkPageTopic:
set = Set<String>()
case .vandalismRevert(let vandalismRevert):
set = Set(vandalismRevert.sections)
case .large(let largeChange):
var sections: [String] = []
for typedChange in largeChange.typedChanges {
switch typedChange {
case .addedText(let addedTextChange):
sections.append(contentsOf: addedTextChange.sections)
case .deletedText(let deletedTextChange):
sections.append(contentsOf: deletedTextChange.sections)
case .newTemplate(let newTemplate):
sections.append(contentsOf: newTemplate.sections)
}
}
set = Set(sections)
case .small:
assertionFailure("This shouldn't happen")
set = Set<String>()
}
// strip == signs from all section titles
let finalSet = set.map { Self.sectionTitleWithWikitextAndHtmlStripped(originalTitle: $0) }
return Set(finalSet)
}
// remove one or more equal signs and zero or more spaces on either side of the title text
// also removing html for display and potential javascript injection issues - https://phabricator.wikimedia.org/T268201
private static func sectionTitleWithWikitextAndHtmlStripped(originalTitle: String) -> String {
var loopTitle = originalTitle.removingHTML
let regex = "^=+\\s*|\\s*=+$"
var maybeMatch = loopTitle.range(of: regex, options: .regularExpression)
while let match = maybeMatch {
loopTitle.removeSubrange(match)
maybeMatch = loopTitle.range(of: regex, options: .regularExpression)
}
return loopTitle
}
private func localizedStringFromSections(sections: [String]) -> String? {
var localizedString: String
switch sections.count {
case 0:
return nil
case 1:
let firstSection = sections[0]
localizedString = String.localizedStringWithFormat(CommonStrings.oneSectionDescription, firstSection)
case 2:
let firstSection = sections[0]
let secondSection = sections[1]
localizedString = String.localizedStringWithFormat(CommonStrings.twoSectionsDescription, firstSection, secondSection)
default:
localizedString = String.localizedStringWithFormat(CommonStrings.manySectionsDescription, sections.count)
}
return " " + localizedString
}
private func localizedSectionHtmlSnippet(sectionsSet: Set<String>) -> String? {
let sections = Array(sectionsSet)
guard let localizedString = localizedStringFromSections(sections: sections) else {
return nil
}
let mutableLocalizedString = NSMutableString(string: localizedString)
var ranges: [NSRange] = []
for section in sections {
let rangeOfSection = (localizedString as NSString).range(of: section)
let rangeValid = rangeOfSection.location != NSNotFound && rangeOfSection.location + rangeOfSection.length <= localizedString.count
if rangeValid {
ranges.append(rangeOfSection)
}
}
var offset = 0
for range in ranges {
let italicStart = "<i>"
let italicEnd = "</i>"
mutableLocalizedString.insert(italicStart, at: range.location + offset)
offset += italicStart.count
mutableLocalizedString.insert(italicEnd, at: range.location + range.length + offset)
offset += italicEnd.count
}
if let returnString = mutableLocalizedString.copy() as? NSString {
return returnString as String
} else {
assertionFailure("This shouldn't happen")
return nil
}
}
private func localizedSectionAttributedString(sectionsSet: Set<String>, traitCollection: UITraitCollection, theme: Theme) -> NSAttributedString? {
let sections = Array(sectionsSet)
guard let localizedString = localizedStringFromSections(sections: sections) else {
return nil
}
let font = UIFont.wmf_font(.body, compatibleWithTraitCollection: traitCollection)
let italicFont = UIFont.wmf_font(.italicBody, compatibleWithTraitCollection: traitCollection)
let attributes = [NSAttributedString.Key.font: font,
NSAttributedString.Key.foregroundColor: theme.colors.primaryText]
var ranges: [NSRange] = []
for section in sections {
let rangeOfSection = (localizedString as NSString).range(of: section)
let rangeValid = rangeOfSection.location != NSNotFound && rangeOfSection.location + rangeOfSection.length <= localizedString.count
if rangeValid {
ranges.append(rangeOfSection)
}
}
let mutableAttributedString = NSMutableAttributedString(string: localizedString, attributes: attributes)
for range in ranges {
mutableAttributedString.addAttribute(NSAttributedString.Key.font, value: italicFont, range: range)
}
guard let attributedString = mutableAttributedString.copy() as? NSAttributedString else {
assertionFailure("This shouldn't happen")
return NSAttributedString(string: localizedString, attributes: attributes)
}
return attributedString
}
static let sideScrollingCellPadding = UIEdgeInsets(top: 17, left: 15, bottom: 17, right: 15)
static let sideScrollingCellWidth: CGFloat = 250
static var availableSideScrollingCellWidth: CGFloat = {
return sideScrollingCellWidth - sideScrollingCellPadding.left - sideScrollingCellPadding.right
}()
private static let changeDetailDescriptionTextStyle = DynamicTextStyle.subheadline
private static let changeDetailDescriptionTextStyleItalic = DynamicTextStyle.italicSubheadline
private static let changeDetailDescriptionFontWeight = UIFont.Weight.regular
static let changeDetailReferenceTitleStyle = DynamicTextStyle.semiboldSubheadline
static let changeDetailReferenceTitleDescriptionSpacing: CGFloat = 13
static let additionalPointsForShadow: CGFloat = 16
@discardableResult func calculateSideScrollingCollectionViewHeightForTraitCollection(_ traitCollection: UITraitCollection, theme: Theme) -> CGFloat {
if let sideScrollingCollectionViewHeight = sideScrollingCollectionViewHeight {
return sideScrollingCollectionViewHeight
}
let changeDetails = changeDetailsForTraitCollection(traitCollection, theme: theme)
let tallestSnippetContentHeight: CGFloat = calculateTallestSnippetContentHeightInChangeDetails(changeDetails: changeDetails)
let tallestReferenceChangeDetailHeight: CGFloat = calculateTallestReferenceContentHeightInChangeDetails(changeDetails: changeDetails, traitCollection: traitCollection, theme: theme)
let maxContentHeight = maxContentHeightFromTallestSnippetContentHeight(tallestSnippetContentHeight: tallestSnippetContentHeight, tallestReferenceContentHeight: tallestReferenceChangeDetailHeight, traitCollection: traitCollection, theme: theme)
let finalHeight = maxContentHeight == 0 ? 0 : maxContentHeight + Self.sideScrollingCellPadding.top + Self.sideScrollingCellPadding.bottom + Self.additionalPointsForShadow
self.sideScrollingCollectionViewHeight = finalHeight
return finalHeight
}
func changeDetailsForTraitCollection(_ traitCollection: UITraitCollection, theme: Theme) -> [ChangeDetail] {
if let changeDetails = changeDetails {
return changeDetails
}
var changeDetails: [ChangeDetail] = []
switch typedEvent {
case .newTalkPageTopic(let newTalkPageTopic):
let attributedString = newTalkPageTopic.snippet.byAttributingHTML(with: Self.changeDetailDescriptionTextStyle, boldWeight: Self.changeDetailDescriptionFontWeight, matching: traitCollection, color: theme.colors.primaryText, linkColor: theme.colors.link, handlingLists: true, handlingSuperSubscripts: true)
let changeDetail = ChangeDetail.snippet(Snippet(description: attributedString))
changeDetails.append(changeDetail)
case .large(let largeChange):
for typedChange in largeChange.typedChanges {
switch typedChange {
case .addedText(let addedText):
// TODO: Add highlighting here. For snippetType 1, add a highlighting attribute across the whole string. Otherwise, seek out highlight-add span ranges and add those attributes
guard let snippet = addedText.snippet else {
continue
}
let attributedString = snippet.byAttributingHTML(with: Self.changeDetailDescriptionTextStyle, boldWeight: Self.changeDetailDescriptionFontWeight, matching: traitCollection, color: theme.colors.primaryText, handlingLinks: true, linkColor: theme.colors.link, handlingLists: true, handlingSuperSubscripts: true)
let changeDetail = ChangeDetail.snippet(Snippet(description: attributedString))
changeDetails.append(changeDetail)
case .deletedText:
continue
case .newTemplate(let newTemplate):
for template in newTemplate.typedTemplates {
switch template {
case .articleDescription(let articleDescription):
let font = UIFont.wmf_font(Self.changeDetailDescriptionTextStyle, compatibleWithTraitCollection: traitCollection)
let attributes = [NSAttributedString.Key.font: font,
NSAttributedString.Key.foregroundColor:
theme.colors.primaryText]
let attributedString = NSAttributedString(string: articleDescription.text, attributes: attributes)
let changeDetail = ChangeDetail.snippet(Snippet(description: attributedString))
changeDetails.append(changeDetail)
// tonitodo: these code blocks are all very similar. make a generic method instead?
case .bookCitation(let bookCitation):
let typeText = referenceTypeForTemplate(template, traitCollection: traitCollection, theme: theme)
let accessYear = accessDateYearForTemplate(template, traitCollection: traitCollection, theme: theme)
let bookCitationDescription = descriptionForBookCitation(bookCitation, traitCollection: traitCollection, theme: theme)
if let reference = Reference(type: typeText, description: bookCitationDescription, accessDateYearDisplay: accessYear) {
let changeDetail = ChangeDetail.reference(reference)
changeDetails.append(changeDetail)
}
case .journalCitation(let journalCitation):
let typeText = referenceTypeForTemplate(template, traitCollection: traitCollection, theme: theme)
let accessYear = accessDateYearForTemplate(template, traitCollection: traitCollection, theme: theme)
let citationDescription = descriptionForJournalCitation(journalCitation, traitCollection: traitCollection, theme: theme)
if let reference = Reference(type: typeText, description: citationDescription, accessDateYearDisplay: accessYear) {
let changeDetail = ChangeDetail.reference(reference)
changeDetails.append(changeDetail)
}
case .newsCitation(let newsCitation):
let typeText = referenceTypeForTemplate(template, traitCollection: traitCollection, theme: theme)
let accessYear = accessDateYearForTemplate(template, traitCollection: traitCollection, theme: theme)
let citationDescription = descriptionForNewsCitation(newsCitation, traitCollection: traitCollection, theme: theme)
if let reference = Reference(type: typeText, description: citationDescription, accessDateYearDisplay: accessYear) {
let changeDetail = ChangeDetail.reference(reference)
changeDetails.append(changeDetail)
}
case .websiteCitation(let websiteCitation):
let typeText = referenceTypeForTemplate(template, traitCollection: traitCollection, theme: theme)
let accessYear = accessDateYearForTemplate(template, traitCollection: traitCollection, theme: theme)
let citationDescription = descriptionForWebsiteCitation(websiteCitation, traitCollection: traitCollection, theme: theme)
if let reference = Reference(type: typeText, description: citationDescription, accessDateYearDisplay: accessYear) {
let changeDetail = ChangeDetail.reference(reference)
changeDetails.append(changeDetail)
}
}
}
}
}
case .vandalismRevert:
return []
case .small:
assertionFailure("This should not happen")
return []
}
self.changeDetails = changeDetails
return changeDetails
}
// Note: heightForThreeLineSnippet and heightForReferenceTitle methods are placeholder calculations when determining a side scrolling cell's content height.
// When there are no reference cells, we are capping off article content snippet cells at 3 lines. If there are reference cells, snippet cells are allowed to show lines to the full height of the tallest reference cell.
// Reference cells titles are only ever 1 line, so we are using placeholder text to calculate that rather than going up against actual view model title values, since the height will be the same regardless of the size of the title value
// heightForThreeLine and heightForReferenceTitle only ever needs to be calculated once per traitCollection's preferredContentSize, so we are optimizing in the similar way that ArticleAsLivingDocViewModel's various NSAttributedStrings are optimized, i.e. calculate once, then reset when the traitCollection changes via resetAttributedStringsIfNeededWithTraitCollection.
private static var heightForThreeLineSnippet: CGFloat?
private static func heightForThreeLineSnippetForTraitCollection(_ traitCollection: UITraitCollection, theme: Theme) -> CGFloat {
if let heightForThreeLineSnippet = heightForThreeLineSnippet {
return heightForThreeLineSnippet
}
let snippetFont = UIFont.wmf_font(Self.changeDetailDescriptionTextStyle, compatibleWithTraitCollection: traitCollection)
let snippetAttributes = [NSAttributedString.Key.font: snippetFont]
let threeLineSnippetText = """
1
2
3
"""
let threeLineSnippetAttString = NSAttributedString(string: threeLineSnippetText, attributes: snippetAttributes)
let finalHeight = ceil(threeLineSnippetAttString.boundingRect(with: CGSize(width: Self.availableSideScrollingCellWidth, height: CGFloat.infinity), options: [.usesLineFragmentOrigin], context: nil).height)
heightForThreeLineSnippet = finalHeight
return finalHeight
}
private static var heightForReferenceTitle: CGFloat?
private static func heightForReferenceTitleForTraitCollection(_ traitCollection: UITraitCollection, theme: Theme) -> CGFloat {
if let heightForReferenceTitle = heightForReferenceTitle {
return heightForReferenceTitle
}
let referenceTitleFont = UIFont.wmf_font(Self.changeDetailReferenceTitleStyle, compatibleWithTraitCollection: traitCollection)
let referenceTitleAttributes = [NSAttributedString.Key.font: referenceTitleFont]
let oneLineTitleText = "1"
let oneLineTitleAttString = NSAttributedString(string: oneLineTitleText, attributes: referenceTitleAttributes)
let finalHeight = ceil(oneLineTitleAttString.boundingRect(with: CGSize(width: Self.availableSideScrollingCellWidth, height: CGFloat.infinity), options: [.usesLineFragmentOrigin], context: nil).height)
heightForReferenceTitle = finalHeight
return finalHeight
}
private func calculateTallestSnippetContentHeightInChangeDetails(changeDetails: [ChangeDetail]) -> CGFloat {
var tallestSnippetChangeDetailHeight: CGFloat = 0
changeDetails.forEach { (changeDetail) in
switch changeDetail {
case .snippet(let snippet):
let snippetHeight = ceil(snippet.description.boundingRect(with: CGSize(width: Self.availableSideScrollingCellWidth, height: CGFloat.infinity), options: [.usesLineFragmentOrigin], context: nil).height)
if tallestSnippetChangeDetailHeight < snippetHeight {
tallestSnippetChangeDetailHeight = snippetHeight
}
case .reference:
break
}
}
return tallestSnippetChangeDetailHeight
}
private func calculateTallestReferenceContentHeightInChangeDetails(changeDetails: [ChangeDetail], traitCollection: UITraitCollection, theme: Theme) -> CGFloat {
var tallestReferenceChangeDetailHeight: CGFloat = 0
changeDetails.forEach { (changeDetail) in
switch changeDetail {
case .snippet:
break
case .reference(let reference):
let titleHeight = Self.heightForReferenceTitleForTraitCollection(traitCollection, theme: theme)
let descriptionHeight = ceil(reference.description.boundingRect(with: CGSize(width: Self.availableSideScrollingCellWidth, height: CGFloat.infinity), options: [.usesLineFragmentOrigin], context: nil).height)
let totalHeight = titleHeight + Self.changeDetailReferenceTitleDescriptionSpacing + descriptionHeight
if tallestReferenceChangeDetailHeight < totalHeight {
tallestReferenceChangeDetailHeight = totalHeight
}
}
}
return tallestReferenceChangeDetailHeight
}
private func maxContentHeightFromTallestSnippetContentHeight(tallestSnippetContentHeight: CGFloat, tallestReferenceContentHeight: CGFloat, traitCollection: UITraitCollection, theme: Theme) -> CGFloat {
guard tallestSnippetContentHeight > 0 else {
return tallestReferenceContentHeight
}
let threeLineSnippetHeight = Self.heightForThreeLineSnippetForTraitCollection(traitCollection, theme: theme)
if tallestReferenceContentHeight == 0 {
return min(tallestSnippetContentHeight, threeLineSnippetHeight)
} else {
let finalSnippetHeight = min(tallestSnippetContentHeight, threeLineSnippetHeight)
return max(tallestReferenceContentHeight, finalSnippetHeight)
}
}
private func referenceTypeForTemplate(_ template: SignificantEvents.Template, traitCollection: UITraitCollection, theme: Theme) -> String {
var typeString: String
switch template {
case .articleDescription:
assertionFailure("This should not happen")
return ""
case .bookCitation:
typeString = CommonStrings.newBookReferenceTitle
case .journalCitation:
typeString = CommonStrings.newJournalReferenceTitle
case .newsCitation:
typeString = CommonStrings.newNewsReferenceTitle
case .websiteCitation:
typeString = CommonStrings.newWebsiteReferenceTitle
}
return typeString
}
private func accessDateYearForTemplate(_ template: SignificantEvents.Template, traitCollection: UITraitCollection, theme: Theme) -> String? {
let accessDateString: String?
switch template {
case .newsCitation(let newsCitation):
accessDateString = newsCitation.accessDateString
case .websiteCitation(let websiteCitation):
accessDateString = websiteCitation.accessDateString
default:
return nil
}
if let accessDateString = accessDateString {
let dateFormatter = DateFormatter.wmf_monthNameDayOfMonthNumberYear()
if let date = dateFormatter?.date(from: accessDateString) {
let yearDateFormatter = DateFormatter.wmf_year()
let year = yearDateFormatter?.string(from: date)
return year
}
}
return nil
}
private func descriptionForJournalCitation(_ journalCitation: SignificantEvents.Citation.Journal, traitCollection: UITraitCollection, theme: Theme) -> NSAttributedString? {
let font = UIFont.wmf_font(Self.changeDetailDescriptionTextStyle, compatibleWithTraitCollection: traitCollection)
let boldFont = UIFont.wmf_font(Self.changeDetailDescriptionTextStyleItalic, compatibleWithTraitCollection: traitCollection)
let attributes = [NSAttributedString.Key.font: font,
NSAttributedString.Key.foregroundColor:
theme.colors.primaryText]
let boldAttributes = [NSAttributedString.Key.font: boldFont,
NSAttributedString.Key.foregroundColor:
theme.colors.primaryText]
let titleString = "\"\(journalCitation.title)\" "
let mutableAttributedString = mutableString(from: titleString, linkedTo: journalCitation.urlString, with: attributes, linkColor: theme.colors.link)
let titleAttributedString = mutableAttributedString.copy() as? NSAttributedString
var descriptionStart = ""
if let firstName = journalCitation.firstName {
if let lastName = journalCitation.lastName {
descriptionStart += "\(lastName), \(firstName)"
}
} else {
if let lastName = journalCitation.lastName {
descriptionStart += "\(lastName)"
}
}
if let sourceDate = journalCitation.sourceDateString {
descriptionStart += " (\(sourceDate)). "
} else {
descriptionStart += ". "
}
let descriptionStartAttributedString = NSAttributedString(string: descriptionStart, attributes: attributes)
var volumeString = ""
if let volumeNumber = journalCitation.volumeNumber {
volumeString = String.localizedStringWithFormat(CommonStrings.newJournalReferenceVolume, volumeNumber)
}
let volumeAttributedString = NSAttributedString(string: "\(volumeString) ", attributes: boldAttributes)
var descriptionEnd = ""
if let database = journalCitation.database {
let viaDatabaseString = String.localizedStringWithFormat(CommonStrings.newJournalReferenceDatabase, database)
if let pages = journalCitation.pages {
descriptionEnd += "\(pages) - \(viaDatabaseString)."
} else {
descriptionEnd += "\(viaDatabaseString)."
}
} else {
if let pages = journalCitation.pages {
descriptionEnd += "\(pages)."
}
}
let descriptionEndAttributedString = NSAttributedString(string: descriptionEnd, attributes: attributes)
let finalMutableAttributedString = NSMutableAttributedString(string: "")
finalMutableAttributedString.append(descriptionStartAttributedString)
if let titleAttributedString = titleAttributedString {
finalMutableAttributedString.append(titleAttributedString)
}
finalMutableAttributedString.append(volumeAttributedString)
finalMutableAttributedString.append(descriptionEndAttributedString)
return finalMutableAttributedString.copy() as? NSAttributedString
}
private func descriptionForWebsiteCitation(_ websiteCitation: SignificantEvents.Citation.Website, traitCollection: UITraitCollection, theme: Theme) -> NSAttributedString? {
let font = UIFont.wmf_font(Self.changeDetailDescriptionTextStyle, compatibleWithTraitCollection: traitCollection)
let italicFont = UIFont.wmf_font(Self.changeDetailDescriptionTextStyleItalic, compatibleWithTraitCollection: traitCollection)
let attributes = [NSAttributedString.Key.font: font,
NSAttributedString.Key.foregroundColor:
theme.colors.primaryText]
let italicAttributes = [NSAttributedString.Key.font: italicFont,
NSAttributedString.Key.foregroundColor:
theme.colors.primaryText]
let titleString = "\"\(websiteCitation.title)\" "
let mutableAttributedString = mutableString(from: titleString, linkedTo: websiteCitation.urlString, with: attributes, linkColor: theme.colors.link)
let titleAttributedString = mutableAttributedString.copy() as? NSAttributedString
var publisherText = ""
if let publisher = websiteCitation.publisher {
publisherText = "\(publisher). "
}
let publisherAttributedString = NSAttributedString(string: publisherText, attributes: italicAttributes)
var accessDateString = ""
if let accessDate = websiteCitation.accessDateString {
accessDateString = "\(accessDate). "
}
let accessDateAttributedString = NSAttributedString(string: accessDateString, attributes: attributes)
let finalMutableAttributedString = NSMutableAttributedString(string: "")
if let titleAttributedString = titleAttributedString {
finalMutableAttributedString.append(titleAttributedString)
}
finalMutableAttributedString.append(publisherAttributedString)
finalMutableAttributedString.append(accessDateAttributedString)
if let archiveDateString = websiteCitation.archiveDateString,
let archiveUrlString = websiteCitation.archiveDotOrgUrlString,
URL(string: archiveUrlString) != nil {
let archiveLinkText = CommonStrings.newWebsiteReferenceArchiveUrlText
let archiveLinkMutableAttributedString = mutableString(from: archiveLinkText, linkedTo: archiveUrlString, with: attributes, linkColor: theme.colors.link)
if let archiveLinkAttributedString = archiveLinkMutableAttributedString.copy() as? NSAttributedString {
let lastText = String.localizedStringWithFormat(CommonStrings.newWebsiteReferenceArchiveDateText, archiveDateString)
let lastAttributedString = NSAttributedString(string: " \(lastText)", attributes: attributes)
finalMutableAttributedString.append(archiveLinkAttributedString)
finalMutableAttributedString.append(lastAttributedString)
}
}
return finalMutableAttributedString.copy() as? NSAttributedString
}
private func descriptionForNewsCitation(_ newsCitation: SignificantEvents.Citation.News, traitCollection: UITraitCollection, theme: Theme) -> NSAttributedString? {
let font = UIFont.wmf_font(Self.changeDetailDescriptionTextStyle, compatibleWithTraitCollection: traitCollection)
let italicFont = UIFont.wmf_font(Self.changeDetailDescriptionTextStyleItalic, compatibleWithTraitCollection: traitCollection)
let attributes = [NSAttributedString.Key.font: font,
NSAttributedString.Key.foregroundColor:
theme.colors.primaryText]
let italicAttributes = [NSAttributedString.Key.font: italicFont,
NSAttributedString.Key.foregroundColor:
theme.colors.primaryText]
let titleString = "\"\(newsCitation.title)\" "
let mutableAttributedString = mutableString(from: titleString, linkedTo: newsCitation.urlString, with: attributes, linkColor: theme.colors.link)
let titleAttributedString = mutableAttributedString.copy() as? NSAttributedString
var descriptionStart = ""
if let firstName = newsCitation.firstName {
if let lastName = newsCitation.lastName {
descriptionStart += "\(lastName), \(firstName) "
}
} else {
if let lastName = newsCitation.lastName {
descriptionStart += "\(lastName) "
}
}
if let sourceDate = newsCitation.sourceDateString {
descriptionStart += "(\(sourceDate)). "
} else {
descriptionStart += ". "
}
let descriptionStartAttributedString = NSAttributedString(string: descriptionStart, attributes: attributes)
var publicationText = ""
if let publication = newsCitation.publication {
publicationText = "\(publication). "
}
let publicationAttributedString = NSAttributedString(string: publicationText, attributes: italicAttributes)
var retrievedString = ""
if let accessDate = newsCitation.accessDateString {
retrievedString = String.localizedStringWithFormat(CommonStrings.newNewsReferenceRetrievedDate, accessDate)
}
let retrievedDateAttributedString = NSAttributedString(string: "\(retrievedString) ", attributes: attributes)
let finalMutableAttributedString = NSMutableAttributedString(string: "")
finalMutableAttributedString.append(descriptionStartAttributedString)
if let titleAttributedString = titleAttributedString {
finalMutableAttributedString.append(titleAttributedString)
}
finalMutableAttributedString.append(publicationAttributedString)
finalMutableAttributedString.append(retrievedDateAttributedString)
return finalMutableAttributedString.copy() as? NSAttributedString
}
private func descriptionForBookCitation(_ bookCitation: SignificantEvents.Citation.Book, traitCollection: UITraitCollection, theme: Theme) -> NSAttributedString? {
let font = UIFont.wmf_font(Self.changeDetailDescriptionTextStyle, compatibleWithTraitCollection: traitCollection)
let italicFont = UIFont.wmf_font(Self.changeDetailDescriptionTextStyleItalic, compatibleWithTraitCollection: traitCollection)
let attributes = [NSAttributedString.Key.font: font,
NSAttributedString.Key.foregroundColor:
theme.colors.primaryText]
let italicAttributes = [NSAttributedString.Key.font: italicFont,
NSAttributedString.Key.foregroundColor:
theme.colors.primaryText]
let titleAttributedString = NSAttributedString(string: "\(bookCitation.title) ", attributes: italicAttributes)
var descriptionStart = ""
if let firstName = bookCitation.firstName {
if let lastName = bookCitation.lastName {
descriptionStart += "\(lastName), \(firstName)"
}
} else {
if let lastName = bookCitation.lastName {
descriptionStart += "\(lastName)"
}
}
if let yearOfPub = bookCitation.yearPublished {
descriptionStart += " (\(yearOfPub)). "
} else {
descriptionStart += ". "
}
let descriptionStartAttributedString = NSAttributedString(string: descriptionStart, attributes: attributes)
var descriptionMiddle = ""
if let locationPublished = bookCitation.locationPublished {
if let publisher = bookCitation.publisher {
descriptionMiddle += "\(locationPublished): \(publisher). "
} else {
descriptionMiddle += "\(locationPublished). "
}
} else {
if let publisher = bookCitation.publisher {
descriptionMiddle += "\(publisher). "
}
}
if let pagesCited = bookCitation.pagesCited {
descriptionMiddle += "pp. \(pagesCited) "
}
let descriptionMiddleAttributedString = NSAttributedString(string: descriptionMiddle, attributes: attributes)
var isbnAttributedString: NSAttributedString?
if let isbn = bookCitation.isbn {
let isbnPrefix = "ISBN: "
let mutableAttributedString = NSMutableAttributedString(string: "\(isbnPrefix + isbn)", attributes: attributes)
let isbnTitle = "Special:BookSources"
let isbnURL = Configuration.current.articleURLForHost(Configuration.Domain.englishWikipedia, languageVariantCode: nil, appending: [isbnTitle, isbn])
let range = NSRange(location: 0, length: isbnPrefix.count + isbn.count)
if let isbnURL = isbnURL {
mutableAttributedString.addAttributes([NSAttributedString.Key.link : isbnURL,
NSAttributedString.Key.foregroundColor: theme.colors.link], range: range)
} else {
mutableAttributedString.addAttributes(attributes, range: range)
}
isbnAttributedString = mutableAttributedString.copy() as? NSAttributedString
}
let finalMutableAttributedString = NSMutableAttributedString(string: "")
finalMutableAttributedString.append(descriptionStartAttributedString)
finalMutableAttributedString.append(titleAttributedString)
finalMutableAttributedString.append(descriptionMiddleAttributedString)
if let isbnAttributedString = isbnAttributedString {
finalMutableAttributedString.append(isbnAttributedString)
}
return finalMutableAttributedString.copy() as? NSAttributedString
}
private func mutableString(from text: String, linkedTo urlString: String?, with textAttributes: [NSAttributedString.Key:Any], linkColor: UIColor) -> NSMutableAttributedString {
let mutableAttributedString: NSMutableAttributedString
if let urlString = urlString, let url = URL(string: urlString), let externalLinkIcon = UIImage(named: "mini-external") {
mutableAttributedString = NSMutableAttributedString(string: text.trimmingCharacters(in: .whitespaces), attributes: textAttributes)
mutableAttributedString.append(NSAttributedString(string: " "))
let externalLinkString = NSAttributedString(attachment: NSTextAttachment(image: externalLinkIcon))
mutableAttributedString.append(externalLinkString)
mutableAttributedString.append(NSAttributedString(string: " "))
let range = NSRange(location: 0, length: mutableAttributedString.length)
mutableAttributedString.addAttributes([NSAttributedString.Key.link : url,
NSAttributedString.Key.foregroundColor: linkColor], range: range)
} else {
mutableAttributedString = NSMutableAttributedString(string: text, attributes: textAttributes)
let range = NSRange(location: 0, length: text.count)
mutableAttributedString.addAttributes(textAttributes, range: range)
}
return mutableAttributedString
}
private func getTimestampString() -> String? {
switch typedEvent {
case .newTalkPageTopic(let newTalkPageTopic):
return newTalkPageTopic.timestampString
case .large(let largeChange):
return largeChange.timestampString
case .vandalismRevert(let vandalismRevert):
return vandalismRevert.timestampString
case .small:
return nil
}
}
// Only used in the html portion of the feature
func fullyRelativeTimestampForDisplay() -> String? {
guard let timestampString = getTimestampString() else {
return nil
}
return ArticleAsLivingDocViewModel.displayTimestamp(timestampString: timestampString, fullyRelative: true)
}
func timestampForDisplay() -> String? {
if let displayTimestamp = displayTimestamp {
return displayTimestamp
} else if let timestampString = getTimestampString() {
self.displayTimestamp = ArticleAsLivingDocViewModel.eventDisplayTimestamp(timestampString: timestampString)
}
return displayTimestamp
}
private func userNameAndEditCount() -> (userName: String, editCount: UInt?)? {
let userName: String
let editCount: UInt?
switch typedEvent {
case .newTalkPageTopic(let newTalkPageTopic):
userName = newTalkPageTopic.user
editCount = newTalkPageTopic.userEditCount
case .large(let largeChange):
userName = largeChange.user
editCount = largeChange.userEditCount
case .vandalismRevert(let vandalismRevert):
userName = vandalismRevert.user
editCount = vandalismRevert.userEditCount
case .small:
return nil
}
return (userName: userName, editCount: editCount)
}
static var botIconName: String {
return "article-as-living-doc-svg-bot"
}
static var anonymousIconName: String = "article-as-living-doc-svg-anon"
private func userInfoHtmlSnippet() -> String? {
guard let userNameAndEditCount = self.userNameAndEditCount() else {
assertionFailure("Shouldn't reach this point")
return nil
}
let userName = userNameAndEditCount.userName
let editCount = userNameAndEditCount.editCount
if let editCount = editCount,
userType != .anonymous {
let formattedEditCount = NumberFormatter.localizedThousandsStringFromNumber(NSNumber(value: editCount)).localizedLowercase
let userInfo = String.localizedStringWithFormat(CommonStrings.revisionUserInfo, userName, formattedEditCount)
let rangeOfUserName = (userInfo as NSString).range(of: userName)
let rangeValid = rangeOfUserName.location != NSNotFound && rangeOfUserName.location + rangeOfUserName.length <= userInfo.count
let userNameHrefString = "#significant-events-username-\(userName)"
if rangeValid {
let mutableUserInfo = NSMutableString(string: userInfo)
let linkStartInsert: String
if userType == .bot {
linkStartInsert = "<a href='\(userNameHrefString)'><img src='\(Self.botIconName)' style='margin: 0em .2em .35em .1em; width: 1em' />"
} else {
linkStartInsert = "<a href='\(userNameHrefString)'>"
}
let linkEndInsert = "</a>"
mutableUserInfo.insert(linkStartInsert, at: rangeOfUserName.location)
mutableUserInfo.insert(linkEndInsert, at: rangeOfUserName.location + rangeOfUserName.length + linkStartInsert.count)
if let userInfoResult = mutableUserInfo.copy() as? NSString {
return (userInfoResult as String)
} else {
assertionFailure("This shouldn't happen")
return nil
}
}
} else {
return "<img src='\(Self.anonymousIconName)' style='margin: 0em .2em .35em .1em; width: 1em' />\(CommonStrings.revisionUserInfoAnonymous)"
}
return nil
}
func userInfoForTraitCollection(_ traitCollection: UITraitCollection, theme: Theme) -> NSAttributedString? {
if let userInfo = userInfo {
return userInfo
}
guard let userNameAndEditCount = self.userNameAndEditCount() else {
assertionFailure("Shouldn't reach this point")
return nil
}
let userName = userNameAndEditCount.userName
let maybeEditCount = userNameAndEditCount.editCount
guard let editCount = maybeEditCount,
userType != .anonymous else {
let anonymousUserInfo = CommonStrings.revisionUserInfoAnonymous
let font = UIFont.wmf_font(.subheadline, compatibleWithTraitCollection: traitCollection)
let attributes = [NSAttributedString.Key.font: font,
NSAttributedString.Key.foregroundColor: theme.colors.secondaryText]
let mutableAttributedString = NSMutableAttributedString(string: anonymousUserInfo, attributes: attributes)
addIcon(to: mutableAttributedString, at: 0, for: userType)
// Need this next line to appropriately color the icon
mutableAttributedString.addAttributes(attributes, range: NSRange(location: 0, length: 2))
guard let attributedString = mutableAttributedString.copy() as? NSAttributedString else {
return nil
}
self.userInfo = attributedString
return attributedString
}
let formattedEditCount = NumberFormatter.localizedThousandsStringFromNumber(NSNumber(value: editCount)).localizedLowercase
let userInfo = String.localizedStringWithFormat( CommonStrings.revisionUserInfo, userName, formattedEditCount)
let font = UIFont.wmf_font(.subheadline, compatibleWithTraitCollection: traitCollection)
let attributes = [NSAttributedString.Key.font: font,
NSAttributedString.Key.foregroundColor: theme.colors.secondaryText]
let rangeOfUserName = (userInfo as NSString).range(of: userName)
let rangeValid = rangeOfUserName.location != NSNotFound && rangeOfUserName.location + rangeOfUserName.length <= userInfo.count
guard let title = "User:\(userName)".percentEncodedPageTitleForPathComponents,
let userNameURL = Configuration.current.articleURLForHost(Configuration.Domain.englishWikipedia, languageVariantCode: nil, appending: [title]),
rangeValid else {
let attributedString = NSAttributedString(string: userInfo, attributes: attributes)
self.userInfo = attributedString
return attributedString
}
let mutableAttributedString = NSMutableAttributedString(string: userInfo, attributes: attributes)
mutableAttributedString.addAttribute(NSAttributedString.Key.link, value: userNameURL as NSURL, range: rangeOfUserName)
mutableAttributedString.addAttribute(NSAttributedString.Key.foregroundColor, value: theme.colors.link, range: rangeOfUserName)
addIcon(to: mutableAttributedString, at: rangeOfUserName.location, for: userType)
guard let attributedString = mutableAttributedString.copy() as? NSAttributedString else {
return nil
}
self.userInfo = attributedString
return attributedString
}
func addIcon(to mutableAttributedString: NSMutableAttributedString, at location: Int, for userType: UserType) {
if userType == .bot || userType == .anonymous {
let imageAttachment = NSTextAttachment()
imageAttachment.image = UIImage(named: (userType == .bot ? Self.botIconName : Self.anonymousIconName))
let imageString = NSAttributedString(attachment: imageAttachment)
mutableAttributedString.insert(imageString, at: location)
mutableAttributedString.insert(NSAttributedString(string: " "), at: location + imageString.length)
}
}
}