Initial commit

Description, date, and total fields are functional. Next step is to
start adding structure to the form and adding a window to the side which
will show previous similar transactions and autocomplete suggestions.
Arrow keys will be able to auto-fill the current field from the selected
suggestion.
This commit is contained in:
Tony Grosinger 2022-08-04 08:39:37 -07:00
commit 461812eace
22 changed files with 1508 additions and 0 deletions

15
.devcontainer/Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM ghcr.io/tgrosinger/hledger-multiarch:1.26.1 as hledger
FROM docker.io/library/golang:1.18-bullseye
# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# && apt-get -y install --no-install-recommends <your-package-list-here>
# [Optional] Uncomment the next lines to use go get to install anything else you need
# USER vscode
# RUN go get -x <your-dependency-or-tool>
COPY --from=hledger /usr/bin/hledger /usr/bin/hledger
ENTRYPOINT bash

View File

@ -0,0 +1,27 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.241.1/containers/go
{
"name": "Go",
"build": {
"dockerfile": "Dockerfile",
},
"runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ],
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
// Set *default* container specific settings.json values on container create.
"settings": {
"go.toolsManagement.checkForUpdates": "local",
"go.useLanguageServer": true,
"go.gopath": "/go"
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"golang.Go"
]
}
}
}

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
ledger-tui
debug.log

12
Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM library/golang:1.18-bullseye as builder
WORKDIR /src
COPY . .
# TODO: Add cross compiling and multiple binary distributions.
RUN go build cmd/ledger-tui/ledger-tui.go
FROM scratch
COPY --from=builder /src/ledger-tui /ledger-tui
ENTRYPOINT /ledger-tui

24
examples/hledger-add.txt Normal file
View File

@ -0,0 +1,24 @@
$ hledger add
Adding transactions to journal file main.journal
Any command line arguments will be used as defaults.
Use tab key to complete, readline keys to edit, enter to accept defaults.
An optional (CODE) may follow transaction dates.
An optional ; COMMENT may follow descriptions or amounts.
If you make a mistake, enter < at any prompt to go one step backward.
To end a transaction, enter . when prompted.
To quit, enter . at a date prompt or press control-d or control-c.
Date [2022-02-08]: 2/15
Description: market
Account 1: expenses:food
Amount 1: $50
Account 2: assets:cash
Amount 2 [$-50]:
Account 3 (or . or enter to finish this transaction):
2022-02-15 market
expenses:food $50
assets:cash $-50
Save this transaction to the journal ? [y]:
Saved.
Starting the next transaction (. or ctrl-D/ctrl-C to quit)
Date [2022-02-15]: 

457
examples/simple.json Normal file
View File

