Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement CSV export of maintenance events #371

Merged
merged 7 commits into from
Dec 12, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions Basic-Car-Maintenance/Shared/Dashboard/Views/CSVEncoder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
//
// CSVEncoder.swift
// Basic-Car-Maintenance
//
// https://github.com/mikaelacaron/Basic-Car-Maintenance
// See LICENSE for license information.
//

import Foundation

internal extension BidirectionalCollection where Element == String {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doesn't need to be marked internal cause it is by default

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also moving this file to just the Dashboard folder, cause it doesn't fit being in the Dashboard > Views folder

var commaDelimited: String { joined(separator: ",") }
var newlineDelimited: String { joined(separator: "\r\n") }
}

public struct CSVColumn<Record> {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this shouldn't be public the "Shared" folder that this is in, is still all in a single project, so it's fine if it stays just internal

Same with like the whole file

/// The header name to use for the column in the CSV file's first row.
public private(set) var header: String

public private(set) var attribute: (Record) -> CSVEncodable

public init(
_ header: String,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

personal preference, but I don't format this on multiple lines unless it's needed because the line is too long, or there's many parameters (which at that point usually it needs to be refactored for passing too many things

attribute: @escaping (Record) -> CSVEncodable
) {
self.header = header
self.attribute = attribute
}
}

extension CSVColumn {
public init<T: CSVEncodable> (
_ header: String,
_ keyPath: KeyPath<Record, T>
) {
self.init(
header,
attribute: { $0[keyPath: keyPath] }
)
}
}

public protocol CSVEncodable {
/// Derive the string representation to be used in the exported CSV.
func encode(configuration: CSVEncoderConfiguration) -> String
}

extension String: CSVEncodable {
public func encode(configuration: CSVEncoderConfiguration) -> String {
self
}
}

extension Date: CSVEncodable {
public func encode(configuration: CSVEncoderConfiguration) -> String {
switch configuration.dateEncodingStrategy {
case .deferredToDate:
String(self.timeIntervalSinceReferenceDate)
case .iso8601:
ISO8601DateFormatter().string(from: self)
case .formatted(let dateFormatter):
dateFormatter.string(from: self)
case .custom(let customFunc):
customFunc(self)
}
}
}

extension UUID: CSVEncodable {
public func encode(configuration: CSVEncoderConfiguration) -> String {
uuidString
}
}

extension Int: CSVEncodable {
public func encode(configuration: CSVEncoderConfiguration) -> String {
String(self)
}
}

extension Double: CSVEncodable {
public func encode(configuration: CSVEncoderConfiguration) -> String {
String(self)
}
}

extension Bool: CSVEncodable {
public func encode(configuration: CSVEncoderConfiguration) -> String {
let (trueValue, falseValue) = configuration.boolEncodingStrategy.encodingValues

return self == true ? trueValue : falseValue
}
}

extension Optional: CSVEncodable where Wrapped: CSVEncodable {
public func encode(configuration: CSVEncoderConfiguration) -> String {
switch self {
case .none:
""
case .some(let wrapped):
wrapped.encode(configuration: configuration)
}
}
}

