From c7f7ae8f72d9b02186ca82efabf038d6377820f9 Mon Sep 17 00:00:00 2001 From: Daniel Kraus Date: Tue, 16 Jun 2026 14:40:29 +0200 Subject: [PATCH] Initial commit. Initial working version. --- Dockerfile | 5 ++ README.md | 56 ++++++++++++++++++- csv2keila | 34 ++++++++++++ keila2csv | 33 ++++++++++++ keila_csv | 48 +++++++++++++++++ lib/keila_csv_lib.rb | 124 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 Dockerfile create mode 100644 csv2keila create mode 100644 keila2csv create mode 100644 keila_csv create mode 100644 lib/keila_csv_lib.rb diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e1b81aa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM ruby:latest +WORKDIR /keila_csv +ADD . . +RUN chmod +x keila_csv keila2csv csv2keila +ENTRYPOINT ["/keila_csv/keila_csv"] diff --git a/README.md b/README.md index 661fdd5..1ecb8b8 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,57 @@ # keila_csv -Contact data exchange with Keila using CSV files. \ No newline at end of file +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 + +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 diff --git a/csv2keila b/csv2keila new file mode 100644 index 0000000..ef3d1c3 --- /dev/null +++ b/csv2keila @@ -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 + +require_relative "lib/keila_csv_lib" + +if ARGV.length != 2 + warn "Usage: #{File.basename($PROGRAM_NAME)} " + 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}" diff --git a/keila2csv b/keila2csv new file mode 100644 index 0000000..0bce045 --- /dev/null +++ b/keila2csv @@ -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 + +require_relative "lib/keila_csv_lib" + +if ARGV.length != 2 + warn "Usage: #{File.basename($PROGRAM_NAME)} " + 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}" diff --git a/keila_csv b/keila_csv new file mode 100644 index 0000000..e8ddfdd --- /dev/null +++ b/keila_csv @@ -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_csv csv2keila + +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 + + 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 diff --git a/lib/keila_csv_lib.rb b/lib/keila_csv_lib.rb new file mode 100644 index 0000000..7af41ed --- /dev/null +++ b/lib/keila_csv_lib.rb @@ -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