Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Inline dropdown picker when editing ENUM and SET columns, covering MySQL, MariaDB, PostgreSQL, ClickHouse, DuckDB, and MongoDB JSON-schema enums (#1283)
- Filter rows show an enum dropdown for `=` and `!=` operators on enum columns (#1283)
- CSV/TSV inspector: open files from Finder or File > Open, edit cells, multi-condition filter (Cmd+F), multi-column sort (shift-click headers), add/remove/rename columns with type override, copy/paste rows as TSV, undo/redo, auto-reload on external changes. Large files stream from disk without loading into memory. (#1259)
- iOS: SQL Server (MSSQL) connections via FreeTDS over TDS 7.4. Uses the shared `SSLConfiguration` model from connection settings. Supports connect, query, streaming results, schema browsing (tables, columns, indexes, foreign keys), database and schema switching, and explicit transactions.
- iOS: data browser, search, filter, and pagination now render correct SQL Server syntax (bracket-quoted identifiers, `OFFSET ... ROWS FETCH NEXT ... ROWS ONLY` pagination, `SELECT TOP 1` for cell value fetch).
- iOS: Settings > Sync now shows last sync time, a Sync Now button, and a Refresh from iCloud action that re-downloads every connection, group, and tag when items are missing on this device but visible on another.

### Changed

Expand Down
154 changes: 154 additions & 0 deletions Plugins/CSVInspectorPlugin/CSVDialect.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import Foundation

struct CSVDialect: Equatable, Sendable {
enum LineEnding: Equatable, Sendable {
case crlf
case lf
case cr

var bytes: [UInt8] {
switch self {
case .crlf: return [0x0D, 0x0A]
case .lf: return [0x0A]
case .cr: return [0x0D]
}
}
}

var delimiter: UInt8
var quoteChar: UInt8
var encoding: String.Encoding
var lineEnding: LineEnding
var hasBom: Bool

init(
delimiter: UInt8,
quoteChar: UInt8 = 0x22,
encoding: String.Encoding = .utf8,
lineEnding: LineEnding = .lf,
hasBom: Bool = false
) {
self.delimiter = delimiter
self.quoteChar = quoteChar
self.encoding = encoding
self.lineEnding = lineEnding
self.hasBom = hasBom
}

static let csv = CSVDialect(delimiter: 0x2C)
static let tsv = CSVDialect(delimiter: 0x09)

private static let detectionScanLimit = 65_536

static func detect(from data: Data) -> CSVDialect {
var hasBom = false
var encoding: String.Encoding = .utf8
var bomLength = 0
let start = data.startIndex

if data.count >= 3,
data[start] == 0xEF, data[start + 1] == 0xBB, data[start + 2] == 0xBF {
hasBom = true
encoding = .utf8
bomLength = 3
} else if data.count >= 2, data[start] == 0xFE, data[start + 1] == 0xFF {
hasBom = true
encoding = .utf16BigEndian
bomLength = 2
} else if data.count >= 2, data[start] == 0xFF, data[start + 1] == 0xFE {
hasBom = true
encoding = .utf16LittleEndian
bomLength = 2
}

let body = data.dropFirst(bomLength)
if !hasBom {
encoding = probeEncoding(body)
}

let sample = Array(body.prefix(detectionScanLimit))
let delimiter = detectDelimiter(sample)
let lineEnding = detectLineEnding(sample)

return CSVDialect(
delimiter: delimiter,
encoding: encoding,
lineEnding: lineEnding,
hasBom: hasBom
)
}

private static func probeEncoding(_ body: Data) -> String.Encoding {
var probe = body.prefix(262_144)
while let last = probe.last, (last & 0xC0) == 0x80 {
probe = probe.dropLast()
}
if let last = probe.last, last >= 0xC0 {
probe = probe.dropLast()
}
if String(data: Data(probe), encoding: .utf8) != nil {
return .utf8
}
return .windowsCP1252
}

private static func detectDelimiter(_ bytes: [UInt8]) -> UInt8 {
var counts: [UInt8: Int] = [0x2C: 0, 0x09: 0, 0x3B: 0, 0x7C: 0]
var insideQuotes = false
var i = 0
while i < bytes.count {
let byte = bytes[i]
if byte == 0x22 {
if insideQuotes, i + 1 < bytes.count, bytes[i + 1] == 0x22 {
i += 2
continue
}
insideQuotes.toggle()
i += 1
continue
}
if !insideQuotes, counts[byte] != nil {
counts[byte, default: 0] += 1
}
i += 1
}
return counts.max(by: { $0.value < $1.value })?.key ?? 0x2C
}

private static func detectLineEnding(_ bytes: [UInt8]) -> LineEnding {
var insideQuotes = false
var i = 0
while i < bytes.count {
let byte = bytes[i]
if byte == 0x22 {
if insideQuotes, i + 1 < bytes.count, bytes[i + 1] == 0x22 {
i += 2
continue
}
insideQuotes.toggle()
i += 1
continue
}
if !insideQuotes {
if byte == 0x0D {
return (i + 1 < bytes.count && bytes[i + 1] == 0x0A) ? .crlf : .cr
}
if byte == 0x0A {
return .lf
}
}
i += 1
}
return .lf
}

var bomBytes: [UInt8] {
guard hasBom else { return [] }
switch encoding {
case .utf8: return [0xEF, 0xBB, 0xBF]
case .utf16BigEndian: return [0xFE, 0xFF]
case .utf16LittleEndian: return [0xFF, 0xFE]
default: return []
}
}
}
Loading
Loading