@ -0,0 +1,457 @@
[
{
"tcode": "",
"tcomment": "",
"tdate": "2008-01-01",
"tdate2": null,
"tdescription": "income",
"tindex": 1,
"tpostings": [
{
"paccount": "assets:bank:checking",
"pamount": [
{
"acommodity": "$",
"aprice": null,
"aquantity": {
"decimalMantissa": 150,
"decimalPlaces": 2,
"floatingPoint": 1.5
},
"astyle": {
"ascommodityside": "L",
"ascommodityspaced": false,
"asdecimalpoint": ".",
"asdigitgroups": null,
"asprecision": 2
}
}
],
"pbalanceassertion": null,
"pcomment": "",
"pdate": null,
"pdate2": null,
"poriginal": null,
"pstatus": "Unmarked",
"ptags": [],
"ptransaction_": "1",
"ptype": "RegularPosting"
},
{
"paccount": "income:salary",
"pamount": [
{
"acommodity": "$",
"aprice": null,
"aquantity": {
"decimalMantissa": -150,
"decimalPlaces": 2,
"floatingPoint": -1.5
},
"astyle": {
"ascommodityside": "L",
"ascommodityspaced": false,
"asdecimalpoint": ".",
"asdigitgroups": null,
"asprecision": 2
}
}
],
"pbalanceassertion": null,
"pcomment": "",
"pdate": null,
"pdate2": null,
"poriginal": null,
"pstatus": "Unmarked",
"ptags": [],
"ptransaction_": "1",
"ptype": "RegularPosting"
}
],
"tprecedingcomment": "",
"tsourcepos": [
{
"sourceColumn": 1,
"sourceLine": 1,
"sourceName": "/workspaces/ledger-tui/examples/simple.ledger"
},
{
"sourceColumn": 1,
"sourceLine": 4,
"sourceName": "/workspaces/ledger-tui/examples/simple.ledger"
}
],
"tstatus": "Unmarked",
"ttags": []
},
{
"tcode": "",
"tcomment": "",
"tdate": "2008-06-01",
"tdate2": null,
"tdescription": "gift",
"tindex": 2,
"tpostings": [
{
"paccount": "assets:bank:checking",
"pamount": [
{
"acommodity": "$",
"aprice": null,
"aquantity": {
"decimalMantissa": 2,
"decimalPlaces": 0,
"floatingPoint": 2
},
"astyle": {
"ascommodityside": "L",
"ascommodityspaced": false,
"asdecimalpoint": ".",
"asdigitgroups": null,
"asprecision": 2
}
}
],
"pbalanceassertion": null,
"pcomment": "",
"pdate": null,
"pdate2": null,
"poriginal": null,
"pstatus": "Unmarked",
"ptags": [],
"ptransaction_": "2",
"ptype": "RegularPosting"
},
{
"paccount": "income:gifts",
"pamount": [
{
"acommodity": "$",
"aprice": null,
"aquantity": {
"decimalMantissa": -2,
"decimalPlaces": 0,
"floatingPoint": -2
},
"astyle": {
"ascommodityside": "L",
"ascommodityspaced": false,
"asdecimalpoint": ".",
"asdigitgroups": null,
"asprecision": 2
}
}
],
"pbalanceassertion": null,
"pcomment": "",
"pdate": null,
"pdate2": null,
"poriginal": null,
"pstatus": "Unmarked",
"ptags": [],
"ptransaction_": "2",
"ptype": "RegularPosting"
}
],
"tprecedingcomment": "",
"tsourcepos": [
{
"sourceColumn": 1,
"sourceLine": 5,
"sourceName": "/workspaces/ledger-tui/examples/simple.ledger"
},
{
"sourceColumn": 1,
"sourceLine": 8,
"sourceName": "/workspaces/ledger-tui/examples/simple.ledger"
}
],
"tstatus": "Unmarked",
"ttags": []
},
{
"tcode": "",
"tcomment": "",
"tdate": "2008-06-02",
"tdate2": null,
"tdescription": "save",
"tindex": 3,
"tpostings": [
{
"paccount": "assets:bank:saving",
"pamount": [
{
"acommodity": "$",
"aprice": null,
"aquantity": {
"decimalMantissa": 345,
"decimalPlaces": 2,
"floatingPoint": 3.45
},
"astyle": {
"ascommodityside": "L",
"ascommodityspaced": false,
"asdecimalpoint": ".",
"asdigitgroups": null,
"asprecision": 2
}
}
],
"pbalanceassertion": null,
"pcomment": "",
"pdate": null,
"pdate2": null,
"poriginal": null,
"pstatus": "Unmarked",
"ptags": [],
"ptransaction_": "3",
"ptype": "RegularPosting"
},
{
"paccount": "assets:bank:checking",
"pamount": [
{
"acommodity": "$",
"aprice": null,
"aquantity": {
"decimalMantissa": -345,
"decimalPlaces": 2,
"floatingPoint": -3.45
},
"astyle": {
"ascommodityside": "L",
"ascommodityspaced": false,
"asdecimalpoint": ".",
"asdigitgroups": null,
"asprecision": 2
}
}
],
"pbalanceassertion": null,
"pcomment": "",
"pdate": null,
"pdate2": null,
"poriginal": null,
"pstatus": "Unmarked",
"ptags": [],
"ptransaction_": "3",
"ptype": "RegularPosting"
}
],
"tprecedingcomment": "",
"tsourcepos": [
{
"sourceColumn": 1,
"sourceLine": 9,
"sourceName": "/workspaces/ledger-tui/examples/simple.ledger"
},
{
"sourceColumn": 1,
"sourceLine": 12,
"sourceName": "/workspaces/ledger-tui/examples/simple.ledger"
}
],
"tstatus": "Unmarked",
"ttags": []
},
{
"tcode": "",
"tcomment": "",
"tdate": "2008-06-03",
"tdate2": null,
"tdescription": "eat & shop",
"tindex": 4,
"tpostings": [
{
"paccount": "expenses:food",
"pamount": [
{
"acommodity": "$",
"aprice": null,
"aquantity": {
"decimalMantissa": 125,
"decimalPlaces": 2,
"floatingPoint": 1.25
},
"astyle": {
"ascommodityside": "L",
"ascommodityspaced": false,
"asdecimalpoint": ".",
"asdigitgroups": null,
"asprecision": 2
}
}
],
"pbalanceassertion": null,
"pcomment": "",
"pdate": null,
"pdate2": null,
"poriginal": null,
"pstatus": "Unmarked",
"ptags": [],
"ptransaction_": "4",
"ptype": "RegularPosting"
},
{
"paccount": "expenses:supplies",
"pamount": [
{
"acommodity": "$",
"aprice": null,
"aquantity": {
"decimalMantissa": 475,
"decimalPlaces": 2,
"floatingPoint": 4.75
},
"astyle": {
"ascommodityside": "L",
"ascommodityspaced": false,
"asdecimalpoint": ".",
"asdigitgroups": null,
"asprecision": 2
}
}
],
"pbalanceassertion": null,
"pcomment": "",
"pdate": null,
"pdate2": null,
"poriginal": null,
"pstatus": "Unmarked",
"ptags": [],
"ptransaction_": "4",
"ptype": "RegularPosting"
},
{
"paccount": "assets:cash",
"pamount": [
{
"acommodity": "$",
"aprice": null,
"aquantity": {
"decimalMantissa": -600,
"decimalPlaces": 2,
"floatingPoint": -6
},
"astyle": {
"ascommodityside": "L",
"ascommodityspaced": false,
"asdecimalpoint": ".",
"asdigitgroups": null,
"asprecision": 2
}
}
],
"pbalanceassertion": null,
"pcomment": "",
"pdate": null,
"pdate2": null,
"poriginal": null,
"pstatus": "Unmarked",
"ptags": [],
"ptransaction_": "4",
"ptype": "RegularPosting"
}
],
"tprecedingcomment": "",
"tsourcepos": [
{
"sourceColumn": 1,
"sourceLine": 13,
"sourceName": "/workspaces/ledger-tui/examples/simple.ledger"
},
{
"sourceColumn": 1,
"sourceLine": 17,
"sourceName": "/workspaces/ledger-tui/examples/simple.ledger"
}
],
"tstatus": "Cleared",
"ttags": []
},
{
"tcode": "",
"tcomment": "",
"tdate": "2008-12-31",
"tdate2": null,
"tdescription": "pay off",
"tindex": 5,
"tpostings": [
{
"paccount": "liabilities:debts",
"pamount": [
{
"acommodity": "$",
"aprice": null,
"aquantity": {
"decimalMantissa": 1,
"decimalPlaces": 0,
"floatingPoint": 1
},
"astyle": {
"ascommodityside": "L",
"ascommodityspaced": false,
"asdecimalpoint": ".",
"asdigitgroups": null,
"asprecision": 2
}
}
],
"pbalanceassertion": null,
"pcomment": "",
"pdate": null,
"pdate2": null,
"poriginal": null,
"pstatus": "Unmarked",
"ptags": [],
"ptransaction_": "5",
"ptype": "RegularPosting"
},
{
"paccount": "assets:bank:checking",
"pamount": [
{
"acommodity": "$",
"aprice": null,
"aquantity": {
"decimalMantissa": -1,
"decimalPlaces": 0,
"floatingPoint": -1
},
"astyle": {
"ascommodityside": "L",
"ascommodityspaced": false,
"asdecimalpoint": ".",
"asdigitgroups": null,
"asprecision": 2
}
}
],
"pbalanceassertion": null,
"pcomment": "",
"pdate": null,
"pdate2": null,
"poriginal": null,
"pstatus": "Unmarked",
"ptags": [],
"ptransaction_": "5",
"ptype": "RegularPosting"
}
],
"tprecedingcomment": "",
"tsourcepos": [
{
"sourceColumn": 1,
"sourceLine": 18,
"sourceName": "/workspaces/ledger-tui/examples/simple.ledger"
},
{
"sourceColumn": 25,
"sourceLine": 20,
"sourceName": "/workspaces/ledger-tui/examples/simple.ledger"
}
],
"tstatus": "Cleared",
"ttags": []
}
]