extension CSVEncodable {
internal func escapedOutput(configuration: CSVEncoderConfiguration) -> String {
let output = self.encode(configuration: configuration)
if output.contains(",") || output.contains("\"") || output.contains(#"\n"#)
|| output.hasPrefix(" ") || output.hasSuffix(" ") {
// Escape existing double quotes by doubling them
let escapedQuotes = output.replacingOccurrences(of: "\"", with: "\"\"")

// Wrap the string in double quotes
return "\"\(escapedQuotes)\""
} else {
// No special characters, return as is
return output
}
}
}

public struct CSVEncoderConfiguration {
/// The strategy to use when encoding dates.
public private(set) var dateEncodingStrategy: DateEncodingStrategy = .iso8601

/// The strategy to use when encoding Boolean values.
public private(set) var boolEncodingStrategy: BoolEncodingStrategy = .trueFalse

public init(
dateEncodingStrategy: DateEncodingStrategy = .iso8601,
boolEncodingStrategy: BoolEncodingStrategy = .trueFalse
) {
self.dateEncodingStrategy = dateEncodingStrategy
self.boolEncodingStrategy = boolEncodingStrategy
}

/// The strategy to use when encoding `Date` objects for CSV output.
public enum DateEncodingStrategy {
case deferredToDate
case iso8601
case formatted(DateFormatter)
case custom(@Sendable (Date) -> String)
}

/// The strategy to use when encoding `Bool` objects for CSV output.
public enum BoolEncodingStrategy {
case trueFalse
case trueFalseUppercase
case yesNo
case yesNoUppercase
case integer
case custom(true: String, false: String)
}
public static var `default`: CSVEncoderConfiguration = CSVEncoderConfiguration()
}

internal extension CSVEncoderConfiguration.BoolEncodingStrategy {
var encodingValues: (String, String) {
switch self {
case .trueFalse:
return ("true", "false")
case .trueFalseUppercase:
return ("TRUE", "FALSE")
case .yesNo:
return ("yes", "no")
case .yesNoUppercase:
return ("YES", "NO")
case .integer:
return ("1", "0")
case .custom(let trueValue, let falseValue):
return (trueValue, falseValue)
}
}
}

public struct CSVTable<Record> {
/// A description of all the columns of the CSV file, order from left to right.
public private(set) var columns: [CSVColumn<Record>]
/// The set of configuration parameters to use while encoding attributes and the whole file.
public private(set) var configuration: CSVEncoderConfiguration

/// Create a CSV table definition.
public init(
columns: [CSVColumn<Record>],
configuration: CSVEncoderConfiguration = .default
) {
self.columns = columns
self.configuration = configuration
}

/// Constructs a CSV text file structure from the given rows of data.
public func export(
rows: any Sequence<Record>
) -> String {
([headers] + allRows(rows: rows)).newlineDelimited
}

// MARK: -

private var headers: String {
columns.map { $0.header.escapedOutput(configuration: configuration) }.commaDelimited
}

private func allRows(rows: any Sequence<Record>) -> [String] {
rows.map { row in
columns.map { $0.attribute(row).escapedOutput(configuration: configuration) }.commaDelimited
}
}
}
118 changes: 118 additions & 0 deletions Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
//
// CSVGeneratorView.swift
// Basic-Car-Maintenance
//
// https://github.com/mikaelacaron/Basic-Car-Maintenance
// See LICENSE for license information.
//

import Foundation
import SwiftUI

struct CSVGeneratorView: View {
@Environment(\.dismiss) var dismiss

let events: [MaintenanceEvent]
let vehicleName: String

func csvData() -> String {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moving the functions below the body, just as a personal preference thing

and they can be private, because they're only used in this view

let table = CSVTable<MaintenanceEvent>(
columns: [
CSVColumn("Date") { $0.date.formatted() },
CSVColumn("Vehicle Name", \.title),
CSVColumn("Notes", \.notes)
],
configuration: CSVEncoderConfiguration(dateEncodingStrategy: .iso8601)
)
return table.export(rows: events)
}

func generateCSVFile(vehicle: String) -> URL? {
// Get the path to the Documents Directory
let fileManager = FileManager.default
guard let documentsDirectory = fileManager.urls(
for: .documentDirectory, in: .userDomainMask).first else {
print("Failed to locate the Documents Directory.")
return nil
}

// Create the file URL
let fileName = "\(vehicle)-MaintenanceReport"
let fileURL = documentsDirectory.appendingPathComponent(fileName).appendingPathExtension("csv")

do {
// Save the CSV content to the file
try csvData().write(to: fileURL, atomically: true, encoding: .utf8)
print("File saved to \(fileURL)")
return fileURL
} catch {
print("Failed to save CSV file: \(error.localizedDescription)")
return nil
}
}

var body: some View {
NavigationStack {
VStack(spacing: 0) {
List {
Grid(alignment: .leading, verticalSpacing: 5) {
GridRow {
Text("Date")
Text("Vehicle Name")
Text("Notes")
}
.font(.headline)
.frame(height: 50)

Divider()

ForEach(events) { event in
GridRow(alignment: .firstTextBaseline) {
Text(event.date.formatted())
.frame(maxWidth: 100, maxHeight: .infinity)
Text(event.title)
Text(event.notes)
}
.font(.subheadline)
if event != events.last {
Divider()
}
}
}
}
VStack {
if let fileURL = generateCSVFile(vehicle: vehicleName) {
ShareLink(item: fileURL) {
Label("Share", systemImage: SFSymbol.share)
}
} else {
Text("Error: Failed to save CSV file.")
.foregroundColor(.red)
.font(.subheadline)
}
}
.safeAreaPadding(.bottom)
}
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Close") {
dismiss()
}
}
}
}
}
}

#Preview {
CSVGeneratorView(
events: [
.init(
vehicleID: "1",
title: "1st service",
date: .now,
notes: "Maintenance and service"
)],
vehicleName: ""
)
}
Loading
Loading