Initial commit. Initial working version.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
FROM ruby:latest
|
||||
WORKDIR /keila_csv
|
||||
ADD . .
|
||||
RUN chmod +x keila_csv keila2csv csv2keila
|
||||
ENTRYPOINT ["/keila_csv/keila_csv"]
|
||||
@@ -1,3 +1,57 @@
|
||||
# keila_csv
|
||||
|
||||
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
|
||||
|
||||
@@ -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}"
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user