diff --git a/BuildTimeAnalyzer.xcodeproj/project.pbxproj b/BuildTimeAnalyzer.xcodeproj/project.pbxproj index a9f1d44..0b8e1a3 100644 --- a/BuildTimeAnalyzer.xcodeproj/project.pbxproj +++ b/BuildTimeAnalyzer.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ 2AE775121D225D5D00D1A744 /* DerivedDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE775111D225D5D00D1A744 /* DerivedDataManager.swift */; }; 2AF821441D21D6B900D65186 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AF821431D21D6B900D65186 /* AppDelegate.swift */; }; 2AF821461D21D6B900D65186 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2AF821451D21D6B900D65186 /* Assets.xcassets */; }; + 5603EB6221EF93E90013D77B /* CSVExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5603EB6121EF93E90013D77B /* CSVExporter.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -67,6 +68,7 @@ 2AF8214A1D21D6B900D65186 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 2AF8214F1D21D6B900D65186 /* BuildTimeAnalyzerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BuildTimeAnalyzerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 2AF821551D21D6B900D65186 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 5603EB6121EF93E90013D77B /* CSVExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVExporter.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -160,6 +162,7 @@ 2A3164C11D21D73F00064045 /* LogProcessor.swift */, 2A9807DE1D7C76FD00B9232C /* ProjectSelection.swift */, 2ACBFD0B1D8835E60009567E /* UserSettings.swift */, + 5603EB6121EF93E90013D77B /* CSVExporter.swift */, ); name = Utilities; sourceTree = ""; @@ -314,6 +317,7 @@ 2A3164C91D21D73F00064045 /* LogProcessor.swift in Sources */, 2839B8691FD2896F004C075C /* ViewControllerDataSource.swift in Sources */, 2A5404011D86D01700DBD44C /* BuildManager.swift in Sources */, + 5603EB6221EF93E90013D77B /* CSVExporter.swift in Sources */, 2A5404051D86F3C700DBD44C /* File.swift in Sources */, 2ABFB6CE1D81F2DE00D060BF /* NSAlert+Extensions.swift in Sources */, 2A5404031D86DE0C00DBD44C /* XcodeDatabase.swift in Sources */, diff --git a/BuildTimeAnalyzer/AppDelegate.swift b/BuildTimeAnalyzer/AppDelegate.swift index b19ae21..9c3b35e 100644 --- a/BuildTimeAnalyzer/AppDelegate.swift +++ b/BuildTimeAnalyzer/AppDelegate.swift @@ -11,6 +11,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { @IBOutlet weak var projectSelectionMenuItem: NSMenuItem! @IBOutlet weak var buildTimesMenuItem: NSMenuItem! @IBOutlet weak var alwaysInFrontMenuItem: NSMenuItem! + + @objc var canExport: Bool = false var viewController: ViewController? { return NSApplication.shared.mainWindow?.contentViewController as? ViewController diff --git a/BuildTimeAnalyzer/CSVExporter.swift b/BuildTimeAnalyzer/CSVExporter.swift new file mode 100644 index 0000000..7ffccf9 --- /dev/null +++ b/BuildTimeAnalyzer/CSVExporter.swift @@ -0,0 +1,77 @@ +// +// CSVExporter.swift +// BuildTimeAnalyzer +// +// Created by Bruno Resende on 16.01.19. +// Copyright © 2019 Cane Media Ltd. All rights reserved. +// + +import Foundation + +struct CSVExporter { + + static var filenameDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd-HHmmss" + return formatter + }() + + func filename(with prefix: String) -> String { + return "\(prefix)_\(CSVExporter.filenameDateFormatter.string(from: Date())).csv" + } + + func export(elements: [T], to url: URL) throws where T: CSVExportable { + + guard let data = elements.joinedAsCSVString(delimiter: .doubleQuote).data(using: .utf8) else { + throw ExportErrors.couldNotParseStringAsUTF8 + } + + do { + try data.write(to: url, options: .atomic) + } catch { + throw ExportErrors.fileIO(error) + } + } + + enum ExportErrors: Error { + case couldNotParseStringAsUTF8 + case fileIO(Error) + } +} + +enum CSVDelimiter: String { + case singleQuote = "'" + case doubleQuote = "\"" + case none = "" +} + +protocol CSVExportable { + + static var csvHeaderLine: String { get } + + var csvLine: String { get } +} + +extension Array where Element: CSVExportable { + + func joinedAsCSVString(delimiter: CSVDelimiter) -> String { + + return ([Element.csvHeaderLine] + self.map({ $0.csvLine })).joined(separator: "\n") + } +} + +extension Array where Element == String { + + func joinedAsCSVLine(delimiter: CSVDelimiter) -> String { + + let formatter: (String) -> String + + switch delimiter { + case .singleQuote: formatter = { $0.replacingOccurrences(of: "'", with: "\\'") } + case .doubleQuote: formatter = { $0.replacingOccurrences(of: "\"", with: "\\\"") } + case .none: formatter = { $0 } + } + + return self.map({ "\(delimiter.rawValue)\(formatter($0))\(delimiter.rawValue)" }).joined(separator: ",") + } +} diff --git a/BuildTimeAnalyzer/CompileMeasure.swift b/BuildTimeAnalyzer/CompileMeasure.swift index 1dd0579..93f56e9 100755 --- a/BuildTimeAnalyzer/CompileMeasure.swift +++ b/BuildTimeAnalyzer/CompileMeasure.swift @@ -81,3 +81,13 @@ import Foundation } } } + +extension CompileMeasure: CSVExportable { + + static var csvHeaderLine: String = ["time", "file", "references", "code"].joinedAsCSVLine(delimiter: .doubleQuote) + + var csvLine: String + { + return [timeString, fileInfo, "\(references)", code].joinedAsCSVLine(delimiter: .doubleQuote) + } +} diff --git a/BuildTimeAnalyzer/Main.storyboard b/BuildTimeAnalyzer/Main.storyboard index ef24909..52023a8 100755 --- a/BuildTimeAnalyzer/Main.storyboard +++ b/BuildTimeAnalyzer/Main.storyboard @@ -1,8 +1,8 @@ - + - + @@ -64,6 +64,13 @@ + + + + + + + @@ -200,7 +207,7 @@ - + @@ -765,7 +772,7 @@ Gw - + diff --git a/BuildTimeAnalyzer/ViewController.swift b/BuildTimeAnalyzer/ViewController.swift index 2e9a5e5..20e92ba 100644 --- a/BuildTimeAnalyzer/ViewController.swift +++ b/BuildTimeAnalyzer/ViewController.swift @@ -26,6 +26,14 @@ class ViewController: NSViewController { private var currentKey: String? private var nextDatabase: XcodeDatabase? + + private(set) var lastProcessedDatabaseSchemeName: String? = nil + { + didSet + { + (NSApp.delegate as? AppDelegate)?.canExport = lastProcessedDatabaseSchemeName != nil + } + } private var processor = LogProcessor() @@ -161,6 +169,36 @@ class ViewController: NSViewController { showInstructions(true) projectSelection.listFolders() } + + @IBAction func exportAsCSVClicked(_ sender: Any?) { + guard let keyWindow = NSApp.keyWindow, let scheme = lastProcessedDatabaseSchemeName else { + return + } + + let exporter = CSVExporter() + + let savePanel = NSSavePanel() + savePanel.title = "Exporting data as CSV…" + savePanel.message = "Pick location for CSV file to be exported:" + savePanel.prompt = "Export" + savePanel.allowedFileTypes = ["csv"] + savePanel.nameFieldStringValue = exporter.filename(with: scheme) + + savePanel.beginSheetModal(for: keyWindow) { [dataSource] (response) in + guard response == NSApplication.ModalResponse.OK, let fileUrl = savePanel.url else { + return + } + + do + { + try dataSource.exportProcessedData(using: exporter, to: fileUrl) + } + catch + { + NSAlert(error: error).runModal() + } + } + } override func controlTextDidChange(_ obj: Notification) { if let field = obj.object as? NSSearchField, field == searchField { @@ -203,6 +241,7 @@ class ViewController: NSViewController { processingState = .processing currentKey = database.key + lastProcessedDatabaseSchemeName = database.schemeName updateTotalLabel(with: database.buildTime) diff --git a/BuildTimeAnalyzer/ViewControllerDataSource.swift b/BuildTimeAnalyzer/ViewControllerDataSource.swift index d8865a5..8686747 100644 --- a/BuildTimeAnalyzer/ViewControllerDataSource.swift +++ b/BuildTimeAnalyzer/ViewControllerDataSource.swift @@ -49,6 +49,10 @@ class ViewControllerDataSource { return processedData[index] } + func exportProcessedData(using exporter: CSVExporter, to url: URL) throws { + try exporter.export(elements: processedData, to: url) + } + // MARK: - Private methods private func processData() {