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
|
# 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
|
||||||
|
|||||||
@@ -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