Files
sure/app/models/sure_import.rb
Juan José Mata 2595885eb7 Full .ndjson import / reorganize UI with Financial Tools / Raw Data tabs (#1208)
* Reorganize import UI with Financial Tools / Raw Data tabs

Split the flat list of import sources into two tabbed sections using
DS::Tabs: "Financial Tools" (Mint, Quicken/QIF, YNAB coming soon) and
"Raw Data" (transactions, investments, accounts, categories, rules,
documents). This prepares for adding more tool-specific importers
without cluttering the list.

https://claude.ai/code/session_01BM4SBWNhATqoKTEvy3qTS3

* Fix import controller test to account for YNAB coming soon entry

The new YNAB "coming soon" disabled entry adds a 5th aria-disabled
element to the import dialog.

https://claude.ai/code/session_01BM4SBWNhATqoKTEvy3qTS3

* Fix system tests to click Raw Data tab before selecting import type

Transaction, trade, and account imports are now under the Raw Data tab
and need an explicit tab click before the buttons are visible.

https://claude.ai/code/session_01BM4SBWNhATqoKTEvy3qTS3

* feat: Add bulk import for NDJSON export files

Implements an import flow that accepts the full all.ndjson file from data exports,
allowing users to restore their complete data including:
- Accounts with accountable types
- Categories with parent relationships
- Tags and merchants
- Transactions with category, merchant, and tag references
- Trades with securities
- Valuations
- Budgets and budget categories
- Rules with conditions and actions (including compound conditions)

Key changes:
- Add BulkImport model extending Import base class
- Add Family::DataImporter to handle NDJSON parsing and import logic
- Update imports controller and views to support NDJSON workflow
- Skip configuration/mapping steps for structured NDJSON imports
- Add i18n translations for bulk import UI
- Add tests for BulkImport and DataImporter

* fix: Fix category import and test query issues

- Add default lucide_icon ("shapes") for categories when not provided
- Fix valuation test to use proper ActiveRecord joins syntax

* Linter errors

* fix: Add default color for tags when not provided in import

* fix: Add default kind for transactions when not provided in import

* Fix test

* Fix tests

* Fix remaining merge conflicts from PR 766 cherry-pick

Resolve conflict markers in test fixtures and clean up BulkImport
entry in new.html.erb to use the _import_option partial consistently.

https://claude.ai/code/session_01BM4SBWNhATqoKTEvy3qTS3

* Import Sure `.ndjson`

* Remove `.ndjson` import from raw data

* Fix support for Sure "bulk" import from old branch

* Linter

* Fix CI test

* Fix more CI tests

* Fix tests

* Fix tests / move PDF import to first tab

* Remove redundant title

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-23 14:27:41 +01:00

133 lines
2.9 KiB
Ruby

class SureImport < Import
MAX_NDJSON_SIZE = 10.megabytes
ALLOWED_NDJSON_CONTENT_TYPES = %w[
application/x-ndjson
application/ndjson
application/json
application/octet-stream
text/plain
].freeze
has_one_attached :ndjson_file, dependent: :purge_later
class << self
# Counts JSON lines by top-level "type" (used for dry-run summaries and row limits).
def ndjson_line_type_counts(content)
return {} unless content.present?
counts = Hash.new(0)
content.each_line do |line|
next if line.strip.empty?
begin
record = JSON.parse(line)
counts[record["type"]] += 1 if record["type"]
rescue JSON::ParserError
# Skip invalid lines
end
end
counts
end
def dry_run_totals_from_ndjson(content)
counts = ndjson_line_type_counts(content)
{
accounts: counts["Account"] || 0,
categories: counts["Category"] || 0,
tags: counts["Tag"] || 0,
merchants: counts["Merchant"] || 0,
transactions: counts["Transaction"] || 0,
trades: counts["Trade"] || 0,
valuations: counts["Valuation"] || 0,
budgets: counts["Budget"] || 0,
budget_categories: counts["BudgetCategory"] || 0,
rules: counts["Rule"] || 0
}
end
def valid_ndjson_first_line?(str)
return false if str.blank?
first_line = str.lines.first&.strip
return false if first_line.blank?
begin
record = JSON.parse(first_line)
record.key?("type") && record.key?("data")
rescue JSON::ParserError
false
end
end
end
def requires_csv_workflow?
false
end
def column_keys
[]
end
def required_column_keys
[]
end
def mapping_steps
[]
end
def csv_template
nil
end
def dry_run
return {} unless uploaded?
self.class.dry_run_totals_from_ndjson(ndjson_blob_string)
end
def import!
importer = Family::DataImporter.new(family, ndjson_blob_string)
result = importer.import!
result[:accounts].each { |account| accounts << account }
result[:entries].each { |entry| entries << entry }
end
def uploaded?
return false unless ndjson_file.attached?
self.class.valid_ndjson_first_line?(ndjson_blob_string)
end
def configured?
uploaded?
end
def cleaned?
configured?
end
def publishable?
cleaned? && dry_run.values.sum.positive?
end
def max_row_count
100_000
end
# Row total for max-row enforcement (counts every parsed line with a "type", including unsupported types).
def sync_ndjson_rows_count!
return unless ndjson_file.attached?
total = self.class.ndjson_line_type_counts(ndjson_blob_string).values.sum
update_column(:rows_count, total)
end
private
def ndjson_blob_string
ndjson_file.download.force_encoding(Encoding::UTF_8)
end
end