20
examples/simple.ledger Normal file
View File

@ -0,0 +1,20 @@
2008/01/01 income
assets:bank:checking $1.50
income:salary
2008/06/01 gift
assets:bank:checking $2
income:gifts
2008/06/02 save
assets:bank:saving $3.45
assets:bank:checking
2008/06/03 * eat & shop
expenses:food $1.25
expenses:supplies $4.75
assets:cash
2008/12/31 * pay off
liabilities:debts $1
assets:bank:checking

29
go.mod Normal file
View File

@ -0,0 +1,29 @@
module github.com/tgrosinger/ledger-tui
go 1.18
require github.com/charmbracelet/bubbletea v0.22.0
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/charmbracelet/lipgloss v0.5.0 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/lrstanley/bubblezone v0.0.0-20220729154607-e408d1dc3890 // indirect
github.com/spf13/cobra v1.5.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
)
require (
github.com/charmbracelet/bubbles v0.13.0
github.com/containerd/console v1.0.3 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect
github.com/muesli/cancelreader v0.2.1 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
)

58
go.sum Normal file
View File

@ -0,0 +1,58 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/charmbracelet/bubbles v0.13.0 h1:zP/ROH3wJEBqZWKIsD50ZKKlx3ydLInq3LdD/Nrlb8w=
github.com/charmbracelet/bubbles v0.13.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc=
github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4=
github.com/charmbracelet/bubbletea v0.22.0 h1:E1BTNSE3iIrq0G0X6TjGAmrQ32cGCbFDPcIuImikrUc=
github.com/charmbracelet/bubbletea v0.22.0/go.mod h1:aoVIwlNlr5wbCB26KhxfrqAn0bMp4YpJcoOelbxApjs=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lrstanley/bubblezone v0.0.0-20220729154607-e408d1dc3890 h1:mAJcJhjnJiwT3w9q1whqbnvDfezUSR+ECVb+B1j3D64=
github.com/lrstanley/bubblezone v0.0.0-20220729154607-e408d1dc3890/go.mod h1:CxaUrg7Y6DmnquTpb1Rgxib+u+NcRxrDi8m/mR1poTM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA=
github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/cancelreader v0.2.1 h1:Xzd1B4U5bWQOuSKuN398MyynIGTNT89dxzpEDsalXZs=
github.com/muesli/cancelreader v0.2.1/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI=
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

