diff --git a/Jenkinsfile b/Jenkinsfile index b2f2f51..85bdaad 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,6 @@ library "openiumpipeline" -env.DEVELOPER_DIR= "/Applications/Xcode-14.3.0.app/Contents/Developer" +env.DEVELOPER_DIR="/Applications/Xcode-15.0.1.app/Contents/Developer" //env.SIMULATOR_DEVICE_TYPES="iPad--7th-generation-" env.IS_PACKAGE_SWIFT=1 env.TARGETS_MACOS=1 diff --git a/README.md b/README.md index eaf249e..904878a 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,71 @@ swift run -c release ResgenSwift strings tags $FORCE_FLAG "./Tags/tags.txt" \ > ⚠️ If extension name is not set or is `Tags`, it will generate the following typaloas `typealias Tags = String`. + +## Analytics + +Analytics will generate all you need to analyze UX with Matomo or Firebase Analytics. Input files are formatted in YAML. This command will generate a manager for each target and an AnalyticsManager. This is this one you will need to use. And it will generate a method for all tags you have declared in the YAML file. Next, you will need to use the `configure()` method of AnalyticsManager and if you want to use matomo to set up the `siteId` and the `url` of the site. + +```sh +swift run -c release ResgenSwift strings tags $FORCE_FLAG "./Tags/tags.txt" \ + --target "matomo firebase" \ + --extension-output-path "./Analytics/Generated" \ + --extension-name "AppAnalytics" \ + --extension-suffix "GreatApp" \ + --static-members true +``` + + **Parameters** + +1. `-f`: force generation +2. Input tags file (must be YAML formatted) +3. `--target`: target with you will log UX +4. `--extension-output-path`: path where to generate generated extension +5. `--extension-name` *(optional)* : name of class to add the extension +6. `--extension-suffix` *(optional)* : additional text which is added to filename (ex: `AppAnalytics+GreatApp.swift`) +7. `--static-members` *(optional)*: generate static properties or not + +> ⚠️ If extension name is not set or is `Analytics`, it will generate the following typaloas `typealias Analytics = String`. + +### YAML + +``` + - id: s1_def_one + name: s1 def one _TITLE_ + path: s1_def_one/_TITLE_ + action: Tap + category: User + tags: ios,droid + comments: + parameters: + - name: title + type: String + replaceIn: name,path +``` + +1. `id`: name of the method (method name will be composed of `log` + `Event|Screen` + `id`) +2. `name`: name of the tag +3. `path` *(optional with firebase)* : needed for matomo but not with firebase (log screen) +4. `action` *(optional with firebase)* : needed for matomo but not with firebase (log event) +5. `category` *(optional with firebase)* : needed for matomo but not with firebase (log event) +6. `tags`: which platform target +7. `comments` *(optional)* +8. `parameters` *(optional)* + + **Parameters** + +You can use parameters in generate methods. + +1. `name`: name of the parameter +2. `type`: type of the parameter (Int, String, Bool, Double) +3. `replaceIn` *(optional)* + +**Replace in** + +This is section is equivalent of `%s | %d | %f | %@`. You can put the content of the parameter in *name*, *path*, *action*, *category*. +You need to put `_` + `NAME OF THE PARAMETER` + `_` in the target and which target you want in the value of `replaceIn`. (name need to be in uppercase) + + ## Images Images generator will generate images assets along with extensions to access those images easily. diff --git a/SampleFiles/Tags/Generated/Analytics+GenAllScript.swift b/SampleFiles/Tags/Generated/Analytics+GenAllScript.swift new file mode 100644 index 0000000..1b5bd44 --- /dev/null +++ b/SampleFiles/Tags/Generated/Analytics+GenAllScript.swift @@ -0,0 +1,199 @@ +// Generated by ResgenSwift.Analytics 1.2 + +import MatomoTracker +import FirebaseAnalytics + +// MARK: - Protocol + +protocol AnalyticsManagerProtocol { + func logScreen(name: String, path: String) + func logEvent( + name: String, + action: String, + category: String, + params: [String: Any]? + ) +} + +// MARK: - Matomo + +class MatomoAnalyticsManager: AnalyticsManagerProtocol { + + // MARK: - Properties + + private var tracker: MatomoTracker + + // MARK: - Init + + init(siteId: String, url: String) { + debugPrint("[Matomo service] Server URL: \(url)") + debugPrint("[Matomo service] Site ID: \(siteId)") + tracker = MatomoTracker( + siteId: siteId, + baseURL: URL(string: url)! + ) + + #if DEBUG + tracker.dispatchInterval = 5 + #endif + + #if DEBUG + tracker.logger = DefaultLogger(minLevel: .verbose) + #endif + + debugPrint("[Matomo service] Configured with content base: \(tracker.contentBase?.absoluteString ?? "-")") + debugPrint("[Matomo service] Opt out: \(tracker.isOptedOut)") + } + + // MARK: - Methods + + func logScreen(name: String, path: String) { + guard !tracker.isOptedOut else { return } + guard let trackerUrl = tracker.contentBase?.absoluteString else { return } + + let urlString = URL(string: "\(trackerUrl)" + "/" + "\(path)" + "iOS") + tracker.track( + view: [name], + url: urlString + ) + } + + func logEvent( + name: String, + action: String, + category: String, + params: [String: Any]? + ) { + guard !tracker.isOptedOut else { return } + + tracker.track( + eventWithCategory: category, + action: action, + name: name, + number: nil, + url: nil + ) + } +} + +// MARK: - Firebase + +class FirebaseAnalyticsManager: AnalyticsManagerProtocol { + func logScreen(name: String, path: String) { + var parameters = [ + AnalyticsParameterScreenName: name + ] + + Analytics.logEvent( + AnalyticsEventScreenView, + parameters: parameters + ) + } + + func logEvent( + name: String, + action: String, + category: String, + params: [String: Any]? + ) { + var parameters: [String:Any] = [ + "action": action, + "category": category, + ] + + if let supplementaryParameters = params { + parameters.merge(supplementaryParameters) { (origin, new) -> Any in + return origin + } + } + + Analytics.logEvent( + name, + parameters: parameters + ) + } +} + +// MARK: - Manager + +class AnalyticsManager { + static var shared = AnalyticsManager() + + // MARK: - Properties + + var managers: [AnalyticsManagerProtocol] = [] + + private var isEnabled: Bool = true + + // MARK: - Methods + + func setAnalyticsEnabled(_ enable: Bool) { + isEnabled = enable + } + + func configure(siteId: String, url: String) { + managers.append( + MatomoAnalyticsManager( + siteId: siteId, + url: url + ) + ) + managers.append(FirebaseAnalyticsManager()) + } + + private func logScreen(name: String, path: String) { + guard isEnabled else { return } + + managers.forEach { manager in + manager.logScreen(name: name, path: path) + } + } + + private func logEvent( + name: String, + action: String, + category: String, + params: [String: Any]? + ) { + guard isEnabled else { return } + + managers.forEach { manager in + manager.logEvent( + name: name, + action: action, + category: category, + params: params + ) + } + } + + // MARK: - section_one + + func logScreenS1DefOne(title: String) { + logScreen( + name: "s1 def one \(title)", + path: "s1_def_one/\(title)" + ) + } + + func logEventS1DefTwo(title: String, count: String) { + logEvent( + name: "s1 def two", + action: "test", + category: "test", + params: [ + "title": title, + "count": count + ] + ) + } + + // MARK: - section_two + + func logScreenS2DefOne() { + logScreen( + name: "s2 def one", + path: "s2_def_one/" + ) + } +} diff --git a/SampleFiles/Tags/sampleTags.yml b/SampleFiles/Tags/sampleTags.yml new file mode 100644 index 0000000..4c91dd0 --- /dev/null +++ b/SampleFiles/Tags/sampleTags.yml @@ -0,0 +1,52 @@ +--- +categories: + - id: section_one + screens: + - id: s1_def_one + name: s1 def one _TITLE_ + path: s1_def_one/_TITLE_ + tags: ios,droid + parameters: + - name: title + type: String + replaceIn: name,path + + events: + - id: s1_def_two + name: s1 def two + action: test + category: test + tags: ios + parameters: + - name: title + type: String + - name: count + type: String + + - id: section_two + screens: + - id: s2_def_one + name: s2 def one + path: s2_def_one/ + tags: ios + + events: + - id: s2_def_two + name: s2 def two + action: test + category: test + tags: droid + + - id: section_three + screens: + - id: s3_def_one + name: s3 def one + path: s3_def_one/ + tags: droid + + events: + - id: s3_def_two + name: s3 def two + action: test + category: test + tags: droid diff --git a/SampleFiles/genAllRessources.sh b/SampleFiles/genAllRessources.sh index 1530efc..fc001fd 100755 --- a/SampleFiles/genAllRessources.sh +++ b/SampleFiles/genAllRessources.sh @@ -30,18 +30,18 @@ FORCE_FLAG="$1" # --default-lang "en" \ # --extension-output-path "./Twine/Generated" -echo "\n-------------------------\n" +#echo "\n-------------------------\n" -# Strings -swift run -c release ResgenSwift strings stringium $FORCE_FLAG "./Strings/sampleStrings.txt" \ - --output-path "./Strings/Generated" \ - --langs "fr en en-us" \ - --default-lang "en" \ - --extension-output-path "./Strings/Generated" \ - --extension-name "String" \ - --extension-suffix "GenAllScript" +## Strings +#swift run -c release ResgenSwift strings stringium $FORCE_FLAG "./Strings/sampleStrings.txt" \ +# --output-path "./Strings/Generated" \ +# --langs "fr en en-us" \ +# --default-lang "en" \ +# --extension-output-path "./Strings/Generated" \ +# --extension-name "String" \ +# --extension-suffix "GenAllScript" -echo "\n-------------------------\n" +#echo "\n-------------------------\n" ## Tags #swift run -c release ResgenSwift strings tags $FORCE_FLAG "./Tags/sampleTags.txt" \ @@ -50,7 +50,16 @@ echo "\n-------------------------\n" # --extension-name "Tags" \ # --extension-suffix "GenAllScript" # -#echo "\n-------------------------\n" +echo "\n-------------------------\n" + +# Analytics +swift run -c release ResgenSwift analytics $FORCE_FLAG "./Tags/sampleTags.yml" \ + --target "matomo firebase" \ + --extension-output-path "./Tags/Generated" \ + --extension-name "Analytics" \ + --extension-suffix "GenAllScript" + +echo "\n-------------------------\n" # ## Images #swift run -c release ResgenSwift images $FORCE_FLAG "./Images/sampleImages.txt" \ diff --git a/SampleFiles/resgenConfiguration.yml b/SampleFiles/resgenConfiguration.yml index 29a6d1a..512fc67 100644 --- a/SampleFiles/resgenConfiguration.yml +++ b/SampleFiles/resgenConfiguration.yml @@ -70,7 +70,7 @@ colors: # Tags # tags: -- +- inputFile: ./Tags/sampleTags.txt lang: ium extensionOutputPath: ./Tags/Generated @@ -78,6 +78,18 @@ tags: extensionSuffix: GenAllScript +# +# Analytics +# +analytics: +- + inputFile: ./Tags/sampleTags.yml + target: "matomo firebase" + extensionOutputPath: ./Tags/Generated + extensionName: Analytics + extensionSuffix: GenAllScript + + # # Fonts # diff --git a/Sources/ResgenSwift/Analytics/Analytics.swift b/Sources/ResgenSwift/Analytics/Analytics.swift new file mode 100644 index 0000000..e9c0e13 --- /dev/null +++ b/Sources/ResgenSwift/Analytics/Analytics.swift @@ -0,0 +1,87 @@ +// +// Analytics.swift +// +// +// Created by Loris Perret on 08/12/2023. +// + +import ToolCore +import Foundation +import ArgumentParser + +struct Analytics: ParsableCommand { + + // MARK: - Command Configuration + + static var configuration = CommandConfiguration( + abstract: "Generate analytics extension file.", + version: ResgenSwiftVersion + ) + + + // MARK: - Static + + static let toolName = "Analytics" + static let defaultExtensionName = "Analytics" + + // MARK: - Command Options + + @OptionGroup var options: AnalyticsOptions + + // MARK: - Run + + mutating func run() { + print("[\(Self.toolName)] Starting analytics generation") + print("[\(Self.toolName)] Will use inputFile \(options.inputFile) to generate analytics for target: \(options.target)") + + // Check requirements + guard checkRequirements() else { return } + + print("[\(Self.toolName)] Will generate analytics") + + // Check requirements + guard checkRequirements() else { return } + + // Parse input file + let sections = AnalyticsFileParser.parse(options.inputFile, target: options.target) + + // Generate extension + AnalyticsGenerator.writeExtensionFiles(sections: sections, + target: options.target, + tags: ["ios", "iosonly"], + staticVar: options.staticMembers, + extensionName: options.extensionName, + extensionFilePath: options.extensionFilePath) + + print("[\(Self.toolName)] Analytics generated") + } + + // MARK: - Requirements + + private func checkRequirements() -> Bool { + let fileManager = FileManager() + + // Input file + guard fileManager.fileExists(atPath: options.inputFile) else { + let error = AnalyticsError.fileNotExists(options.inputFile) + print(error.description) + Analytics.exit(withError: error) + } + + guard TrackerType.hasValidTarget(in: options.target) else { + let error = AnalyticsError.noValidTracker(options.target) + print(error.description) + Analytics.exit(withError: error) + } + + // Check if needed to regenerate + guard GeneratorChecker.shouldGenerate(force: options.forceGeneration, + inputFilePath: options.inputFile, + extensionFilePath: options.extensionFilePath) else { + print("[\(Self.toolName)] Analytics are already up to date :) ") + return false + } + + return true + } +} diff --git a/Sources/ResgenSwift/Analytics/AnalyticsError.swift b/Sources/ResgenSwift/Analytics/AnalyticsError.swift new file mode 100644 index 0000000..b32198d --- /dev/null +++ b/Sources/ResgenSwift/Analytics/AnalyticsError.swift @@ -0,0 +1,39 @@ +// +// AnalyticsError.swift +// +// +// Created by Loris Perret on 11/12/2023. +// + +import Foundation + +enum AnalyticsError: Error { + case noValidTracker(String) + case fileNotExists(String) + case missingElement(String) + case invalidParameter(String) + case parseFailed(String) + case writeFile(String, String) + + var description: String { + switch self { + case .noValidTracker(let inputTargets): + return "error: [\(Analytics.toolName)] '\(inputTargets)' ne contient aucun tracker valid" + + case .fileNotExists(let filename): + return "error: [\(Analytics.toolName)] File \(filename) does not exists" + + case .missingElement(let element): + return "error: [\(Analytics.toolName)] Missing \(element) for Matomo" + + case .invalidParameter(let reason): + return "error: [\(Analytics.toolName)] Invalid parameter \(reason)" + + case .parseFailed(let baseError): + return "error: [\(Analytics.toolName)] Parse input file failed: \(baseError)" + + case .writeFile(let subErrorDescription, let filename): + return "error: [\(Analytics.toolName)] An error occured while writing content to \(filename): \(subErrorDescription)" + } + } +} diff --git a/Sources/ResgenSwift/Analytics/AnalyticsOptions.swift b/Sources/ResgenSwift/Analytics/AnalyticsOptions.swift new file mode 100644 index 0000000..0f3172e --- /dev/null +++ b/Sources/ResgenSwift/Analytics/AnalyticsOptions.swift @@ -0,0 +1,47 @@ +// +// AnalyticsOptions.swift +// +// +// Created by Loris Perret on 08/12/2023. +// + +import Foundation +import ArgumentParser + +struct AnalyticsOptions: ParsableArguments { + @Flag(name: [.customShort("f"), .customShort("F")], help: "Should force generation") + var forceGeneration = false + + @Argument(help: "Input files where tags ared defined.", transform: { $0.replaceTiltWithHomeDirectoryPath() }) + var inputFile: String + + @Option(help: "Target(s) analytics to generate. (\"matomo\" | \"firebase\")") + var target: String + + @Option(help: "Path where to generate the extension.", transform: { $0.replaceTiltWithHomeDirectoryPath() }) + var extensionOutputPath: String + + @Option(help: "Tell if it will generate static properties or not") + var staticMembers: Bool = false + + @Option(help: "Extension name. If not specified, it will generate a Analytics extension.") + var extensionName: String = Analytics.defaultExtensionName + + @Option(help: "Extension suffix. Ex: MyApp, it will generate {extensionName}+Analytics{extensionSuffix}.swift") + var extensionSuffix: String? +} + +// MARK: - Computed var + +extension AnalyticsOptions { + var extensionFileName: String { + if let extensionSuffix = extensionSuffix { + return "\(extensionName)+\(extensionSuffix).swift" + } + return "\(extensionName).swift" + } + + var extensionFilePath: String { + "\(extensionOutputPath)/\(extensionFileName)" + } +} diff --git a/Sources/ResgenSwift/Analytics/Generator/AnalyticsGenerator.swift b/Sources/ResgenSwift/Analytics/Generator/AnalyticsGenerator.swift new file mode 100644 index 0000000..a4a9e1f --- /dev/null +++ b/Sources/ResgenSwift/Analytics/Generator/AnalyticsGenerator.swift @@ -0,0 +1,227 @@ +// +// AnalyticsGenerator.swift +// +// +// Created by Loris Perret on 08/12/2023. +// + +import Foundation +import ToolCore +import CoreVideo + +class AnalyticsGenerator { + static var targets: [TrackerType] = [] + + static func writeExtensionFiles(sections: [AnalyticsCategory], target: String, tags: [String], staticVar: Bool, extensionName: String, extensionFilePath: String) { + // Get target type from enum + let targetsString: [String] = target.components(separatedBy: " ") + + TrackerType.allCases.forEach { enumTarget in + if targetsString.contains(enumTarget.value) { + targets.append(enumTarget) + } + } + + // Get extension content + let extensionFileContent = Self.getExtensionContent(sections: sections, + tags: tags, + staticVar: staticVar, + extensionName: extensionName) + + // Write content + let extensionFilePathURL = URL(fileURLWithPath: extensionFilePath) + do { + try extensionFileContent.write(to: extensionFilePathURL, atomically: false, encoding: .utf8) + } catch (let error) { + let error = AnalyticsError.writeFile(extensionFilePath, error.localizedDescription) + print(error.description) + Analytics.exit(withError: error) + } + } + + // MARK: - Extension content + + static func getExtensionContent(sections: [AnalyticsCategory], tags: [String], staticVar: Bool, extensionName: String) -> String { + [ + Self.getHeader(extensionClassname: extensionName, staticVar: staticVar), + Self.getProperties(sections: sections, tags: tags, staticVar: staticVar), + Self.getFooter() + ] + .joined(separator: "\n") + } + + // MARK: - Extension part + + private static func getHeader(extensionClassname: String, staticVar: Bool) -> String { + """ + // Generated by ResgenSwift.\(Analytics.toolName) \(ResgenSwiftVersion) + + \(Self.getImport()) + + \(Self.getAnalyticsProtocol()) + // MARK: - Manager + + class AnalyticsManager { + static var shared = AnalyticsManager() + + // MARK: - Properties + + var managers: [AnalyticsManagerProtocol] = [] + + \(Self.getEnabledContent()) + + \(Self.getAnalyticsProperties()) + + \(Self.getPrivateLogFunction()) + """ + } + + private static func getEnabledContent() -> String { + """ + private var isEnabled: Bool = true + + // MARK: - Methods + + func setAnalyticsEnabled(_ enable: Bool) { + isEnabled = enable + } + """ + } + + private static func getImport() -> String { + var result: [String] = [] + + if targets.contains(TrackerType.matomo) { + result.append("import MatomoTracker") + } + if targets.contains(TrackerType.firebase) { + result.append("import FirebaseAnalytics") + } + + return result.joined(separator: "\n") + } + + private static func getPrivateLogFunction() -> String { + """ + private func logScreen(name: String, path: String) { + guard isEnabled else { return } + + managers.forEach { manager in + manager.logScreen(name: name, path: path) + } + } + + private func logEvent( + name: String, + action: String, + category: String, + params: [String: Any]? + ) { + guard isEnabled else { return } + + managers.forEach { manager in + manager.logEvent( + name: name, + action: action, + category: category, + params: params + ) + } + } + """ + } + + private static func getAnalyticsProperties() -> String { + var header = "" + var content: [String] = [] + let footer = " }" + + if targets.contains(TrackerType.matomo) { + header = "func configure(siteId: String, url: String) {" + } else if targets.contains(TrackerType.firebase) { + header = "func configure() {" + } + + if targets.contains(TrackerType.matomo) { + content.append(""" + managers.append( + MatomoAnalyticsManager( + siteId: siteId, + url: url + ) + ) + """) + } + if targets.contains(TrackerType.firebase) { + content.append(" managers.append(FirebaseAnalyticsManager())") + } + + return [ + header, + content.joined(separator: "\n"), + footer + ] + .joined(separator: "\n") + } + + private static func getAnalyticsProtocol() -> String { + let proto = """ + // MARK: - Protocol + + protocol AnalyticsManagerProtocol { + func logScreen(name: String, path: String) + func logEvent( + name: String, + action: String, + category: String, + params: [String: Any]? + ) + } + + """ + + var result: [String] = [proto] + + if targets.contains(TrackerType.matomo) { + result.append(MatomoGenerator.service) + } + + if targets.contains(TrackerType.firebase) { + result.append(FirebaseGenerator.service) + } + + return result.joined(separator: "\n") + } + + private static func getProperties(sections: [AnalyticsCategory], tags: [String], staticVar: Bool) -> String { + sections + .compactMap { section in + // Check that at least one string will be generated + guard section.hasOneOrMoreMatchingTags(tags: tags) else { + return nil// Go to next section + } + + var res = "\n // MARK: - \(section.id)" + section.definitions.forEach { definition in + guard definition.hasOneOrMoreMatchingTags(inputTags: tags) == true else { + return // Go to next definition + } + + if staticVar { + res += "\n\n\(definition.getStaticProperty())" + } else { + res += "\n\n\(definition.getProperty())" + } + } + return res + } + .joined(separator: "\n") + } + + private static func getFooter() -> String { + """ + } + + """ + } +} diff --git a/Sources/ResgenSwift/Analytics/Generator/FirebaseGenerator.swift b/Sources/ResgenSwift/Analytics/Generator/FirebaseGenerator.swift new file mode 100644 index 0000000..95ab7f4 --- /dev/null +++ b/Sources/ResgenSwift/Analytics/Generator/FirebaseGenerator.swift @@ -0,0 +1,81 @@ +// +// FirebaseGenerator.swift +// +// +// Created by Loris Perret on 05/12/2023. +// + +import Foundation + +enum FirebaseGenerator { + + static var service: String { + [ + FirebaseGenerator.header, + FirebaseGenerator.logScreen, + FirebaseGenerator.logEvent, + FirebaseGenerator.footer + ] + .joined(separator: "\n") + } + + // MARK: - Private vars + + private static var header: String { + """ + // MARK: - Firebase + + class FirebaseAnalyticsManager: AnalyticsManagerProtocol { + """ + } + + private static var logScreen: String { + """ + func logScreen(name: String, path: String) { + var parameters = [ + AnalyticsParameterScreenName: name + ] + + Analytics.logEvent( + AnalyticsEventScreenView, + parameters: parameters + ) + } + + """ + } + + private static var logEvent: String { + """ + func logEvent( + name: String, + action: String, + category: String, + params: [String: Any]? + ) { + var parameters: [String:Any] = [ + "action": action, + "category": category, + ] + + if let supplementaryParameters = params { + parameters.merge(supplementaryParameters) { (origin, new) -> Any in + return origin + } + } + + Analytics.logEvent( + name, + parameters: parameters + ) + } + """ + } + + private static var footer: String { + """ + } + + """ + } +} diff --git a/Sources/ResgenSwift/Analytics/Generator/MatomoGenerator.swift b/Sources/ResgenSwift/Analytics/Generator/MatomoGenerator.swift new file mode 100644 index 0000000..740cf95 --- /dev/null +++ b/Sources/ResgenSwift/Analytics/Generator/MatomoGenerator.swift @@ -0,0 +1,110 @@ +// +// MatomoGenerator.swift +// +// +// Created by Loris Perret on 05/12/2023. +// + +import Foundation + +enum MatomoGenerator { + + static var service: String { + [ + MatomoGenerator.header, + MatomoGenerator.setup, + MatomoGenerator.logScreen, + MatomoGenerator.logEvent, + MatomoGenerator.footer + ] + .joined(separator: "\n") + } + + // MARK: - Private vars + + private static var header: String { + """ + // MARK: - Matomo + + class MatomoAnalyticsManager: AnalyticsManagerProtocol { + + // MARK: - Properties + + private var tracker: MatomoTracker + + """ + } + + private static var setup: String { + """ + // MARK: - Init + + init(siteId: String, url: String) { + debugPrint("[Matomo service] Server URL: \\(url)") + debugPrint("[Matomo service] Site ID: \\(siteId)") + tracker = MatomoTracker( + siteId: siteId, + baseURL: URL(string: url)! + ) + + #if DEBUG + tracker.dispatchInterval = 5 + #endif + + #if DEBUG + tracker.logger = DefaultLogger(minLevel: .verbose) + #endif + + debugPrint("[Matomo service] Configured with content base: \\(tracker.contentBase?.absoluteString ?? "-")") + debugPrint("[Matomo service] Opt out: \\(tracker.isOptedOut)") + } + + // MARK: - Methods + + """ + } + + private static var logScreen: String { + """ + func logScreen(name: String, path: String) { + guard !tracker.isOptedOut else { return } + guard let trackerUrl = tracker.contentBase?.absoluteString else { return } + + let urlString = URL(string: "\\(trackerUrl)" + "/" + "\\(path)" + "iOS") + tracker.track( + view: [name], + url: urlString + ) + } + + """ + } + + private static var logEvent: String { + """ + func logEvent( + name: String, + action: String, + category: String, + params: [String: Any]? + ) { + guard !tracker.isOptedOut else { return } + + tracker.track( + eventWithCategory: category, + action: action, + name: name, + number: nil, + url: nil + ) + } + """ + } + + private static var footer: String { + """ + } + + """ + } +} diff --git a/Sources/ResgenSwift/Analytics/Model/AnalyticsCategory.swift b/Sources/ResgenSwift/Analytics/Model/AnalyticsCategory.swift new file mode 100644 index 0000000..9451d99 --- /dev/null +++ b/Sources/ResgenSwift/Analytics/Model/AnalyticsCategory.swift @@ -0,0 +1,32 @@ +// +// AnalyticsCategory.swift +// +// +// Created by Loris Perret on 05/12/2023. +// + +import Foundation + +class AnalyticsCategory { + let id: String // OnBoarding + var definitions = [AnalyticsDefinition]() + + // MARK: - Init + + init(id: String) { + self.id = id + } + + // MARK: - Methods + + func hasOneOrMoreMatchingTags(tags: [String]) -> Bool { + let allTags = definitions.flatMap { $0.tags } + let allTagsSet = Set(allTags) + + let intersection = Set(tags).intersection(allTagsSet) + if intersection.isEmpty { + return false + } + return true + } +} diff --git a/Sources/ResgenSwift/Analytics/Model/AnalyticsDefinition.swift b/Sources/ResgenSwift/Analytics/Model/AnalyticsDefinition.swift new file mode 100644 index 0000000..acef609 --- /dev/null +++ b/Sources/ResgenSwift/Analytics/Model/AnalyticsDefinition.swift @@ -0,0 +1,157 @@ +// +// AnalyticsDefinition.swift +// +// +// Created by Loris Perret on 05/12/2023. +// + +import Foundation +import ToolCore + +class AnalyticsDefinition { + let id: String + var name: String + var path: String = "" + var category: String = "" + var action: String = "" + var comments: String = "" + var tags: [String] = [] + var parameters: [AnalyticsParameter] = [] + var type: TagType + + // MARK: - Init + + init(id: String, name: String, type: TagType) { + self.id = id + self.name = name + self.type = type + } + + // MARK: - Methods + + func hasOneOrMoreMatchingTags(inputTags: [String]) -> Bool { + if Set(inputTags).intersection(Set(self.tags)).isEmpty { + return false + } + return true + } + + // MARK: - Private Methods + + private func getFuncName() -> String { + var pascalCaseTitle: String = "" + id.components(separatedBy: "_").forEach { word in + pascalCaseTitle.append(contentsOf: word.uppercasedFirst()) + } + + return "log\(type == .screen ? "Screen" : "Event")\(pascalCaseTitle)" + } + + private func getParameters() -> String { + var params = parameters + var result: String + + if type == .screen { + params = params.filter{ param in + !param.replaceIn.isEmpty + } + } + + let paramsString = params.map { parameter in + "\(parameter.name): \(parameter.type)" + } + + if paramsString.count > 2 { + result = """ + ( + \(paramsString.joined(separator: ",\n\t\t")) + ) + """ + } else { + result = """ + (\(paramsString.joined(separator: ", "))) + """ + } + + return result + } + + private func replaceIn(){ + for parameter in parameters { + for rep in parameter.replaceIn { + switch rep { + case "name": name = name.replacingFirstOccurrence(of: "_\(parameter.name.uppercased())_", with: "\\(\(parameter.name))") + case "path": path = path.replacingFirstOccurrence(of: "_\(parameter.name.uppercased())_", with: "\\(\(parameter.name))") + case "category": category = category.replacingFirstOccurrence(of: "_\(parameter.name.uppercased())_", with: "\\(\(parameter.name))") + case "action": action = action.replacingFirstOccurrence(of: "_\(parameter.name.uppercased())_", with: "\\(\(parameter.name))") + default: break + } + } + } + } + + private func getlogFunction() -> String { + var params: [String] = [] + var result: String + + let supplementaryParams = parameters.filter { param in + param.replaceIn.isEmpty + } + + supplementaryParams.forEach { param in + params.append("\"\(param.name)\": \(param.name)") + } + + if params.count > 1 { + result = """ + [ + \(params.joined(separator: ",\n\t\t\t\t")) + ] + """ + } else if params.count == 1 { + result = """ + [\(params.joined(separator: ", "))] + """ + } else { + result = "[:]" + } + + if type == .screen { + return """ + logScreen( + name: "\(name)", + path: "\(path)" + ) + """ + } else { + return """ + logEvent( + name: "\(name)", + action: "\(action)", + category: "\(category)", + params: \(result) + ) + """ + } + } + + // MARK: - Raw strings + + func getProperty() -> String { + replaceIn() + return """ + func \(getFuncName())\(getParameters()) { + \(getlogFunction()) + } + """ + } + + func getStaticProperty() -> String { + replaceIn() + return """ + static func \(getFuncName())\(getParameters()) { + \(getlogFunction()) + } + """ + } +} diff --git a/Sources/ResgenSwift/Analytics/Model/AnalyticsFile.swift b/Sources/ResgenSwift/Analytics/Model/AnalyticsFile.swift new file mode 100644 index 0000000..d775768 --- /dev/null +++ b/Sources/ResgenSwift/Analytics/Model/AnalyticsFile.swift @@ -0,0 +1,45 @@ +// +// AnalyticsFile.swift +// +// +// Created by Loris Perret on 06/12/2023. +// + +import Foundation + +struct AnalyticsFile: Codable { + var categories: [AnalyticsCategoryDTO] +} + +struct AnalyticsCategoryDTO: Codable { + var id: String + var screens: [AnalyticsDefinitionScreenDTO]? + var events: [AnalyticsDefinitionEventDTO]? +} + +struct AnalyticsDefinitionScreenDTO: Codable { + var id: String + var name: String + var tags: String + var comments: String? + var parameters: [AnalyticsParameterDTO]? + + var path: String? +} + +struct AnalyticsDefinitionEventDTO: Codable { + var id: String + var name: String + var tags: String + var comments: String? + var parameters: [AnalyticsParameterDTO]? + + var category: String? + var action: String? +} + +struct AnalyticsParameterDTO: Codable { + var name: String + var type: String + var replaceIn: String? +} diff --git a/Sources/ResgenSwift/Analytics/Model/AnalyticsParameter.swift b/Sources/ResgenSwift/Analytics/Model/AnalyticsParameter.swift new file mode 100644 index 0000000..9c659c3 --- /dev/null +++ b/Sources/ResgenSwift/Analytics/Model/AnalyticsParameter.swift @@ -0,0 +1,21 @@ +// +// AnalyticsParameter.swift +// +// +// Created by Loris Perret on 06/12/2023. +// + +import Foundation + +class AnalyticsParameter { + var name: String + var type: String + var replaceIn: [String] = [] + + // MARK: - Init + + init(name: String, type: String) { + self.name = name + self.type = type + } +} diff --git a/Sources/ResgenSwift/Analytics/Model/TagType.swift b/Sources/ResgenSwift/Analytics/Model/TagType.swift new file mode 100644 index 0000000..62d3d28 --- /dev/null +++ b/Sources/ResgenSwift/Analytics/Model/TagType.swift @@ -0,0 +1,16 @@ +// +// TagType.swift +// +// +// Created by Thibaut Schmitt on 08/12/2023. +// + +import Foundation + +extension AnalyticsDefinition { + + enum TagType { + case screen + case event + } +} diff --git a/Sources/ResgenSwift/Analytics/Model/TargetType.swift b/Sources/ResgenSwift/Analytics/Model/TargetType.swift new file mode 100644 index 0000000..412d5c3 --- /dev/null +++ b/Sources/ResgenSwift/Analytics/Model/TargetType.swift @@ -0,0 +1,29 @@ +// +// TargetType.swift +// +// +// Created by Thibaut Schmitt on 08/12/2023. +// + +import Foundation + +enum TrackerType: CaseIterable { + case matomo + case firebase + + var value: String { + switch self { + case .matomo: + "matomo" + case .firebase: + "firebase" + } + } + + static func hasValidTarget(in targets: String) -> Bool { + for tracker in Self.allCases where targets.contains(tracker.value) { + return true + } + return false + } +} diff --git a/Sources/ResgenSwift/Analytics/Parser/AnalyticsFileParser.swift b/Sources/ResgenSwift/Analytics/Parser/AnalyticsFileParser.swift new file mode 100644 index 0000000..1137f8e --- /dev/null +++ b/Sources/ResgenSwift/Analytics/Parser/AnalyticsFileParser.swift @@ -0,0 +1,178 @@ +// +// AnalyticsFileParser.swift +// +// +// Created by Loris Perret on 06/12/2023. +// + +import Foundation +import Yams + +class AnalyticsFileParser { + private static var inputFile: String = "" + private static var target: String = "" + + private static func parseYaml() -> AnalyticsFile { + guard let data = FileManager().contents(atPath: inputFile) else { + let error = AnalyticsError.fileNotExists(inputFile) + print(error.description) + Analytics.exit(withError: error) + } + + do { + let tagFile = try YAMLDecoder().decode(AnalyticsFile.self, from: data) + return tagFile + } catch { + let error = AnalyticsError.parseFailed(error.localizedDescription) + print(error.description) + Analytics.exit(withError: error) + } + } + + private static func getParameters(from parameters: [AnalyticsParameterDTO]) -> [AnalyticsParameter] { + parameters.map { dtoParameter in + // Type + + let type = dtoParameter.type.uppercasedFirst() + + guard + type == "String" || + type == "Int" || + type == "Double" || + type == "Bool" + else { + let error = AnalyticsError.invalidParameter("type of \(dtoParameter.name)") + print(error.description) + Analytics.exit(withError: error) + } + + let parameter = AnalyticsParameter( + name: dtoParameter.name, + type: type + ) + + if let replaceIn = dtoParameter.replaceIn { + parameter.replaceIn = replaceIn.components(separatedBy: ",") + } + + return parameter + } + } + + private static func getTagDefinition( + id: String, + name: String, + type: AnalyticsDefinition.TagType, + tags: String, + comments: String?, + parameters: [AnalyticsParameterDTO]? + ) -> AnalyticsDefinition { + let definition = AnalyticsDefinition(id: id, name: name, type: type) + definition.tags = tags + .components(separatedBy: ",") + .map { $0.removeLeadingTrailingWhitespace() } + + if let comments = comments { + definition.comments = comments + } + + if let parameters = parameters { + definition.parameters = Self.getParameters(from: parameters) + } + + return definition + } + + private static func getTagDefinitionScreen(from screens: [AnalyticsDefinitionScreenDTO]) -> [AnalyticsDefinition] { + screens.map { screen in + let definition: AnalyticsDefinition = Self.getTagDefinition( + id: screen.id, + name: screen.name, + type: .screen, + tags: screen.tags, + comments: screen.comments, + parameters: screen.parameters + ) + + if target.contains(TrackerType.matomo.value) { + // Path + + guard let path = screen.path else { + let error = AnalyticsError.missingElement("screen path") + print(error.description) + Analytics.exit(withError: error) + } + + definition.path = path + } + + return definition + } + } + + private static func getTagDefinitionEvent(from events: [AnalyticsDefinitionEventDTO]) -> [AnalyticsDefinition] { + events.map { event in + let definition: AnalyticsDefinition = Self.getTagDefinition( + id: event.id, + name: event.name, + type: .event, + tags: event.tags, + comments: event.comments, + parameters: event.parameters + ) + + if target.contains(TrackerType.matomo.value) { + // Category + guard let category = event.category else { + let error = AnalyticsError.missingElement("event category") + print(error.description) + Analytics.exit(withError: error) + } + + definition.category = category + + // Action + guard let action = event.action else { + let error = AnalyticsError.missingElement("event action") + print(error.description) + Analytics.exit(withError: error) + } + + definition.action = action + } + + return definition + } + } + + static func parse(_ inputFile: String, target: String) -> [AnalyticsCategory] { + self.inputFile = inputFile + self.target = target + + let tagFile = Self.parseYaml() + + return tagFile + .categories + .map { categorie in + let section: AnalyticsCategory = AnalyticsCategory(id: categorie.id) + + if let screens = categorie.screens { + section + .definitions + .append( + contentsOf: Self.getTagDefinitionScreen(from: screens) + ) + } + + if let events = categorie.events { + section + .definitions + .append( + contentsOf: Self.getTagDefinitionEvent(from: events) + ) + } + + return section + } + } +} diff --git a/Sources/ResgenSwift/Generate/Generate.swift b/Sources/ResgenSwift/Generate/Generate.swift index 4ac6685..a051046 100644 --- a/Sources/ResgenSwift/Generate/Generate.swift +++ b/Sources/ResgenSwift/Generate/Generate.swift @@ -34,6 +34,7 @@ struct Generate: ParsableCommand { // Parse let configuration = ConfigurationFileParser.parse(options.configurationFile) print("Found configurations :") + print(" - \(configuration.analytics.count) analytics configuration(s)") print(" - \(configuration.colors.count) colors configuration(s)") print(" - \(configuration.fonts.count) fonts configuration(s)") print(" - \(configuration.images.count) images configuration(s)") diff --git a/Sources/ResgenSwift/Generate/Generator/ArchitectureGenerator.swift b/Sources/ResgenSwift/Generate/Generator/ArchitectureGenerator.swift index ba1413c..df81bf8 100644 --- a/Sources/ResgenSwift/Generate/Generator/ArchitectureGenerator.swift +++ b/Sources/ResgenSwift/Generate/Generator/ArchitectureGenerator.swift @@ -11,12 +11,14 @@ import Foundation struct ArchitectureGenerator { static func writeArchitecture(_ architecture: ConfigurationArchitecture, projectDirectory: String) { // Create extension content - let architectureContent = [ + var architectureContent = [ "// Generated by ResgenSwift.\(Generate.toolName) \(ResgenSwiftVersion)", architecture.getClass() ] .joined(separator: "\n\n") + architectureContent += "\n" + let filename = "\(architecture.classname).swift" guard let filePath = architecture.path?.prependIfRelativePath(projectDirectory) else { let error = GenerateError.writeFile(filename, "Path of file is not defined.") diff --git a/Sources/ResgenSwift/Generate/Model/ConfigurationFile.swift b/Sources/ResgenSwift/Generate/Model/ConfigurationFile.swift index 9cf8c47..a631426 100644 --- a/Sources/ResgenSwift/Generate/Model/ConfigurationFile.swift +++ b/Sources/ResgenSwift/Generate/Model/ConfigurationFile.swift @@ -9,6 +9,7 @@ import Foundation struct ConfigurationFile: Codable, CustomDebugStringConvertible { var architecture: ConfigurationArchitecture? + var analytics: [AnalyticsConfiguration] var colors: [ColorsConfiguration] var fonts: [FontsConfiguration] var images: [ImagesConfiguration] @@ -16,12 +17,15 @@ struct ConfigurationFile: Codable, CustomDebugStringConvertible { var tags: [TagsConfiguration] var runnableConfigurations: [Runnable] { - let runnables: [[Runnable]] = [colors, fonts, images, strings, tags] + let runnables: [[Runnable]] = [analytics, colors, fonts, images, strings, tags] return Array(runnables.joined()) } var debugDescription: String { """ + \(analytics) + ----------- + ----------- \(colors) ----------- ----------- @@ -76,6 +80,47 @@ struct ConfigurationArchitecture: Codable { } } +struct AnalyticsConfiguration: Codable, CustomDebugStringConvertible { + let inputFile: String + let target: String + let extensionOutputPath: String + let extensionName: String? + let extensionSuffix: String? + private let staticMembers: Bool? + + var staticMembersOptions: Bool { + if let staticMembers = staticMembers { + return staticMembers + } + return false + } + + internal init(inputFile: String, + target: String, + extensionOutputPath: String, + extensionName: String?, + extensionSuffix: String?, + staticMembers: Bool?) { + self.inputFile = inputFile + self.target = target + self.extensionOutputPath = extensionOutputPath + self.extensionName = extensionName + self.extensionSuffix = extensionSuffix + self.staticMembers = staticMembers + } + + var debugDescription: String { + """ + Analytics configuration: + - Input file: \(inputFile) + - Target: \(target) + - Extension output path: \(extensionOutputPath) + - Extension name: \(extensionName ?? "-") + - Extension suffix: \(extensionSuffix ?? "-") + """ + } +} + struct ColorsConfiguration: Codable, CustomDebugStringConvertible { let inputFile: String let style: String diff --git a/Sources/ResgenSwift/Generate/Runnable/AnalyticsConfiguration+Runnable.swift b/Sources/ResgenSwift/Generate/Runnable/AnalyticsConfiguration+Runnable.swift new file mode 100644 index 0000000..1689a24 --- /dev/null +++ b/Sources/ResgenSwift/Generate/Runnable/AnalyticsConfiguration+Runnable.swift @@ -0,0 +1,43 @@ +// +// AnalyticsConfiguration+Runnable.swift +// +// +// Created by Loris Perret on 08/12/2023. +// + +import Foundation + +extension AnalyticsConfiguration: Runnable { + func run(projectDirectory: String, force: Bool) { + var args = [String]() + + if force { + args += ["-f"] + } + + args += [ + inputFile.prependIfRelativePath(projectDirectory), + "--target", + target, + "--extension-output-path", + extensionOutputPath.prependIfRelativePath(projectDirectory), + "--static-members", + "\(staticMembersOptions)" + ] + + if let extensionName = extensionName { + args += [ + "--extension-name", + extensionName + ] + } + if let extensionSuffix = extensionSuffix { + args += [ + "--extension-suffix", + extensionSuffix + ] + } + + Analytics.main(args) + } +} diff --git a/Sources/ResgenSwift/main.swift b/Sources/ResgenSwift/main.swift index 996161e..01bd9c2 100644 --- a/Sources/ResgenSwift/main.swift +++ b/Sources/ResgenSwift/main.swift @@ -19,10 +19,12 @@ struct ResgenSwift: ParsableCommand { // With language support for type-level introspection, this could be // provided by automatically finding nested `ParsableCommand` types. subcommands: [ + Analytics.self, Colors.self, Fonts.self, Images.self, Strings.self, + Tags.self, Generate.self ] diff --git a/Sources/ToolCore/StringExtensions.swift b/Sources/ToolCore/StringExtensions.swift index 52ea186..401f7a7 100644 --- a/Sources/ToolCore/StringExtensions.swift +++ b/Sources/ToolCore/StringExtensions.swift @@ -63,7 +63,7 @@ public extension String { replacingOccurrences(of: "~", with: "\(FileManager.default.homeDirectoryForCurrentUser.relativePath)") } - func colorComponent() -> (alpha: String, red: String, green: String, blue: String) { + func colorComponent() -> (alpha: String, red: String, green: String, blue: String) { var alpha: String = "FF" var red: String var green: String @@ -89,4 +89,19 @@ public extension String { func uppercasedFirst() -> String { prefix(1).uppercased() + dropFirst() } + + func replacingFirstOccurrence(of: String, with: String) -> Self { + if let range = self.range(of: of) { + let tmp = self.replacingOccurrences( + of: of, + with: with, + options: .literal, + range: range + ) + + return tmp + } + + return self + } } diff --git a/Tests/ResgenSwiftTests/Analytics/AnalyticsDefinitionTests.swift b/Tests/ResgenSwiftTests/Analytics/AnalyticsDefinitionTests.swift new file mode 100644 index 0000000..756f79e --- /dev/null +++ b/Tests/ResgenSwiftTests/Analytics/AnalyticsDefinitionTests.swift @@ -0,0 +1,137 @@ +// +// AnalyticsDefinitionTests.swift +// +// +// Created by Loris Perret on 06/12/2023. +// + +import Foundation +import XCTest + +@testable import ResgenSwift + +final class AnalyticsDefinitionTests: XCTestCase { + + // MARK: - Matching tags + + func testMatchingAnalyticss() { + // Given + let definition = AnalyticsDefinition(id: "definition_name", name: "", type: .screen) + definition.tags = ["ios","iosonly","notranslation"] + + // When + let match1 = definition.hasOneOrMoreMatchingTags(inputTags: ["ios"]) + let match2 = definition.hasOneOrMoreMatchingTags(inputTags: ["iosonly"]) + let match3 = definition.hasOneOrMoreMatchingTags(inputTags: ["notranslation"]) + + + // Expect + XCTAssertTrue(match1) + XCTAssertTrue(match2) + XCTAssertTrue(match3) + } + + func testNotMatchingAnalyticss() { + // Given + let definition = AnalyticsDefinition(id: "definition_name", name: "", type: .screen) + definition.tags = ["ios","iosonly","notranslation"] + + // When + let match1 = definition.hasOneOrMoreMatchingTags(inputTags: ["droid"]) + let match2 = definition.hasOneOrMoreMatchingTags(inputTags: ["droidonly"]) + let match3 = definition.hasOneOrMoreMatchingTags(inputTags: ["azerty"]) + + // Expect + XCTAssertFalse(match1) + XCTAssertFalse(match2) + XCTAssertFalse(match3) + } + + // MARK: - Raw properties + + func testGeneratedRawPropertyScreen() { + // Given + let definition = AnalyticsDefinition(id: "definition_name", name: "Ecran un", type: .screen) + definition.path = "ecran_un/" + + // When + let propertyScreen = definition.getProperty() + + // Expect + let expectScreen = """ + func logScreenDefinitionName() { + logScreen( + name: "Ecran un", + path: "ecran_un/" + ) + } + """ + + XCTAssertEqual(propertyScreen.adaptForXCTest(), expectScreen.adaptForXCTest()) + } + + func testGeneratedRawPropertyEvent() { + // Given + let definition = AnalyticsDefinition(id: "definition_name", name: "Ecran un", type: .event) + + // When + let propertyEvent = definition.getProperty() + + // Expect + let expectEvent = """ + func logEventDefinitionName() { + logEvent( + name: "Ecran un", + action: "", + category: "", + params: [] + ) + } + """ + + XCTAssertEqual(propertyEvent.adaptForXCTest(), expectEvent.adaptForXCTest()) + } + + func testGeneratedRawStaticPropertyScreen() { + // Given + let definition = AnalyticsDefinition(id: "definition_name", name: "Ecran un", type: .screen) + definition.path = "ecran_un/" + + // When + let propertyScreen = definition.getStaticProperty() + + // Expect + let expectScreen = """ + static func logScreenDefinitionName() { + logScreen( + name: "Ecran un", + path: "ecran_un/" + ) + } + """ + + XCTAssertEqual(propertyScreen.adaptForXCTest(), expectScreen.adaptForXCTest()) + } + + func testGeneratedRawStaticPropertyEvent() { + // Given + let definition = AnalyticsDefinition(id: "definition_name", name: "Ecran un", type: .event) + + // When + let propertyEvent = definition.getStaticProperty() + + // Expect + let expectEvent = """ + static func logEventDefinitionName() { + logEvent( + name: "Ecran un", + action: "", + category: "", + params: [] + ) + } + """ + + XCTAssertEqual(propertyEvent.adaptForXCTest(), expectEvent.adaptForXCTest()) + } +} diff --git a/Tests/ResgenSwiftTests/Analytics/AnalyticsGeneratorTests.swift b/Tests/ResgenSwiftTests/Analytics/AnalyticsGeneratorTests.swift new file mode 100644 index 0000000..7abe69f --- /dev/null +++ b/Tests/ResgenSwiftTests/Analytics/AnalyticsGeneratorTests.swift @@ -0,0 +1,623 @@ +// +// AnalyticsGeneratorTests.swift +// +// +// Created by Thibaut Schmitt on 06/09/2022. +// + +import Foundation +import XCTest +import ToolCore + +@testable import ResgenSwift + +final class AnalyticsGeneratorTests: XCTestCase { + + private func getAnalyticsDefinition( + id: String, + path: String = "", + action: String = "", + category: String = "", + name: String, + type: AnalyticsDefinition.TagType, + tags: [String] + ) -> AnalyticsDefinition { + let definition = AnalyticsDefinition(id: id, name: name, type: type) + definition.tags = tags + definition.path = path + definition.action = action + definition.category = category + return definition + } + + func testGeneratedExtensionContentFirebase() { + // Given + let sectionOne = AnalyticsCategory(id: "section_one") + sectionOne.definitions = [ + getAnalyticsDefinition(id: "s1_def_one", name: "s1 def one", type: .screen, tags: ["ios", "iosonly"]), + getAnalyticsDefinition(id: "s1_def_two", name: "s1 def two", type: .event, tags: ["ios", "iosonly"]), + ] + + let sectionTwo = AnalyticsCategory(id: "section_two") + sectionTwo.definitions = [ + getAnalyticsDefinition(id: "s2_def_one", name: "s2 def one", type: .screen, tags: ["ios","iosonly"]), + getAnalyticsDefinition(id: "s2_def_two", name: "s2 def two", type: .event, tags: ["droid","droidonly"]), + ] + + let sectionThree = AnalyticsCategory(id: "section_three") + sectionThree.definitions = [ + getAnalyticsDefinition(id: "s3_def_one", name: "s3 def one", type: .screen, tags: ["droid","droidonly"]), + getAnalyticsDefinition(id: "s3_def_two", name: "s3 def two", type: .event, tags: ["droid","droidonly"]), + ] + + // When + AnalyticsGenerator.targets = [TrackerType.firebase] + let extensionContent = AnalyticsGenerator.getExtensionContent(sections: [sectionOne, sectionTwo, sectionThree], + tags: ["ios", "iosonly"], + staticVar: false, + extensionName: "GenAnalytics") + // Expect Analytics + let expect = """ + // Generated by ResgenSwift.Analytics 1.2 + + import Firebase + + // MARK: - Protocol + + protocol AnalyticsManagerProtocol { + func logScreen(name: String, path: String) + func logEvent( + name: String, + action: String, + category: String, + params: [String: Any]? + ) + } + + // MARK: - Firebase + + class FirebaseAnalyticsManager: AnalyticsManagerProtocol { + func logScreen(name: String, path: String) { + var parameters = [ + AnalyticsParameterScreenName: name + ] + + Analytics.logEvent( + AnalyticsEventScreenView, + parameters: parameters + ) + } + + func logEvent( + name: String, + action: String, + category: String, + params: [String: Any]? + ) { + var parameters: [String:Any] = [ + "action": action, + "category": category, + ] + + if let supplementaryParameters = params { + parameters.merge(supplementaryParameters) { (origin, new) -> Any in + return origin + } + } + + Analytics.logEvent( + name, + parameters: parameters + ) + } + } + + // MARK: - Manager + + class AnalyticsManager { + static var shared = AnalyticsManager() + + // MARK: - Properties + + var managers: [AnalyticsManagerProtocol] = [] + + private var isEnabled: Bool = true + + // MARK: - Methods + + func setAnalyticsEnabled(_ enable: Bool) { + isEnabled = enable + } + + func configure() { + managers.append(FirebaseAnalyticsManager()) + } + + private func logScreen(name: String, path: String) { + guard isEnabled else { return } + + managers.forEach { manager in + manager.logScreen(name: name, path: path) + } + } + + private func logEvent( + name: String, + action: String, + category: String, + params: [String: Any]? + ) { + guard isEnabled else { return } + + managers.forEach { manager in + manager.logEvent( + name: name, + action: action, + category: category, + params: params + ) + } + } + + // MARK: - section_one + + func logScreenS1DefOne() { + logScreen( + name: "s1 def one", + path: "" + ) + } + + func logEventS1DefTwo() { + logEvent( + name: "s1 def two", + action: "", + category: "", + params: [] + ) + } + + // MARK: - section_two + + func logScreenS2DefOne() { + logScreen( + name: "s2 def one", + path: "" + ) + } + } + + """ + + if extensionContent != expect { + print(prettyFirstDifferenceBetweenStrings(s1: extensionContent, s2: expect)) + } + XCTAssertEqual(extensionContent.adaptForXCTest(), expect.adaptForXCTest()) + } + + func testGeneratedExtensionContentMatomo() { + // Given + let sectionOne = AnalyticsCategory(id: "section_one") + sectionOne.definitions = [ + getAnalyticsDefinition(id: "s1_def_one", path: "s1_def_one/", name: "s1 def one", type: .screen, tags: ["ios", "iosonly"]), + getAnalyticsDefinition(id: "s1_def_two", action: "test", category: "test", name: "s1 def two", type: .event, tags: ["ios", "iosonly"]), + ] + + let sectionTwo = AnalyticsCategory(id: "section_two") + sectionTwo.definitions = [ + getAnalyticsDefinition(id: "s2_def_one", path: "s2_def_one/", name: "s2 def one", type: .screen, tags: ["ios","iosonly"]), + getAnalyticsDefinition(id: "s2_def_two", action: "test", category: "test", name: "s2 def two", type: .event, tags: ["droid","droidonly"]), + ] + + let sectionThree = AnalyticsCategory(id: "section_three") + sectionThree.definitions = [ + getAnalyticsDefinition(id: "s3_def_one", path: "s3_def_one/", name: "s3 def one", type: .screen, tags: ["droid","droidonly"]), + getAnalyticsDefinition(id: "s3_def_two", action: "test", category: "test", name: "s3 def two", type: .event, tags: ["droid","droidonly"]), + ] + + // When + AnalyticsGenerator.targets = [TrackerType.matomo] + let extensionContent = AnalyticsGenerator.getExtensionContent(sections: [sectionOne, sectionTwo, sectionThree], + tags: ["ios", "iosonly"], + staticVar: false, + extensionName: "GenAnalytics") + // Expect Analytics + let expect = """ + // Generated by ResgenSwift.Analytics 1.2 + + import MatomoTracker + + // MARK: - Protocol + + protocol AnalyticsManagerProtocol { + func logScreen(name: String, path: String) + func logEvent( + name: String, + action: String, + category: String, + params: [String: Any]? + ) + } + + // MARK: - Matomo + + class MatomoAnalyticsManager: AnalyticsManagerProtocol { + + // MARK: - Properties + + private var tracker: MatomoTracker + + // MARK: - Init + + init(siteId: String, url: String) { + debugPrint("[Matomo service] Server URL: \\(url)") + debugPrint("[Matomo service] Site ID: \\(siteId)") + tracker = MatomoTracker( + siteId: siteId, + baseURL: URL(string: url)! + ) + + #if DEBUG + tracker.dispatchInterval = 5 + #endif + + #if DEBUG + tracker.logger = DefaultLogger(minLevel: .verbose) + #endif + + debugPrint("[Matomo service] Configured with content base: \\(tracker.contentBase?.absoluteString ?? "-")") + debugPrint("[Matomo service] Opt out: \\(tracker.isOptedOut)") + } + + // MARK: - Methods + + func logScreen(name: String, path: String) { + guard !tracker.isOptedOut else { return } + guard let trackerUrl = tracker.contentBase?.absoluteString else { return } + + let urlString = URL(string: "\\(trackerUrl)" + "/" + "\\(path)" + "iOS") + tracker.track( + view: [name], + url: urlString + ) + } + + func logEvent( + name: String, + action: String, + category: String, + params: [String: Any]? + ) { + guard !tracker.isOptedOut else { return } + + tracker.track( + eventWithCategory: category, + action: action, + name: name, + number: nil, + url: nil + ) + } + } + + // MARK: - Manager + + class AnalyticsManager { + static var shared = AnalyticsManager() + + // MARK: - Properties + + var managers: [AnalyticsManagerProtocol] = [] + + private var isEnabled: Bool = true + + // MARK: - Methods + + func setAnalyticsEnabled(_ enable: Bool) { + isEnabled = enable + } + + func configure(siteId: String, url: String) { + managers.append( + MatomoAnalyticsManager( + siteId: siteId, + url: url + ) + ) + } + + private func logScreen(name: String, path: String) { + guard isEnabled else { return } + + managers.forEach { manager in + manager.logScreen(name: name, path: path) + } + } + + private func logEvent( + name: String, + action: String, + category: String, + params: [String: Any]? + ) { + guard isEnabled else { return } + + managers.forEach { manager in + manager.logEvent( + name: name, + action: action, + category: category, + params: params + ) + } + } + + // MARK: - section_one + + func logScreenS1DefOne() { + logScreen( + name: "s1 def one", + path: "s1_def_one/" + ) + } + + func logEventS1DefTwo() { + logEvent( + name: "s1 def two", + action: "test", + category: "test", + params: [] + ) + } + + // MARK: - section_two + + func logScreenS2DefOne() { + logScreen( + name: "s2 def one", + path: "s2_def_one/" + ) + } + } + + """ + + if extensionContent != expect { + print(prettyFirstDifferenceBetweenStrings(s1: extensionContent, s2: expect)) + } + XCTAssertEqual(extensionContent.adaptForXCTest(), expect.adaptForXCTest()) + } + + func testGeneratedExtensionContentMatomoAndFirebase() { + // Given + let sectionOne = AnalyticsCategory(id: "section_one") + sectionOne.definitions = [ + getAnalyticsDefinition(id: "s1_def_one", path: "s1_def_one/", name: "s1 def one", type: .screen, tags: ["ios", "iosonly"]), + getAnalyticsDefinition(id: "s1_def_two", action: "test", category: "test", name: "s1 def two", type: .event, tags: ["ios", "iosonly"]), + ] + + let sectionTwo = AnalyticsCategory(id: "section_two") + sectionTwo.definitions = [ + getAnalyticsDefinition(id: "s2_def_one", path: "s2_def_one/", name: "s2 def one", type: .screen, tags: ["ios","iosonly"]), + getAnalyticsDefinition(id: "s2_def_two", action: "test", category: "test", name: "s2 def two", type: .event, tags: ["droid","droidonly"]), + ] + + let sectionThree = AnalyticsCategory(id: "section_three") + sectionThree.definitions = [ + getAnalyticsDefinition(id: "s3_def_one", path: "s3_def_one/", name: "s3 def one", type: .screen, tags: ["droid","droidonly"]), + getAnalyticsDefinition(id: "s3_def_two", action: "test", category: "test", name: "s3 def two", type: .event, tags: ["droid","droidonly"]), + ] + + // When + AnalyticsGenerator.targets = [TrackerType.matomo, TrackerType.firebase] + let extensionContent = AnalyticsGenerator.getExtensionContent(sections: [sectionOne, sectionTwo, sectionThree], + tags: ["ios", "iosonly"], + staticVar: false, + extensionName: "GenAnalytics") + // Expect Analytics + let expect = """ + // Generated by ResgenSwift.Analytics 1.2 + + import MatomoTracker + import Firebase + + // MARK: - Protocol + + protocol AnalyticsManagerProtocol { + func logScreen(name: String, path: String) + func logEvent( + name: String, + action: String, + category: String, + params: [String: Any]? + ) + } + + // MARK: - Matomo + + class MatomoAnalyticsManager: AnalyticsManagerProtocol { + + // MARK: - Properties + + private var tracker: MatomoTracker + + // MARK: - Init + + init(siteId: String, url: String) { + debugPrint("[Matomo service] Server URL: \\(url)") + debugPrint("[Matomo service] Site ID: \\(siteId)") + tracker = MatomoTracker( + siteId: siteId, + baseURL: URL(string: url)! + ) + + #if DEBUG + tracker.dispatchInterval = 5 + #endif + + #if DEBUG + tracker.logger = DefaultLogger(minLevel: .verbose) + #endif + + debugPrint("[Matomo service] Configured with content base: \\(tracker.contentBase?.absoluteString ?? "-")") + debugPrint("[Matomo service] Opt out: \\(tracker.isOptedOut)") + } + + // MARK: - Methods + + func logScreen(name: String, path: String) { + guard !tracker.isOptedOut else { return } + guard let trackerUrl = tracker.contentBase?.absoluteString else { return } + + let urlString = URL(string: "\\(trackerUrl)" + "/" + "\\(path)" + "iOS") + tracker.track( + view: [name], + url: urlString + ) + } + + func logEvent( + name: String, + action: String, + category: String, + params: [String: Any]? + ) { + guard !tracker.isOptedOut else { return } + + tracker.track( + eventWithCategory: category, + action: action, + name: name, + number: nil, + url: nil + ) + } + } + + // MARK: - Firebase + + class FirebaseAnalyticsManager: AnalyticsManagerProtocol { + func logScreen(name: String, path: String) { + var parameters = [ + AnalyticsParameterScreenName: name + ] + + Analytics.logEvent( + AnalyticsEventScreenView, + parameters: parameters + ) + } + + func logEvent( + name: String, + action: String, + category: String, + params: [String: Any]? + ) { + var parameters: [String:Any] = [ + "action": action, + "category": category, + ] + + if let supplementaryParameters = params { + parameters.merge(supplementaryParameters) { (origin, new) -> Any in + return origin + } + } + + Analytics.logEvent( + name, + parameters: parameters + ) + } + } + + // MARK: - Manager + + class AnalyticsManager { + static var shared = AnalyticsManager() + + // MARK: - Properties + + var managers: [AnalyticsManagerProtocol] = [] + + private var isEnabled: Bool = true + + // MARK: - Methods + + func setAnalyticsEnabled(_ enable: Bool) { + isEnabled = enable + } + + func configure(siteId: String, url: String) { + managers.append( + MatomoAnalyticsManager( + siteId: siteId, + url: url + ) + ) + managers.append(FirebaseAnalyticsManager()) + } + + private func logScreen(name: String, path: String) { + guard isEnabled else { return } + + managers.forEach { manager in + manager.logScreen(name: name, path: path) + } + } + + private func logEvent( + name: String, + action: String, + category: String, + params: [String: Any]? + ) { + guard isEnabled else { return } + + managers.forEach { manager in + manager.logEvent( + name: name, + action: action, + category: category, + params: params + ) + } + } + + // MARK: - section_one + + func logScreenS1DefOne() { + logScreen( + name: "s1 def one", + path: "s1_def_one/" + ) + } + + func logEventS1DefTwo() { + logEvent( + name: "s1 def two", + action: "test", + category: "test", + params: [] + ) + } + + // MARK: - section_two + + func logScreenS2DefOne() { + logScreen( + name: "s2 def one", + path: "s2_def_one/" + ) + } + } + + """ + + if extensionContent != expect { + print(prettyFirstDifferenceBetweenStrings(s1: extensionContent, s2: expect)) + } + XCTAssertEqual(extensionContent.adaptForXCTest(), expect.adaptForXCTest()) + } +} diff --git a/Tests/ResgenSwiftTests/Analytics/AnalyticsSectionTests.swift b/Tests/ResgenSwiftTests/Analytics/AnalyticsSectionTests.swift new file mode 100644 index 0000000..47447e7 --- /dev/null +++ b/Tests/ResgenSwiftTests/Analytics/AnalyticsSectionTests.swift @@ -0,0 +1,72 @@ +// +// AnalyticsSectionTests.swift +// +// +// Created by Loris Perret on 06/12/2023. +// + +import Foundation +import XCTest + +@testable import ResgenSwift + +final class AnalyticsSectionTests: XCTestCase { + + // MARK: - Matching tags + + func testMatchingAnalytics() { + // Given + let section = AnalyticsCategory(id: "section_name") + section.definitions = [ + { + let def = AnalyticsDefinition(id: "definition_name", name: "", type: .screen) + def.tags = ["ios","iosonly"] + return def + }(), + { + let def = AnalyticsDefinition(id: "definition_name_two", name: "", type: .screen) + def.tags = ["droid","droidonly"] + return def + }() + ] + + // When + let match1 = section.hasOneOrMoreMatchingTags(tags: ["ios"]) + let match2 = section.hasOneOrMoreMatchingTags(tags: ["iosonly"]) + let match3 = section.hasOneOrMoreMatchingTags(tags: ["droid"]) + let match4 = section.hasOneOrMoreMatchingTags(tags: ["droidonly"]) + + // Expect + XCTAssertTrue(match1) + XCTAssertTrue(match2) + XCTAssertTrue(match3) + XCTAssertTrue(match4) + } + + func testNotMatchingAnalytics() { + // Given + let section = AnalyticsCategory(id: "section_name") + section.definitions = [ + { + let def = AnalyticsDefinition(id: "definition_name", name: "", type: .screen) + def.tags = ["ios","iosonly"] + return def + }(), + { + let def = AnalyticsDefinition(id: "definition_name_two", name: "", type: .screen) + def.tags = ["droid","droidonly"] + return def + }() + ] + + // When + let match1 = section.hasOneOrMoreMatchingTags(tags: ["web"]) + let match2 = section.hasOneOrMoreMatchingTags(tags: ["webonly"]) + let match3 = section.hasOneOrMoreMatchingTags(tags: ["azerty"]) + + // Expect + XCTAssertFalse(match1) + XCTAssertFalse(match2) + XCTAssertFalse(match3) + } +} diff --git a/Tests/ResgenSwiftTests/Analytics/DiffString.swift b/Tests/ResgenSwiftTests/Analytics/DiffString.swift new file mode 100644 index 0000000..182eb23 --- /dev/null +++ b/Tests/ResgenSwiftTests/Analytics/DiffString.swift @@ -0,0 +1,134 @@ +// +// DiffString.swift +// +// +// Created by Loris Perret on 06/12/2023. +// + +import Foundation + +/// Find first differing character between two strings +/// +/// :param: s1 First String +/// :param: s2 Second String +/// +/// :returns: .DifferenceAtIndex(i) or .NoDifference +public func firstDifferenceBetweenStrings(s1: NSString, s2: NSString) -> FirstDifferenceResult { + let len1 = s1.length + let len2 = s2.length + + let lenMin = min(len1, len2) + + for i in 0.. String { + let firstDifferenceResult = firstDifferenceBetweenStrings(s1: s1 as NSString, s2: s2 as NSString) + return prettyDescriptionOfFirstDifferenceResult(firstDifferenceResult: firstDifferenceResult, s1: s1 as NSString, s2: s2 as NSString) as String +} + + +/// Create a formatted String representation of a FirstDifferenceResult for two strings +/// +/// :param: firstDifferenceResult FirstDifferenceResult +/// :param: s1 First string used in generation of firstDifferenceResult +/// :param: s2 Second string used in generation of firstDifferenceResult +/// +/// :returns: a printable string, possibly containing significant whitespace and newlines +public func prettyDescriptionOfFirstDifferenceResult(firstDifferenceResult: FirstDifferenceResult, s1: NSString, s2: NSString) -> NSString { + + func diffString(index: Int, s1: NSString, s2: NSString) -> NSString { + let markerArrow = "\u{2b06}" // "⬆" + let ellipsis = "\u{2026}" // "…" + /// Given a string and a range, return a string representing that substring. + /// + /// If the range starts at a position other than 0, an ellipsis + /// will be included at the beginning. + /// + /// If the range ends before the actual end of the string, + /// an ellipsis is added at the end. + func windowSubstring(s: NSString, range: NSRange) -> String { + let validRange = NSMakeRange(range.location, min(range.length, s.length - range.location)) + let substring = s.substring(with: validRange) + + let prefix = range.location > 0 ? ellipsis : "" + let suffix = (s.length - range.location > range.length) ? ellipsis : "" + + return "\(prefix)\(substring)\(suffix)" + } + + // Show this many characters before and after the first difference + let windowPrefixLength = 10 + let windowSuffixLength = 10 + let windowLength = windowPrefixLength + 1 + windowSuffixLength + + let windowIndex = max(index - windowPrefixLength, 0) + let windowRange = NSMakeRange(windowIndex, windowLength) + + let sub1 = windowSubstring(s: s1, range: windowRange) + let sub2 = windowSubstring(s: s2, range: windowRange) + + let markerPosition = min(windowSuffixLength, index) + (windowIndex > 0 ? 1 : 0) + + let markerPrefix = String(repeating: " " as Character, count: markerPosition) + let markerLine = "\(markerPrefix)\(markerArrow)" + + return "Difference at index \(index):\n\(sub1)\n\(sub2)\n\(markerLine)" as NSString + } + + switch firstDifferenceResult { + case .NoDifference: return "No difference" + case .DifferenceAtIndex(let index): return diffString(index: index, s1: s1, s2: s2) + } +} + + +/// Result type for firstDifferenceBetweenStrings() +public enum FirstDifferenceResult { + /// Strings are identical + case NoDifference + + /// Strings differ at the specified index. + /// + /// This could mean that characters at the specified index are different, + /// or that one string is longer than the other + case DifferenceAtIndex(Int) +} + +extension FirstDifferenceResult { + /// Textual representation of a FirstDifferenceResult + public var description: String { + switch self { + case .NoDifference: + return "NoDifference" + case .DifferenceAtIndex(let index): + return "DifferenceAtIndex(\(index))" + } + } + + /// Textual representation of a FirstDifferenceResult for debugging purposes + public var debugDescription: String { + return self.description + } +} diff --git a/Tests/ResgenSwiftTests/Strings/StringsFileGeneratorTests.swift b/Tests/ResgenSwiftTests/Strings/StringsFileGeneratorTests.swift index 5d3193c..764e5af 100644 --- a/Tests/ResgenSwiftTests/Strings/StringsFileGeneratorTests.swift +++ b/Tests/ResgenSwiftTests/Strings/StringsFileGeneratorTests.swift @@ -144,6 +144,22 @@ final class StringsFileGeneratorTests: XCTestCase { extension GenStrings { + enum KeyStrings: String { + case s1_def_one = "s1_def_one" + case s1_def_two = "s1_def_two" + case s2_def_one = "s2_def_one" + case s2_def_two = "s2_def_two" + + var keyPath: KeyPath { + switch self { + case .s1_def_one: return \\GenStrings.s1_def_one + case .s1_def_two: return \\GenStrings.s1_def_two + case .s2_def_one: return \\GenStrings.s2_def_one + case .s2_def_two: return \\GenStrings.s2_def_two + } + } + } + // MARK: - section_one /// Translation in fr : @@ -174,6 +190,9 @@ final class StringsFileGeneratorTests: XCTestCase { } """ + if extensionContent != expect { + print(prettyFirstDifferenceBetweenStrings(s1: extensionContent, s2: expect)) + } XCTAssertEqual(extensionContent.adaptForXCTest(), expect.adaptForXCTest()) } @@ -221,6 +240,22 @@ final class StringsFileGeneratorTests: XCTestCase { extension GenStrings { + enum KeyStrings: String { + case s1_def_one = "s1_def_one" + case s1_def_two = "s1_def_two" + case s2_def_one = "s2_def_one" + case s2_def_two = "s2_def_two" + + var keyPath: KeyPath { + switch self { + case .s1_def_one: return \\GenStrings.s1_def_one + case .s1_def_two: return \\GenStrings.s1_def_two + case .s2_def_one: return \\GenStrings.s2_def_one + case .s2_def_two: return \\GenStrings.s2_def_two + } + } + } + // MARK: - section_one /// Translation in fr : @@ -251,6 +286,9 @@ final class StringsFileGeneratorTests: XCTestCase { } """ + if extensionContent != expect { + print(prettyFirstDifferenceBetweenStrings(s1: extensionContent, s2: expect)) + } XCTAssertEqual(extensionContent.adaptForXCTest(), expect.adaptForXCTest()) } }