From af53f06eac32ff4ea4bc8342d1bd62261c29d5c7 Mon Sep 17 00:00:00 2001
From: Jed Fox <git@jedfox.com>
Date: Fri, 21 Jul 2023 13:14:30 -0400
Subject: [PATCH] Add support for importing the first row of a CSV file without
 a header row (#1373)

---
 .../components/modals/ImportTransactions.js   | 257 ++++++++++--------
 .../src/client/state-types/prefs.d.ts         |   1 +
 .../src/server/accounts/parse-file.ts         |   8 +-
 upcoming-release-notes/1373.md                |   6 +
 4 files changed, 151 insertions(+), 121 deletions(-)
 create mode 100644 upcoming-release-notes/1373.md

diff --git a/packages/desktop-client/src/components/modals/ImportTransactions.js b/packages/desktop-client/src/components/modals/ImportTransactions.js
index 4165a49d5..22b4c2683 100644
--- a/packages/desktop-client/src/components/modals/ImportTransactions.js
+++ b/packages/desktop-client/src/components/modals/ImportTransactions.js
@@ -1,9 +1,8 @@
 import React, { useState, useEffect, useMemo } from 'react';
-import { connect } from 'react-redux';
+import { useSelector } from 'react-redux';
 
 import * as d from 'date-fns';
 
-import * as actions from 'loot-core/src/client/actions';
 import { format as formatDate_ } from 'loot-core/src/shared/months';
 import {
   amountToCurrency,
@@ -11,6 +10,7 @@ import {
   looselyParseAmount,
 } from 'loot-core/src/shared/util';
 
+import { useActions } from '../../hooks/useActions';
 import { colors, styles } from '../../style';
 import {
   View,
@@ -340,12 +340,24 @@ function SubLabel({ title }) {
   );
 }
 
-function SelectField({ width, style, options, value, onChange }) {
+function SelectField({
+  style,
+  options,
+  value,
+  onChange,
+  hasHeaderRow,
+  firstTransaction,
+}) {
   return (
     <Select
       options={[
         ['choose-field', 'Choose field...'],
-        ...options.map(option => [option, option]),
+        ...options.map(option => [
+          option,
+          hasHeaderRow
+            ? option
+            : `Column ${parseInt(option) + 1} (${firstTransaction[option]})`,
+        ]),
       ]}
       value={value === null ? 'choose-field' : value}
       style={{ borderWidth: 1, width: '100%' }}
@@ -388,67 +400,69 @@ function DateFormatSelect({
   );
 }
 
-function MultipliersOption({ value, onChange }) {
+function CheckboxOption({ id, checked, disabled, onChange, children, style }) {
   return (
     <View
-      style={{
-        flex: 1,
-        flexDirection: 'row',
-        alignItems: 'center',
-        userSelect: 'none',
-      }}
-    >
-      <Checkbox id="add_multiplier" checked={value} onChange={onChange} />
-      <label htmlFor="add_multiplier">Add Multiplier</label>
-    </View>
-  );
-}
-
-function FlipAmountOption({ value, disabled, onChange }) {
-  return (
-    <View
-      style={{
-        flex: 1,
-        flexDirection: 'row',
-        alignItems: 'center',
-        userSelect: 'none',
-      }}
+      style={[
+        {
+          flex: 1,
+          flexDirection: 'row',
+          alignItems: 'center',
+          userSelect: 'none',
+          minHeight: 28,
+        },
+        style,
+      ]}
     >
       <Checkbox
-        id="form_flip"
-        checked={value}
+        id={id}
+        checked={checked}
         disabled={disabled}
         onChange={onChange}
       />
       <label
-        htmlFor="form_flip"
+        htmlFor={id}
         style={{ userSelect: 'none', color: disabled ? colors.n6 : null }}
       >
-        Flip amount
+        {children}
       </label>
     </View>
   );
 }
 
-function SplitOption({ value, onChange }) {
+function MultiplierOption({
+  multiplierEnabled,
+  multiplierAmount,
+  onToggle,
+  onChangeAmount,
+}) {
   return (
-    <View
-      style={{
-        flex: 1,
-        flexDirection: 'row',
-        alignItems: 'center',
-        userSelect: 'none',
-      }}
-    >
-      <Checkbox id="form_split" checked={value} onChange={onChange} />
-      <label htmlFor="form_split" style={{ userSelect: 'none' }}>
-        Split amount into separate inflow/outflow columns
-      </label>
+    <View style={{ flexDirection: 'row', gap: 10, height: 28 }}>
+      <CheckboxOption
+        id="add_multiplier"
+        checked={multiplierEnabled}
+        onChange={onToggle}
+      >
+        Add multiplier
+      </CheckboxOption>
+      <Input
+        type="text"
+        style={{ display: multiplierEnabled ? 'inherit' : 'none' }}
+        value={multiplierAmount}
+        placeholder="Multiplier"
+        onUpdate={onChangeAmount}
+      />
     </View>
   );
 }
 
-function FieldMappings({ transactions, mappings, onChange, splitMode }) {
+function FieldMappings({
+  transactions,
+  mappings,
+  onChange,
+  splitMode,
+  hasHeaderRow,
+}) {
   if (transactions.length === 0) {
     return null;
   }
@@ -472,6 +486,8 @@ function FieldMappings({ transactions, mappings, onChange, splitMode }) {
             value={mappings.date}
             style={{ marginRight: 5 }}
             onChange={name => onChange('date', name)}
+            hasHeaderRow={hasHeaderRow}
+            firstTransaction={transactions[0]}
           />
         </View>
         <View style={{ flex: 1 }}>
@@ -481,6 +497,8 @@ function FieldMappings({ transactions, mappings, onChange, splitMode }) {
             value={mappings.payee}
             style={{ marginRight: 5 }}
             onChange={name => onChange('payee', name)}
+            hasHeaderRow={hasHeaderRow}
+            firstTransaction={transactions[0]}
           />
         </View>
         <View style={{ flex: 1 }}>
@@ -490,6 +508,8 @@ function FieldMappings({ transactions, mappings, onChange, splitMode }) {
             value={mappings.notes}
             style={{ marginRight: 5 }}
             onChange={name => onChange('notes', name)}
+            hasHeaderRow={hasHeaderRow}
+            firstTransaction={transactions[0]}
           />
         </View>
         {splitMode ? (
@@ -500,6 +520,8 @@ function FieldMappings({ transactions, mappings, onChange, splitMode }) {
                 options={options}
                 value={mappings.outflow}
                 onChange={name => onChange('outflow', name)}
+                hasHeaderRow={hasHeaderRow}
+                firstTransaction={transactions[0]}
               />
             </View>
             <View style={{ flex: 0.5 }}>
@@ -508,6 +530,8 @@ function FieldMappings({ transactions, mappings, onChange, splitMode }) {
                 options={options}
                 value={mappings.inflow}
                 onChange={name => onChange('inflow', name)}
+                hasHeaderRow={hasHeaderRow}
+                firstTransaction={transactions[0]}
               />
             </View>
           </>
@@ -518,6 +542,8 @@ function FieldMappings({ transactions, mappings, onChange, splitMode }) {
               options={options}
               value={mappings.amount}
               onChange={name => onChange('amount', name)}
+              hasHeaderRow={hasHeaderRow}
+              firstTransaction={transactions[0]}
             />
           </View>
         )}
@@ -526,30 +552,14 @@ function FieldMappings({ transactions, mappings, onChange, splitMode }) {
   );
 }
 
-function MultipliersField({ multiplierCB, value, onChange }) {
-  const styl = multiplierCB ? 'inherit' : 'none';
-
-  return (
-    <Input
-      type="text"
-      style={{ display: styl }}
-      value={value}
-      placeholder="Optional"
-      onUpdate={onChange}
-    />
+export default function ImportTransactions({ modalProps, options }) {
+  let dateFormat = useSelector(
+    state => state.prefs.local.dateFormat || 'MM/dd/yyyy',
   );
-}
+  let prefs = useSelector(state => state.prefs.local);
+  let { parseTransactions, importTransactions, getPayees, savePrefs } =
+    useActions();
 
-function ImportTransactions({
-  modalProps,
-  options,
-  dateFormat = 'MM/dd/yyyy',
-  prefs,
-  parseTransactions,
-  importTransactions,
-  getPayees,
-  savePrefs,
-}) {
   let [multiplierAmount, setMultiplierAmount] = useState('');
   let [loadingState, setLoadingState] = useState('parsing');
   let [error, setError] = useState(null);
@@ -571,6 +581,9 @@ function ImportTransactions({
     prefs[`csv-delimiter-${accountId}`] ||
       (filename.endsWith('.tsv') ? '\t' : ','),
   );
+  let [hasHeaderRow, setHasHeaderRow] = useState(
+    prefs[`csv-has-header-${accountId}`] ?? true,
+  );
 
   let [parseDateFormat, setParseDateFormat] = useState(null);
 
@@ -640,7 +653,7 @@ function ImportTransactions({
     parse(
       options.filename,
       getFileType(options.filename) === 'csv'
-        ? { delimiter: csvDelimiter }
+        ? { delimiter: csvDelimiter, hasHeaderRow }
         : null,
     );
   }, [parseTransactions, options.filename]);
@@ -867,6 +880,7 @@ function ImportTransactions({
             onChange={onUpdateFields}
             mappings={fieldMappings}
             splitMode={splitMode}
+            hasHeaderRow={hasHeaderRow}
           />
         </View>
       )}
@@ -892,11 +906,19 @@ function ImportTransactions({
               )}
             </View>
 
-            {/*csv Delimiter */}
-            <View>
-              {filetype === 'csv' && (
-                <View style={{ marginLeft: 25 }}>
-                  <SectionLabel title="CSV DELIMITER" />
+            {/* CSV Options */}
+            {filetype === 'csv' && (
+              <View style={{ marginLeft: 25, gap: 5 }}>
+                <SectionLabel title="CSV OPTIONS" />
+                <label
+                  style={{
+                    display: 'flex',
+                    flexDirection: 'row',
+                    gap: 5,
+                    alignItems: 'baseline',
+                  }}
+                >
+                  Delimiter:
                   <Select
                     options={[
                       [',', ','],
@@ -906,50 +928,57 @@ function ImportTransactions({
                     value={csvDelimiter}
                     onChange={value => {
                       setCsvDelimiter(value);
-                      parse(filename, { delimiter: value });
+                      parse(filename, { delimiter: value, hasHeaderRow });
                     }}
-                    style={{ borderWidth: 1, width: '100%' }}
+                    style={{ borderWidth: 1, width: 50 }}
                   />
-                </View>
-              )}
-            </View>
-
-            <View style={{ flex: 1 }} />
-
-            <View style={{ marginRight: 25 }}>
-              <SectionLabel title="IMPORT OPTIONS" />
-              <View style={{ marginTop: 5 }}>
-                <FlipAmountOption
-                  value={flipAmount}
-                  disabled={splitMode}
+                </label>
+                <CheckboxOption
+                  id="form_has_header"
+                  checked={hasHeaderRow}
                   onChange={() => {
-                    setFlipAmount(!flipAmount);
+                    setHasHeaderRow(!hasHeaderRow);
+                    parse(filename, {
+                      delimiter: csvDelimiter,
+                      hasHeaderRow: !hasHeaderRow,
+                    });
                   }}
-                />
+                >
+                  File has header row
+                </CheckboxOption>
               </View>
+            )}
+
+            <View style={{ flex: 1 }} />
+
+            <View style={{ marginRight: 25, gap: 5 }}>
+              <SectionLabel title="AMOUNT OPTIONS" />
+              <CheckboxOption
+                id="form_flip"
+                checked={flipAmount}
+                disabled={splitMode}
+                onChange={() => setFlipAmount(!flipAmount)}
+              >
+                Flip amount
+              </CheckboxOption>
               {filetype === 'csv' && (
-                <View style={{ marginTop: 10 }}>
-                  <SplitOption value={splitMode} onChange={onSplitMode} />
-                </View>
+                <CheckboxOption
+                  id="form_split"
+                  checked={splitMode}
+                  onChange={onSplitMode}
+                >
+                  Split amount into separate inflow/outflow columns
+                </CheckboxOption>
               )}
-              <View style={{ flexDirection: 'row', marginTop: 10 }}>
-                <View style={{ marginRight: 30 }}>
-                  <MultipliersOption
-                    value={multiplierEnabled}
-                    onChange={() => {
-                      setMultiplierEnabled(!multiplierEnabled);
-                      setMultiplierAmount('');
-                    }}
-                  />
-                </View>
-                <View style={{ width: 75 }}>
-                  <MultipliersField
-                    multiplierCB={multiplierEnabled}
-                    value={multiplierAmount}
-                    onChange={onMultiplierChange}
-                  />
-                </View>
-              </View>
+              <MultiplierOption
+                multiplierEnabled={multiplierEnabled}
+                multiplierAmount={multiplierAmount}
+                onToggle={() => {
+                  setMultiplierEnabled(!multiplierEnabled);
+                  setMultiplierAmount('');
+                }}
+                onChangeAmount={onMultiplierChange}
+              />
             </View>
           </Stack>
         </View>
@@ -977,11 +1006,3 @@ function ImportTransactions({
     </Modal>
   );
 }
-
-export default connect(
-  state => ({
-    dateFormat: state.prefs.local.dateFormat || 'MM/dd/yyyy',
-    prefs: state.prefs.local,
-  }),
-  actions,
-)(ImportTransactions);
diff --git a/packages/loot-core/src/client/state-types/prefs.d.ts b/packages/loot-core/src/client/state-types/prefs.d.ts
index 644b5f84f..dad0df89a 100644
--- a/packages/loot-core/src/client/state-types/prefs.d.ts
+++ b/packages/loot-core/src/client/state-types/prefs.d.ts
@@ -30,6 +30,7 @@ export type LocalPrefs = NullableValues<
     [key: `parse-date-${string}-${'csv' | 'qif'}`]: string;
     [key: `csv-mappings-${string}`]: string;
     [key: `csv-delimiter-${string}`]: ',' | ';' | '\t';
+    [key: `csv-has-header-${string}`]: boolean;
     [key: `flip-amount-${string}-${'csv' | 'qif'}`]: boolean;
     'flags.updateNotificationShownForVersion': string;
     id: string;
diff --git a/packages/loot-core/src/server/accounts/parse-file.ts b/packages/loot-core/src/server/accounts/parse-file.ts
index f1ffda03b..ea6384083 100644
--- a/packages/loot-core/src/server/accounts/parse-file.ts
+++ b/packages/loot-core/src/server/accounts/parse-file.ts
@@ -14,7 +14,7 @@ export type ParseFileResult = {
 
 export async function parseFile(
   filepath,
-  options?: unknown,
+  options?: { delimiter?: string; hasHeaderRow: boolean },
 ): Promise<ParseFileResult> {
   let errors = Array<ParseError>();
   let m = filepath.match(/\.[^.]*$/);
@@ -44,7 +44,9 @@ export async function parseFile(
 
 async function parseCSV(
   filepath,
-  options: { delimiter?: string } = {},
+  options: { delimiter?: string; hasHeaderRow: boolean } = {
+    hasHeaderRow: true,
+  },
 ): Promise<ParseFileResult> {
   let errors = Array<ParseError>();
   let contents = await fs.readFile(filepath);
@@ -52,7 +54,7 @@ async function parseCSV(
   let data;
   try {
     data = csv2json(contents, {
-      columns: true,
+      columns: options.hasHeaderRow,
       bom: true,
       delimiter: options.delimiter || ',',
       // eslint-disable-next-line rulesdir/typography
diff --git a/upcoming-release-notes/1373.md b/upcoming-release-notes/1373.md
new file mode 100644
index 000000000..105ba8e5e
--- /dev/null
+++ b/upcoming-release-notes/1373.md
@@ -0,0 +1,6 @@
+---
+category: Enhancements
+authors: [j-f1]
+---
+
+Allow importing the first row of a CSV file that does not contain a header row
-- 
GitLab