Here’s a collection of handlers based on the idea of deriving a mutable array of mutable arrays from the CSV text, performing any required edits in the array, and then creating a new text from the array. There are CSVToArray() and arrayToCSV() handlers (returning an NSMutableArray and NSMutableString respectively) and handlers for inserting and deleting rows and columns from the array. Individual field values can be set using AppleScript values and syntax and it’s up to the scripter how they load and store the CSV text.
Unfortunately, the CSVToArray() handler doesn’t guess the field delimiter, but assumes it’s a comma unless told otherwise. The record separator can be either (return & linefeed)
, linefeed
, or return
. The arrayToCSV() handler’s defaults are comma and (return & linefeed)
respectively unless specified otherwise via its separators
parameter.
(* Assumes that the CSV text follows the RFC 4180 convention:
Records are delimited by CRLF line breaks (but LFs or CRs are OK too here).
The last record may or may not be followed by a line break.
All the records have the same number of fields.
Fields in the same record are separated by commas (but a different separator can be specified here).
The last field in a record is NOT followed by a field separator.
Any field value may be enclosed in double-quotes and MUST be if its value contains line breaks, separator characters, or double-quote characters.
Double-quotes within quoted fields are escaped as pairs of double-quotes.
Trailing or leading spaces in unquoted fields are included in the field values.
Spaces (or anything else!) outside the quotes of quoted fields are not allowed.
No other variations are currently supported. *)
use AppleScript version "2.4" -- Yosemite (10.10) or later
use framework "Foundation"
use scripting additions
property |⌘| : current application
property regex : a reference to |⌘|'s NSRegularExpressionSearch
demo()
on demo()
set CSV to "One,two,three,four
1,2,3,4
\"5,000\", 60 ,\"7,890\",
111,222,333,444
555,\"5, 6, or 7\",777,888"
set table to CSVToArray(CSV, {}) -- same as: CSVToArray(CSV, {fieldSeparator:","})
-- return (table as list)
--> {{"One", "two", "three", "four"}, {"1", "2", "3", "4"}, {"5,000", " 60 ", "7,890", ""}, {"111", "222", "333", "444"}, {"555", "5, 6, or 7", "777", "888"}}
deleteRow(table, 4)
insertRow(table, 2)
insertColumn(table, 2)
set table's item 1's item 2 to "The proverbial \"1a\"" -- Field 2 of record 1. Entered with AS text and syntax.
set CSV to arrayToCSV(table, {}) -- same as: arrayToCSV(table, {fieldSeparator:",", recordSeparator:(return & linefeed)})
return CSV as text
(*
--> "One,\"The proverbial \"\"1a\"\"\",two,three,four
,,,,
1,,2,3,4
\"5,000\",, 60 ,\"7,890\",
555,,\"5, 6, or 7\",777,888"
*)
end demo
(* Derive an NSMutableArray of NSMutableArrays from a CSV text. *)
on CSVToArray(CSV, separator) -- (CSV text or NSString, record with optional fieldSeparator property)
set {fieldSeparator:fieldSeparator} to separator & {fieldSeparator:","} -- Given or default field separator.
set CSV to (|⌘|'s class "NSMutableString"'s stringWithString:(CSV))
-- Strip any trailing line breaks.
CSV's replaceOccurrencesOfString:("\\R++\\Z") withString:("") options:(regex) range:({0, CSV's |length|()})
-- If the very first field's empty, insert an additional field separator at the beginning to make the field visible to the regex stuff below.
if ((CSV's rangeOfString:(fieldSeparator & "|\\R") options:(regex))'s location() is 0) then CSV's insertString:(fieldSeparator) atIndex:(0)
-- Get all matches for a regex pattern having a capture group for the separator or text start before a field and a capture group for the field's value.
-- The field value is either quoted text or an optional run of non-separator characters.
set fieldPattern to "(" & fieldSeparator & "|\\R|\\A)(\"(?:[^\"]|\"\")*+\"|[^" & fieldSeparator & "\\u000a\\u000d]*+)"
set fieldRegex to (|⌘|'s class "NSRegularExpression"'s regularExpressionWithPattern:(fieldPattern) options:(0) |error|:(missing value))
set CSVRange to {0, CSV's |length|()}
set fieldMatches to fieldRegex's matchesInString:(CSV) options:(0) range:(CSVRange)
-- The number of fields per record is the number of matches before the first match whose first capture group matches a line break.
set fieldsPerRecord to 0
repeat with thisMatch in fieldMatches
tell (thisMatch's rangeAtIndex:(1)) to if ((CSV's rangeOfString:("\\R") options:(regex) range:(it)) = it) then exit repeat
set fieldsPerRecord to fieldsPerRecord + 1
end repeat
-- Replace every match with character id 1 and the field value.
fieldRegex's replaceMatchesInString:(CSV) options:(0) range:(CSVRange) withTemplate:((character id 1) & "$2")
-- Delete any field-enclosing double-quotes.
CSV's replaceOccurrencesOfString:("(?<=\\u0001)\"|\"(?=\\u0001)") withString:("") options:(regex) range:({0, CSV's |length|()})
-- Unescape any CSV-escaped double-quotes.
CSV's replaceOccurrencesOfString:("\"\"") withString:("\"") options:(0) range:({0, CSV's |length|()})
-- Split the string into an array of the field values. (The first item will be a superfluous empty string.)
set allFields to (CSV's componentsSeparatedByString:(character id 1))
-- Transfer the values to an output array in (mutable) arrays of a record's worth of fields at a time.
set array to |⌘|'s class "NSMutableArray"'s arrayWithCapacity:((count allFields) div fieldsPerRecord)
repeat with i from 1 to ((count allFields) - 1) by fieldsPerRecord -- 0-indexed.
(array's addObject:((allFields's subarrayWithRange:({i, fieldsPerRecord}))'s mutableCopy()))
end repeat
return array
end CSVToArray
(* Derive a CSV NSString from an array of arrays or list of lists. *)
on arrayToCSV(array, separators) -- (NSArray or list, record with optional fieldSeparator and recordSeparator properties)
set {fieldSeparator:fieldSeparator, recordSeparator:recordSeparator} to separators & {fieldSeparator:",", recordSeparator:(return & linefeed)} -- Specified or default separators.
set array to |⌘|'s class "NSMutableArray"'s arrayWithArray:(array)
-- Flatten the input array to an NSMutableString of (character id 1)-delimited fields and (character id 2)-delimited records.
repeat with thisRecord in array
set thisRecord's contents to (thisRecord's componentsJoinedByString:(character id 1))
end repeat
set CSV to (array's componentsJoinedByString:(character id 2))'s mutableCopy()
-- Escape any double-quotes in the string.
CSV's replaceOccurrencesOfString:(quote) withString:(quote & quote) options:(0) range:({0, CSV's |length|()})
-- Enquote any fields containing field separator(s), quote(s), and/or line ending(s).
set quoteFieldPattern to "(?<=\\A|\\u0001|\\u0002)[^\\u0001\\u0002" & fieldSeparator & "\"\\u000a\\u000d]*+[" & fieldSeparator & "\"\\u000a\\u000d][^\\u0001\\u0002]*+"
CSV's replaceOccurrencesOfString:(quoteFieldPattern) withString:("\"$0\"") options:(regex) range:({0, CSV's |length|()})
-- Replace the (character id 1)s with field separators.
CSV's replaceOccurrencesOfString:(character id 1) withString:(fieldSeparator) options:(0) range:({0, CSV's |length|()})
-- Replace the (character id 2)s with record separators.
CSV's replaceOccurrencesOfString:(character id 2) withString:(recordSeparator) options:(0) range:({0, CSV's |length|()})
return CSV
end arrayToCSV
(* Insert a row of empty fields into the table created above. *)
on insertRow(table, rowNumber) -- (NSMutable array, 1-based row number)
set columnCount to (count table's first item)
set newRow to |⌘|'s class "NSMutableArray"'s arrayWithCapacity:(columnCount)
repeat columnCount times
newRow's addObject:("")
end repeat
table's insertObject:(newRow) atIndex:(rowNumber - 1)
end insertRow
(* Delete a row from the table. *)
on deleteRow(table, rowNumber) -- (NSMutable array, 1-based row number)
table's removeObjectAtIndex:(rowNumber - 1)
end deleteRow
(* Insert a column of empty fields into the table. *)
on insertColumn(table, columnNumber) -- (NSMutable array, 1-based column number)
repeat with thisRow in table
(thisRow's insertObject:("") atIndex:(columnNumber - 1))
end repeat
end insertColumn
(* Delete a column from from the table. *)
on deleteColumn(table, columnNumber) -- (NSMutable array, 1-based column number)
table's makeObjectsPerformSelector:("removeObjectsAtIndexes:") withObject:(|⌘|'s class "NSIndexSet"'s indexSetWithIndex:(columnNumber - 1))
end deleteColumn