Initial commit. Initial working version.

This commit is contained in:
2026-06-16 14:40:29 +02:00
parent 2c63996f31
commit c7f7ae8f72
6 changed files with 299 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
FROM ruby:latest
WORKDIR /keila_csv
ADD . .
RUN chmod +x keila_csv keila2csv csv2keila
ENTRYPOINT ["/keila_csv/keila_csv"]
+54
View File
@@ -1,3 +1,57 @@
# keila_csv # keila_csv
Contact data exchange with Keila using CSV files. Contact data exchange with Keila using CSV files.
**Tools to work with contact data from Keila.**
These are two scripts to work with contact data that is imported to
or exported from [Keila][], the mailings software.
## Run inside a Docker container
### Build image
```bash
docker build . -t keila_csv
```
### Run program
```bash
# Normal use, mounting a local data directory
docker run -v $(pwd)/data:/data keila_csv keila2csv /data/export.csv /data/flat.csv
docker run -v $(pwd)/data:/data keila_csv csv2keila /data/flat.csv /data/import.csv
# Explicit help
docker run keila_csv --help
```
## Use of Artificial Intelligence (AI)
Anthropic Claude was used in conjunction with Human Brain to write
this program.
## The MIT License (MIT)
Copyright © 2026 Daniel Kraus <bovender@bovender.de>
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the
“Software”), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
[Keila]: https://keila.io
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
# csv2keila — Convert a flat CSV to a Keila-importable contact file
#
# Collects any non-standard columns back into Keila's "data" JSON column.
# Standard Keila fields (email, first_name, last_name, external_id, tags)
# are passed through as-is. Empty cells are omitted from the JSON.
#
# Usage:
# csv2keila <flat_contacts.csv> <keila_import.csv>
require_relative "lib/keila_csv_lib"
if ARGV.length != 2
warn "Usage: #{File.basename($PROGRAM_NAME)} <flat_contacts.csv> <keila_import.csv>"
exit 1
end
input_path, output_path = ARGV
unless File.exist?(input_path)
warn "Error: Input file not found: #{input_path}"
exit 1
end
result = KeilaCsv.csv_to_keila(input_path, output_path)
fields_info = result[:custom_fields].empty? \
? "no custom fields found" \
: "packed fields: #{result[:custom_fields].join(', ')}"
puts "Converted #{result[:contacts]} contact(s) — #{fields_info}"
puts "Written to: #{output_path}"
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
# keila2csv — Convert a Keila contact export to a flat CSV
#
# Expands the "data" JSON column into individual columns, one per custom field.
# The resulting file is easy to open in a spreadsheet or process with other tools.
#
# Usage:
# keila2csv <keila_export.csv> <output.csv>
require_relative "lib/keila_csv_lib"
if ARGV.length != 2
warn "Usage: #{File.basename($PROGRAM_NAME)} <keila_export.csv> <output.csv>"
exit 1
end
input_path, output_path = ARGV
unless File.exist?(input_path)
warn "Error: Input file not found: #{input_path}"
exit 1
end
result = KeilaCsv.keila_to_csv(input_path, output_path)
fields_info = result[:custom_fields].empty? \
? "no custom fields found" \
: "custom fields: #{result[:custom_fields].join(', ')}"
puts "Converted #{result[:contacts]} contact(s) — #{fields_info}"
puts "Written to: #{output_path}"
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
# keila_csv — Entrypoint dispatcher for the Keila CSV tools
#
# Usage:
# keila_csv keila2csv <keila_export.csv> <output.csv>
# keila_csv csv2keila <flat_contacts.csv> <keila_import.csv>
COMMANDS = {
"keila2csv" => "Convert a Keila contact export to a flat CSV\n" \
" (expands the 'data' JSON column into individual columns)",
"csv2keila" => "Convert a flat CSV to a Keila-importable contact file\n" \
" (packs non-standard columns back into the 'data' JSON column)"
}.freeze
def help
puts <<~HELP
Usage:
keila_csv <command> <input.csv> <output.csv>
Commands:
keila2csv #{COMMANDS['keila2csv']}
csv2keila #{COMMANDS['csv2keila']}
Examples:
keila_csv keila2csv export.csv contacts_flat.csv
keila_csv csv2keila contacts.csv keila_import.csv
HELP
end
command = ARGV.shift
if command.nil? || %w[--help -h help].include?(command)
help
exit 0
end
unless COMMANDS.key?(command)
warn "Error: unknown command '#{command}'"
warn ""
help
exit 1
end
# Re-dispatch to the actual script, passing remaining ARGV through
script = File.join(__dir__, command)
exec script, *ARGV
+124
View File
@@ -0,0 +1,124 @@
# frozen_string_literal: true
# keila_csv_lib.rb — Shared library for keila2csv and csv2keila
#
# Keila stores custom contact data as a JSON object in a "data" column.
# This library converts between:
# - Keila's format: standard columns + a "data" column with a JSON blob
# - A flat format: standard columns + one column per custom data key
require "csv"
require "json"
module KeilaCsv
# Keila's built-in contact fields in their canonical capitalisation for import.
# Used both for identifying standard fields (downcased) and for writing output headers.
KEILA_FIELDS_CANONICAL = %w[Email First_name Last_name External_id Tags Data].freeze
KEILA_FIELDS = KEILA_FIELDS_CANONICAL.map(&:downcase).freeze
# Lookup from lowercase field name → canonical capitalisation
KEILA_FIELDS_MAP = KEILA_FIELDS.zip(KEILA_FIELDS_CANONICAL).to_h.freeze
# keila2csv: Expand Keila's "data" JSON column into individual columns.
#
# Each top-level key in the JSON object becomes its own column.
# Nested objects/arrays are kept as JSON strings in their column.
# Contacts without a "data" value simply get empty cells.
# The output column order is: standard fields (minus "data"), then all
# discovered custom keys (union across all rows, alphabetically sorted).
def self.keila_to_csv(input_path, output_path)
rows = CSV.read(input_path, headers: true, header_converters: :downcase)
# First pass: collect all custom data keys across all rows
data_keys = []
rows.each do |row|
raw = row["data"]
next if raw.nil? || raw.strip.empty?
parsed = JSON.parse(raw)
data_keys |= parsed.keys.sort if parsed.is_a?(Hash)
rescue JSON::ParserError => e
warn "Warning: could not parse JSON for #{row['email']}: #{e.message}"
end
standard_headers = rows.headers.reject { |h| h == "data" }
output_headers = standard_headers + data_keys
# Second pass: write output
CSV.open(output_path, "w", headers: output_headers, write_headers: true) do |csv|
rows.each do |row|
out = {}
standard_headers.each { |h| out[h] = row[h] }
raw = row["data"]
if raw && !raw.strip.empty?
begin
parsed = JSON.parse(raw)
if parsed.is_a?(Hash)
parsed.each do |key, value|
out[key] = value.is_a?(String) ? value : value.to_json
end
else
warn "Warning: 'data' for #{row['email']} is not a JSON object — skipping."
end
rescue JSON::ParserError => e
warn "Warning: could not parse JSON for #{row['email']}: #{e.message}"
end
end
csv << output_headers.map { |h| out[h] }
end
end
{
contacts: rows.length,
custom_fields: data_keys
}
end
# csv2keila: Collect non-standard columns back into a Keila "data" JSON object.
#
# Any column not in KEILA_FIELDS is treated as custom data and packed into
# the "data" JSON column. Empty cells are omitted from the JSON entirely
# (so they don't overwrite existing values on re-import).
# Values that look like JSON objects or arrays are re-parsed as structured
# data; plain strings remain strings.
def self.csv_to_keila(input_path, output_path)
rows = CSV.read(input_path, headers: true, header_converters: :downcase)
custom_headers = rows.headers - KEILA_FIELDS
present_standard = rows.headers & (KEILA_FIELDS - ["data"])
# Write headers in Keila's canonical capitalisation (e.g. "Email", "First_name")
output_headers = present_standard.map { |h| KEILA_FIELDS_MAP[h] } + ["Data"]
CSV.open(output_path, "w", headers: output_headers, write_headers: true) do |csv|
rows.each do |row|
out = {}
# Row data is still keyed by lowercase; map to canonical for output
present_standard.each { |h| out[KEILA_FIELDS_MAP[h]] = row[h] }
data_hash = {}
custom_headers.each do |key|
val = row[key]
next if val.nil? || val.strip.empty?
# Re-parse JSON structures (objects/arrays); keep everything else as string
data_hash[key] = begin
parsed = JSON.parse(val)
parsed.is_a?(Hash) || parsed.is_a?(Array) ? parsed : val
rescue JSON::ParserError
val
end
end
out["Data"] = data_hash.empty? ? nil : data_hash.to_json
csv << output_headers.map { |h| out[h] }
end
end
{
contacts: rows.length,
custom_fields: custom_headers
}
end
end