diff --git a/CHANGELOG.md b/CHANGELOG.md index 7de26595d..8dafe4cd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - In the CSV editor, insert a column to the left or right, and delete several selected columns at once. Column verbs also appear in the Edit menu, and deleting a column that holds data asks you to confirm first. (#1469) - In the CSV editor, split a column into several by a delimiter or regular expression, and merge a column with the one next to it using a separator. Both are single undo steps. (#1469) - In the CSV editor, switch the first row between header and data with `Cmd+Shift+H` when auto-detection guesses wrong. Files without a header are no longer saved with a generated header row. (#1469) +- In the CSV editor, set the delimiter, quote character, encoding, and line ending by hand from Edit > Set CSV Properties…, then Reload to re-read the file with those settings. (#1469) - Add llama.cpp and MLX as local AI providers. Each preset points at the server's default local endpoint and needs no API key, alongside the existing Ollama and custom OpenAI-compatible options. (#1777) ### Fixed diff --git a/Plugins/CSVInspectorPlugin/CSVDocument.swift b/Plugins/CSVInspectorPlugin/CSVDocument.swift index 3d818f645..402b77824 100644 --- a/Plugins/CSVInspectorPlugin/CSVDocument.swift +++ b/Plugins/CSVInspectorPlugin/CSVDocument.swift @@ -2,7 +2,7 @@ import AppKit import TableProPluginKit import os -public final class CSVDocument: NSDocument, InspectorDocument { +public final class CSVDocument: NSDocument, CSVConfigurableDocument { static let logger = Logger(subsystem: "com.TablePro", category: "CSVInspector") private static let typeInferenceSampleSize = 200 @@ -356,6 +356,18 @@ public final class CSVDocument: NSDocument, InspectorDocument { } } + public var csvDialect: CSVDialect { dialect } + + public func reload(with newDialect: CSVDialect) { + let data = store.data + dialect = newDialect + store = CSVRowStore(data: data, dialect: newDialect) + recomputeInferredTypes() + undoManager?.removeAllActions() + updateChangeCount(.changeDone) + onChange?() + } + private func recomputeInferredTypes() { let sample = store.pageRows(offset: 0, limit: Self.typeInferenceSampleSize) inferredTypes = CSVTypeInferrer.inferColumns(rows: sample, columnCount: store.columnCount) diff --git a/Plugins/TableProPluginKit/DocumentInspectorPlugin.swift b/Plugins/TableProPluginKit/DocumentInspectorPlugin.swift index 727f9e513..84c1ed1cc 100644 --- a/Plugins/TableProPluginKit/DocumentInspectorPlugin.swift +++ b/Plugins/TableProPluginKit/DocumentInspectorPlugin.swift @@ -72,3 +72,9 @@ public extension InspectorDocument { func mergeColumns(at index: Int, separator: String) {} func toggleHeaderRow() {} } + +@MainActor +public protocol CSVConfigurableDocument: InspectorDocument { + var csvDialect: CSVDialect { get } + func reload(with dialect: CSVDialect) +} diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index c8b91ca2b..8f26ff4c4 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -651,6 +651,11 @@ struct AppMenuCommands: Commands { .optionalKeyboardShortcut(shortcut(for: .toggleHeaderRow)) .disabled(!keyWindowIsInspector) + Button(String(localized: "Set CSV Properties…")) { + NSApp.sendAction(#selector(InspectorViewController.inspectorSetCSVProperties(_:)), to: nil, from: nil) + } + .disabled(!keyWindowIsInspector) + Divider() // Table operations (work when tables selected in sidebar) diff --git a/TablePro/Views/Inspector/CSVPropertiesSheet.swift b/TablePro/Views/Inspector/CSVPropertiesSheet.swift new file mode 100644 index 000000000..1173f02a4 --- /dev/null +++ b/TablePro/Views/Inspector/CSVPropertiesSheet.swift @@ -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 + ) + } +} diff --git a/TablePro/Views/Inspector/CSVPropertyOptions.swift b/TablePro/Views/Inspector/CSVPropertyOptions.swift new file mode 100644 index 000000000..f0bd28286 --- /dev/null +++ b/TablePro/Views/Inspector/CSVPropertyOptions.swift @@ -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), + ("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, + encoding: encodings.indices.contains(encodingIndex) ? encodings[encodingIndex].encoding : base.encoding, + lineEnding: lineEndings.indices.contains(lineEndingIndex) ? lineEndings[lineEndingIndex].value : base.lineEnding, + hasBom: base.hasBom + ) + } +} diff --git a/TablePro/Views/Inspector/InspectorViewController.swift b/TablePro/Views/Inspector/InspectorViewController.swift index 5ada05114..d09d7b318 100644 --- a/TablePro/Views/Inspector/InspectorViewController.swift +++ b/TablePro/Views/Inspector/InspectorViewController.swift @@ -25,6 +25,7 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation private var lastFilterClauses: [FilterClause] = [] private var lastSortSpecs: [SortSpec] = [] private var pendingPostRefresh: PostRefreshAction? + private var propertiesSheetController: NSViewController? private enum PostRefreshAction { case selectClamped(displayRow: Int) @@ -221,6 +222,51 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation inspectorDocument?.toggleHeaderRow() } + @objc func inspectorSetCSVProperties(_ sender: Any?) { + guard let configurable = inspectorDocument as? CSVConfigurableDocument else { return } + presentCSVProperties(configurable) + } + + private func presentCSVProperties(_ document: CSVConfigurableDocument) { + guard propertiesSheetController == nil else { return } + let sheet = CSVPropertiesSheet( + dialect: document.csvDialect, + onReload: { [weak self, weak document] dialect in + self?.dismissPropertiesSheet() + guard let document else { return } + DispatchQueue.main.async { self?.reloadCSV(document, with: dialect) } + }, + onCancel: { [weak self] in self?.dismissPropertiesSheet() } + ) + let hosting = NSHostingController(rootView: sheet) + propertiesSheetController = hosting + presentAsSheet(hosting) + } + + private func dismissPropertiesSheet() { + guard let controller = propertiesSheetController else { return } + dismiss(controller) + propertiesSheetController = nil + } + + private func reloadCSV(_ document: CSVConfigurableDocument, with dialect: CSVDialect) { + guard nsDocument?.isDocumentEdited == true, let window = view.window else { + document.reload(with: dialect) + return + } + let alert = NSAlert() + alert.messageText = String(localized: "Reload with new properties?") + alert.informativeText = String(localized: "This discards your unsaved changes and re-reads the file with the chosen settings.") + alert.alertStyle = .warning + let reloadButton = alert.addButton(withTitle: String(localized: "Reload")) + reloadButton.hasDestructiveAction = true + alert.addButton(withTitle: String(localized: "Cancel")) + alert.beginSheetModal(for: window) { [weak document] response in + guard response == .alertFirstButtonReturn, let document else { return } + document.reload(with: dialect) + } + } + @objc func inspectorInsertRowAbove(_ sender: Any?) { performInsertRow(anchoredBy: sender, below: false) } @@ -595,6 +641,8 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation #selector(inspectorSplitColumn(_:)), #selector(inspectorMergeColumns(_:)), #selector(inspectorToggleHeaderRow(_:)): return nsDocument != nil + case #selector(inspectorSetCSVProperties(_:)): + return inspectorDocument is CSVConfigurableDocument case #selector(inspectorDeleteColumn(_:)): guard nsDocument != nil else { return false } if let menuItem = item as? NSMenuItem, menuItem.representedObject is [Int] { return true } diff --git a/TableProTests/Views/CSVPropertyOptionsTests.swift b/TableProTests/Views/CSVPropertyOptionsTests.swift new file mode 100644 index 000000000..d3dcfcdd9 --- /dev/null +++ b/TableProTests/Views/CSVPropertyOptionsTests.swift @@ -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), + lineEndingIndex: CSVPropertyOptions.lineEndingIndex(for: .cr) + ) + #expect(dialect.delimiter == 0x09) + #expect(dialect.quoteChar == 0x27) + #expect(dialect.encoding == .utf16LittleEndian) + #expect(dialect.lineEnding == .cr) + #expect(dialect.hasBom) + } +} diff --git a/docs/features/csv-inspector.mdx b/docs/features/csv-inspector.mdx index cf0de90d4..2a6aa0f10 100644 --- a/docs/features/csv-inspector.mdx +++ b/docs/features/csv-inspector.mdx @@ -26,6 +26,8 @@ When a file opens, TablePro detects: - **Line ending**: CRLF, LF, or CR, whichever appears first in the first 64 KB. - **Header row**: row one is treated as headers when at least half of its cells are non-empty and not numbers. Otherwise TablePro generates `Column 1`, `Column 2`, ... and treats every row as data. +When a guess is wrong, choose **Edit > Set CSV Properties…** to pick the delimiter, quote character, encoding, and line ending by hand, then **Reload** to re-read the file with those settings. Reload discards unsaved edits and asks first if you have any. + ## Pagination Rows load in pages. The page size comes from the Default page size setting in [Settings > Data Grid](/customization/settings), 1,000 rows by default. The status bar shows row and column counts, and previous/next page controls when the file spans more than one page.