mirror of https://github.com/oxen-io/session-ios
Merge branch 'database-refactor' into ui-media-message
commit
8f2bf58728
@ -0,0 +1,251 @@
|
||||
#!/usr/bin/xcrun --sdk macosx swift
|
||||
|
||||
//
|
||||
// ListLocalizableStrings.swift
|
||||
// Archa
|
||||
//
|
||||
// Created by Morgan Pretty on 18/5/20.
|
||||
// Copyright © 2020 Archa. All rights reserved.
|
||||
//
|
||||
// This script is based on https://github.com/ginowu7/CleanSwiftLocalizableExample the main difference
|
||||
// is canges to the localized usage regex
|
||||
|
||||
import Foundation
|
||||
|
||||
let fileManager = FileManager.default
|
||||
let currentPath = (
|
||||
ProcessInfo.processInfo.environment["PROJECT_DIR"] ?? fileManager.currentDirectoryPath
|
||||
)
|
||||
|
||||
/// List of files in currentPath - recursive
|
||||
var pathFiles: [String] = {
|
||||
guard let enumerator = fileManager.enumerator(atPath: currentPath), let files = enumerator.allObjects as? [String] else {
|
||||
fatalError("Could not locate files in path directory: \(currentPath)")
|
||||
}
|
||||
|
||||
return files
|
||||
}()
|
||||
|
||||
|
||||
/// List of localizable files - not including Localizable files in the Pods
|
||||
var localizableFiles: [String] = {
|
||||
return pathFiles
|
||||
.filter {
|
||||
$0.hasSuffix("Localizable.strings") &&
|
||||
!$0.contains(".app/") && // Exclude Built Localizable.strings files
|
||||
!$0.contains("Pods") // Exclude Pods
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
/// List of executable files
|
||||
var executableFiles: [String] = {
|
||||
return pathFiles.filter {
|
||||
!$0.localizedCaseInsensitiveContains("test") && // Exclude test files
|
||||
!$0.contains(".app/") && // Exclude Built Localizable.strings files
|
||||
!$0.contains("Pods") && // Exclude Pods
|
||||
(
|
||||
NSString(string: $0).pathExtension == "swift" ||
|
||||
NSString(string: $0).pathExtension == "m"
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
/// Reads contents in path
|
||||
///
|
||||
/// - Parameter path: path of file
|
||||
/// - Returns: content in file
|
||||
func contents(atPath path: String) -> String {
|
||||
print("Path: \(path)")
|
||||
guard let data = fileManager.contents(atPath: path), let content = String(data: data, encoding: .utf8) else {
|
||||
fatalError("Could not read from path: \(path)")
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/// Returns a list of strings that match regex pattern from content
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - pattern: regex pattern
|
||||
/// - content: content to match
|
||||
/// - Returns: list of results
|
||||
func regexFor(_ pattern: String, content: String) -> [String] {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
|
||||
fatalError("Regex not formatted correctly: \(pattern)")
|
||||
}
|
||||
|
||||
let matches = regex.matches(in: content, options: [], range: NSRange(location: 0, length: content.utf16.count))
|
||||
|
||||
return matches.map {
|
||||
guard let range = Range($0.range(at: 0), in: content) else {
|
||||
fatalError("Incorrect range match")
|
||||
}
|
||||
|
||||
return String(content[range])
|
||||
}
|
||||
}
|
||||
|
||||
func create() -> [LocalizationStringsFile] {
|
||||
return localizableFiles.map(LocalizationStringsFile.init(path:))
|
||||
}
|
||||
|
||||
///
|
||||
///
|
||||
/// - Returns: A list of LocalizationCodeFile - contains path of file and all keys in it
|
||||
func localizedStringsInCode() -> [LocalizationCodeFile] {
|
||||
return executableFiles.compactMap {
|
||||
let content = contents(atPath: $0)
|
||||
// Note: Need to exclude escaped quotation marks from strings
|
||||
let matchesOld = regexFor("(?<=NSLocalizedString\\()\\s*\"(?!.*?%d)(.*?)\"", content: content)
|
||||
let matchesNew = regexFor("\"(?!.*?%d)([^(\\\")]*?)\"(?=\\s*)(?=\\.localized)", content: content)
|
||||
let allMatches = (matchesOld + matchesNew)
|
||||
|
||||
return allMatches.isEmpty ? nil : LocalizationCodeFile(path: $0, keys: Set(allMatches))
|
||||
}
|
||||
}
|
||||
|
||||
/// Throws error if ALL localizable files does not have matching keys
|
||||
///
|
||||
/// - Parameter files: list of localizable files to validate
|
||||
func validateMatchKeys(_ files: [LocalizationStringsFile]) {
|
||||
print("------------ Validating keys match in all localizable files ------------")
|
||||
|
||||
guard let base = files.first, files.count > 1 else { return }
|
||||
|
||||
let files = Array(files.dropFirst())
|
||||
|
||||
files.forEach {
|
||||
guard let extraKey = Set(base.keys).symmetricDifference($0.keys).first else { return }
|
||||
let incorrectFile = $0.keys.contains(extraKey) ? $0 : base
|
||||
printPretty("error: Found extra key: \(extraKey) in file: \(incorrectFile.path)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Throws error if localizable files are missing keys
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - codeFiles: Array of LocalizationCodeFile
|
||||
/// - localizationFiles: Array of LocalizableStringFiles
|
||||
func validateMissingKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) {
|
||||
print("------------ Checking for missing keys -----------")
|
||||
|
||||
guard let baseFile = localizationFiles.first else {
|
||||
fatalError("Could not locate base localization file")
|
||||
}
|
||||
|
||||
let baseKeys = Set(baseFile.keys)
|
||||
|
||||
codeFiles.forEach {
|
||||
let extraKeys = $0.keys.subtracting(baseKeys)
|
||||
if !extraKeys.isEmpty {
|
||||
printPretty("error: Found keys in code missing in strings file: \(extraKeys) from \($0.path)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Throws warning if keys exist in localizable file but are not being used
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - codeFiles: Array of LocalizationCodeFile
|
||||
/// - localizationFiles: Array of LocalizableStringFiles
|
||||
func validateDeadKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) {
|
||||
print("------------ Checking for any dead keys in localizable file -----------")
|
||||
|
||||
guard let baseFile = localizationFiles.first else {
|
||||
fatalError("Could not locate base localization file")
|
||||
}
|
||||
|
||||
let baseKeys: Set<String> = Set(baseFile.keys)
|
||||
let allCodeFileKeys: [String] = codeFiles.flatMap { $0.keys }
|
||||
let deadKeys: [String] = Array(baseKeys.subtracting(allCodeFileKeys))
|
||||
.sorted()
|
||||
.map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) }
|
||||
|
||||
if !deadKeys.isEmpty {
|
||||
printPretty("warning: \(deadKeys) - Suggest cleaning dead keys")
|
||||
}
|
||||
}
|
||||
|
||||
protocol Pathable {
|
||||
var path: String { get }
|
||||
}
|
||||
|
||||
struct LocalizationStringsFile: Pathable {
|
||||
let path: String
|
||||
let kv: [String: String]
|
||||
|
||||
var keys: [String] {
|
||||
return Array(kv.keys)
|
||||
}
|
||||
|
||||
init(path: String) {
|
||||
self.path = path
|
||||
self.kv = ContentParser.parse(path)
|
||||
}
|
||||
|
||||
/// Writes back to localizable file with sorted keys and removed whitespaces and new lines
|
||||
func cleanWrite() {
|
||||
print("------------ Sort and remove whitespaces: \(path) ------------")
|
||||
let content = kv.keys.sorted().map { "\($0) = \(kv[$0]!);" }.joined(separator: "\n")
|
||||
try! content.write(toFile: path, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct LocalizationCodeFile: Pathable {
|
||||
let path: String
|
||||
let keys: Set<String>
|
||||
}
|
||||
|
||||
struct ContentParser {
|
||||
|
||||
/// Parses contents of a file to localizable keys and values - Throws error if localizable file have duplicated keys
|
||||
///
|
||||
/// - Parameter path: Localizable file paths
|
||||
/// - Returns: localizable key and value for content at path
|
||||
static func parse(_ path: String) -> [String: String] {
|
||||
print("------------ Checking for duplicate keys: \(path) ------------")
|
||||
|
||||
let content = contents(atPath: path)
|
||||
let trimmed = content
|
||||
.replacingOccurrences(of: "\n+", with: "", options: .regularExpression, range: nil)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let keys = regexFor("\"([^\"]*?)\"(?= =)", content: trimmed)
|
||||
let values = regexFor("(?<== )\"(.*?)\"(?=;)", content: trimmed)
|
||||
|
||||
if keys.count != values.count {
|
||||
fatalError("Error parsing contents: Make sure all keys and values are in correct format (this could be due to extra spaces between keys and values)")
|
||||
}
|
||||
|
||||
return zip(keys, values).reduce(into: [String: String]()) { results, keyValue in
|
||||
if results[keyValue.0] != nil {
|
||||
printPretty("error: Found duplicate key: \(keyValue.0) in file: \(path)")
|
||||
abort()
|
||||
}
|
||||
results[keyValue.0] = keyValue.1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printPretty(_ string: String) {
|
||||
print(string.replacingOccurrences(of: "\\", with: ""))
|
||||
}
|
||||
|
||||
let stringFiles = create()
|
||||
|
||||
if !stringFiles.isEmpty {
|
||||
print("------------ Found \(stringFiles.count) file(s) ------------")
|
||||
|
||||
stringFiles.forEach { print($0.path) }
|
||||
validateMatchKeys(stringFiles)
|
||||
|
||||
// Note: Uncomment the below file to clean out all comments from the localizable file (we don't want this because comments make it readable...)
|
||||
// stringFiles.forEach { $0.cleanWrite() }
|
||||
|
||||
let codeFiles = localizedStringsInCode()
|
||||
validateMissingKeys(codeFiles, localizationFiles: stringFiles)
|
||||
validateDeadKeys(codeFiles, localizationFiles: stringFiles)
|
||||
}
|
||||
|
||||
print("------------ SUCCESS ------------")
|
@ -0,0 +1,161 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
public extension NSAttributedString.Key {
|
||||
static let currentUserMentionBackgroundColor: NSAttributedString.Key = NSAttributedString.Key(rawValue: "currentUserMentionBackgroundColor")
|
||||
static let currentUserMentionBackgroundCornerRadius: NSAttributedString.Key = NSAttributedString.Key(rawValue: "currentUserMentionBackgroundCornerRadius")
|
||||
static let currentUserMentionBackgroundPadding: NSAttributedString.Key = NSAttributedString.Key(rawValue: "currentUserMentionBackgroundPadding")
|
||||
}
|
||||
|
||||
class HighlightMentionBackgroundView: UIView {
|
||||
var maxPadding: CGFloat = 0
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
self.isOpaque = false
|
||||
self.layer.zPosition = -1
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public func calculateMaxPadding(for attributedText: NSAttributedString) -> CGFloat {
|
||||
var allMentionRadii: [CGFloat?] = []
|
||||
let path: CGMutablePath = CGMutablePath()
|
||||
path.addRect(CGRect(
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: CGFloat.greatestFiniteMagnitude,
|
||||
height: CGFloat.greatestFiniteMagnitude
|
||||
))
|
||||
|
||||
let framesetter = CTFramesetterCreateWithAttributedString(attributedText as CFAttributedString)
|
||||
let frame: CTFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attributedText.length), path, nil)
|
||||
let lines: [CTLine] = frame.lines
|
||||
|
||||
lines.forEach { line in
|
||||
let runs: [CTRun] = line.ctruns
|
||||
|
||||
runs.forEach { run in
|
||||
let attributes: NSDictionary = CTRunGetAttributes(run)
|
||||
allMentionRadii.append(
|
||||
attributes
|
||||
.value(forKey: NSAttributedString.Key.currentUserMentionBackgroundPadding.rawValue) as? CGFloat
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return allMentionRadii
|
||||
.compactMap { $0 }
|
||||
.max()
|
||||
.defaulting(to: 0)
|
||||
}
|
||||
|
||||
// MARK: - Drawing
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
guard
|
||||
let superview: UITextView = (self.superview as? UITextView),
|
||||
let context = UIGraphicsGetCurrentContext()
|
||||
else { return }
|
||||
|
||||
// Need to invery the Y axis because iOS likes to render from the bottom left instead of the top left
|
||||
context.textMatrix = .identity
|
||||
context.translateBy(x: 0, y: bounds.size.height)
|
||||
context.scaleBy(x: 1.0, y: -1.0)
|
||||
|
||||
// Note: Calculations MUST happen based on the 'superview' size as this class has extra padding which
|
||||
// can result in calculations being off
|
||||
let path = CGMutablePath()
|
||||
let size = superview.sizeThatFits(CGSize(width: superview.bounds.width, height: .greatestFiniteMagnitude))
|
||||
path.addRect(CGRect(x: 0, y: 0, width: size.width, height: size.height), transform: .identity)
|
||||
|
||||
let framesetter = CTFramesetterCreateWithAttributedString(superview.attributedText as CFAttributedString)
|
||||
let frame: CTFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, superview.attributedText.length), path, nil)
|
||||
let lines: [CTLine] = frame.lines
|
||||
|
||||
var origins = [CGPoint](repeating: .zero, count: lines.count)
|
||||
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
|
||||
|
||||
for lineIndex in 0..<lines.count {
|
||||
let line = lines[lineIndex]
|
||||
let runs: [CTRun] = line.ctruns
|
||||
var ascent: CGFloat = 0
|
||||
var descent: CGFloat = 0
|
||||
var leading: CGFloat = 0
|
||||
let lineWidth = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, &leading))
|
||||
|
||||
for run in runs {
|
||||
let attributes: NSDictionary = CTRunGetAttributes(run)
|
||||
|
||||
guard let mentionBackgroundColor: UIColor = attributes.value(forKey: NSAttributedString.Key.currentUserMentionBackgroundColor.rawValue) as? UIColor else {
|
||||
continue
|
||||
}
|
||||
|
||||
let cornerRadius: CGFloat = (attributes
|
||||
.value(forKey: NSAttributedString.Key.currentUserMentionBackgroundCornerRadius.rawValue) as? CGFloat)
|
||||
.defaulting(to: 0)
|
||||
let padding: CGFloat = (attributes
|
||||
.value(forKey: NSAttributedString.Key.currentUserMentionBackgroundPadding.rawValue) as? CGFloat)
|
||||
.defaulting(to: 0)
|
||||
|
||||
let range = CTRunGetStringRange(run)
|
||||
var runBounds: CGRect = .zero
|
||||
var runAscent: CGFloat = 0
|
||||
var runDescent: CGFloat = 0
|
||||
runBounds.size.width = CGFloat(CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &runAscent, &runDescent, nil) + (padding * 2))
|
||||
runBounds.size.height = (runAscent + runDescent + (padding * 2))
|
||||
|
||||
let xOffset: CGFloat = {
|
||||
switch CTRunGetStatus(run) {
|
||||
case .rightToLeft:
|
||||
return CTLineGetOffsetForStringIndex(line, range.location + range.length, nil)
|
||||
|
||||
default:
|
||||
return CTLineGetOffsetForStringIndex(line, range.location, nil)
|
||||
}
|
||||
}()
|
||||
|
||||
// HACK: This `extraYOffset` value is a hack to resolve a weird issue where the positioning
|
||||
// seems to be slightly off every additional line of text we add (it doesn't seem to be related
|
||||
// to line spacing or anything, more related to the bold mention text being positioned slightly
|
||||
// differently from the non-bold text)
|
||||
let extraYOffset: CGFloat = (CGFloat(lineIndex) * (runDescent / 12))
|
||||
|
||||
// Note: Changes to `origin.y` need to be inverted since the context has been flipped
|
||||
runBounds.origin.x = origins[lineIndex].x + rect.origin.x + self.maxPadding + xOffset - padding
|
||||
runBounds.origin.y = (
|
||||
origins[lineIndex].y + rect.origin.y +
|
||||
self.maxPadding -
|
||||
padding -
|
||||
runDescent -
|
||||
extraYOffset
|
||||
)
|
||||
|
||||
// We don't want to draw too far to the right
|
||||
runBounds.size.width = (runBounds.width > lineWidth ? lineWidth : runBounds.width)
|
||||
|
||||
let path = UIBezierPath(roundedRect: runBounds, cornerRadius: cornerRadius)
|
||||
mentionBackgroundColor.setFill()
|
||||
path.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CTFrame {
|
||||
var lines: [CTLine] {
|
||||
return ((CTFrameGetLines(self) as [AnyObject] as? [CTLine]) ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
extension CTLine {
|
||||
var ctruns: [CTRun] {
|
||||
return ((CTLineGetGlyphRuns(self) as [AnyObject] as? [CTRun]) ?? [])
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue