///|
/// Escape one field for CSV, following RFC 4180.
///
/// A field is quoted when it contains a comma, a quote or a line break, and its
/// quotes are doubled inside the quotes. This matters here more than in most
/// exporters: a bank memo is free text and routinely contains commas, and a
/// ledger that splits `memo` into two columns because of one is worse than no
/// export at all.
pub fn csv_escape(field : String) -> String {
if field.contains_char(',') ||
field.contains_char('"') ||
field.contains_char('\n') ||
field.contains_char('\r') {
"\"" + field.replace_all(old="\"", new="\"\"") + "\""
} else {
field
}
}
///|
/// One CSV line from already-escaped-or-not fields.
fn csv_line(fields : Array[String]) -> String {
fields.map(csv_escape).join(",")
}
///|
/// The column names of the transaction table.
pub fn transaction_columns() -> Array[String] {
[
"fitid", "kind", "posted", "user_date", "amount", "check_number", "ref_number",
"payee_id", "sic", "name", "memo",
]
}
///|
/// One transaction as CSV fields, without escaping.
fn transaction_fields(transaction : Transaction) -> Array[String] {
[
transaction.fitid,
transaction.kind,
transaction.posted,
transaction.user_date,
transaction.amount,
transaction.check_number,
transaction.ref_number,
transaction.payee_id,
transaction.sic,
transaction.name,
transaction.memo,
]
}
///|
/// The column names of the investment table.
pub fn investment_columns() -> Array[String] {
[
"fitid", "kind", "tag", "trade_date", "settle_date", "security", "units", "unit_price",
"commission", "fees", "total", "sub_account_sec", "sub_account_fund", "income_type",
"buy_type", "sell_type", "name", "memo",
]
}
///|
/// One investment record as CSV fields, without escaping.
fn investment_fields(investment : Investment) -> Array[String] {
[
investment.fitid,
investment.kind.to_string(),
investment.tag,
investment.trade_date,
investment.settle_date,
investment.security_label(),
investment.units,
investment.unit_price,
investment.commission,
investment.fees,
investment.total,
investment.sub_account_sec,
investment.sub_account_fund,
investment.income_type,
investment.buy_type,
investment.sell_type,
investment.name,
investment.memo,
]
}
///|
/// A statement's transactions as a CSV table with a header row.
///
/// Amounts are written exactly as the document spelled them; the conversion to
/// minor units is the caller's decision, because the scale belongs to the
/// ledger being written, not to the export.
pub fn Statement::to_csv(self : Statement) -> String {
let lines : Array[String] = [csv_line(transaction_columns())]
for transaction in self.transactions {
lines.push(csv_line(transaction_fields(transaction)))
}
lines.join("\n")
}
///|
/// A statement's investment records as a CSV table with a header row.
pub fn Statement::investments_to_csv(self : Statement) -> String {
let lines : Array[String] = [csv_line(investment_columns())]
for investment in self.investments {
lines.push(csv_line(investment_fields(investment)))
}
lines.join("\n")
}
///|
/// Every statement's transactions as one CSV table, with the account and the
/// currency leading each row.
///
/// The two extra columns are what makes a multi-statement export usable: a row
/// that cannot be attributed to an account is a row a spreadsheet cannot
/// filter, and a row without its currency cannot be summed with another
/// statement's rows.
pub fn Document::to_csv(self : Document) -> String {
let header : Array[String] = ["account", "currency"]
header.push_iter(transaction_columns().iter())
let lines : Array[String] = [csv_line(header)]
for statement in self.statements {
let prefix : Array[String] = [
account_label(statement),
currency_label(statement.currency),
]
for transaction in statement.transactions {
let fields = prefix.copy()
fields.push_iter(transaction_fields(transaction).iter())
lines.push(csv_line(fields))
}
}
lines.join("\n")
}
///|
/// Every statement's investment records as one CSV table, with the account and
/// the currency leading each row.
pub fn Document::investments_to_csv(self : Document) -> String {
let header : Array[String] = ["account", "currency"]
header.push_iter(investment_columns().iter())
let lines : Array[String] = [csv_line(header)]
for statement in self.statements {
let prefix : Array[String] = [
account_label(statement),
currency_label(statement.currency),
]
for investment in statement.investments {
let fields = prefix.copy()
fields.push_iter(investment_fields(investment).iter())
lines.push(csv_line(fields))
}
}
lines.join("\n")
}
///|
/// The widest cell in each column of a table, which is what makes the columns
/// line up without a layout engine.
///
/// Widths are counted in the string's own units, so a name that is mostly
/// non-Latin will be padded by its code-unit count rather than its printed
/// width. Padding a terminal table exactly would need the font; being close is
/// enough for a report a human reads.
fn column_widths(rows : Array[Array[String]]) -> Array[Int] {
let widths : Array[Int] = []
for row in rows {
let mut index = 0
for cell in row {
let width = cell.length()
if index >= widths.length() {
widths.push(width)
} else if width > widths[index] {
widths[index] = width
}
index += 1
}
}
widths
}
///|
/// One table row with its cells padded to the column widths.
fn padded_row(cells : Array[String], widths : Array[Int]) -> String {
let out = StringBuilder()
let mut index = 0
for cell in cells {
if index > 0 {
out.write_string(" ")
}
let width = if index < widths.length() {
widths[index]
} else {
cell.length()
}
out.write_string(cell.pad_end(width, ' '))
index += 1
}
out.to_string()
}
///|
/// A statement's transactions as an aligned text table.
///
/// The instants are printed in UTC, because a table is exactly where two rows
/// written in different zones would otherwise look out of order, and the raw
/// stamp is kept in the CSV for a caller that wants it.
pub fn Statement::render_table(self : Statement) -> String {
let rows : Array[Array[String]] = [["fitid", "utc", "dir", "amount", "name"]]
for transaction in self.transactions {
let utc = match transaction.utc_posted() {
Some(stamp) => stamp
None => "invalid"
}
rows.push([
transaction.fitid,
utc,
transaction.direction().to_string(),
transaction.amount,
transaction.name,
])
}
let widths = column_widths(rows)
let lines : Array[String] = []
for row in rows {
lines.push(padded_row(row, widths))
}
lines.join("\n")
}
///|
/// A statement as a short human report.
///
/// This is the text an operator reads before deciding to import: who the
/// statement belongs to, what period it covers, how many records it holds, how
/// the balances look against those records, and every diagnostic. It is
/// deliberately verbose about the diagnostics and silent about the amounts —
/// the CSV is for the amounts.
pub fn Statement::render_report(self : Statement) -> String {
let out = StringBuilder()
out.write_string("Account: " + account_label(self))
if self.account_type != "" {
out.write_string(" (" + self.account_type + ")")
}
out.write_string(" in " + currency_label(self.currency) + "\n")
if self.start_date != "" || self.end_date != "" {
out.write_string(
"Period: " +
(if self.start_date == "" { "?" } else { self.start_date }) +
" .. " +
(if self.end_date == "" { "?" } else { self.end_date }) +
"\n",
)
}
let signon_text = self.signon.to_string()
if signon_text != "" {
out.write_string("Sign-on: " + signon_text + "\n")
}
match self.ledger_balance {
Some(balance) =>
out.write_string("Ledger balance: " + balance.to_string() + "\n")
None => ()
}
match self.avail_balance {
Some(balance) =>
out.write_string("Available balance: " + balance.to_string() + "\n")
None => ()
}
let debits = self.transactions.filter(tx => tx.direction() is Debit).length()
out.write_string(
"Transactions: " +
self.transactions.length().to_string() +
" (" +
debits.to_string() +
" debits, " +
(self.transactions.length() - debits).to_string() +
" credits)\n",
)
if self.investments.length() > 0 {
out.write_string(
"Investments: " + self.investments.length().to_string() + "\n",
)
}
out.write_string(
"Diagnostics: " +
self.diagnostics.length().to_string() +
" (errors " +
self.error_count().to_string() +
", warnings " +
self.warning_count().to_string() +
")\n",
)
for line in self.render_diagnostics() {
out.write_string(" " + line + "\n")
}
out.to_string()
}
///|
/// A document as a short human report: one block per statement, then the file
/// totals.
pub fn Document::render_report(self : Document) -> String {
let out = StringBuilder()
out.write_string(
"Header: " +
(if self.header.is_empty() { "(none)" } else { self.header.render() }) +
"\n",
)
let signon_text = self.signon.to_string()
if signon_text != "" {
out.write_string("Sign-on: " + signon_text + "\n")
}
out.write_string(
"Statements: " +
self.statement_count().to_string() +
" (errors " +
self.error_count().to_string() +
", warnings " +
self.warning_count().to_string() +
")\n",
)
let mut index = 0
for statement in self.statements {
index += 1
out.write_string("\n--- statement " + index.to_string() + " ---\n")
out.write_string(statement.render_report())
}
out.to_string()
}