View File

@ -0,0 +1,69 @@
package hledger
import (
"errors"
tx "github.com/tgrosinger/ledger-tui/pkg/transaction"
)
// HLedger implements the Ledger interface. It uses the hledger binary to pull
// data from the ledger file.
type HLedger struct {
filepath string
}
func (hl HLedger) GetTransactions() ([]tx.Transaction, error) {
return nil, errors.New("Method not implemented")
}
func (hl HLedger) AddTransaction(newTx tx.Transaction) error {
return errors.New("Method not implemented")
}
// Transaction is the data format for transactions as they come from hledger
// when executing `hledger print -O json`.
type Transaction struct {
Tcode string `json:"tcode"`
Tcomment string `json:"tcomment"`
Tdate string `json:"tdate"`
Tdate2 interface{} `json:"tdate2"`
Tdescription string `json:"tdescription"`
Tindex int `json:"tindex"`
Tpostings []struct {
Paccount string `json:"paccount"`
Pamount []struct {
Acommodity string `json:"acommodity"`
Aprice interface{} `json:"aprice"`
Aquantity struct {
DecimalMantissa int `json:"decimalMantissa"`
DecimalPlaces int `json:"decimalPlaces"`
FloatingPoint float64 `json:"floatingPoint"`
} `json:"aquantity"`
Astyle struct {
Ascommodityside string `json:"ascommodityside"`
Ascommodityspaced bool `json:"ascommodityspaced"`
Asdecimalpoint string `json:"asdecimalpoint"`
Asdigitgroups interface{} `json:"asdigitgroups"`
Asprecision int `json:"asprecision"`
} `json:"astyle"`
} `json:"pamount"`
Pbalanceassertion interface{} `json:"pbalanceassertion"`
Pcomment string `json:"pcomment"`
Pdate interface{} `json:"pdate"`
Pdate2 interface{} `json:"pdate2"`
Poriginal interface{} `json:"poriginal"`
Pstatus string `json:"pstatus"`
Ptags []interface{} `json:"ptags"`
Ptransaction string `json:"ptransaction_"`
Ptype string `json:"ptype"`
} `json:"tpostings"`
Tprecedingcomment string `json:"tprecedingcomment"`
Tsourcepos []struct {
SourceColumn int `json:"sourceColumn"`
SourceLine int `json:"sourceLine"`
SourceName string `json:"sourceName"`
} `json:"tsourcepos"`
Tstatus string `json:"tstatus"`
Ttags []interface{} `json:"ttags"`
}

