commit 461812eace2b6cf3e11f59a79cf2234094fc400e Author: Tony Grosinger Date: Thu Aug 4 08:39:37 2022 -0700 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. diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..6387707 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -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 + +# [Optional] Uncomment the next lines to use go get to install anything else you need +# USER vscode +# RUN go get -x + +COPY --from=hledger /usr/bin/hledger /usr/bin/hledger + +ENTRYPOINT bash diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..1251459 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -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" + ] + } + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d2538b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +ledger-tui +debug.log \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a921e53 --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/examples/hledger-add.txt b/examples/hledger-add.txt new file mode 100644 index 0000000..e6911a5 --- /dev/null +++ b/examples/hledger-add.txt @@ -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]:  \ No newline at end of file diff --git a/examples/simple.json b/examples/simple.json new file mode 100644 index 0000000..bd26d29 --- /dev/null +++ b/examples/simple.json @@ -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": [] + } +] diff --git a/examples/simple.ledger b/examples/simple.ledger new file mode 100644 index 0000000..3fc5121 --- /dev/null +++ b/examples/simple.ledger @@ -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 \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c72a064 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..44a9bd6 --- /dev/null +++ b/go.sum @@ -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= diff --git a/pkg/ledger/hledger/hledger.go b/pkg/ledger/hledger/hledger.go new file mode 100644 index 0000000..abfd500 --- /dev/null +++ b/pkg/ledger/hledger/hledger.go @@ -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"` +} diff --git a/pkg/ledger/ledger.go b/pkg/ledger/ledger.go new file mode 100644 index 0000000..9560f73 --- /dev/null +++ b/pkg/ledger/ledger.go @@ -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 +} diff --git a/pkg/transaction/collection.go b/pkg/transaction/collection.go new file mode 100644 index 0000000..0619207 --- /dev/null +++ b/pkg/transaction/collection.go @@ -0,0 +1 @@ +package transaction diff --git a/pkg/transaction/transaction.go b/pkg/transaction/transaction.go new file mode 100644 index 0000000..c95ea2b --- /dev/null +++ b/pkg/transaction/transaction.go @@ -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" +} diff --git a/pkg/tui/cmds.go b/pkg/tui/cmds.go new file mode 100644 index 0000000..5b07351 --- /dev/null +++ b/pkg/tui/cmds.go @@ -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 +} diff --git a/pkg/tui/commands/add/add.go b/pkg/tui/commands/add/add.go new file mode 100644 index 0000000..42d2d83 --- /dev/null +++ b/pkg/tui/commands/add/add.go @@ -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() +} diff --git a/pkg/tui/currencyinput/currencyinput.go b/pkg/tui/currencyinput/currencyinput.go new file mode 100644 index 0000000..1e87399 --- /dev/null +++ b/pkg/tui/currencyinput/currencyinput.go @@ -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() +} diff --git a/pkg/tui/dateinput/cmds.go b/pkg/tui/dateinput/cmds.go new file mode 100644 index 0000000..4a76124 --- /dev/null +++ b/pkg/tui/dateinput/cmds.go @@ -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 +} diff --git a/pkg/tui/dateinput/dateInput.go b/pkg/tui/dateinput/dateInput.go new file mode 100644 index 0000000..fa03c67 --- /dev/null +++ b/pkg/tui/dateinput/dateInput.go @@ -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) +} diff --git a/pkg/tui/fuzzyinput/fuzzyInput.go b/pkg/tui/fuzzyinput/fuzzyInput.go new file mode 100644 index 0000000..365e995 --- /dev/null +++ b/pkg/tui/fuzzyinput/fuzzyInput.go @@ -0,0 +1,5 @@ +package tui + +// TODO: This could probably be its own repo + +// Use https://github.com/lithammer/fuzzysearch diff --git a/pkg/tui/msgs.go b/pkg/tui/msgs.go new file mode 100644 index 0000000..d8b0508 --- /dev/null +++ b/pkg/tui/msgs.go @@ -0,0 +1,8 @@ +package tui + +type TUIMsg int + +const ( + NextInputMsg TUIMsg = iota + PrevInputMsg +) diff --git a/pkg/tui/postingsinput/cmds.go b/pkg/tui/postingsinput/cmds.go new file mode 100644 index 0000000..1b23bb2 --- /dev/null +++ b/pkg/tui/postingsinput/cmds.go @@ -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 +} diff --git a/pkg/tui/postingsinput/postingsInput.go b/pkg/tui/postingsinput/postingsInput.go new file mode 100644 index 0000000..7f6364d --- /dev/null +++ b/pkg/tui/postingsinput/postingsInput.go @@ -0,0 +1 @@ +package postingsinput