csv-to-qif (empty) → 0.3
raw patch · 8 files changed
+538/−0 lines, 8 filesdep +Cabaldep +basedep +explicit-exceptionsetup-changed
Dependencies added: Cabal, base, explicit-exception, hspec, regex-tdfa, split, spreadsheet
Files
- LICENSE +24/−0
- Setup.hs +6/−0
- csv-to-qif.cabal +49/−0
- main/Main.hs +120/−0
- main/QifOptions.hs +148/−0
- src/Parser.hs +49/−0
- src/Qifer.hs +91/−0
- test/Tester.hs +51/−0
+ LICENSE view
@@ -0,0 +1,24 @@+Copyright 2013, Ingolf Wagner+All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++- Redistributions of source code must retain the above copyright notice,+this list of conditions and the following disclaimer.+ +- Redistributions in binary form must reproduce the above copyright notice,+this list of conditions and the following disclaimer in the documentation+and/or other materials provided with the distribution.+ +THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND THE CONTRIBUTORS "AS IS"+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR+PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR THE+CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,+EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,+PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ Setup.hs view
@@ -0,0 +1,6 @@+module Main (main) where++import Distribution.Simple++main :: IO ()+main = defaultMain
+ csv-to-qif.cabal view
@@ -0,0 +1,49 @@+name: csv-to-qif+version: 0.3+cabal-version: >=1.10+build-type: Simple+license: BSD3+license-file: LICENSE+copyright: (c) Ingolf Wagner+maintainer: Ingolf Wagner <palipalo9@googlemail.com>+stability: stable+homepage: https://github.com/mrVanDalo/csv-to-qif/+bug-reports: mailto:palipalo9@gmail.com+synopsis: A small program that will read csv files and create qif files+description: Binary to convert a wide range of csv files to qif files.+ The main target is to read them into GnuCash.+category: Console, Text+author: Ingolf Wagner+data-dir: ""++source-repository head+ type: git+ location: https://github.com/mrVanDalo/csv-to-qif++flag threaded+ Default: False++executable to-qif+ build-depends: base >=4.2 && <5, split >=0.2.2, regex-tdfa >= 1.2.0,+ spreadsheet >= 0.1.3, explicit-exception >= 0.1.7++ if flag(threaded)+ buildable: True+ ghc-options: -threaded+ main-is: Main.hs+ buildable: True+ default-language: Haskell2010+ hs-source-dirs: src main+ other-modules: Main Parser QifOptions Qifer++test-suite tester+ build-depends: base >=4.2 && <5, split >=0.2.2, regex-tdfa >= 1.2.0,+ Cabal >=1.9.2, hspec >=1.8+ type: exitcode-stdio-1.0+ main-is: Tester.hs+ buildable: True+ default-language: Haskell2010+ default-extensions: OverloadedStrings+ hs-source-dirs: src test+ other-modules: Parser+ ghc-options: -Wall
+ main/Main.hs view
@@ -0,0 +1,120 @@+-----------------------------------------------------------------------------+--+-- Module : Main+-- Copyright : (c) Ingolf Wagner+-- License : BSD3+--+-- Maintainer : Ingolf Wagner <palipalo9@googlemail.com>+-- Stability : unstable+-- Portability :+--+-- |+--+-----------------------------------------------------------------------------++module Main (main) where++import QifOptions+import Qifer+import Parser+import Data.List.Split++import System.Environment+import System.Console.GetOpt+import System.IO+import Data.Maybe+import System.Exit++++++-- | the famous main method+main = do+ args <- getArgs++ -- Parse options, getting a list of option actions+ let (actions, nonOptions, errors) = getOpt RequireOrder options args++ -- Here we thread startOptions through all supplied option actions+ opts <- foldl (>>=) (return startOptions) actions++ let Options { optInput = input+ , optOutput = output } = opts++ checkArguments opts+ let rules = rule opts++ parseResult <- parseCSVFromFile (optSkip opts) (optInput opts) (optSeparator opts)+ case parseResult of+ Left error -> do+ hPutStrLn stderr $ error+ exitFailure+ Right csv -> do++ let actions = toTransactions rules csv++ updatedActions <- case (optUpdater opts) of+ Nothing ->+ return $ actions+ Just file -> do+ updaterConfig <- readUpdaterFile file+ return $ update updaterConfig actions++ let qif = transToQif updatedActions++ withFile (optOutput opts) WriteMode (\h ->+ mapM_ ( hPutStrLn h) qif)+++-- | reads updater file+readUpdaterFile :: String -> IO [(String,String)]+readUpdaterFile fileName = do+ content <- readFile fileName+ return $ tupler . onlyTuple . splitter . lines $ content+ where+ tupler = map (\(x:(y:[])) -> (x,y))+ onlyTuple = filter (\l -> length l == 2)+ splitter = map (\l -> splitOn "<->" l)+++++-- | this function is unsafe call it after checkArguments+-- @todo : make me safer+rule :: Options -> Rule+rule opts = Rule {+ dateField = fromJust $ optDate opts+ , balanceField = fromJust $ optBalance opts+ , textField = optLongText opts+ , descField = optText opts+ }++-- | checks input arguments for minimum of configuration+checkArguments :: Options -> IO ()+checkArguments opts = do+ case errors of+ [] ->+ return ()+ _ -> do+ mapM_ putStrLn errors+ exitFailure+ return ()+ where+ errors = catMaybes list+ list = [ (check optDate "need date column")+ , (check optBalance "need balance column")+ , (checkL optText "need text columns")+ , (checkL optLongText "need long text columns")+ , (checkL optInput "need input file")+ ]+ check getter text = case (getter opts) of+ Nothing -> Just text+ Just _ -> Nothing+ checkL getter text = case (getter opts) of+ [] -> Just text+ _ -> Nothing++++
+ main/QifOptions.hs view
@@ -0,0 +1,148 @@+-----------------------------------------------------------------------------+--+-- Module : QifOptions+-- Copyright : (c) Ingolf Wagner+-- License : BSD3+--+-- Maintainer : Ingolf Wagner <palipalo9@googlemail.com>+-- Stability : unstable+-- Portability :+--+-- |+--+-----------------------------------------------------------------------------++module QifOptions where++import System.Console.GetOpt+import System.Exit+import System.IO+import System.Environment+import Control.Monad+import Text.Read+import Data.List.Split+import Data.Maybe++++data Options = Options { optVerbose :: Bool+ , optInput :: String+ , optOutput :: String+ , optDate :: Maybe Int+ , optBalance :: Maybe Int+ , optText :: [Int]+ , optLongText :: [Int]+ , optSkip :: Int+ , optSeparator :: Char+ , optUpdater :: Maybe String+ } deriving (Show)++startOptions :: Options+startOptions = Options { optVerbose = False+ , optInput = ""+ , optOutput = ""+ , optDate = Nothing+ , optBalance = Nothing+ , optText = []+ , optLongText = []+ , optSkip = 0+ , optSeparator = ','+ , optUpdater = Nothing+ }++--instance Show (a -> b) where+-- show _ = "<function>"++instance Show (IO a) where+ show _ = "<IoString>"+++options :: [ OptDescr (Options -> IO Options) ]+options =+ [ Option "i" ["input"]+ (ReqArg+ (\arg opt -> return opt { optInput = arg })+ "FILE")+ "Input file (CSV Format)"++ , Option "o" ["output"]+ (ReqArg+ (\arg opt -> return opt { optOutput = arg })+ "FILE")+ "Output file (Qif Format)"++ , Option "u" ["updater"]+ (ReqArg+ (\arg opt -> return opt { optUpdater = Just arg })+ "FILE")+ "Updater file format is `match`<->`replacement`"+ , Option "z" ["separator"]+ (ReqArg+ (\arg opt -> return opt { optSeparator = head arg })+ "char")+ "separator of the csv file"++ , Option "d" ["date"]+ (ReqArg+ (\arg opt -> return opt { optDate = readColumn arg})+ "<column number>")+ "Date Column"+ , Option "b" ["balance"]+ (ReqArg+ (\arg opt -> return opt { optBalance = readColumn arg})+ "<column number>")+ "Balance Column"+ , Option "t" ["text"]+ (ReqArg+ (\arg opt -> return opt { optText = readColumns arg})+ "<column numbers>")+ "Text Columns"+ , Option "l" ["longtext"]+ (ReqArg+ (\arg opt -> return opt { optLongText = readColumns arg})+ "<column numbers>")+ "Long Text Columns"+ , Option "s" ["skip"]+ (ReqArg+ (\arg opt -> case (readColumn arg) of+ Nothing -> return opt+ Just r -> return opt { optSkip = r} )+ "<number of rows>")+ "Rows to Skip before reading"++ , Option "v" ["verbose"]+ (NoArg+ (\opt -> return opt { optVerbose = True }))+ "Enable verbose messages"++ , Option "V" ["version"]+ (NoArg+ (\_ -> do+ hPutStrLn stderr "Version 0.2"+ exitWith ExitSuccess))+ "Print version"++ , Option "h" ["help"]+ (NoArg+ (\_ -> do+ prg <- getProgName+ hPutStrLn stderr (usageInfo (useage prg) options)+ exitWith ExitSuccess))+ "Show help"+ ]++useage :: String -> String+useage prog = prog ++ "\n\n" +++ "author : Ingolf Wagner\n\n" +++ header ++ "\n\n" +++ information ++ "\n\n"+ where header = "Converting CSV files to Qif Files"+ information = "all column numbers start at 0!"+++readColumn :: String -> Maybe Int+readColumn input = readMaybe input++readColumns :: String -> [Int]+readColumns input = catMaybes $ map readColumn $ splitOn "," input+
+ src/Parser.hs view
@@ -0,0 +1,49 @@+{-# LANGUAGE ScopedTypeVariables #-}+-----------------------------------------------------------------------------+--+-- Module : Parser+-- Copyright : (c) Ingolf Wagner+-- License : BSD3+--+-- Maintainer : Ingolf Wagner <palipalo9@googlemail.com>+-- Stability : unstable+-- Portability :+--+-- | This package should create a parser for csv files.+-- it's just a wrapper+--+-----------------------------------------------------------------------------++module Parser where++import Data.Char+import Data.Spreadsheet+import Control.Monad.Exception.Asynchronous.Lazy(Exceptional(..))+import Data.List+import System.IO.Error+import System.IO++import Qifer+++++type ParseError = String++-- | parse csv file to read transactions from it+parseCSVFromFile :: Int -> String -> Char -> IO (Either ParseError CSV)+parseCSVFromFile skip file separator = do+ firstContent <- readFile file+ let content = unlines . separatorAtEndFix . drop skip . lines $ firstContent+ separatorAtEndFix = map (\l -> l ++ " ")+ case (fromString '\n' separator content) of+ Exceptional (Just s) result -> do+ hPutStrLn stderr s+ return $ Right $ strip result+ Exceptional Nothing result -> return $ Right $ strip result++strip :: CSV -> CSV+strip = map (\line -> map (\word -> stripWord word) line)+ where stripWord = dropWhile dontNeed . dropWhileEnd dontNeed+ dontNeed c = (c == '"') || (isSpace c)+
+ src/Qifer.hs view
@@ -0,0 +1,91 @@+-----------------------------------------------------------------------------+--+-- Module : Qifer+-- Copyright : (c) Ingolf Wagner+-- License : BSD3+--+-- Maintainer : Ingolf Wagner <palipalo9@googlemail.com>+-- Stability : unstable+-- Portability :+--+-- |+--+-----------------------------------------------------------------------------++module Qifer where++import Data.List+import Text.Regex.TDFA++type CSV = [Row]+type Row = [Column]+type Column = String++data Transaction = Transaction { date :: String+ , description :: String+ , text :: String+ , balance :: String+ } deriving (Eq,Show)++type Position = Int++data Rule = Rule { dateField :: Position+ , descField :: [Position]+ , textField :: [Position]+ , balanceField :: Position+ } deriving (Show)++highestPosition :: Rule -> Position+highestPosition (Rule dF tF ltF bF) =+ maximum . concat $ [[dF],tF,ltF,[bF]]++toTransactions :: Rule -> CSV -> [Transaction]+toTransactions _ [] = []+toTransactions rule (c:sv)+ | (highestPosition rule) >= (length c) = stepDeeper+ | otherwise = transform : stepDeeper+ where pick n = (c !! (n rule))+ pock n = concat . intersperse " " . map (\k -> c !! k ) $ (n rule)+ stepDeeper = toTransactions rule sv+ transform = (Transaction (pick dateField)+ (pock descField)+ (pock textField)+ (pick balanceField))++qifHeader :: String+qifHeader = "!Type:Bank"++toQif :: Transaction -> [String]+toQif trans = ["P" ++ d, "T" ++ money, "D" ++ time, "M" ++ msg]+ where d = (description trans)+ money = (balance trans)+ time = (date trans)+ msg = (text trans)++transToQif :: [Transaction] -> [String]+transToQif trans = qifHeader : (foo trans)+ where foo [] = []+ foo (t:ts) = (toQif t) ++ ["^"] ++ (foo ts)++-- | updates a Transaction if regex works+updateTransaction :: String -> String -> Transaction -> Transaction+updateTransaction regex replacement transaction =+ transaction { description = updateDesc }+ where+ updateDesc = if matches then replacement else (description transaction)+ matches = (description transaction) =~ regex :: Bool++-- | very inefficient but better than nothing+update :: [(String,String)] -> [Transaction] -> [Transaction]+update _ [] = []+update regex (t:ts) = (updateSingle regex t) : (update regex ts)+ where+ updateSingle [] r = r+ updateSingle ((rgx,replacement):rest) transaction =+ if updatedTrans == transaction+ then+ updateSingle rest transaction+ else+ updatedTrans+ where+ updatedTrans = updateTransaction rgx replacement transaction
+ test/Tester.hs view
@@ -0,0 +1,51 @@+-----------------------------------------------------------------------------+--+-- Module : Tester+-- Copyright : (c) Ingolf Wagner+-- License : BSD3+--+-- Maintainer : Ingolf Wagner <palipalo9@googlemail.com>+-- Stability : unstable+-- Portability :+--+-- |+--+-----------------------------------------------------------------------------++module Main (main) where+++import Test.Hspec+import Qifer++main :: IO ()+main = hspec $ do+ describe "Csv" $ do+ it "should transform CSV" $ do+ let rule = (Rule 1 [2] [0,3] 4)+ trans= (Transaction "2" "3" "1 4" "5")+ csv = [["1","2","3","4","5"]]+ (toTransactions rule csv) `shouldBe` [trans]+ it "should generate Qif" $ do+ let trans = (Transaction "heute" "short" "long bla bla" "100$")+ qif = ["Pshort","T100$","Dheute","Mlong bla bla"]+ (toQif trans) `shouldBe` qif+ it "should generate Qif File content" $ do+ let trans = (Transaction "heute" "short" "long bla bla" "100$")+ qif = ["!Type:Bank","Pshort","T100$","Dheute","Mlong bla bla","^"]+ (transToQif [trans]) `shouldBe` qif+ it "should update a transaction" $ do+ let trans = (Transaction "heute" "short" "long bla bla" "100$")+ theUpdate = (Transaction "heute" "bam" "long bla bla" "100$")+ (updateTransaction "ort" "bam" trans) `shouldBe` theUpdate+ (updateTransaction "foo" "bam" trans) `shouldBe` trans+ it "should update multiple transactions" $ do+ let transA = (Transaction "heute" "short story long its .." "long bla bla" "100$")+ transB = (Transaction "morgen" "balla ball bam bam boom" "long bla bla" "100$")+ transA1 = (Transaction "heute" "found story" "long bla bla" "100$")+ transB1 = (Transaction "morgen" "found balla" "long bla bla" "100$")+ regex = [("story","found story"),("balla","found balla")]+ transactions = [transA,transB]+ (update regex transactions) `shouldBe` [transA1,transB1]++