8
pkg/ledger/ledger.go Normal file
View File

@ -0,0 +1,8 @@
package ledger
import "github.com/tgrosinger/ledger-tui/pkg/transaction"
type Ledger interface {
GetTransactions() ([]transaction.Transaction, error)
AddTransaction(newTx transaction.Transaction) error
}

View File

@ -0,0 +1 @@
package transaction

View File

@ -0,0 +1,29 @@
package transaction
import "time"
// Transaction contains the information stored in a ledger file about a single
// transaction.
type Transaction struct {
Index int
Date time.Time
Description string
Comment string
PreceedingComment string
Postings []Posting
Status string // TODO: Enum?
Tags []string
}
type Posting struct {
Amount float64
Commodity string
Account string
Status string // TODO: Enum?
Type string // TODO: Enum?
Tags []string
}
func (t Transaction) string() string {
return "TODO"
}

17
pkg/tui/cmds.go Normal file
View File

@ -0,0 +1,17 @@
package tui
import (
tea "github.com/charmbracelet/bubbletea"
)
//
// Bubbletea Commands
//
func NextInput() tea.Msg {
return NextInputMsg
}
func PrevInput() tea.Msg {
return PrevInputMsg
}

230
pkg/tui/commands/add/add.go Normal file
View File

@ -0,0 +1,230 @@
package add
import (
"errors"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
"github.com/tgrosinger/ledger-tui/pkg/tui/currencyinput"
"github.com/tgrosinger/ledger-tui/pkg/tui/dateinput"
"github.com/tgrosinger/ledger-tui/pkg/tui/postingsinput"
)
var AddCmd = &cobra.Command{
Use: "add",
Short: "Add a new transaction to a ledger file",
Args: cobra.NoArgs,
Run: executeAddTUI,
}
func executeAddTUI(cmd *cobra.Command, args []string) {
addTUI := AddTxTUI{
focused: date,
furthestFocus: date,
date: dateinput.New(time.Now(), true),
description: textinput.New(),
total: currencyinput.New("$"),
help: help.New(),
keymap: keymap{
nextField: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "Next Field")),
prevField: key.NewBinding(
key.WithKeys("shift+tab"),
key.WithHelp("shift+tab", "Previous Field")),
quit: key.NewBinding(
key.WithKeys("esc", "ctrl+c"),
key.WithHelp("esc", "Quit")),
},
}
addTUI.description.Placeholder = "Transaction Description"
addTUI.description.Validate = descriptionValidator
p := tea.NewProgram(addTUI)
if err := p.Start(); err != nil {
fmt.Printf("Alas, there's been an error: %v", err)
os.Exit(1)
}
}
func descriptionValidator(s string) error {
// The description must not have a line break
if strings.Contains(s, "\n") {
return errors.New("description must not contain a new line")
}
return nil
}
type focus int
const (
none focus = iota
date
description
total
lines
)
type keymap struct {
nextField key.Binding
prevField key.Binding
//increment key.Binding
//decrement key.Binding
quit key.Binding
}
type AddTxTUI struct {
// Add New Tx Form
focused focus
furthestFocus focus
date dateinput.Model
description textinput.Model
total currencyinput.Model
keymap keymap
help help.Model
}
func (m AddTxTUI) Init() tea.Cmd {
return textinput.Blink
}
func (m AddTxTUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds := make([]tea.Cmd, 3)
switch msg := msg.(type) {
case dateinput.Msg:
switch msg {
case dateinput.NextInputMsg:
if m.focused == date {
log.Println("Focusing description")
m.focused = description
}
case dateinput.PrevInputMsg:
// No input field before date, so ignore
}
case postingsinput.Msg:
switch msg {
case postingsinput.NextInputMsg:
if m.focused == lines {
// TODO: Focus on save button
}
case postingsinput.PrevInputMsg:
if m.focused == lines {
m.focused = total
}
}
case tea.KeyMsg:
switch msg.Type {
// These keys should exit the program.
case tea.KeyCtrlC, tea.KeyEsc:
return m, tea.Quit
// Navigate between fields
case tea.KeyTab:
if m.focused == description {
m.focused = total
} else if m.focused == total {
m.focused = lines
}
case tea.KeyShiftTab:
if m.focused == description {
m.focused = date
} else if m.focused == total {
m.focused = description
}
}
}
m.setFocus()
var cmd tea.Cmd
m.date, cmd = m.date.Update(msg)
cmds = append(cmds, cmd)
m.description, cmd = m.description.Update(msg)
cmds = append(cmds, cmd)
m.total, cmd = m.total.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m *AddTxTUI) setFocus() {
if m.focused != date && m.date.Focused() {
m.date.Blur()
}
if m.focused != description && m.description.Focused() {
m.description.Blur()
}
if m.focused != total && m.total.Focused() {
m.total.Blur()
}
// TODO: Blur accounts
switch m.focused {
case date:
m.date.Focus()
if m.furthestFocus < date {
m.furthestFocus = date
}
case description:
m.description.Focus()
if m.furthestFocus < description {
m.furthestFocus = description
}
case total:
m.total.Focus()
if m.furthestFocus < total {
m.furthestFocus = total
}
case lines:
// TODO: Focus accounts
if m.furthestFocus < lines {
m.furthestFocus = lines
}
}
}
func (m AddTxTUI) helpView() string {
return "\n" + m.help.ShortHelpView([]key.Binding{
m.keymap.nextField,
m.keymap.prevField,
m.keymap.quit,
})
}
func (m AddTxTUI) View() string {
log.Println("Rendering LedgerTUI")
output := strings.Builder{}
output.WriteString("Tx Date: " + m.date.View())
if m.furthestFocus >= description {
output.WriteString("\nDescription: " + m.description.View())
}
if m.furthestFocus >= total {
output.WriteString("\nTotal: " + m.total.View())
}
// TODO: When accounts is focused, only show the account view.
// The account view should have a text box at the top, and a list of
// suggestions below that can be selected.
// Maybe use a "next" button or something to make it less jarring.
output.WriteString(m.helpView())
return output.String()
}

