diff --git a/CHANGELOG.md b/CHANGELOG.md index 09b2f6533..a925a8566 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Oracle Database 11g (11.1 and 11.2) now connects. Previously only 12c and later worked, so 11g servers failed with a "Server Version Not Supported" error. (#1425) - Oracle connections can now use a SID instead of a service name. Set Connection Type to SID in the connection form and enter the SID. (#1425) - Cmd-click a foreign key arrow to open the referenced table in a new tab instead of the current one. The right-click menu has the same Open in New Tab option. (#1421) +- Cells holding JSON or PHP serialized values in text columns now open in the structured viewer automatically, without requiring the column type to be JSON. ### Changed diff --git a/TablePro/Core/Services/Formatting/CellValueContentDetector.swift b/TablePro/Core/Services/Formatting/CellValueContentDetector.swift new file mode 100644 index 000000000..430dbad4c --- /dev/null +++ b/TablePro/Core/Services/Formatting/CellValueContentDetector.swift @@ -0,0 +1,33 @@ +// +// CellValueContentDetector.swift +// TablePro +// + +import Foundation + +internal enum CellValueContent: Equatable { + case json + case phpSerialized + case plain +} + +internal enum CellValueContentDetector { + private static let sizeCapBytes = 5_000_000 + + static func detect(_ value: String) -> CellValueContent { + guard !value.isEmpty else { return .plain } + guard (value as NSString).length <= sizeCapBytes else { return .plain } + + let first = value.unicodeScalars.first + if first == "{" || first == "[" { + if value.looksLikeJson { return .json } + } + + let phpFirstScalars: Set = ["N", "b", "i", "d", "s", "S", "a", "O", "C", "o", "r", "R"] + if let first, phpFirstScalars.contains(first) { + if PhpSerializeParser.looksLikePhpSerialized(value) { return .phpSerialized } + } + + return .plain + } +} diff --git a/TablePro/Core/Services/Formatting/PhpSerializeParser.swift b/TablePro/Core/Services/Formatting/PhpSerializeParser.swift new file mode 100644 index 000000000..1481c4732 --- /dev/null +++ b/TablePro/Core/Services/Formatting/PhpSerializeParser.swift @@ -0,0 +1,273 @@ +// +// PhpSerializeParser.swift +// TablePro +// + +import Foundation + +internal indirect enum PhpValue: Equatable { + case null + case bool(Bool) + case int(Int) + case float(Double) + case string(String) + case array([PhpKeyValue]) + case object(className: String, properties: [PhpProperty]) + case serializable(className: String, rawPayload: String) + case reference(id: Int) + case unsupported(token: String) + case depthExceeded + case tooLarge + + static func == (lhs: PhpValue, rhs: PhpValue) -> Bool { + switch (lhs, rhs) { + case (.null, .null): return true + case (.bool(let a), .bool(let b)): return a == b + case (.int(let a), .int(let b)): return a == b + case (.float(let a), .float(let b)): + if a.isNaN && b.isNaN { return true } + return a == b + case (.string(let a), .string(let b)): return a == b + case (.array(let a), .array(let b)): return a == b + case (.object(let aName, let aProps), .object(let bName, let bProps)): + return aName == bName && aProps == bProps + case (.serializable(let aName, let aPayload), .serializable(let bName, let bPayload)): + return aName == bName && aPayload == bPayload + case (.reference(let a), .reference(let b)): return a == b + case (.unsupported(let a), .unsupported(let b)): return a == b + case (.depthExceeded, .depthExceeded): return true + case (.tooLarge, .tooLarge): return true + default: return false + } + } +} + +internal struct PhpKeyValue: Equatable { + let key: PhpValue + let value: PhpValue +} + +internal struct PhpProperty: Equatable { + let name: String + let visibility: PhpVisibility + let value: PhpValue +} + +internal enum PhpVisibility: Equatable { + case publicVisibility + case protectedVisibility + case privateVisibility(className: String) +} + +internal enum PhpSerializeParser { + static let sizeCapBytes = 5_000_000 + static let depthCap = 256 + + static func looksLikePhpSerialized(_ value: String) -> Bool { + guard !value.isEmpty else { return false } + guard (value as NSString).length <= sizeCapBytes else { return false } + guard let first = value.unicodeScalars.first else { return false } + let validFirst: Set = ["N", "b", "i", "d", "s", "S", "a", "O", "C", "o", "r", "R"] + guard validFirst.contains(first) else { return false } + let bytes = Array(value.utf8) + guard bytes.count >= 2 else { return false } + if bytes[0] == UInt8(ascii: "N") { + return bytes[1] == UInt8(ascii: ";") + } + return bytes.count >= 3 && bytes[1] == UInt8(ascii: ":") + } + + static func parse(_ value: String) -> PhpValue? { + guard !value.isEmpty else { return nil } + guard (value as NSString).length <= sizeCapBytes else { return nil } + var cursor = Cursor(bytes: Array(value.utf8)) + guard let result = cursor.parseValue(depth: 0) else { return nil } + return result + } +} + +private struct Cursor { + let bytes: [UInt8] + var index: Int = 0 + + var isAtEnd: Bool { index >= bytes.count } + + mutating func parseValue(depth: Int) -> PhpValue? { + guard depth <= PhpSerializeParser.depthCap else { return .depthExceeded } + guard index < bytes.count else { return nil } + let token = bytes[index] + switch token { + case UInt8(ascii: "N"): return parseNull() + case UInt8(ascii: "b"): return parseBool() + case UInt8(ascii: "i"): return parseInt() + case UInt8(ascii: "d"): return parseFloat() + case UInt8(ascii: "s"), UInt8(ascii: "S"): return parseString(token: token) + case UInt8(ascii: "a"): return parseArray(depth: depth) + case UInt8(ascii: "O"): return parseObject(depth: depth) + case UInt8(ascii: "C"): return parseSerializable() + case UInt8(ascii: "o"): + return .unsupported(token: "o") + case UInt8(ascii: "r"), UInt8(ascii: "R"): return parseReference() + default: return nil + } + } + + private mutating func expect(_ byte: UInt8) -> Bool { + guard index < bytes.count, bytes[index] == byte else { return false } + index += 1 + return true + } + + private mutating func parseNull() -> PhpValue? { + index += 1 + guard expect(UInt8(ascii: ";")) else { return nil } + return .null + } + + private mutating func parseBool() -> PhpValue? { + index += 1 + guard expect(UInt8(ascii: ":")), index < bytes.count else { return nil } + let digit = bytes[index] + guard digit == UInt8(ascii: "0") || digit == UInt8(ascii: "1") else { return nil } + index += 1 + guard expect(UInt8(ascii: ";")) else { return nil } + return .bool(digit == UInt8(ascii: "1")) + } + + private mutating func parseInt() -> PhpValue? { + index += 1 + guard expect(UInt8(ascii: ":")) else { return nil } + guard let raw = readUntil(UInt8(ascii: ";")) else { return nil } + guard let value = Int(raw) else { return nil } + guard expect(UInt8(ascii: ";")) else { return nil } + return .int(value) + } + + private mutating func parseFloat() -> PhpValue? { + index += 1 + guard expect(UInt8(ascii: ":")) else { return nil } + guard let raw = readUntil(UInt8(ascii: ";")) else { return nil } + guard expect(UInt8(ascii: ";")) else { return nil } + switch raw { + case "INF": return .float(.infinity) + case "-INF": return .float(-.infinity) + case "NAN": return .float(.nan) + default: + guard let value = Double(raw) else { return nil } + return .float(value) + } + } + + private mutating func parseString(token: UInt8) -> PhpValue? { + index += 1 + guard expect(UInt8(ascii: ":")) else { return nil } + guard let lengthRaw = readUntil(UInt8(ascii: ":")) else { return nil } + guard let length = Int(lengthRaw), length >= 0 else { return nil } + guard expect(UInt8(ascii: ":")), expect(UInt8(ascii: "\"")) else { return nil } + guard index + length <= bytes.count else { return nil } + guard let decoded = String(bytes: bytes[index..<(index + length)], encoding: .utf8) else { return nil } + index += length + guard expect(UInt8(ascii: "\"")), expect(UInt8(ascii: ";")) else { return nil } + return .string(decoded) + } + + private mutating func parseArray(depth: Int) -> PhpValue? { + index += 1 + guard expect(UInt8(ascii: ":")) else { return nil } + guard let countRaw = readUntil(UInt8(ascii: ":")) else { return nil } + guard let count = Int(countRaw), count >= 0 else { return nil } + guard expect(UInt8(ascii: ":")), expect(UInt8(ascii: "{")) else { return nil } + + var entries: [PhpKeyValue] = [] + entries.reserveCapacity(count) + for _ in 0.. PhpValue? { + index += 1 + guard expect(UInt8(ascii: ":")) else { return nil } + guard let nameLengthRaw = readUntil(UInt8(ascii: ":")) else { return nil } + guard let nameLength = Int(nameLengthRaw), nameLength >= 0 else { return nil } + guard expect(UInt8(ascii: ":")), expect(UInt8(ascii: "\"")) else { return nil } + guard index + nameLength <= bytes.count else { return nil } + guard let className = String(bytes: bytes[index..<(index + nameLength)], encoding: .utf8) else { return nil } + index += nameLength + guard expect(UInt8(ascii: "\"")), expect(UInt8(ascii: ":")) else { return nil } + guard let countRaw = readUntil(UInt8(ascii: ":")) else { return nil } + guard let count = Int(countRaw), count >= 0 else { return nil } + guard expect(UInt8(ascii: ":")), expect(UInt8(ascii: "{")) else { return nil } + + var properties: [PhpProperty] = [] + properties.reserveCapacity(count) + for _ in 0.. PhpValue? { + index += 1 + guard expect(UInt8(ascii: ":")) else { return nil } + guard let nameLengthRaw = readUntil(UInt8(ascii: ":")) else { return nil } + guard let nameLength = Int(nameLengthRaw), nameLength >= 0 else { return nil } + guard expect(UInt8(ascii: ":")), expect(UInt8(ascii: "\"")) else { return nil } + guard index + nameLength <= bytes.count else { return nil } + guard let className = String(bytes: bytes[index..<(index + nameLength)], encoding: .utf8) else { return nil } + index += nameLength + guard expect(UInt8(ascii: "\"")), expect(UInt8(ascii: ":")) else { return nil } + guard let payloadLengthRaw = readUntil(UInt8(ascii: ":")) else { return nil } + guard let payloadLength = Int(payloadLengthRaw), payloadLength >= 0 else { return nil } + guard expect(UInt8(ascii: ":")), expect(UInt8(ascii: "{")) else { return nil } + guard index + payloadLength <= bytes.count else { return nil } + let payload = String(bytes: bytes[index..<(index + payloadLength)], encoding: .utf8) ?? "" + index += payloadLength + guard expect(UInt8(ascii: "}")) else { return nil } + return .serializable(className: className, rawPayload: payload) + } + + private mutating func parseReference() -> PhpValue? { + index += 1 + guard expect(UInt8(ascii: ":")) else { return nil } + guard let raw = readUntil(UInt8(ascii: ";")) else { return nil } + guard let value = Int(raw) else { return nil } + guard expect(UInt8(ascii: ";")) else { return nil } + return .reference(id: value) + } + + private mutating func readUntil(_ terminator: UInt8) -> String? { + let start = index + while index < bytes.count, bytes[index] != terminator { + index += 1 + } + guard index < bytes.count else { return nil } + return String(bytes: bytes[start.. (name: String, visibility: PhpVisibility) { + let scalars = Array(raw.unicodeScalars) + guard scalars.first?.value == 0 else { + return (raw, .publicVisibility) + } + let secondNullIndex = scalars.dropFirst().firstIndex { $0.value == 0 } + guard let nullIndex = secondNullIndex, nullIndex < scalars.count - 1 else { + return (raw, .publicVisibility) + } + let middle = String(String.UnicodeScalarView(scalars[1.. PhpTreeNode { + var nodeCount = 0 + return makeNode(key: nil, keyPath: "$", value: phpValue, nodeCount: &nodeCount) + } + + private static func makeNode( + key: String?, + keyPath: String, + value: PhpValue, + nodeCount: inout Int, + visibility: PhpVisibility = .publicVisibility + ) -> PhpTreeNode { + nodeCount += 1 + let badge = visibilityBadge(for: visibility) + + switch value { + case .null: + return PhpTreeNode( + key: key, keyPath: keyPath, nodeType: .null, + displayValue: "null", visibilityBadge: badge + ) + + case .bool(let flag): + return PhpTreeNode( + key: key, keyPath: keyPath, nodeType: .bool, + displayValue: flag ? "true" : "false", visibilityBadge: badge + ) + + case .int(let intValue): + return PhpTreeNode( + key: key, keyPath: keyPath, nodeType: .int, + displayValue: String(intValue), visibilityBadge: badge + ) + + case .float(let doubleValue): + return PhpTreeNode( + key: key, keyPath: keyPath, nodeType: .float, + displayValue: floatDisplay(doubleValue), visibilityBadge: badge + ) + + case .string(let stringValue): + return PhpTreeNode( + key: key, keyPath: keyPath, nodeType: .string, + displayValue: stringDisplay(stringValue), visibilityBadge: badge + ) + + case .array(let entries): + var children: [PhpTreeNode] = [] + children.reserveCapacity(entries.count) + for entry in entries { + if nodeCount >= maxNodes { + children.append(truncationNode(remaining: entries.count - children.count)) + break + } + let childKey = arrayKeyDisplay(entry.key) + let childPath = arrayChildPath(parent: keyPath, key: entry.key) + children.append( + makeNode(key: childKey, keyPath: childPath, value: entry.value, nodeCount: &nodeCount) + ) + } + return PhpTreeNode( + key: key, keyPath: keyPath, nodeType: .array, + displayValue: "[\(entries.count) items]", + visibilityBadge: badge, children: children + ) + + case .object(let className, let properties): + var children: [PhpTreeNode] = [] + children.reserveCapacity(properties.count) + for property in properties { + if nodeCount >= maxNodes { + children.append(truncationNode(remaining: properties.count - children.count)) + break + } + let childPath = "\(keyPath).\(property.name)" + children.append( + makeNode( + key: property.name, + keyPath: childPath, + value: property.value, + nodeCount: &nodeCount, + visibility: property.visibility + ) + ) + } + return PhpTreeNode( + key: key, keyPath: keyPath, nodeType: .object, + displayValue: "\(className) {\(properties.count) properties}", + visibilityBadge: badge, children: children + ) + + case .serializable(let className, let rawPayload): + return PhpTreeNode( + key: key, keyPath: keyPath, nodeType: .serializable, + displayValue: serializableDisplay(className: className, payload: rawPayload), + visibilityBadge: badge + ) + + case .reference(let identifier): + return PhpTreeNode( + key: key, keyPath: keyPath, nodeType: .reference, + displayValue: "→ #\(identifier)", visibilityBadge: badge + ) + + case .unsupported(let token): + return PhpTreeNode( + key: key, keyPath: keyPath, nodeType: .unsupported, + displayValue: String(format: String(localized: "Unsupported token: %@"), token), + visibilityBadge: badge + ) + + case .depthExceeded: + return PhpTreeNode( + key: key, keyPath: keyPath, nodeType: .truncated, + displayValue: String(localized: "Maximum depth reached"), + visibilityBadge: badge + ) + + case .tooLarge: + return PhpTreeNode( + key: key, keyPath: keyPath, nodeType: .truncated, + displayValue: String(localized: "Value too large to parse"), + visibilityBadge: badge + ) + } + } + + private static func truncationNode(remaining: Int) -> PhpTreeNode { + PhpTreeNode( + key: nil, keyPath: "", nodeType: .truncated, + displayValue: "… (\(remaining) more)" + ) + } + + private static func arrayKeyDisplay(_ key: PhpValue) -> String { + switch key { + case .int(let intValue): return "[\(intValue)]" + case .string(let stringValue): return stringValue + default: return "?" + } + } + + private static func arrayChildPath(parent: String, key: PhpValue) -> String { + switch key { + case .int(let intValue): return "\(parent)[\(intValue)]" + case .string(let stringValue): return "\(parent).\(stringValue)" + default: return parent + } + } + + private static func visibilityBadge(for visibility: PhpVisibility) -> String? { + switch visibility { + case .publicVisibility: return nil + case .protectedVisibility: return String(localized: "protected") + case .privateVisibility(let className): + return String(format: String(localized: "private (%@)"), className) + } + } + + private static func stringDisplay(_ value: String) -> String { + let escaped = value.replacingOccurrences(of: "\"", with: "\\\"") + let length = (escaped as NSString).length + if length > 80 { + let head = (escaped as NSString).substring(to: 80) + return "\"\(head)...\"" + } + return "\"\(escaped)\"" + } + + private static func floatDisplay(_ value: Double) -> String { + if value.isNaN { return "NAN" } + if value.isInfinite { return value > 0 ? "INF" : "-INF" } + return String(value) + } + + private static func serializableDisplay(className: String, payload: String) -> String { + let length = (payload as NSString).length + if length > 80 { + let head = (payload as NSString).substring(to: 80) + return "\(className) \(head)..." + } + return "\(className) \(payload)" + } +} diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 28500ba9d..d9f25edc0 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -1719,6 +1719,16 @@ }, "%d selected" : { + }, + "%d-%d of ? rows" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d-%2$d of ? rows" + } + } + } }, "%d-%d of %@%@ rows" : { "localizations" : { @@ -1929,6 +1939,7 @@ } }, "%lld of %lld" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5367,6 +5378,9 @@ } } } + }, + "All rows…" : { + }, "ALL SCHEMAS" : { "extractionState" : "stale", @@ -9461,6 +9475,9 @@ } } } + }, + "Clear Results" : { + }, "Clear search" : { "extractionState" : "stale", @@ -10164,6 +10181,9 @@ }, "cloudflared was not found. Install it with `brew install cloudflared`, or set its path in the connection's Cloudflare Tunnel settings." : { + }, + "cloudflared was not found. Set its path below first." : { + }, "CMD" : { "extractionState" : "stale", @@ -13034,6 +13054,16 @@ }, "Could Not Reset Sample" : { + }, + "Could not run the credential_process command for profile \"%@\": %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Could not run the credential_process command for profile \"%1$@\": %2$@" + } + } + } }, "Could not save the connection. Check disk space and permissions, then try again." : { @@ -13987,6 +14017,9 @@ }, "Custom Slash Commands" : { + }, + "Custom…" : { + }, "Customization" : { @@ -22115,6 +22148,12 @@ }, "First column" : { + }, + "First page" : { + + }, + "First Page" : { + }, "Fit to Window" : { "localizations" : { @@ -22401,6 +22440,7 @@ } }, "Format JSON" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -23023,6 +23063,9 @@ } } } + }, + "Go to page" : { + }, "Go to Settings…" : { "localizations" : { @@ -25788,6 +25831,9 @@ } } } + }, + "Invalid PHP Serialized Value" : { + }, "Invalid plugin bundle: %@" : { "localizations" : { @@ -26626,6 +26672,12 @@ }, "Last Activity" : { + }, + "Last page" : { + + }, + "Last Page" : { + }, "Last query execution summary" : { "localizations" : { @@ -27229,6 +27281,7 @@ } }, "Limit" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -27726,6 +27779,9 @@ } } } + }, + "Loading tables…" : { + }, "Loading..." : { "localizations" : { @@ -28401,6 +28457,9 @@ } } } + }, + "Maximum depth reached" : { + }, "Maximum entries cannot be negative" : { "localizations" : { @@ -30113,6 +30172,9 @@ } } } + }, + "Next page" : { + }, "Next Page" : { "localizations" : { @@ -30137,6 +30199,7 @@ } }, "Next Page (⌘])" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -30677,6 +30740,9 @@ }, "No databases match “%@”" : { + }, + "No Datasets" : { + }, "No DDL available" : { "localizations" : { @@ -31652,6 +31718,9 @@ } } } + }, + "No tables" : { + }, "No Tables" : { "extractionState" : "stale", @@ -32566,6 +32635,7 @@ } }, "Offset" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -32749,6 +32819,9 @@ } } } + }, + "Open %@ in New Tab" : { + }, "Open a connection to insert" : { @@ -33710,6 +33783,9 @@ } } } + }, + "Page number" : { + }, "Page Size" : { "localizations" : { @@ -33806,6 +33882,7 @@ } }, "Pagination Settings" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -34460,6 +34537,15 @@ } } } + }, + "PHP — %@" : { + + }, + "PHP Serialized" : { + + }, + "PHP Viewer" : { + }, "Pick the type of database you want to connect to." : { @@ -35800,6 +35886,9 @@ } } } + }, + "Previous page" : { + }, "Previous Page" : { "localizations" : { @@ -35824,6 +35913,7 @@ } }, "Previous Page (⌘[)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -36049,6 +36139,9 @@ } } } + }, + "private (%@)" : { + }, "Private Key" : { "localizations" : { @@ -36232,7 +36325,7 @@ "Profile \"%@\" not found in ~/.aws/config." : { }, - "Profile \"%@\" was not found or is missing keys in ~/.aws/credentials." : { + "Profile \"%@\" was not found, or has no access keys or credential_process, in ~/.aws/config or ~/.aws/credentials." : { }, "Profile Details" : { @@ -36369,6 +36462,9 @@ }, "Prompt template" : { + }, + "protected" : { + }, "Providers" : { "localizations" : { @@ -39858,6 +39954,9 @@ } } } + }, + "Rows per page" : { + }, "Rules" : { @@ -42692,6 +42791,9 @@ } } } + }, + "Show All Rows" : { + }, "Show All Tables" : { "extractionState" : "stale", @@ -44561,6 +44663,9 @@ } } } + }, + "SSH tunnel disconnected. Click to reconnect." : { + }, "SSH tunneling and SSL/TLS encryption support" : { "localizations" : { @@ -47260,6 +47365,32 @@ } } }, + "The credential_process command for profile \"%@\" did not return valid credentials JSON." : { + + }, + "The credential_process command for profile \"%@\" exited with status %lld.%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The credential_process command for profile \"%1$@\" exited with status %2$lld.%3$@" + } + } + } + }, + "The credential_process command for profile \"%@\" is empty or invalid." : { + + }, + "The credential_process command for profile \"%@\" returned unsupported Version %lld (expected 1)." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The credential_process command for profile \"%1$@\" returned unsupported Version %2$lld (expected 1)." + } + } + } + }, "The destructive query to execute" : { }, @@ -47609,6 +47740,9 @@ } } } + }, + "The value could not be parsed. Use raw mode to inspect it as text." : { + }, "Theme" : { "extractionState" : "stale", @@ -48256,6 +48390,9 @@ } } } + }, + "This project has no datasets yet." : { + }, "This query may permanently modify or delete data." : { "localizations" : { @@ -48379,6 +48516,9 @@ } } } + }, + "This value is too large to parse. Use raw mode to inspect it as text." : { + }, "This Week" : { "localizations" : { @@ -48467,6 +48607,9 @@ } } } + }, + "This will load all %@ rows on a single page. Large result sets use significant memory. Continue?" : { + }, "This will permanently delete %lld %@. This action cannot be undone." : { "localizations" : { @@ -50745,6 +50888,9 @@ } } } + }, + "Unsupported token: %@" : { + }, "Untitled" : { "localizations" : { @@ -51515,6 +51661,12 @@ } } } + }, + "Value Too Large" : { + + }, + "Value too large to parse" : { + }, "VERBOSE (print progress)" : { "localizations" : { diff --git a/TablePro/Views/Results/CellInteractionResolver.swift b/TablePro/Views/Results/CellInteractionResolver.swift index 1812195d8..6df8a3a3b 100644 --- a/TablePro/Views/Results/CellInteractionResolver.swift +++ b/TablePro/Views/Results/CellInteractionResolver.swift @@ -11,12 +11,39 @@ internal struct CellContext: Equatable { let isTableEditable: Bool let isRowDeleted: Bool let isImmutableColumn: Bool + let columnName: String? + let connectionId: UUID? + let tableName: String? + let displayFormatOverride: ValueDisplayFormat? + + init( + columnType: ColumnType?, + value: String?, + isTableEditable: Bool, + isRowDeleted: Bool, + isImmutableColumn: Bool, + columnName: String? = nil, + connectionId: UUID? = nil, + tableName: String? = nil, + displayFormatOverride: ValueDisplayFormat? = nil + ) { + self.columnType = columnType + self.value = value + self.isTableEditable = isTableEditable + self.isRowDeleted = isRowDeleted + self.isImmutableColumn = isImmutableColumn + self.columnName = columnName + self.connectionId = connectionId + self.tableName = tableName + self.displayFormatOverride = displayFormatOverride + } } internal enum CellInteractionMode: Equatable { case viewInline(value: String) case viewJson case viewBlob + case viewPhpSerialized case editInline(value: String) case editOverlay(value: String) @@ -32,22 +59,41 @@ internal struct CellInteractionResolver { let isReadOnly = !context.isTableEditable || context.isImmutableColumn - if isReadOnly { - if let columnType = context.columnType { - if columnType.isBlobType { return .viewBlob } - if columnType.isJsonType { return .viewJson } + if let override = context.displayFormatOverride { + switch override { + case .raw: + return plainText(for: context, isReadOnly: isReadOnly) + case .json: + return isReadOnly ? .viewJson : .editJson + case .phpSerialized: + return .viewPhpSerialized + case .uuid, .unixTimestamp, .unixTimestampMillis: + break } - return .viewInline(value: context.value ?? "NULL") } if let columnType = context.columnType { - if columnType.isBlobType { return .editBlob } - if columnType.isJsonType { return .editJson } + if columnType.isBlobType { return isReadOnly ? .viewBlob : .editBlob } + if columnType.isJsonType { return isReadOnly ? .viewJson : .editJson } } + let value = context.value ?? "" + switch CellValueContentDetector.detect(value) { + case .json: + return isReadOnly ? .viewJson : .editJson + case .phpSerialized: + return .viewPhpSerialized + case .plain: + return plainText(for: context, isReadOnly: isReadOnly) + } + } + + private func plainText(for context: CellContext, isReadOnly: Bool) -> CellInteractionMode { + if isReadOnly { + return .viewInline(value: context.value ?? "NULL") + } let value = context.value ?? "" if value.containsLineBreak { return .editOverlay(value: value) } - if value.looksLikeJson { return .editJson } return .editInline(value: value) } } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Click.swift b/TablePro/Views/Results/Extensions/DataGridView+Click.swift index fd07215a1..3c432422a 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Click.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Click.swift @@ -30,6 +30,8 @@ extension TableViewCoordinator { showJSONViewerPopover(tableView: tableView, row: row, column: tableColumn, columnIndex: columnIndex) case .viewBlob: showBlobViewerPopover(tableView: tableView, row: row, column: tableColumn, columnIndex: columnIndex) + case .viewPhpSerialized: + showPhpViewerPopover(tableView: tableView, row: row, column: tableColumn, columnIndex: columnIndex) case .editInline: beginCellEdit(row: row, tableColumnIndex: tableColumn) case .editOverlay(let value): @@ -48,13 +50,22 @@ extension TableViewCoordinator { let columnName = tableRows.columns[columnIndex] let columnType = columnIndex < tableRows.columnTypes.count ? tableRows.columnTypes[columnIndex] : nil let immutable = databaseType.map { PluginManager.shared.immutableColumns(for: $0) } ?? [] + let override = ValueDisplayFormatService.shared.effectiveFormat( + columnName: columnName, + connectionId: connectionId, + tableName: tableName + ) return CellContext( columnType: columnType, value: cellValue(at: row, column: columnIndex), isTableEditable: isEditable, isRowDeleted: changeManager.isRowDeleted(row), - isImmutableColumn: immutable.contains(columnName) + isImmutableColumn: immutable.contains(columnName), + columnName: columnName, + connectionId: connectionId, + tableName: tableName, + displayFormatOverride: override ) } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index 19c2546cf..238dfd8d6 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -347,6 +347,32 @@ extension TableViewCoordinator { } } + func showPhpViewerPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { + let currentValue = cellValue(at: row, column: columnIndex) + let tableRows = tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } + let columnName = tableRows.columns[columnIndex] + + guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } + + let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column)) + PopoverPresenter.show( + relativeTo: cellRect, + of: tableView, + contentSize: NSSize(width: 560, height: 360) + ) { dismiss in + PhpViewerContentView( + initialValue: currentValue, + columnName: columnName, + onDismiss: dismiss, + onPopOut: { currentText in + dismiss() + PhpViewerWindowController.open(text: currentText, columnName: columnName) + } + ) + } + } + func showBlobViewerPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { let currentValue = blobStringValue(at: row, columnIndex: columnIndex) diff --git a/TablePro/Views/Results/PhpTreeView.swift b/TablePro/Views/Results/PhpTreeView.swift new file mode 100644 index 000000000..8507504d9 --- /dev/null +++ b/TablePro/Views/Results/PhpTreeView.swift @@ -0,0 +1,233 @@ +// +// PhpTreeView.swift +// TablePro +// + +import Foundation +import SwiftUI + +internal struct PhpTreeView: View { + let rootNode: PhpTreeNode + @Binding var searchText: String + + @State private var expandedNodeIDs: Set = [] + + var body: some View { + VStack(spacing: 0) { + treeToolbar + Divider() + List { + PhpTreeContentView( + nodes: filteredRootNodes, + expandedNodeIDs: $expandedNodeIDs, + onExpandAll: expandAll, + onCollapseAll: collapseAll + ) + } + .listStyle(.inset(alternatesRowBackgrounds: true)) + } + .onAppear { expandRootLevel() } + .onChange(of: searchText) { expandMatchingNodes() } + } + + // MARK: - Toolbar + + private var treeToolbar: some View { + HStack(spacing: 6) { + NativeSearchField( + text: $searchText, + placeholder: String(localized: "Filter keys or values..."), + controlSize: .small + ) + Button(action: expandAll) { + Image(systemName: "rectangle.expand.vertical") + } + .buttonStyle(.borderless) + .help(String(localized: "Expand All")) + Button(action: collapseAll) { + Image(systemName: "rectangle.compress.vertical") + } + .buttonStyle(.borderless) + .help(String(localized: "Collapse All")) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + + // MARK: - Filtering + + private var filteredRootNodes: [PhpTreeNode] { + let nodes = rootNode.children.isEmpty ? [rootNode] : rootNode.children + if searchText.isEmpty { return nodes } + return Self.filterNodes(nodes, matching: searchText) + } + + private static func filterNodes(_ nodes: [PhpTreeNode], matching query: String) -> [PhpTreeNode] { + nodes.compactMap { node in + let keyMatches = node.key?.localizedCaseInsensitiveContains(query) ?? false + let valueMatches = node.displayValue.localizedCaseInsensitiveContains(query) + let filteredChildren = filterNodes(node.children, matching: query) + + if !filteredChildren.isEmpty { + return PhpTreeNode( + id: node.id, key: node.key, keyPath: node.keyPath, + nodeType: node.nodeType, displayValue: node.displayValue, + visibilityBadge: node.visibilityBadge, children: filteredChildren + ) + } + if keyMatches || valueMatches { + return PhpTreeNode( + id: node.id, key: node.key, keyPath: node.keyPath, + nodeType: node.nodeType, displayValue: node.displayValue, + visibilityBadge: node.visibilityBadge, children: [] + ) + } + return nil + } + } + + private func expandMatchingNodes() { + if searchText.isEmpty { + expandedNodeIDs.removeAll() + expandRootLevel() + return + } + expandedNodeIDs.formUnion(collectMatchingContainerIDs(filteredRootNodes)) + } + + private func collectMatchingContainerIDs(_ nodes: [PhpTreeNode]) -> Set { + var ids: Set = [] + for node in nodes where !node.children.isEmpty { + ids.insert(node.id) + ids.formUnion(collectMatchingContainerIDs(node.children)) + } + return ids + } + + // MARK: - Actions + + private func expandAll() { + withAnimation(nil) { expandedNodeIDs = collectAllContainerIDs(rootNode) } + } + + private func collapseAll() { + withAnimation(nil) { expandedNodeIDs.removeAll() } + } + + private func expandRootLevel() { + for child in rootNode.children where !child.children.isEmpty { + expandedNodeIDs.insert(child.id) + } + } + + private func collectAllContainerIDs(_ node: PhpTreeNode) -> Set { + var ids: Set = [] + if !node.children.isEmpty { + ids.insert(node.id) + for child in node.children { + ids.formUnion(collectAllContainerIDs(child)) + } + } + return ids + } +} + +// MARK: - Recursive Tree Content + +private struct PhpTreeContentView: View { + let nodes: [PhpTreeNode] + @Binding var expandedNodeIDs: Set + let onExpandAll: () -> Void + let onCollapseAll: () -> Void + + var body: some View { + ForEach(nodes) { node in + if node.children.isEmpty { + PhpTreeRowView(node: node) + .contextMenu { nodeContextMenu(for: node) } + } else { + DisclosureGroup( + isExpanded: Binding( + get: { expandedNodeIDs.contains(node.id) }, + set: { expanded in + if expanded { expandedNodeIDs.insert(node.id) } else { expandedNodeIDs.remove(node.id) } + } + ) + ) { + PhpTreeContentView( + nodes: node.children, + expandedNodeIDs: $expandedNodeIDs, + onExpandAll: onExpandAll, + onCollapseAll: onCollapseAll + ) + } label: { + PhpTreeRowView(node: node) + .contextMenu { nodeContextMenu(for: node) } + } + } + } + } + + @ViewBuilder + private func nodeContextMenu(for node: PhpTreeNode) -> some View { + Button(String(localized: "Copy Value")) { + ClipboardService.shared.writeText(node.displayValue) + } + if !node.keyPath.isEmpty { + Button(String(localized: "Copy Key Path")) { + ClipboardService.shared.writeText(node.keyPath) + } + } + if let key = node.key { + Button(String(localized: "Copy Key")) { + ClipboardService.shared.writeText(key) + } + } + Divider() + if !node.children.isEmpty { + Button(String(localized: "Expand All")) { onExpandAll() } + Button(String(localized: "Collapse All")) { onCollapseAll() } + } + } +} + +// MARK: - Row View + +private struct PhpTreeRowView: View { + let node: PhpTreeNode + + var body: some View { + HStack(spacing: 4) { + if let key = node.key { + Text(key) + .font(.system(.body, design: .monospaced).weight(.medium)) + .foregroundStyle(.blue) + .lineLimit(1) + if let badge = node.visibilityBadge { + Text(badge) + .font(.caption2) + .foregroundStyle(.secondary) + } + Text(":") + .foregroundStyle(.secondary) + } + Text(node.displayValue) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(Color(nsColor: node.nodeType.color)) + .lineLimit(1) + Spacer(minLength: 4) + TypeBadge(node.nodeType.badgeLabel) + } + .padding(.vertical, 1) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + } + + private var accessibilityLabel: String { + let prefix = node.key ?? "" + let head = (node.displayValue as NSString).length > 120 + ? (node.displayValue as NSString).substring(to: 120) + : node.displayValue + return "\(prefix), \(node.nodeType.badgeLabel), \(head)" + } +} diff --git a/TablePro/Views/Results/PhpViewerContentView.swift b/TablePro/Views/Results/PhpViewerContentView.swift new file mode 100644 index 000000000..e5be0b8b0 --- /dev/null +++ b/TablePro/Views/Results/PhpViewerContentView.swift @@ -0,0 +1,35 @@ +// +// PhpViewerContentView.swift +// TablePro +// + +import SwiftUI + +struct PhpViewerContentView: View { + let initialValue: String? + let columnName: String? + let onDismiss: () -> Void + var onPopOut: ((String) -> Void)? + + init( + initialValue: String?, + columnName: String? = nil, + onDismiss: @escaping () -> Void, + onPopOut: ((String) -> Void)? = nil + ) { + self.initialValue = initialValue + self.columnName = columnName + self.onDismiss = onDismiss + self.onPopOut = onPopOut + } + + var body: some View { + PhpViewerView( + rawValue: initialValue ?? "", + onDismiss: onDismiss, + onPopOut: onPopOut + ) + .frame(width: 560) + .frame(minHeight: 200, maxHeight: 480) + } +} diff --git a/TablePro/Views/Results/PhpViewerView.swift b/TablePro/Views/Results/PhpViewerView.swift new file mode 100644 index 000000000..83f8f98f0 --- /dev/null +++ b/TablePro/Views/Results/PhpViewerView.swift @@ -0,0 +1,145 @@ +// +// PhpViewerView.swift +// TablePro +// + +import SwiftUI + +internal enum PhpViewMode: String, CaseIterable { + case tree + case raw +} + +internal enum PhpParseResult: Equatable { + case idle + case parsing + case parsed(PhpTreeNode) + case tooLarge + case failed + + static func == (lhs: PhpParseResult, rhs: PhpParseResult) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle), (.parsing, .parsing), (.tooLarge, .tooLarge), (.failed, .failed): + return true + case (.parsed(let a), .parsed(let b)): + return a.id == b.id + default: + return false + } + } +} + +@MainActor +internal struct PhpViewerView: View { + let rawValue: String + var onDismiss: (() -> Void)? + var onPopOut: ((String) -> Void)? + + @State private var viewMode: PhpViewMode = .tree + @State private var parseResult: PhpParseResult = .idle + @State private var searchText: String = "" + + var body: some View { + VStack(spacing: 0) { + viewerToolbar + Divider() + viewerContent + } + .task(id: rawValue) { + await loadParse() + } + } + + // MARK: - Toolbar + + private var viewerToolbar: some View { + HStack(spacing: 8) { + Picker("", selection: $viewMode) { + Text(String(localized: "Tree")).tag(PhpViewMode.tree) + Text(String(localized: "Raw")).tag(PhpViewMode.raw) + } + .pickerStyle(.segmented) + .fixedSize() + Spacer() + if let onPopOut { + Button { onPopOut(rawValue) } label: { + Image(systemName: "arrow.up.forward.app") + } + .buttonStyle(.borderless) + .help(String(localized: "Open in Window")) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + } + + // MARK: - Content + + @ViewBuilder + private var viewerContent: some View { + switch viewMode { + case .tree: + treeBody + case .raw: + rawBody + } + } + + @ViewBuilder + private var treeBody: some View { + switch parseResult { + case .idle, .parsing: + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .parsed(let node): + PhpTreeView(rootNode: node, searchText: $searchText) + case .tooLarge: + errorPlaceholder( + title: String(localized: "Value Too Large"), + detail: String(localized: "This value is too large to parse. Use raw mode to inspect it as text."), + systemImage: "doc.text" + ) + case .failed: + errorPlaceholder( + title: String(localized: "Invalid PHP Serialized Value"), + detail: String(localized: "The value could not be parsed. Use raw mode to inspect it as text."), + systemImage: "exclamationmark.triangle" + ) + } + } + + private var rawBody: some View { + ScrollView { + Text(rawValue) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + } + } + + private func errorPlaceholder(title: String, detail: String, systemImage: String) -> some View { + ContentUnavailableView { + Label(title, systemImage: systemImage) + } description: { + Text(detail) + } + } + + // MARK: - Parse + + private func loadParse() async { + parseResult = .parsing + let raw = rawValue + let parsed = await Task.detached(priority: .userInitiated) { () -> PhpParseResult in + if (raw as NSString).length > PhpSerializeParser.sizeCapBytes { + return .tooLarge + } + guard let value = PhpSerializeParser.parse(raw) else { return .failed } + let tree = PhpTreeBuilder.build(from: value) + return .parsed(tree) + }.value + parseResult = parsed + } +} diff --git a/TablePro/Views/Results/PhpViewerWindowController.swift b/TablePro/Views/Results/PhpViewerWindowController.swift new file mode 100644 index 000000000..eb53e301d --- /dev/null +++ b/TablePro/Views/Results/PhpViewerWindowController.swift @@ -0,0 +1,84 @@ +// +// PhpViewerWindowController.swift +// TablePro +// + +import AppKit +import SwiftUI + +@MainActor +final class PhpViewerWindowController { + private static var activeWindows: [ObjectIdentifier: PhpViewerWindowController] = [:] + private static let defaultSize = NSSize(width: 640, height: 500) + private static let minSize = NSSize(width: 400, height: 300) + private static let autosaveName: NSWindow.FrameAutosaveName = "PhpViewerWindow" + + private var window: NSWindow? + private var closeObserver: NSObjectProtocol? + + static func open(text: String?, columnName: String?) { + let controller = PhpViewerWindowController() + controller.showWindow(text: text, columnName: columnName) + } + + private func showWindow(text: String?, columnName: String?) { + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: Self.defaultSize), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.identifier = NSUserInterfaceItemIdentifier("php-viewer") + if let columnName { + window.title = String(format: String(localized: "PHP — %@"), columnName) + } else { + window.title = String(localized: "PHP Viewer") + } + window.isReleasedWhenClosed = false + window.minSize = Self.minSize + window.collectionBehavior = [.fullScreenPrimary] + + let closeWindow: () -> Void = { [weak window] in window?.close() } + let contentView = PhpViewerWindowContent( + initialValue: text, + onDismiss: closeWindow + ) + window.contentView = NSHostingView(rootView: contentView) + + self.window = window + + let key = ObjectIdentifier(self) + Self.activeWindows[key] = self + + closeObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + Self.activeWindows.removeValue(forKey: key) + self?.closeObserver.map { NotificationCenter.default.removeObserver($0) } + self?.closeObserver = nil + self?.window = nil + } + } + + window.applyAutosaveName(Self.autosaveName) + window.makeKeyAndOrderFront(nil) + } +} + +// MARK: - Window Content + +private struct PhpViewerWindowContent: View { + let initialValue: String? + let onDismiss: (() -> Void)? + + var body: some View { + PhpViewerView( + rawValue: initialValue ?? "", + onDismiss: onDismiss, + onPopOut: nil + ) + } +} diff --git a/TablePro/Views/RightSidebar/EditableFieldView.swift b/TablePro/Views/RightSidebar/EditableFieldView.swift index 84c096fb6..8f0d36da6 100644 --- a/TablePro/Views/RightSidebar/EditableFieldView.swift +++ b/TablePro/Views/RightSidebar/EditableFieldView.swift @@ -109,6 +109,8 @@ internal struct FieldDetailView: View { switch kind { case .json: return context.isReadOnly ? 60 : 80 + case .phpSerialized: + return 80 case .blobHex: return 60 default: @@ -123,6 +125,8 @@ internal struct FieldDetailView: View { switch kind { case .json: JsonEditorView(context: context, onExpand: onExpand, onPopOut: onPopOut) + case .phpSerialized: + PhpSerializedFieldView(context: context, onExpand: onExpand, onPopOut: onPopOut) case .blobHex: BlobHexEditorView(context: context) case .boolean: diff --git a/TablePro/Views/RightSidebar/FieldEditors/PhpSerializedFieldView.swift b/TablePro/Views/RightSidebar/FieldEditors/PhpSerializedFieldView.swift new file mode 100644 index 000000000..b699a34bb --- /dev/null +++ b/TablePro/Views/RightSidebar/FieldEditors/PhpSerializedFieldView.swift @@ -0,0 +1,35 @@ +// +// PhpSerializedFieldView.swift +// TablePro +// + +import SwiftUI + +internal struct PhpSerializedFieldView: View { + let context: FieldEditorContext + var onExpand: (() -> Void)? + var onPopOut: ((String) -> Void)? + + var body: some View { + PhpViewerView( + rawValue: context.value.wrappedValue, + onPopOut: onPopOut + ) + .frame(minHeight: 80, maxHeight: 200) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .overlay(alignment: .bottomTrailing) { + if let onExpand { + Button(action: onExpand) { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.caption2) + .padding(4) + .themeMaterial(.inlineControl, .ultraThinMaterial, in: RoundedRectangle(cornerRadius: 4)) + } + .buttonStyle(.borderless) + .help(String(localized: "Expand in Sidebar")) + .padding(4) + } + } + } +} diff --git a/TablePro/Views/RightSidebar/RightSidebarView.swift b/TablePro/Views/RightSidebar/RightSidebarView.swift index 8b162d0bf..90495798b 100644 --- a/TablePro/Views/RightSidebar/RightSidebarView.swift +++ b/TablePro/Views/RightSidebar/RightSidebarView.swift @@ -19,6 +19,7 @@ struct RightSidebarView: View { @State private var searchText: String = "" @State private var expandedJsonFieldId: UUID? + @State private var expandedPhpFieldId: UUID? // MARK: - Inspector Mode @@ -133,6 +134,10 @@ struct RightSidebarView: View { let field = editState.fields.first(where: { $0.id == expandedId }) { expandedJsonViewer(field: field, isEditable: contentMode == .editRow) .onChange(of: selectedRowData?.count) { expandedJsonFieldId = nil } + } else if let expandedId = expandedPhpFieldId, + let field = editState.fields.first(where: { $0.id == expandedId }) { + expandedPhpViewer(field: field) + .onChange(of: selectedRowData?.count) { expandedPhpFieldId = nil } } else { fieldListForm(rowData) } @@ -197,6 +202,50 @@ struct RightSidebarView: View { ) } + // MARK: - Expanded PHP Viewer + + private func expandedPhpViewer(field: FieldEditState) -> some View { + VStack(spacing: 0) { + HStack { + Button { expandedPhpFieldId = nil } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + Text("Fields") + } + } + .buttonStyle(.borderless) + + Spacer() + + Text(field.columnName) + .font(.headline) + + Spacer() + + Button { + popOutPhpField(field: field) + } label: { + Image(systemName: "arrow.up.forward.app") + } + .buttonStyle(.borderless) + .help(String(localized: "Open in Window")) + + TypeBadge(field.columnTypeEnum.badgeLabel) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + + Divider() + + PhpViewerView(rawValue: field.pendingValue ?? field.originalValue ?? "") + } + } + + private func popOutPhpField(text: String? = nil, field: FieldEditState) { + let text = text ?? field.pendingValue ?? field.originalValue + PhpViewerWindowController.open(text: text, columnName: field.columnName) + } + // MARK: - Field List private func fieldListForm( @@ -247,11 +296,14 @@ struct RightSidebarView: View { @ViewBuilder private func fieldDetailRow(_ field: FieldEditState, at index: Int, isEditable: Bool) -> some View { - let isJsonField = FieldEditorResolver.resolve( + let kind = FieldEditorResolver.resolve( for: field.columnTypeEnum, isLongText: field.isLongText, originalValue: field.originalValue - ) == .json + ) + let isJsonField = kind == .json + let isPhpField = kind == .phpSerialized + let isStructuredField = isJsonField || isPhpField FieldDetailView( context: FieldEditorContext( @@ -264,7 +316,7 @@ struct RightSidebarView: View { ) : .constant(field.originalValue ?? ""), originalValue: field.originalValue, hasMultipleValues: field.hasMultipleValues, - isReadOnly: !isEditable, + isReadOnly: !isEditable || isPhpField, commitBytes: isEditable ? { data in editState.setFieldToBytes(at: index, data: data) } : nil ), isPendingNull: field.isPendingNull, @@ -277,9 +329,19 @@ struct RightSidebarView: View { onSetFunction: { editState.setFieldToFunction(at: index, function: $0) }, isPrimaryKey: field.isPrimaryKey, isForeignKey: field.isForeignKey, - onExpand: isJsonField ? { expandedJsonFieldId = field.id } : nil, - onPopOut: isJsonField ? { currentText in - popOutJsonField(text: currentText, field: field, isEditable: isEditable) + onExpand: isStructuredField ? { + if isJsonField { + expandedJsonFieldId = field.id + } else { + expandedPhpFieldId = field.id + } + } : nil, + onPopOut: isStructuredField ? { currentText in + if isJsonField { + popOutJsonField(text: currentText, field: field, isEditable: isEditable) + } else { + popOutPhpField(text: currentText, field: field) + } } : nil ) } diff --git a/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift b/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift index 853edbbb6..b6ae4eb18 100644 --- a/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift +++ b/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift @@ -2,8 +2,11 @@ // FieldEditorResolver.swift // TablePro +import Foundation + internal enum FieldEditorKind: Equatable { case json + case phpSerialized case blobHex case boolean case enumPicker(values: [String]) @@ -14,9 +17,35 @@ internal enum FieldEditorKind: Equatable { @MainActor internal enum FieldEditorResolver { - static func resolve(for type: ColumnType, isLongText: Bool, originalValue: String?) -> FieldEditorKind { - if type.isJsonType || (originalValue ?? "").looksLikeJson { - return .json + static func resolve( + for type: ColumnType, + isLongText: Bool, + originalValue: String?, + displayFormatOverride: ValueDisplayFormat? = nil + ) -> FieldEditorKind { + let structuredAllowed: Bool + if let override = displayFormatOverride { + switch override { + case .raw: + structuredAllowed = false + case .phpSerialized: + return .phpSerialized + case .json: + return .json + case .uuid, .unixTimestamp, .unixTimestampMillis: + structuredAllowed = true + } + } else { + structuredAllowed = true + } + + if structuredAllowed { + if type.isJsonType || (originalValue ?? "").looksLikeJson { + return .json + } + if CellValueContentDetector.detect(originalValue ?? "") == .phpSerialized { + return .phpSerialized + } } if type.isEnumType, let values = type.enumValues, !values.isEmpty { return .enumPicker(values: values) diff --git a/TableProTests/Core/Services/Formatting/CellValueContentDetectorTests.swift b/TableProTests/Core/Services/Formatting/CellValueContentDetectorTests.swift new file mode 100644 index 000000000..fa3631eee --- /dev/null +++ b/TableProTests/Core/Services/Formatting/CellValueContentDetectorTests.swift @@ -0,0 +1,88 @@ +// +// CellValueContentDetectorTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("CellValueContentDetector") +struct CellValueContentDetectorTests { + @Test("empty string is plain") + func emptyIsPlain() { + #expect(CellValueContentDetector.detect("") == .plain) + } + + @Test("JSON object is detected") + func jsonObjectDetected() { + #expect(CellValueContentDetector.detect(#"{"a":1}"#) == .json) + } + + @Test("JSON array is detected") + func jsonArrayDetected() { + #expect(CellValueContentDetector.detect("[1,2,3]") == .json) + } + + @Test("Invalid JSON falls through to plain") + func invalidJsonIsPlain() { + #expect(CellValueContentDetector.detect("{not json") == .plain) + } + + @Test("PHP null is detected") + func phpNullDetected() { + #expect(CellValueContentDetector.detect("N;") == .phpSerialized) + } + + @Test("PHP array is detected") + func phpArrayDetected() { + #expect(CellValueContentDetector.detect("a:0:{}") == .phpSerialized) + } + + @Test("PHP object is detected") + func phpObjectDetected() { + #expect(CellValueContentDetector.detect("O:4:\"User\":0:{}") == .phpSerialized) + } + + @Test("plain text starting with s: stays plain when not PHP-shaped") + func plainSPrefix() { + #expect(CellValueContentDetector.detect("some text") == .plain) + } + + @Test("plain text starting with a stays plain") + func plainAPrefix() { + #expect(CellValueContentDetector.detect("a quick brown fox") == .plain) + } + + @Test("plain JSON-looking text without object braces is plain") + func barePrimitiveIsPlain() { + #expect(CellValueContentDetector.detect("hello world") == .plain) + #expect(CellValueContentDetector.detect("123") == .plain) + } + + @Test("English text starting with any PHP token character stays plain") + func englishStartingWithPhpTokenChars() { + #expect(CellValueContentDetector.detect("because of this") == .plain) + #expect(CellValueContentDetector.detect("it works correctly") == .plain) + #expect(CellValueContentDetector.detect("data not loaded") == .plain) + #expect(CellValueContentDetector.detect("Some upper-case text") == .plain) + #expect(CellValueContentDetector.detect("Other text starting with O") == .plain) + #expect(CellValueContentDetector.detect("Custom message here") == .plain) + #expect(CellValueContentDetector.detect("offset = 0") == .plain) + #expect(CellValueContentDetector.detect("running test") == .plain) + #expect(CellValueContentDetector.detect("Remote URL") == .plain) + #expect(CellValueContentDetector.detect("No data found") == .plain) + } + + @Test("malformed but PHP-prefix shaped text is detected as PHP (parser rejects later)") + func malformedPhpStillDetected() { + #expect(CellValueContentDetector.detect("s:99:\"short\";") == .phpSerialized) + } + + @Test("value above 5 MB is plain regardless of shape") + func sizeCapEnforced() { + let huge = String(repeating: "a", count: 5_000_001) + #expect(CellValueContentDetector.detect(huge) == .plain) + } +} diff --git a/TableProTests/Core/Services/Formatting/PhpSerializeParserTests.swift b/TableProTests/Core/Services/Formatting/PhpSerializeParserTests.swift new file mode 100644 index 000000000..b9696b8d1 --- /dev/null +++ b/TableProTests/Core/Services/Formatting/PhpSerializeParserTests.swift @@ -0,0 +1,275 @@ +// +// PhpSerializeParserTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("PhpSerializeParser - scalar tokens") +struct PhpSerializeParserScalarTests { + @Test("null token parses to .null") + func nullToken() { + #expect(PhpSerializeParser.parse("N;") == .null) + } + + @Test("boolean true parses to .bool(true)") + func boolTrue() { + #expect(PhpSerializeParser.parse("b:1;") == .bool(true)) + } + + @Test("boolean false parses to .bool(false)") + func boolFalse() { + #expect(PhpSerializeParser.parse("b:0;") == .bool(false)) + } + + @Test("integer parses to .int") + func integer() { + #expect(PhpSerializeParser.parse("i:42;") == .int(42)) + } + + @Test("negative integer parses to .int") + func negativeInteger() { + #expect(PhpSerializeParser.parse("i:-7;") == .int(-7)) + } + + @Test("float parses to .float") + func float() { + #expect(PhpSerializeParser.parse("d:3.14;") == .float(3.14)) + } + + @Test("INF parses to positive infinity") + func floatInfinity() { + guard case let .float(value)? = PhpSerializeParser.parse("d:INF;") else { + Issue.record("expected float") + return + } + #expect(value.isInfinite && value > 0) + } + + @Test("-INF parses to negative infinity") + func floatNegativeInfinity() { + guard case let .float(value)? = PhpSerializeParser.parse("d:-INF;") else { + Issue.record("expected float") + return + } + #expect(value.isInfinite && value < 0) + } + + @Test("NAN parses to .nan") + func floatNan() { + guard case let .float(value)? = PhpSerializeParser.parse("d:NAN;") else { + Issue.record("expected float") + return + } + #expect(value.isNaN) + } +} + +@Suite("PhpSerializeParser - strings") +struct PhpSerializeParserStringTests { + @Test("ASCII string parses") + func asciiString() { + #expect(PhpSerializeParser.parse("s:5:\"hello\";") == .string("hello")) + } + + @Test("empty string parses") + func emptyString() { + #expect(PhpSerializeParser.parse("s:0:\"\";") == .string("")) + } + + @Test("S token parses identically to s") + func capitalS() { + #expect(PhpSerializeParser.parse("S:5:\"hello\";") == .string("hello")) + } + + @Test("multi-byte UTF-8 string respects byte length") + func multiByteString() { + let utf8Bytes = Array("héllo".utf8) + let serialized = "s:\(utf8Bytes.count):\"héllo\";" + #expect(PhpSerializeParser.parse(serialized) == .string("héllo")) + } + + @Test("declared length mismatch returns nil") + func lengthMismatch() { + #expect(PhpSerializeParser.parse("s:10:\"hi\";") == nil) + } + + @Test("string containing quotes parses") + func stringWithQuotes() { + let raw = "say \"hi\"" + let bytes = Array(raw.utf8) + let serialized = "s:\(bytes.count):\"\(raw)\";" + #expect(PhpSerializeParser.parse(serialized) == .string(raw)) + } +} + +@Suite("PhpSerializeParser - arrays") +struct PhpSerializeParserArrayTests { + @Test("empty array parses") + func emptyArray() { + #expect(PhpSerializeParser.parse("a:0:{}") == .array([])) + } + + @Test("integer-keyed array parses") + func intKeyedArray() { + let result = PhpSerializeParser.parse("a:2:{i:0;s:1:\"a\";i:1;s:1:\"b\";}") + guard case let .array(entries)? = result else { + Issue.record("expected array") + return + } + #expect(entries.count == 2) + #expect(entries[0].key == .int(0)) + #expect(entries[0].value == .string("a")) + #expect(entries[1].key == .int(1)) + #expect(entries[1].value == .string("b")) + } + + @Test("string-keyed array preserves source order") + func stringKeyedArrayOrder() { + let result = PhpSerializeParser.parse( + "a:3:{s:1:\"z\";i:1;s:1:\"a\";i:2;s:1:\"m\";i:3;}" + ) + guard case let .array(entries)? = result else { + Issue.record("expected array") + return + } + #expect(entries.count == 3) + #expect(entries[0].key == .string("z")) + #expect(entries[1].key == .string("a")) + #expect(entries[2].key == .string("m")) + } + + @Test("nested array parses") + func nestedArray() { + let result = PhpSerializeParser.parse("a:1:{i:0;a:1:{i:0;i:42;}}") + guard case let .array(outer)? = result, + outer.count == 1, + case let .array(inner) = outer[0].value, + inner.count == 1, + case .int(42) = inner[0].value else { + Issue.record("expected nested array structure") + return + } + } +} + +@Suite("PhpSerializeParser - objects") +struct PhpSerializeParserObjectTests { + @Test("object with public property") + func publicProperty() { + let result = PhpSerializeParser.parse("O:4:\"User\":1:{s:4:\"name\";s:3:\"Bob\";}") + guard case let .object(className, properties)? = result else { + Issue.record("expected object") + return + } + #expect(className == "User") + #expect(properties.count == 1) + #expect(properties[0].name == "name") + #expect(properties[0].visibility == .publicVisibility) + #expect(properties[0].value == .string("Bob")) + } + + @Test("protected property decodes mangling") + func protectedProperty() { + let nullByte = "\u{0000}" + let mangled = "\(nullByte)*\(nullByte)secret" + let mangledBytes = Array(mangled.utf8) + let serialized = "O:4:\"User\":1:{s:\(mangledBytes.count):\"\(mangled)\";s:5:\"value\";}" + let result = PhpSerializeParser.parse(serialized) + guard case let .object(_, properties)? = result, properties.count == 1 else { + Issue.record("expected one property") + return + } + #expect(properties[0].name == "secret") + #expect(properties[0].visibility == .protectedVisibility) + } + + @Test("private property decodes mangling with class name") + func privateProperty() { + let nullByte = "\u{0000}" + let mangled = "\(nullByte)User\(nullByte)secret" + let mangledBytes = Array(mangled.utf8) + let serialized = "O:4:\"User\":1:{s:\(mangledBytes.count):\"\(mangled)\";s:5:\"value\";}" + let result = PhpSerializeParser.parse(serialized) + guard case let .object(_, properties)? = result, properties.count == 1 else { + Issue.record("expected one property") + return + } + #expect(properties[0].name == "secret") + #expect(properties[0].visibility == .privateVisibility(className: "User")) + } +} + +@Suite("PhpSerializeParser - special tokens") +struct PhpSerializeParserSpecialTests { + @Test("C token returns .serializable with class + payload") + func serializableToken() { + let result = PhpSerializeParser.parse("C:3:\"Foo\":5:{xyzab}") + guard case let .serializable(className, payload)? = result else { + Issue.record("expected serializable") + return + } + #expect(className == "Foo") + #expect(payload == "xyzab") + } + + @Test("r token returns .reference") + func referenceLowerR() { + #expect(PhpSerializeParser.parse("r:7;") == .reference(id: 7)) + } + + @Test("R token returns .reference") + func referenceUpperR() { + #expect(PhpSerializeParser.parse("R:42;") == .reference(id: 42)) + } + + @Test("o (PHP-3) token returns .unsupported") + func oToken() { + guard case let .unsupported(token)? = PhpSerializeParser.parse("o:0:\"X\":0:{}") else { + Issue.record("expected unsupported") + return + } + #expect(token == "o") + } + + @Test("truncated input returns nil") + func truncatedInput() { + #expect(PhpSerializeParser.parse("s:5:\"he") == nil) + } + + @Test("malformed token returns nil") + func malformedInput() { + #expect(PhpSerializeParser.parse("x:1;") == nil) + } + + @Test("empty string returns nil") + func emptyInput() { + #expect(PhpSerializeParser.parse("") == nil) + } +} + +@Suite("PhpSerializeParser - depth cap") +struct PhpSerializeParserDepthTests { + @Test("looksLikePhpSerialized accepts valid PHP-like prefix") + func looksLikePositive() { + #expect(PhpSerializeParser.looksLikePhpSerialized("N;")) + #expect(PhpSerializeParser.looksLikePhpSerialized("a:0:{}")) + #expect(PhpSerializeParser.looksLikePhpSerialized("s:5:\"hello\";")) + } + + @Test("looksLikePhpSerialized rejects unrelated content") + func looksLikeNegative() { + #expect(!PhpSerializeParser.looksLikePhpSerialized("")) + #expect(!PhpSerializeParser.looksLikePhpSerialized("hello world")) + #expect(!PhpSerializeParser.looksLikePhpSerialized("{\"a\":1}")) + } + + @Test("looksLikePhpSerialized rejects unlikely first-char text") + func looksLikeRejectsAmbiguous() { + #expect(!PhpSerializeParser.looksLikePhpSerialized("a quick brown fox")) + #expect(!PhpSerializeParser.looksLikePhpSerialized("some text")) + } +} diff --git a/TableProTests/Core/Services/Formatting/ValueDisplayFormatTests.swift b/TableProTests/Core/Services/Formatting/ValueDisplayFormatTests.swift new file mode 100644 index 000000000..7fb4f5120 --- /dev/null +++ b/TableProTests/Core/Services/Formatting/ValueDisplayFormatTests.swift @@ -0,0 +1,63 @@ +// +// ValueDisplayFormatTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("ValueDisplayFormat") +struct ValueDisplayFormatTests { + @Test("rawValue strings stay stable") + func rawValueStability() { + #expect(ValueDisplayFormat.raw.rawValue == "raw") + #expect(ValueDisplayFormat.uuid.rawValue == "uuid") + #expect(ValueDisplayFormat.unixTimestamp.rawValue == "unixTimestamp") + #expect(ValueDisplayFormat.unixTimestampMillis.rawValue == "unixTimestampMillis") + #expect(ValueDisplayFormat.json.rawValue == "json") + #expect(ValueDisplayFormat.phpSerialized.rawValue == "phpSerialized") + } + + @Test("Codable round-trip preserves value") + func codableRoundTrip() throws { + for format in ValueDisplayFormat.allCases { + let encoded = try JSONEncoder().encode(format) + let decoded = try JSONDecoder().decode(ValueDisplayFormat.self, from: encoded) + #expect(decoded == format) + } + } + + @Test("text column applicable formats include json and phpSerialized") + func applicableForText() { + let formats = ValueDisplayFormat.applicableFormats(for: .text(rawType: "TEXT")) + #expect(formats.contains(.json)) + #expect(formats.contains(.phpSerialized)) + #expect(formats.contains(.uuid)) + #expect(formats.contains(.raw)) + #expect(!formats.contains(.unixTimestamp)) + } + + @Test("integer column applicable formats do not include json or phpSerialized") + func applicableForInteger() { + let formats = ValueDisplayFormat.applicableFormats(for: .integer(rawType: "INT")) + #expect(!formats.contains(.json)) + #expect(!formats.contains(.phpSerialized)) + #expect(formats.contains(.unixTimestamp)) + } + + @Test("blob column does not include json or phpSerialized") + func applicableForBlob() { + let formats = ValueDisplayFormat.applicableFormats(for: .blob(rawType: "BLOB")) + #expect(!formats.contains(.json)) + #expect(!formats.contains(.phpSerialized)) + #expect(formats.contains(.uuid)) + } + + @Test("nil column type returns only raw") + func applicableForNil() { + let formats = ValueDisplayFormat.applicableFormats(for: nil) + #expect(formats == [.raw]) + } +} diff --git a/TableProTests/Views/Results/CellInteractionResolverTests.swift b/TableProTests/Views/Results/CellInteractionResolverTests.swift index 042e4e882..ac903252e 100644 --- a/TableProTests/Views/Results/CellInteractionResolverTests.swift +++ b/TableProTests/Views/Results/CellInteractionResolverTests.swift @@ -71,9 +71,59 @@ struct CellInteractionResolverReadOnlyTests { #expect(resolver.resolve(context) == .viewJson) } - @Test("read-only JSON-looking plain text without columnType returns viewInline, not viewJson") - func readOnlyJsonLikeTextWithoutTypeReturnsViewInline() { + @Test("read-only JSON-looking plain text without columnType returns viewJson via detector") + func readOnlyJsonLikeTextWithoutTypeReturnsViewJson() { let context = ContextFactory.make(value: #"{"k":1}"#, columnType: nil, isTableEditable: false) + #expect(resolver.resolve(context) == .viewJson) + } + + @Test("read-only PHP-shaped plain text returns viewPhpSerialized") + func readOnlyPhpLikeTextReturnsViewPhpSerialized() { + let context = ContextFactory.make(value: "a:0:{}", columnType: .text(rawType: "TEXT"), isTableEditable: false) + #expect(resolver.resolve(context) == .viewPhpSerialized) + } + + @Test("read-only override .raw beats content sniffing") + func readOnlyOverrideRawWins() { + let context = ContextFactory.make( + value: #"{"k":1}"#, + columnType: .text(rawType: "TEXT"), + isTableEditable: false, + displayFormatOverride: .raw + ) + #expect(resolver.resolve(context) == .viewInline(value: #"{"k":1}"#)) + } + + @Test("read-only override .json forces viewJson on non-JSON text") + func readOnlyOverrideJsonForces() { + let context = ContextFactory.make( + value: "plain", + columnType: .text(rawType: "TEXT"), + isTableEditable: false, + displayFormatOverride: .json + ) + #expect(resolver.resolve(context) == .viewJson) + } + + @Test("read-only override .phpSerialized forces viewPhpSerialized") + func readOnlyOverridePhpSerializedForces() { + let context = ContextFactory.make( + value: "plain", + columnType: .text(rawType: "TEXT"), + isTableEditable: false, + displayFormatOverride: .phpSerialized + ) + #expect(resolver.resolve(context) == .viewPhpSerialized) + } + + @Test("read-only override .raw on declared JSON column returns viewInline (override beats type)") + func readOnlyOverrideRawBypassesJsonColumn() { + let context = ContextFactory.make( + value: #"{"k":1}"#, + columnType: .json(rawType: "JSON"), + isTableEditable: false, + displayFormatOverride: .raw + ) #expect(resolver.resolve(context) == .viewInline(value: #"{"k":1}"#)) } } @@ -100,6 +150,65 @@ struct CellInteractionResolverEditableTests { #expect(resolver.resolve(context) == .editJson) } + @Test("editable PHP-shaped text returns viewPhpSerialized (read-only)") + func editablePhpLikeTextReturnsView() { + let context = ContextFactory.make(value: "a:0:{}", isTableEditable: true) + #expect(resolver.resolve(context) == .viewPhpSerialized) + } + + @Test("editable override .phpSerialized forces viewPhpSerialized") + func editableOverridePhpForces() { + let context = ContextFactory.make( + value: "plain", + isTableEditable: true, + displayFormatOverride: .phpSerialized + ) + #expect(resolver.resolve(context) == .viewPhpSerialized) + } + + @Test("editable override .json forces editJson") + func editableOverrideJsonForces() { + let context = ContextFactory.make( + value: "plain", + isTableEditable: true, + displayFormatOverride: .json + ) + #expect(resolver.resolve(context) == .editJson) + } + + @Test("editable override .raw bypasses JSON content detection") + func editableOverrideRawSkipsJson() { + let context = ContextFactory.make( + value: #"{"k":1}"#, + columnType: .text(rawType: "TEXT"), + isTableEditable: true, + displayFormatOverride: .raw + ) + #expect(resolver.resolve(context) == .editInline(value: #"{"k":1}"#)) + } + + @Test("editable override .raw bypasses PHP content detection") + func editableOverrideRawSkipsPhp() { + let context = ContextFactory.make( + value: "a:0:{}", + columnType: .text(rawType: "TEXT"), + isTableEditable: true, + displayFormatOverride: .raw + ) + #expect(resolver.resolve(context) == .editInline(value: "a:0:{}")) + } + + @Test("editable override .raw on multiline value returns editOverlay") + func editableOverrideRawMultilineReturnsOverlay() { + let context = ContextFactory.make( + value: "line1\nline2", + columnType: .text(rawType: "TEXT"), + isTableEditable: true, + displayFormatOverride: .raw + ) + #expect(resolver.resolve(context) == .editOverlay(value: "line1\nline2")) + } + @Test("editable JSON column returns editJson") func editableJsonColumnReturnsEditJson() { let context = ContextFactory.make(value: "{}", columnType: .json(rawType: "JSON"), isTableEditable: true) @@ -147,14 +256,16 @@ private enum ContextFactory { columnType: ColumnType? = nil, isTableEditable: Bool = false, isRowDeleted: Bool = false, - isImmutableColumn: Bool = false + isImmutableColumn: Bool = false, + displayFormatOverride: ValueDisplayFormat? = nil ) -> CellContext { CellContext( columnType: columnType, value: value, isTableEditable: isTableEditable, isRowDeleted: isRowDeleted, - isImmutableColumn: isImmutableColumn + isImmutableColumn: isImmutableColumn, + displayFormatOverride: displayFormatOverride ) } } diff --git a/TableProTests/Views/Shared/FieldEditorResolverTests.swift b/TableProTests/Views/Shared/FieldEditorResolverTests.swift new file mode 100644 index 000000000..158240377 --- /dev/null +++ b/TableProTests/Views/Shared/FieldEditorResolverTests.swift @@ -0,0 +1,105 @@ +// +// FieldEditorResolverTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +@Suite("FieldEditorResolver") +struct FieldEditorResolverTests { + @Test("JSON column resolves to .json") + func jsonColumnReturnsJson() { + let kind = FieldEditorResolver.resolve( + for: .json(rawType: "JSON"), + isLongText: false, + originalValue: "{}" + ) + #expect(kind == .json) + } + + @Test("text column with JSON-shaped value resolves to .json") + func jsonShapedTextReturnsJson() { + let kind = FieldEditorResolver.resolve( + for: .text(rawType: "TEXT"), + isLongText: false, + originalValue: #"{"k":1}"# + ) + #expect(kind == .json) + } + + @Test("text column with PHP-shaped value resolves to .phpSerialized") + func phpShapedTextReturnsPhpSerialized() { + let kind = FieldEditorResolver.resolve( + for: .text(rawType: "TEXT"), + isLongText: false, + originalValue: "a:0:{}" + ) + #expect(kind == .phpSerialized) + } + + @Test("override .phpSerialized forces .phpSerialized") + func overridePhpSerializedWins() { + let kind = FieldEditorResolver.resolve( + for: .text(rawType: "TEXT"), + isLongText: false, + originalValue: "not php", + displayFormatOverride: .phpSerialized + ) + #expect(kind == .phpSerialized) + } + + @Test("override .json forces .json on non-JSON text") + func overrideJsonWins() { + let kind = FieldEditorResolver.resolve( + for: .text(rawType: "TEXT"), + isLongText: false, + originalValue: "plain text", + displayFormatOverride: .json + ) + #expect(kind == .json) + } + + @Test("override .raw skips structured detection for PHP") + func overrideRawSkipsPhp() { + let kind = FieldEditorResolver.resolve( + for: .text(rawType: "TEXT"), + isLongText: false, + originalValue: "a:0:{}", + displayFormatOverride: .raw + ) + #expect(kind != .phpSerialized) + } + + @Test("boolean column resolves to .boolean") + func booleanColumn() { + let kind = FieldEditorResolver.resolve( + for: .boolean(rawType: "BOOL"), + isLongText: false, + originalValue: "1" + ) + #expect(kind == .boolean) + } + + @Test("long text resolves to .multiLine") + func longTextMultiLine() { + let kind = FieldEditorResolver.resolve( + for: .text(rawType: "TEXT"), + isLongText: true, + originalValue: "long content" + ) + #expect(kind == .multiLine) + } + + @Test("short plain text resolves to .singleLine") + func plainSingleLine() { + let kind = FieldEditorResolver.resolve( + for: .text(rawType: "VARCHAR"), + isLongText: false, + originalValue: "short" + ) + #expect(kind == .singleLine) + } +} diff --git a/docs/features/json-viewer.mdx b/docs/features/json-viewer.mdx index b08995c6c..642ed1b89 100644 --- a/docs/features/json-viewer.mdx +++ b/docs/features/json-viewer.mdx @@ -12,7 +12,9 @@ Open JSON values from cells in two view modes: Text and Tree. - Double-click a cell whose value parses as JSON, or - Click the chevron in a JSON-typed column. -The viewer opens as a popover anchored to the cell. +The viewer opens as a popover anchored to the cell. JSON values stored in a `TEXT` or `VARCHAR` column open the viewer the same way; double-click runs a parse and routes to the structured viewer when the value parses cleanly. + +To opt out for a specific column, right-click the column header and pick **Display as > Raw Value**. Pick **Display as > JSON** to force JSON routing on a column whose values do not start with `{` or `[`. ## Modes diff --git a/docs/features/php-viewer.mdx b/docs/features/php-viewer.mdx new file mode 100644 index 000000000..8f84f48ab --- /dev/null +++ b/docs/features/php-viewer.mdx @@ -0,0 +1,43 @@ +--- +title: PHP Serialized Viewer +description: Inspect PHP serialize() values from cells as a collapsible tree, with a raw text fallback. +--- + +# PHP Serialized Viewer + +Read PHP `serialize()` output stored in a text column as a structured tree, without pulling the value into a separate tool. + +## Open + +- Double-click a cell whose value parses as PHP serialized data, or +- Right-click the column header and pick **Display as > PHP Serialized** to force the viewer on a column whose values do not auto-detect. + +The viewer opens as a popover anchored to the cell. Detection runs only when you open the cell, so the grid scroll path stays fast. + +## Modes + +Switch with the segmented control in the viewer toolbar. + +- **Tree**: collapsible tree with a search field. Each node shows its key, value, and a type badge (`str`, `int`, `arr`, `obj`, `ser`, `ref`). +- **Raw**: the original serialized string with text selection. Use this when the value fails to parse or when you want to copy the exact bytes. + +The viewer is read-only. PHP serialized values round-trip through PHP itself, so TablePro does not write them back. + +## Object members + +Object properties show their visibility next to the key: + +- Public members have no badge. +- Protected members show a `protected` badge. +- Private members show `private (ClassName)` so you can tell whose private slot you are looking at. + +Classes implementing `Serializable` (the `C:` token) show as a single leaf with the class name and the raw payload, since the payload is opaque to TablePro. Reference tokens (`r:` / `R:`) show as `→ #N` and are not followed. + +## Limits + +- Values above 5 MB are not parsed and the viewer drops to raw mode. +- The tree is capped at 5000 nodes and 256 levels of nesting; anything beyond is shown as a truncation marker. + +## Opt out + +Pick **Display as > Raw Value** on the column header to disable structured viewing for that column. The cell then opens in the standard text viewer.