-
-
Notifications
You must be signed in to change notification settings - Fork 336
feat(inspector): set CSV delimiter, quote, encoding, and line ending with reload #1943
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| // | ||
| // CSVPropertiesSheet.swift | ||
| // TablePro | ||
| // | ||
|
|
||
| import SwiftUI | ||
| import TableProPluginKit | ||
|
|
||
| struct CSVPropertiesSheet: View { | ||
| private let baseDialect: CSVDialect | ||
| private let onReload: (CSVDialect) -> Void | ||
| private let onCancel: () -> Void | ||
|
|
||
| @State private var delimiterIndex: Int | ||
| @State private var quoteIndex: Int | ||
| @State private var encodingIndex: Int | ||
| @State private var lineEndingIndex: Int | ||
|
|
||
| init( | ||
| dialect: CSVDialect, | ||
| onReload: @escaping (CSVDialect) -> Void, | ||
| onCancel: @escaping () -> Void | ||
| ) { | ||
| self.baseDialect = dialect | ||
| self.onReload = onReload | ||
| self.onCancel = onCancel | ||
| _delimiterIndex = State(initialValue: CSVPropertyOptions.delimiterIndex(for: dialect.delimiter)) | ||
| _quoteIndex = State(initialValue: CSVPropertyOptions.quoteIndex(for: dialect.quoteChar)) | ||
| _encodingIndex = State(initialValue: CSVPropertyOptions.encodingIndex(for: dialect.encoding)) | ||
| _lineEndingIndex = State(initialValue: CSVPropertyOptions.lineEndingIndex(for: dialect.lineEnding)) | ||
| } | ||
|
|
||
| var body: some View { | ||
| VStack(alignment: .leading, spacing: 16) { | ||
| Text("CSV Properties").font(.headline) | ||
| Text("Re-read the file with these settings. This discards unsaved changes.") | ||
| .font(.subheadline) | ||
| .foregroundStyle(.secondary) | ||
|
|
||
| Form { | ||
| Picker("Delimiter", selection: $delimiterIndex) { | ||
| ForEach(CSVPropertyOptions.delimiters.indices, id: \.self) { index in | ||
| Text(CSVPropertyOptions.delimiters[index].label).tag(index) | ||
| } | ||
| } | ||
| Picker("Quote character", selection: $quoteIndex) { | ||
| ForEach(CSVPropertyOptions.quotes.indices, id: \.self) { index in | ||
| Text(CSVPropertyOptions.quotes[index].label).tag(index) | ||
| } | ||
| } | ||
| Picker("Encoding", selection: $encodingIndex) { | ||
| ForEach(CSVPropertyOptions.encodings.indices, id: \.self) { index in | ||
| Text(CSVPropertyOptions.encodings[index].label).tag(index) | ||
| } | ||
| } | ||
| Picker("Line ending", selection: $lineEndingIndex) { | ||
| ForEach(CSVPropertyOptions.lineEndings.indices, id: \.self) { index in | ||
| Text(CSVPropertyOptions.lineEndings[index].label).tag(index) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| HStack { | ||
| Spacer() | ||
| Button("Cancel", role: .cancel, action: onCancel) | ||
| .keyboardShortcut(.cancelAction) | ||
| Button("Reload") { onReload(selectedDialect) } | ||
| .keyboardShortcut(.defaultAction) | ||
| } | ||
| } | ||
| .padding(20) | ||
| .frame(width: 340) | ||
| } | ||
|
|
||
| private var selectedDialect: CSVDialect { | ||
| CSVPropertyOptions.dialect( | ||
| base: baseDialect, | ||
| delimiterIndex: delimiterIndex, | ||
| quoteIndex: quoteIndex, | ||
| encodingIndex: encodingIndex, | ||
| lineEndingIndex: lineEndingIndex | ||
| ) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| // | ||
| // CSVPropertyOptions.swift | ||
| // TablePro | ||
| // | ||
|
|
||
| import Foundation | ||
| import TableProPluginKit | ||
|
|
||
| enum CSVPropertyOptions { | ||
| static let delimiters: [(label: String, byte: UInt8)] = [ | ||
| (String(localized: "Comma ,"), 0x2C), | ||
| (String(localized: "Semicolon ;"), 0x3B), | ||
| (String(localized: "Tab"), 0x09), | ||
| (String(localized: "Pipe |"), 0x7C), | ||
| (String(localized: "Colon :"), 0x3A), | ||
| (String(localized: "Space"), 0x20), | ||
| ] | ||
|
|
||
| static let quotes: [(label: String, byte: UInt8)] = [ | ||
| (String(localized: "Double Quote \""), 0x22), | ||
| (String(localized: "Single Quote '"), 0x27), | ||
| ] | ||
|
|
||
| static let encodings: [(label: String, encoding: String.Encoding)] = [ | ||
| ("UTF-8", .utf8), | ||
| ("UTF-16 LE", .utf16LittleEndian), | ||
| ("UTF-16 BE", .utf16BigEndian), | ||
|
Comment on lines
+26
to
+27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a BOM-less UTF-16 CSV, selecting either option here cannot correctly repair detection: Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a user selects Latin-1 or Windows-1252 and then edits a row containing an unrepresentable character (for example, an emoji from an originally UTF-8 file), Useful? React with 👍 / 👎. |
||
| ("Latin-1", .isoLatin1), | ||
| ("Windows-1252", .windowsCP1252), | ||
| ] | ||
|
|
||
| static let lineEndings: [(label: String, value: CSVDialect.LineEnding)] = [ | ||
| ("LF", .lf), | ||
| ("CRLF", .crlf), | ||
| ("CR", .cr), | ||
| ] | ||
|
|
||
| static func delimiterIndex(for byte: UInt8) -> Int { | ||
| delimiters.firstIndex { $0.byte == byte } ?? 0 | ||
| } | ||
|
|
||
| static func quoteIndex(for byte: UInt8) -> Int { | ||
| quotes.firstIndex { $0.byte == byte } ?? 0 | ||
| } | ||
|
|
||
| static func encodingIndex(for encoding: String.Encoding) -> Int { | ||
| encodings.firstIndex { $0.encoding == encoding } ?? 0 | ||
| } | ||
|
|
||
| static func lineEndingIndex(for value: CSVDialect.LineEnding) -> Int { | ||
| lineEndings.firstIndex { $0.value == value } ?? 0 | ||
| } | ||
|
|
||
| static func dialect( | ||
| base: CSVDialect, | ||
| delimiterIndex: Int, | ||
| quoteIndex: Int, | ||
| encodingIndex: Int, | ||
| lineEndingIndex: Int | ||
| ) -> CSVDialect { | ||
| CSVDialect( | ||
| delimiter: delimiters.indices.contains(delimiterIndex) ? delimiters[delimiterIndex].byte : base.delimiter, | ||
| quoteChar: quotes.indices.contains(quoteIndex) ? quotes[quoteIndex].byte : base.quoteChar, | ||
|
Comment on lines
+61
to
+63
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a user selects (for example) Comma for a Useful? React with 👍 / 👎. |
||
| encoding: encodings.indices.contains(encodingIndex) ? encodings[encodingIndex].encoding : base.encoding, | ||
| lineEnding: lineEndings.indices.contains(lineEndingIndex) ? lineEndings[lineEndingIndex].value : base.lineEnding, | ||
| hasBom: base.hasBom | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a UTF-8-BOM CSV is reloaded after selecting Latin-1 or Windows-1252, this retains Useful? React with 👍 / 👎. |
||
| ) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| // | ||
| // CSVPropertyOptionsTests.swift | ||
| // TableProTests | ||
| // | ||
|
|
||
| @testable import TablePro | ||
| import TableProPluginKit | ||
| import Testing | ||
|
|
||
| @Suite("CSVPropertyOptions") | ||
| struct CSVPropertyOptionsTests { | ||
| @Test("Indices map back from a dialect's bytes and values") | ||
| func indicesFromDialect() { | ||
| #expect(CSVPropertyOptions.delimiterIndex(for: 0x3B) == 1) | ||
| #expect(CSVPropertyOptions.quoteIndex(for: 0x27) == 1) | ||
| #expect(CSVPropertyOptions.encodingIndex(for: .windowsCP1252) == 4) | ||
| #expect(CSVPropertyOptions.lineEndingIndex(for: .crlf) == 1) | ||
| } | ||
|
|
||
| @Test("An unknown delimiter falls back to the first option") | ||
| func unknownFallsBack() { | ||
| #expect(CSVPropertyOptions.delimiterIndex(for: 0x5E) == 0) | ||
| } | ||
|
|
||
| @Test("Building a dialect from indices sets the four properties and keeps the BOM") | ||
| func dialectRoundTrip() { | ||
| let base = CSVDialect(delimiter: 0x2C, hasBom: true) | ||
| let dialect = CSVPropertyOptions.dialect( | ||
| base: base, | ||
| delimiterIndex: CSVPropertyOptions.delimiterIndex(for: 0x09), | ||
| quoteIndex: CSVPropertyOptions.quoteIndex(for: 0x27), | ||
| encodingIndex: CSVPropertyOptions.encodingIndex(for: .utf16LittleEndian), | ||
|
Check failure on line 32 in TableProTests/Views/CSVPropertyOptionsTests.swift
|
||
| lineEndingIndex: CSVPropertyOptions.lineEndingIndex(for: .cr) | ||
| ) | ||
| #expect(dialect.delimiter == 0x09) | ||
| #expect(dialect.quoteChar == 0x27) | ||
| #expect(dialect.encoding == .utf16LittleEndian) | ||
|
Check failure on line 37 in TableProTests/Views/CSVPropertyOptionsTests.swift
|
||
| #expect(dialect.lineEnding == .cr) | ||
| #expect(dialect.hasBom) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After any property change, this recreates the store with every row still backed by the original byte ranges. On Save,
CSVRowStore.rowSource(at:)returns those ranges as.rawBytes, andCSVWriter.appendcopies them verbatim instead of serializing them with the newly selected dialect. Thus changing encoding or line ending and saving leaves existing rows in the old format (and editing only one row produces a mixed-format file); with an encoding change, the writer can even prepend a new BOM to payload bytes still in the prior encoding. Materialize/rewrite the original rows when the dialect changes so the Save action promised by this flow actually writes the selected settings.Useful? React with 👍 / 👎.