606 lines
17 KiB
Lua
606 lines
17 KiB
Lua
-- Custom Lua helpers for math operations
|
|
function min(a, b) return math.min(a, b) end
|
|
|
|
function max(a, b) return math.max(a, b) end
|
|
|
|
function round(x, n)
|
|
if n == nil then n = 0 end
|
|
return math.floor(x * 10 ^ n + 0.5) / 10 ^ n
|
|
end
|
|
|
|
function floor(x) return math.floor(x) end
|
|
|
|
function ceil(x) return math.ceil(x) end
|
|
|
|
function upper(s) return string.upper(s) end
|
|
|
|
function lower(s) return string.lower(s) end
|
|
|
|
function format(s, ...) return string.format(s, ...) end
|
|
|
|
function trim(s) return string.gsub(s, "^%s*(.-)%s*$", "%1") end
|
|
|
|
-- String split helper
|
|
function strsplit(inputstr, sep)
|
|
if sep == nil then sep = "%s" end
|
|
local t = {}
|
|
for str in string.gmatch(inputstr, "([^" .. sep .. "]+)") do
|
|
table.insert(t, str)
|
|
end
|
|
return t
|
|
end
|
|
|
|
---@param table table
|
|
---@param depth number?
|
|
function dump(table, depth)
|
|
if depth == nil then depth = 0 end
|
|
if depth > 200 then
|
|
print("Error: Depth > 200 in dump()")
|
|
return
|
|
end
|
|
for k, v in pairs(table) do
|
|
if type(v) == "table" then
|
|
print(string.rep(" ", depth) .. k .. ":")
|
|
dump(v, depth + 1)
|
|
else
|
|
print(string.rep(" ", depth) .. k .. ": ", v)
|
|
end
|
|
end
|
|
end
|
|
|
|
--- @class ParserOptions
|
|
--- @field delimiter string? The field delimiter (default: ",").
|
|
--- @field hasheader boolean? If true, first non-comment row is treated as headers (default: false).
|
|
--- @field hascomments boolean? If true, lines starting with '#' are skipped (default: false).
|
|
|
|
--- @type ParserOptions
|
|
parserDefaultOptions = { delimiter = ",", hasheader = false, hascomments = false }
|
|
|
|
--- Validates options against a set of valid option keys.
|
|
--- @param options ParserOptions? The options table to validate
|
|
function areOptionsValid(options)
|
|
if options == nil then return end
|
|
|
|
if type(options) ~= "table" then error("options must be a table") end
|
|
|
|
-- Build valid options list from validOptions table
|
|
local validOptionsStr = ""
|
|
for k, _ in pairs(parserDefaultOptions) do
|
|
validOptionsStr = validOptionsStr .. k .. ", "
|
|
end
|
|
|
|
for k, _ in pairs(options) do
|
|
if parserDefaultOptions[k] == nil then
|
|
error(
|
|
"unknown option: " .. tostring(k) .. " (valid options: " .. validOptionsStr .. ")"
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Parses CSV text into rows and fields using a minimal RFC 4180 state machine.
|
|
---
|
|
--- Requirements/assumptions:
|
|
--- - Input is a single string containing the entire CSV content.
|
|
--- - Field separators are specified by delimiter option (default: comma).
|
|
--- - Newlines between rows may be "\n" or "\r\n". "\r\n" is treated as one line break.
|
|
--- - Fields may be quoted with double quotes (").
|
|
--- - Inside quoted fields, doubled quotes ("") represent a literal quote character.
|
|
--- - No backslash escaping is supported (not part of RFC 4180).
|
|
--- - Newlines inside quoted fields are preserved as part of the field.
|
|
--- - Leading/trailing spaces are preserved; no trimming is performed.
|
|
--- - Empty fields and empty rows are preserved.
|
|
--- - The final row is emitted even if the text does not end with a newline.
|
|
--- - Lines starting with '#' (after optional leading whitespace) are treated as comments and skipped if hascomments is true.
|
|
---
|
|
--- @param csv string The CSV text to parse.
|
|
--- @param options ParserOptions? Options for the parser
|
|
--- @return table #A table (array) of rows; each row is a table with numeric indices and optionally header-named keys.
|
|
function fromCSV(csv, options)
|
|
if options == nil then options = {} end
|
|
|
|
-- Validate options
|
|
areOptionsValid(options)
|
|
|
|
local delimiter = options.delimiter or parserDefaultOptions.delimiter
|
|
local hasheader = options.hasheader or parserDefaultOptions.hasheader
|
|
local hascomments = options.hascomments or parserDefaultOptions.hascomments
|
|
|
|
local allRows = {}
|
|
local fields = {}
|
|
local field = {}
|
|
|
|
local STATE_DEFAULT = 1
|
|
local STATE_IN_QUOTES = 2
|
|
local STATE_QUOTE_IN_QUOTES = 3
|
|
local state = STATE_DEFAULT
|
|
|
|
local i = 1
|
|
local len = #csv
|
|
|
|
while i <= len do
|
|
local c = csv:sub(i, i)
|
|
|
|
if state == STATE_DEFAULT then
|
|
if c == '"' then
|
|
state = STATE_IN_QUOTES
|
|
i = i + 1
|
|
elseif c == delimiter then
|
|
table.insert(fields, table.concat(field))
|
|
field = {}
|
|
i = i + 1
|
|
elseif c == "\r" or c == "\n" then
|
|
table.insert(fields, table.concat(field))
|
|
field = {}
|
|
local shouldAdd = true
|
|
if hascomments and #fields > 0 then
|
|
local firstField = fields[1]
|
|
local trimmed = trim(firstField)
|
|
if string.sub(trimmed, 1, 1) == "#" then shouldAdd = false end
|
|
end
|
|
if shouldAdd then table.insert(allRows, fields) end
|
|
fields = {}
|
|
if c == "\r" and i < len and csv:sub(i + 1, i + 1) == "\n" then
|
|
i = i + 2
|
|
else
|
|
i = i + 1
|
|
end
|
|
else
|
|
table.insert(field, c)
|
|
i = i + 1
|
|
end
|
|
elseif state == STATE_IN_QUOTES then
|
|
if c == '"' then
|
|
state = STATE_QUOTE_IN_QUOTES
|
|
i = i + 1
|
|
else
|
|
table.insert(field, c)
|
|
i = i + 1
|
|
end
|
|
else -- STATE_QUOTE_IN_QUOTES
|
|
if c == '"' then
|
|
table.insert(field, '"')
|
|
state = STATE_IN_QUOTES
|
|
i = i + 1
|
|
elseif c == delimiter then
|
|
table.insert(fields, table.concat(field))
|
|
field = {}
|
|
state = STATE_DEFAULT
|
|
i = i + 1
|
|
elseif c == "\r" or c == "\n" then
|
|
table.insert(fields, table.concat(field))
|
|
field = {}
|
|
local shouldAdd = true
|
|
if hascomments and #fields > 0 then
|
|
local firstField = fields[1]
|
|
local trimmed = string.gsub(firstField, "^%s*(.-)%s*$", "%1")
|
|
if string.sub(trimmed, 1, 1) == "#" then shouldAdd = false end
|
|
end
|
|
if shouldAdd then table.insert(allRows, fields) end
|
|
fields = {}
|
|
state = STATE_DEFAULT
|
|
if c == "\r" and i < len and csv:sub(i + 1, i + 1) == "\n" then
|
|
i = i + 2
|
|
else
|
|
i = i + 1
|
|
end
|
|
else
|
|
state = STATE_DEFAULT
|
|
-- Don't increment i, reprocess character in DEFAULT state
|
|
end
|
|
end
|
|
end
|
|
|
|
if #field > 0 or #fields > 0 then
|
|
table.insert(fields, table.concat(field))
|
|
local shouldAdd = true
|
|
if hascomments and #fields > 0 then
|
|
local firstField = fields[1]
|
|
local trimmed = string.gsub(firstField, "^%s*(.-)%s*$", "%1")
|
|
if string.sub(trimmed, 1, 1) == "#" then shouldAdd = false end
|
|
end
|
|
if shouldAdd then table.insert(allRows, fields) end
|
|
end
|
|
|
|
if hasheader and #allRows > 0 then
|
|
local headers = allRows[1]
|
|
local headerMap = {}
|
|
for j = 1, #headers do
|
|
if headers[j] ~= nil and headers[j] ~= "" then
|
|
local headerName = trim(headers[j])
|
|
headerMap[headerName] = j
|
|
end
|
|
end
|
|
|
|
local header_mt = {
|
|
headers = headerMap,
|
|
__index = function(t, key)
|
|
local mt = getmetatable(t)
|
|
if type(key) == "string" and mt.headers and mt.headers[key] then
|
|
return rawget(t, mt.headers[key])
|
|
end
|
|
return rawget(t, key)
|
|
end,
|
|
__newindex = function(t, key, value)
|
|
local mt = getmetatable(t)
|
|
if type(key) == "string" and mt.headers then
|
|
if mt.headers[key] then
|
|
rawset(t, mt.headers[key], value)
|
|
else
|
|
error("unknown header: " .. tostring(key))
|
|
end
|
|
else
|
|
rawset(t, key, value)
|
|
end
|
|
end,
|
|
}
|
|
|
|
local rows = {}
|
|
for ii = 2, #allRows do
|
|
local row = {}
|
|
local dataRow = allRows[ii]
|
|
for j = 1, #dataRow do
|
|
row[j] = dataRow[j]
|
|
end
|
|
setmetatable(row, header_mt)
|
|
table.insert(rows, row)
|
|
end
|
|
rows.Headers = headers
|
|
return rows
|
|
end
|
|
|
|
return allRows
|
|
end
|
|
|
|
--- Converts a table of rows back to CSV text format (RFC 4180 compliant).
|
|
---
|
|
--- Requirements:
|
|
--- - Input is a table (array) of rows, where each row is a table (array) of field values.
|
|
--- - Field values are converted to strings using tostring().
|
|
--- - Fields are quoted if they contain the delimiter, newlines, or double quotes.
|
|
--- - Double quotes inside quoted fields are doubled ("").
|
|
--- - Fields are joined with the specified delimiter; rows are joined with newlines.
|
|
--- - If includeHeaders is true and rows have a Headers field, headers are included as the first row.
|
|
---
|
|
--- @param rows table Array of rows, where each row is an array of field values.
|
|
--- @param options ParserOptions? Options for the parser
|
|
--- @return string #CSV-formatted text
|
|
function toCSV(rows, options)
|
|
if options == nil then options = {} end
|
|
|
|
-- Validate options
|
|
areOptionsValid(options)
|
|
|
|
local delimiter = options.delimiter or parserDefaultOptions.delimiter
|
|
local includeHeaders = options.hasheader or parserDefaultOptions.hasheader
|
|
local rowStrings = {}
|
|
|
|
-- Include headers row if requested and available
|
|
if includeHeaders and #rows > 0 and rows.Headers ~= nil then
|
|
local headerStrings = {}
|
|
for _, header in ipairs(rows.Headers) do
|
|
local headerStr = tostring(header)
|
|
local needsQuoting = false
|
|
if
|
|
headerStr:find(delimiter)
|
|
or headerStr:find("\n")
|
|
or headerStr:find("\r")
|
|
or headerStr:find('"')
|
|
then
|
|
needsQuoting = true
|
|
end
|
|
if needsQuoting then
|
|
headerStr = headerStr:gsub('"', '""')
|
|
headerStr = '"' .. headerStr .. '"'
|
|
end
|
|
table.insert(headerStrings, headerStr)
|
|
end
|
|
table.insert(rowStrings, table.concat(headerStrings, delimiter))
|
|
end
|
|
|
|
for _, row in ipairs(rows) do
|
|
local fieldStrings = {}
|
|
|
|
for _, field in ipairs(row) do
|
|
local fieldStr = tostring(field)
|
|
local needsQuoting = false
|
|
|
|
if
|
|
fieldStr:find(delimiter)
|
|
or fieldStr:find("\n")
|
|
or fieldStr:find("\r")
|
|
or fieldStr:find('"')
|
|
then
|
|
needsQuoting = true
|
|
end
|
|
|
|
if needsQuoting then
|
|
fieldStr = fieldStr:gsub('"', '""')
|
|
fieldStr = '"' .. fieldStr .. '"'
|
|
end
|
|
|
|
table.insert(fieldStrings, fieldStr)
|
|
end
|
|
|
|
table.insert(rowStrings, table.concat(fieldStrings, delimiter))
|
|
end
|
|
|
|
return table.concat(rowStrings, "\n")
|
|
end
|
|
|
|
-- String to number conversion helper
|
|
function num(str) return tonumber(str) or 0 end
|
|
|
|
-- Number to string conversion
|
|
function str(num) return tostring(num) end
|
|
|
|
-- Check if string is numeric
|
|
function is_number(str) return tonumber(str) ~= nil end
|
|
|
|
function isArray(t)
|
|
if type(t) ~= "table" then return false end
|
|
local max = 0
|
|
local count = 0
|
|
for k, _ in pairs(t) do
|
|
if type(k) ~= "number" or k < 1 or math.floor(k) ~= k then return false end
|
|
max = math.max(max, k)
|
|
count = count + 1
|
|
end
|
|
return max == count
|
|
end
|
|
|
|
modified = false
|
|
|
|
-- ============================================================================
|
|
-- XML HELPER FUNCTIONS
|
|
-- ============================================================================
|
|
|
|
--- Find all elements with a specific tag name (recursive search)
|
|
--- @param root table The root XML element (with _tag, _attr, _children fields)
|
|
--- @param tagName string The tag name to search for
|
|
--- @return table Array of matching elements
|
|
function findElements(root, tagName)
|
|
local results = {}
|
|
|
|
local function search(element)
|
|
if element._tag == tagName then
|
|
table.insert(results, element)
|
|
end
|
|
if element._children then
|
|
for _, child in ipairs(element._children) do
|
|
search(child)
|
|
end
|
|
end
|
|
end
|
|
|
|
search(root)
|
|
return results
|
|
end
|
|
|
|
--- Visit all elements recursively and call a function on each
|
|
--- @param root table The root XML element
|
|
--- @param callback function Function to call with each element: callback(element, depth, path)
|
|
function visitElements(root, callback)
|
|
local function visit(element, depth, path)
|
|
callback(element, depth, path)
|
|
if element._children then
|
|
for i, child in ipairs(element._children) do
|
|
local childPath = path .. "/" .. child._tag .. "[" .. i .. "]"
|
|
visit(child, depth + 1, childPath)
|
|
end
|
|
end
|
|
end
|
|
visit(root, 0, "/" .. root._tag)
|
|
end
|
|
|
|
--- Get numeric value from XML element attribute
|
|
--- @param element table XML element with _attr field
|
|
--- @param attrName string Attribute name
|
|
--- @return number|nil The numeric value or nil if not found/not numeric
|
|
function getNumAttr(element, attrName)
|
|
if not element._attr then return nil end
|
|
local value = element._attr[attrName]
|
|
if not value then return nil end
|
|
return tonumber(value)
|
|
end
|
|
|
|
--- Set numeric value to XML element attribute
|
|
--- @param element table XML element with _attr field
|
|
--- @param attrName string Attribute name
|
|
--- @param value number Numeric value to set
|
|
function setNumAttr(element, attrName, value)
|
|
if not element._attr then
|
|
element._attr = {}
|
|
end
|
|
element._attr[attrName] = tostring(value)
|
|
end
|
|
|
|
--- Modify numeric attribute by applying a function
|
|
--- @param element table XML element
|
|
--- @param attrName string Attribute name
|
|
--- @param func function Function that takes current value and returns new value
|
|
--- @return boolean True if modification was made
|
|
function modifyNumAttr(element, attrName, func)
|
|
local current = getNumAttr(element, attrName)
|
|
if current then
|
|
setNumAttr(element, attrName, func(current))
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
--- Find all elements matching a predicate function
|
|
--- @param root table The root XML element
|
|
--- @param predicate function Function that takes element and returns true/false
|
|
--- @return table Array of matching elements
|
|
function filterElements(root, predicate)
|
|
local results = {}
|
|
visitElements(root, function(element)
|
|
if predicate(element) then
|
|
table.insert(results, element)
|
|
end
|
|
end)
|
|
return results
|
|
end
|
|
|
|
--- Get text content of an element
|
|
--- @param element table XML element
|
|
--- @return string|nil The text content or nil
|
|
function getText(element)
|
|
return element._text
|
|
end
|
|
|
|
--- Set text content of an element
|
|
--- @param element table XML element
|
|
--- @param text string Text content to set
|
|
function setText(element, text)
|
|
element._text = text
|
|
end
|
|
|
|
--- Check if element has an attribute
|
|
--- @param element table XML element
|
|
--- @param attrName string Attribute name
|
|
--- @return boolean True if attribute exists
|
|
function hasAttr(element, attrName)
|
|
return element._attr and element._attr[attrName] ~= nil
|
|
end
|
|
|
|
--- Get attribute value as string
|
|
--- @param element table XML element
|
|
--- @param attrName string Attribute name
|
|
--- @return string|nil The attribute value or nil
|
|
function getAttr(element, attrName)
|
|
if not element._attr then return nil end
|
|
return element._attr[attrName]
|
|
end
|
|
|
|
--- Set attribute value
|
|
--- @param element table XML element
|
|
--- @param attrName string Attribute name
|
|
--- @param value any Value to set (will be converted to string)
|
|
function setAttr(element, attrName, value)
|
|
if not element._attr then
|
|
element._attr = {}
|
|
end
|
|
element._attr[attrName] = tostring(value)
|
|
end
|
|
|
|
--- Find first element with a specific tag name (searches direct children only)
|
|
--- @param parent table The parent XML element
|
|
--- @param tagName string The tag name to search for
|
|
--- @return table|nil The first matching element or nil
|
|
function findFirstElement(parent, tagName)
|
|
if not parent._children then return nil end
|
|
for _, child in ipairs(parent._children) do
|
|
if child._tag == tagName then
|
|
return child
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
--- Add a child element to a parent
|
|
--- @param parent table The parent XML element
|
|
--- @param child table The child element to add
|
|
function addChild(parent, child)
|
|
if not parent._children then
|
|
parent._children = {}
|
|
end
|
|
table.insert(parent._children, child)
|
|
end
|
|
|
|
--- Remove all children with a specific tag name
|
|
--- @param parent table The parent XML element
|
|
--- @param tagName string The tag name to remove
|
|
--- @return number Count of removed children
|
|
function removeChildren(parent, tagName)
|
|
if not parent._children then return 0 end
|
|
local removed = 0
|
|
local i = 1
|
|
while i <= #parent._children do
|
|
if parent._children[i]._tag == tagName then
|
|
table.remove(parent._children, i)
|
|
removed = removed + 1
|
|
else
|
|
i = i + 1
|
|
end
|
|
end
|
|
return removed
|
|
end
|
|
|
|
--- Get all direct children with a specific tag name
|
|
--- @param parent table The parent XML element
|
|
--- @param tagName string The tag name to search for
|
|
--- @return table Array of matching children
|
|
function getChildren(parent, tagName)
|
|
local results = {}
|
|
if not parent._children then return results end
|
|
for _, child in ipairs(parent._children) do
|
|
if child._tag == tagName then
|
|
table.insert(results, child)
|
|
end
|
|
end
|
|
return results
|
|
end
|
|
|
|
--- Count children with a specific tag name
|
|
--- @param parent table The parent XML element
|
|
--- @param tagName string The tag name to count
|
|
--- @return number Count of matching children
|
|
function countChildren(parent, tagName)
|
|
if not parent._children then return 0 end
|
|
local count = 0
|
|
for _, child in ipairs(parent._children) do
|
|
if child._tag == tagName then
|
|
count = count + 1
|
|
end
|
|
end
|
|
return count
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- JSON HELPER FUNCTIONS
|
|
-- ============================================================================
|
|
|
|
--- Recursively visit all values in a JSON structure
|
|
--- @param data table JSON data (nested tables)
|
|
--- @param callback function Function called with (value, key, parent)
|
|
function visitJSON(data, callback)
|
|
local function visit(obj, key, parent)
|
|
callback(obj, key, parent)
|
|
if type(obj) == "table" then
|
|
for k, v in pairs(obj) do
|
|
visit(v, k, obj)
|
|
end
|
|
end
|
|
end
|
|
visit(data, nil, nil)
|
|
end
|
|
|
|
--- Find all values in JSON matching a predicate
|
|
--- @param data table JSON data
|
|
--- @param predicate function Function that takes (value, key, parent) and returns true/false
|
|
--- @return table Array of matching values
|
|
function findInJSON(data, predicate)
|
|
local results = {}
|
|
visitJSON(data, function(value, key, parent)
|
|
if predicate(value, key, parent) then
|
|
table.insert(results, value)
|
|
end
|
|
end)
|
|
return results
|
|
end
|
|
|
|
--- Modify all numeric values in JSON matching a condition
|
|
--- @param data table JSON data
|
|
--- @param predicate function Function that takes (value, key, parent) and returns true/false
|
|
--- @param modifier function Function that takes current value and returns new value
|
|
function modifyJSONNumbers(data, predicate, modifier)
|
|
visitJSON(data, function(value, key, parent)
|
|
if type(value) == "number" and predicate(value, key, parent) then
|
|
if parent and key then
|
|
parent[key] = modifier(value)
|
|
end
|
|
end
|
|
end)
|
|
end |