View File

@ -0,0 +1,74 @@
package currencyinput
import (
"errors"
"log"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
type Model struct {
amount textinput.Model
}
func New(prefix string) Model {
m := Model{
amount: textinput.New(),
}
m.amount.Prompt = prefix
m.amount.Validate = numberValidator
m.amount.Placeholder = "00.00"
return m
}
func numberValidator(s string) error {
// Should be a number with a maximum of two decimal places
_, err := strconv.ParseFloat(s, 64)
if err != nil {
return err
}
decimalIndex := strings.Index(s, ".")
if decimalIndex == -1 {
return nil
}
decimalPlaces := (len(s) - 1) - decimalIndex
log.Println(decimalPlaces)
if decimalPlaces > 2 {
return errors.New("currency may only have two decimal places")
}
return nil
}
func (m Model) Init() tea.Cmd {
return textinput.Blink
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
var cmd tea.Cmd
m.amount, cmd = m.amount.Update(msg)
return m, cmd
}
func (m Model) View() string {
return m.amount.View()
}
func (m *Model) Focused() bool {
return m.amount.Focused()
}
func (m *Model) Focus() {
m.amount.Focus()
}
func (m *Model) Blur() {
m.amount.Blur()
}

18
pkg/tui/dateinput/cmds.go Normal file
View File

@ -0,0 +1,18 @@
package dateinput
import tea "github.com/charmbracelet/bubbletea"
type Msg int
const (
NextInputMsg Msg = iota
PrevInputMsg
)
func NextInput() tea.Msg {
return NextInputMsg
}
func PrevInput() tea.Msg {
return PrevInputMsg
}

View File

@ -0,0 +1,386 @@
package dateinput
import (
"errors"
"fmt"
"log"
"strconv"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
// TODO: This could probably be its own repo
// focus denotes which field in this input is focused.
type focus int
const (
none focus = iota
year
month
day
)
type Model struct {
year textinput.Model
month textinput.Model
day textinput.Model
focused focus
}
func New(defaultDate time.Time, focused bool) Model {
d := Model{
year: textinput.New(),
month: textinput.New(),
day: textinput.New(),
}
d.year.Placeholder = "YYYY"
d.year.Prompt = ""
d.year.Width = 4
d.year.SetValue(strconv.Itoa(defaultDate.Year()))
d.year.CharLimit = 4
d.year.Validate = yearValidator
d.month.Placeholder = "MM"
d.month.Prompt = ""
d.month.Width = 2
d.month.SetValue(defaultDate.Format("01"))
d.month.CharLimit = 2
d.month.Validate = monthValidator
d.day.Placeholder = "DD"
d.day.Prompt = ""
d.day.Width = 2
d.day.SetValue(defaultDate.Format("02"))
d.day.CharLimit = 2
d.day.Validate = dayValidator
if focused {
d.log("Setting initial focus to year")
d.focused = year
d.year.Focus()
}
return d
}
func yearValidator(s string) error {
// The input already restricts to 4 digits, so we just need to ensure the
// value is greater than 0 (technically).
val, err := strconv.Atoi(s)
if err != nil {
return err
}
if val <= 0 {
return errors.New("year must be positive")
}
// TODO: Ensure that the month-day combination is still valid.
return nil
}
func monthValidator(s string) error {
// The input already restricts to 4 digits, so we just need to ensure the
// value is between 1 and 12.
val, err := strconv.Atoi(s)
if err != nil {
return err
}
if val < 1 || val > 12 {
return errors.New("month must be between 1 and 12")
}
// TODO: Ensure that the month-day combination is valid.
return nil
}
func dayValidator(s string) error {
// The input already restricts to 4 digits, so we just need to ensure the
// value is between 1 and 31.
val, err := strconv.Atoi(s)
if err != nil {
return err
}
if val < 1 || val > 31 {
return errors.New("day must be between 1 and 31")
}
// TODO: Ensure that the month-day combination is valid.
return nil
}
func (m Model) Init() tea.Cmd {
return textinput.Blink
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
cmds := make([]tea.Cmd, 3)
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
// These keys should exit the program.
case tea.KeyCtrlC, tea.KeyEsc:
return m, tea.Quit
// Navigate the input fields
case tea.KeyTab:
cmds = append(cmds, m.nextInput(true))
case tea.KeyShiftTab:
cmds = append(cmds, m.prevInput(true))
case tea.KeyRight:
// Only advance fields if cursor is at the end of this field
cmds = append(cmds, m.nextInput(false))
case tea.KeyLeft:
// Only advance fields if cursor is at the start of this field
cmds = append(cmds, m.prevInput(false))
// Modify the focused input field with arrow keys
case tea.KeyUp:
m.incrementField()
case tea.KeyDown:
m.decrementField()
}
m.setFocus()
}
var cmd tea.Cmd
m.year, cmd = m.year.Update(msg)
cmds = append(cmds, cmd)
m.month, cmd = m.month.Update(msg)
cmds = append(cmds, cmd)
m.day, cmd = m.day.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m Model) View() string {
return fmt.Sprintf(`%s- %s- %s`,
m.year.View(),
m.month.View(),
m.day.View())
}
func (m *Model) Focused() bool {
return m.focused != none
}
// Focus gives focus to the first field in this model.
func (m *Model) Focus() {
if m.focused == none {
m.focused = year
m.setFocus()
}
}
// Blur removes focus from all fields contained in this model.
func (m *Model) Blur() {
if m.focused != none {
m.focused = none
m.setFocus()
}
}
// AtEnd returns true if the cursor is at the end of the last field in this
// model. If ignoreCursor it will return true if the cursor is anywhere in the
// last field.
func (m *Model) AtEnd(ignoreCursor bool) bool {
if m.focused != day {
return false
}
return ignoreCursor || m.day.Cursor() == 3
}
// AtStart returns true if the cursor is at the start of the first field in this
// model. If ignoreCursor it will return true if the cursor is anywhere in the
// first field.
func (m *Model) AtStart(ignoreCursor bool) bool {
if m.focused != year {
return false
}
return ignoreCursor || m.year.Cursor() == 0
}
// setFocus updates the contained fields to ensure only the correct one has
// focus.
func (m *Model) setFocus() {
if m.focused != year && m.year.Focused() {
m.year.Blur()
}
if m.focused != month && m.month.Focused() {
m.month.Blur()
}
if m.focused != day && m.day.Focused() {
m.day.Blur()
}
switch m.focused {
case year:
m.year.Focus()
case month:
m.month.Focus()
case day:
m.day.Focus()
}
}
// nextInput changes the focus in this model to the next field. Does nothing if
// already on the last field. Also places the cursor in that field at the end.
func (m *Model) nextInput(force bool) tea.Cmd {
if !force {
switch m.focused {
case year:
pos := m.year.Cursor()
if pos != 4 {
return nil
}
case month:
pos := m.month.Cursor()
if pos != 2 {
return nil
}
}
}
m.log(fmt.Sprintf("Switching to next input from %d", m.focused))
// Setting the cursor to the end of the text box so that backspace is easily available.
switch m.focused {
case year:
m.focused = month
m.month.CursorEnd()
case month:
m.focused = day
m.day.CursorEnd()
default:
// On the last input already
m.focused = none
return NextInput
}
return nil
}
// prevInput changes the focus in this model to the previous field. Does nothing
// if already on the first field. Also places the cursor in that field at the
// end.
func (m *Model) prevInput(force bool) tea.Cmd {
if !force {
switch m.focused {
case year:
pos := m.year.Cursor()
if pos != 0 {
return nil
}
case month:
pos := m.month.Cursor()
if pos != 0 {
return nil
}
case day:
pos := m.day.Cursor()
if pos != 0 {
return nil
}
}
}
m.log(fmt.Sprintf("Switching to prev input from %d", m.focused))
switch m.focused {
case month:
m.focused = year
m.year.CursorEnd()
case day:
m.focused = month
m.month.CursorEnd()
// TODO: Reset blink if needed
default:
// On the first input already
m.focused = none
return PrevInput
}
return nil
}
// incrementField intelligently increments the value in the selected field. Will
// roll over other fields if necessary to keep the date valid.
func (m *Model) incrementField() {
m.log(fmt.Sprintf("Incrementing %d field", m.focused))
currentDate, err := m.currentDate()
if err != nil {
log.Fatal(err.Error())
}
switch m.focused {
case year:
currentDate = currentDate.AddDate(1, 0, 0)
case month:
currentDate = currentDate.AddDate(0, 1, 0)
case day:
currentDate = currentDate.AddDate(0, 0, 1)
}
m.setDate(currentDate)
}
// decrementField intelligently decrements the value in the selected field. Will
// roll over other fields if necessary to keep the date valid.
func (m *Model) decrementField() {
m.log(fmt.Sprintf("Decrementing %d field", m.focused))
currentDate, err := m.currentDate()
if err != nil {
log.Fatal(err.Error())
}
switch m.focused {
case year:
currentDate = currentDate.AddDate(-1, 0, 0)
case month:
currentDate = currentDate.AddDate(0, -1, 0)
case day:
currentDate = currentDate.AddDate(0, 0, -1)
}
m.setDate(currentDate)
}
// setDate takes a time.Time and sets the values of the three date fields accordingly.
func (m *Model) setDate(date time.Time) {
m.log("Setting date to " + date.Format("2006-01-02"))
m.year.SetValue(date.Format("2006"))
m.month.SetValue(date.Format("01"))
m.day.SetValue(date.Format("02"))
}
// currentDate retrieves the values from the three date fields and constructs
// them into a time.Time object if possible. Returns an error if the values are
// not valid and cannot be turned into a date.
func (m *Model) currentDate() (time.Time, error) {
parsed, err := time.Parse("2006-01-02",
fmt.Sprintf("%s-%s-%s", m.year.Value(), m.month.Value(), m.day.Value()))
if err != nil {
return time.Time{}, fmt.Errorf("failed to parse the currently configured time: %w", err)
}
return parsed, nil
}
func (m *Model) log(msg string) {
log.Println("DateInput: " + msg)
}

View File

@ -0,0 +1,5 @@
package tui
// TODO: This could probably be its own repo
// Use https://github.com/lithammer/fuzzysearch

8
pkg/tui/msgs.go Normal file
View File

@ -0,0 +1,8 @@
package tui
type TUIMsg int
const (
NextInputMsg TUIMsg = iota
PrevInputMsg
)

View File

@ -0,0 +1,18 @@
package postingsinput
import tea "github.com/charmbracelet/bubbletea"
type Msg int
const (
NextInputMsg Msg = iota
PrevInputMsg
)
func NextInput() tea.Msg {
return NextInputMsg
}
func PrevInput() tea.Msg {
return PrevInputMsg
}

View File

@ -0,0 +1 @@
package postingsinput