Repository: dkaslovsky/textnote Branch: main Commit: 154eeae2bc3d Files: 31 Total size: 179.7 KB Directory structure: gitextract_a03jodqx/ ├── .github/ │ └── workflows/ │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── .travis.yml ├── CHANGELOG.md ├── CREDITS ├── LICENSE ├── Makefile ├── README.md ├── cmd/ │ ├── archive/ │ │ └── archive.go │ ├── config/ │ │ └── config.go │ ├── initialize/ │ │ └── initialize.go │ ├── open/ │ │ ├── open.go │ │ └── open_test.go │ └── root.go ├── go.mod ├── go.sum ├── main.go └── pkg/ ├── archive/ │ ├── archive.go │ └── archive_test.go ├── config/ │ ├── config.go │ └── config_test.go ├── editor/ │ └── editor.go ├── file/ │ └── file.go └── template/ ├── archive.go ├── archive_test.go ├── section.go ├── section_test.go ├── template.go ├── template_test.go └── templatetest/ └── templatetest.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/release.yml ================================================ name: goreleaser on: push: tags: - 'v*.*.*' permissions: contents: write jobs: goreleaser: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v4 with: go-version-file: 'go.mod' - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: distribution: goreleaser version: latest args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, built with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # Dependency directories (remove the comment below to include it) # vendor/ # Directory for binaries dist/ ================================================ FILE: .goreleaser.yml ================================================ builds: - env: - CGO_ENABLED=0 ldflags: - -s -w -X main.version={{.Version}} goos: - darwin - linux - windows goarch: - amd64 - arm64 ignore: - goos: windows goarch: arm64 changelog: skip: true archives: - format: binary name_template: "{{.Binary}}_{{.Os}}_{{.Arch}}" ================================================ FILE: .travis.yml ================================================ language: go env: - GO111MODULE=on TEXTNOTE_DIR=/tmp go: - 1.16.x branches: except: - /^(?i:dev)\/.*$/ before_install: - go get github.com/modocache/gover - go get github.com/mattn/goveralls script: - go test -v github.com/dkaslovsky/textnote/... -coverprofile=all.coverprofile - gover - goveralls -race -coverprofile gover.coverprofile -service travis-ci ================================================ FILE: CHANGELOG.md ================================================ ## 1.3.0 / 2021-06-19 * [ADDED] Second `--delete`/`-x` flag deletes source file left empty after moving section(s) ## 1.2.0 / 2021-04-26 * [ADDED] Flag to open most recently dated ("latest") note * [ADDED] Configurable threshold for warning user of too many template files * [ADDED] Flags to display configuration file contents (`-f`) and active configuration (`-a`) * [ADDED] `update` subcommand for `config` command to overwrite configuration file with active configuration * [ADDED] `init` command to more cleanly initialize textnote application directories and files * [FIXED] Copy command defaults to latest note instead of potentially nonexistent note from previous day * [INTERNAL] Upgraded to Go 1.16 * [INTERNAL] Deprecated use of `io/ioutil` ## 1.1.1 / 2021-02-28 * [FIXED] Fall back on defaults for parameters missing from configuration file * [FIXED] Warning for unsupported editor configuration for cursorLine > 1 ## 1.1.0 / 2021-02-16 * [ADDED] Use $EDITOR environment variable for opening notes * [ADDED] Add support for vi/vim, nano, neovim, and emacs for using `file.cursorLine` config parameter ## 1.0.0 / 2021-02-09 * Initial release ================================================ FILE: CREDITS ================================================ Go (the standard library) https://golang.org/ ---------------------------------------------------------------- Copyright (c) 2009 The Go Authors. 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. * Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND 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 COPYRIGHT OWNER OR 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. ================================================================ dario.cat/mergo https://dario.cat/mergo ---------------------------------------------------------------- Copyright (c) 2013 Dario Castañé. All rights reserved. Copyright (c) 2012 The Go Authors. 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. * Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND 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 COPYRIGHT OWNER OR 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. ================================================================ github.com/BurntSushi/toml https://github.com/BurntSushi/toml ---------------------------------------------------------------- The MIT License (MIT) Copyright (c) 2013 TOML authors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================ github.com/davecgh/go-spew https://github.com/davecgh/go-spew ---------------------------------------------------------------- ISC License Copyright (c) 2012-2016 Dave Collins Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ================================================================ github.com/ilyakaznacheev/cleanenv https://github.com/ilyakaznacheev/cleanenv ---------------------------------------------------------------- MIT License Copyright (c) 2019 Ilya Kaznacheev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================ github.com/inconshreveable/mousetrap https://github.com/inconshreveable/mousetrap ---------------------------------------------------------------- Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2022 Alan Shreve (@inconshreveable) Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================================ github.com/joho/godotenv https://github.com/joho/godotenv ---------------------------------------------------------------- Copyright (c) 2013 John Barton MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================ github.com/pkg/errors https://github.com/pkg/errors ---------------------------------------------------------------- Copyright (c) 2015, Dave Cheney 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 COPYRIGHT HOLDERS AND 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 COPYRIGHT HOLDER OR 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. ================================================================ github.com/pmezard/go-difflib https://github.com/pmezard/go-difflib ---------------------------------------------------------------- Copyright (c) 2013, Patrick Mezard 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. The names of its contributors may not be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND 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 COPYRIGHT HOLDER OR 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. ================================================================ github.com/spf13/cobra https://github.com/spf13/cobra ---------------------------------------------------------------- Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. ================================================================ github.com/spf13/pflag https://github.com/spf13/pflag ---------------------------------------------------------------- Copyright (c) 2012 Alex Ogier. All rights reserved. Copyright (c) 2012 The Go Authors. 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. * Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND 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 COPYRIGHT OWNER OR 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. ================================================================ github.com/stretchr/testify https://github.com/stretchr/testify ---------------------------------------------------------------- MIT License Copyright (c) 2012-2020 Mat Ryer, Tyler Bunnell and contributors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================ gopkg.in/check.v1 https://gopkg.in/check.v1 ---------------------------------------------------------------- Gocheck - A rich testing framework for Go Copyright (c) 2010-2013 Gustavo Niemeyer All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. 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 COPYRIGHT HOLDERS AND 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 COPYRIGHT OWNER OR 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. ================================================================ gopkg.in/yaml.v3 https://gopkg.in/yaml.v3 ---------------------------------------------------------------- This project is covered by two different licenses: MIT and Apache. #### MIT License #### The following files were ported to Go from C files of libyaml, and thus are still covered by their original MIT license, with the additional copyright staring in 2011 when the project was ported over: apic.go emitterc.go parserc.go readerc.go scannerc.go writerc.go yamlh.go yamlprivateh.go Copyright (c) 2006-2010 Kirill Simonov Copyright (c) 2006-2011 Kirill Simonov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ### Apache License ### All the remaining project files are covered by the Apache license: Copyright (c) 2011-2019 Canonical Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================================ olympos.io/encoding/edn https://olympos.io/encoding/edn ---------------------------------------------------------------- Copyright (c) 2015, The Go Authors, Jean Niklas L'orange 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. * Neither the name of Google Inc., the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND 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 COPYRIGHT HOLDER OR 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. ================================================================ ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021 Daniel Kaslovsky Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ PROJ := "$(notdir $(shell pwd))" BRANCH := "$(shell git rev-parse --abbrev-ref HEAD)" STATUS := "$(shell git status -s)" BUILD_OUTDIR = "dist" BUILD_FILE_PATTERN := "${PROJ}_{{.OS}}_{{.Arch}}" BUILD_ARCH = "amd64 arm64" BUILD_OS = "linux darwin windows" BUILD_LDFLAGS := "-s -w -X main.version=$(BRANCH)" TAG_REGEX = "^v[0-9]\.[0-9]\.[0-9]$$" export GO111MODULE=on .PHONY: test test: go test ./... .PHONY: tidy tidy: @go mod tidy @sleep 1 .PHONY: credits credits: tidy @gocredits -w @sleep 1 .PHONY: prepare prepare: test tidy credits .PHONY: build build: test gox -ldflags=${BUILD_LDFLAGS} -os=${BUILD_OS} -arch=${BUILD_ARCH} -output=${BUILD_OUTDIR}/${BRANCH}/${BUILD_FILE_PATTERN} .PHONY: release release: checkbranch checkstatus build ghr "${BRANCH}" "${BUILD_OUTDIR}/${BRANCH}/" .PHONY: checkbranch checkbranch: ifeq (${BRANCH}, "$(shell echo ${BRANCH} | grep ${TAG_REGEX})") @echo "branch name ${BRANCH} successfully checked for release" else @echo "branch name ${BRANCH} does not follow semver naming convention, will not release" @exit 1 endif .PHONY: checkstatus checkstatus: ifneq (${STATUS}, "") @echo "dirty branch: check git status" @exit 1 endif @: ================================================ FILE: README.md ================================================ # textnote Simple tool for creating and organizing daily notes on the command line [![Build Status](https://travis-ci.com/dkaslovsky/textnote.svg?branch=main)](https://travis-ci.com/github/dkaslovsky/textnote) [![Coverage Status](https://coveralls.io/repos/github/dkaslovsky/textnote/badge.svg?branch=main)](https://coveralls.io/github/dkaslovsky/textnote?branch=main) [![Go Report Card](https://goreportcard.com/badge/github.com/dkaslovsky/textnote)](https://goreportcard.com/report/github.com/dkaslovsky/textnote) [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/dkaslovsky/textnote/blob/main/LICENSE)
## Overview textnote is a command line tool for quickly creating and managing daily plain text notes. It is designed for ease of use to encourage the practice of daily, organized note taking. textnote intentionally facilitates only the management (creation, opening, organizing, and consolidated archiving) of notes, following the philosophy that notes are best written in a text editor and not via a CLI. Key features: - Configurable, sectioned note template - Easily bring content forward to the next day's note (for those to-dos that didn't quite get done today...) - Simple command to consolidate daily notes into monthly archive files - Create and open today's note with the default `textnote` command All note files are stored locally on the file system in a single directory. Notes can easily be synced to a remote server or cloud service if so desired by ensuring the application directory is remotely synced. textnote opens notes using the text editor specified by the environment variable `$EDITOR` and defaults to Vim if the environment variable is not set. See the [Editor-Specific Configuration](#editor-specific-configuration) subsection for more details.
## Table of Contents - [Overview](#overview) - [Quick Start](#quick-start) - [Installation](#installation) - [Releases](#releases) - [Installing from source](#installing-from-source) - [Usage](#usage) - [`open`](#open) - [`archive`](#archive) - [Additional Functionality](#additional-functionality) - [Configuration](#configuration) - [Defaults](#defaults) - [Environment Variable Overrides](#environment-variable-overrides) - [Editor-Specific Configuration](#editor-specific-configuration) - [License](#license)
## Quick Start 1. Install textnote (see [Installation](#installation)) 2. Set a single environment variable `TEXTNOTE_DIR` to specify the directory for textnote's files That's it, textnote is ready to go! The directory specified by `TEXTNOTE_DIR` and the default configuration file will be automatically created the first time textnote is run. Start writing notes for today with a single command ``` $ textnote ``` To first configure textnote before creating notes, run ``` $ textnote init ``` and then edit the configuration file found at the displayed path.
## Installation textnote can be installed by downloading a prebuilt binary or by the `go get` command.
### Releases The recommended installation method is downloading the latest released binary. Download the appropriate binary for your operating system from this repository's [releases](https://github.com/dkaslovsky/textnote/releases/latest) page or via `curl`: macOS ``` $ curl -o textnote -L https://github.com/dkaslovsky/textnote/releases/latest/download/textnote_darwin_amd64 ``` Linux ``` $ curl -o textnote -L https://github.com/dkaslovsky/textnote/releases/latest/download/textnote_linux_amd64 ``` Windows ``` > curl.exe -o textnote.exe -L https://github.com/dkaslovsky/textnote/releases/latest/download/textnote_windows_amd64.exe ```
### Installing from source textnote can also be installed using Go's built-in tooling: ``` $ go get -u github.com/dkaslovsky/textnote ``` Build from source by cloning this repository and running `go build`. It is recommended to build using Go 1.15.7 or greater to avoid a potential security issue when looking for the desired editor in the `$PATH` ([details](https://blog.golang.org/path-security)).
## Usage textnote is intentionally simple to use and supports two main commands: `open` for creating/opening notes and `archive` for consolidating notes into monthly archive files.
### **`open`** The `open` command will open a dated note in an editor, creating it first if it does not exist. Opening or creating a note for the current day is the default action. Simply run the root command to open or create a note for the current day: ``` $ textnote ``` which, using the default configuration and assuming today is 2021-01-24, will create and open an empty note template: ``` [Sun] 24 Jan 2021 ___TODO___ ___DONE___ ___NOTES___ ``` To open a note for a specific date other than the current day, specify the date with the `--date` flag: ``` $ textnote open --date 2020-12-22 ``` where the date format is specified in the configuration. Alternatively, a note can be opened by passing the number of days prior to the current day using the `-d` flag. For example, ``` $ textnote open -d 1 ``` opens yesterday's note. Sections from previous notes can be copied or moved into a current note. Each section to be copied is specified in a separate `-s` flag. The most recent dated note is used as the source by default and a specific date for a source note can be provided through the `--copy` flag. For example, ``` $ textnote open -s TODO -s NOTES ``` will create today's note with the "TODO" and "NOTES" sections copied from the most recently dated (often yesterday's) note, while ``` $ textnote open --copy 2021-01-17 -s TODO ``` creates today's note with the "TODO" section copied from the 2021-01-17 note. Use the `-c` flag to instead specify the source by the number of days back from the current day. For example, ``` $ textnote open -c 3 -s TODO ``` creates today's note with the "TODO" section copied from 3 days ago. To move instead of copy, add the `-x` flag to any copy command. For example, ``` $ textnote open --copy 2021-01-17 -s NOTES -x ``` moves the "NOTES" section contents from the 2021-01-17 note into the note for today. Pass two delete flags (`-xx`) to also delete the source note if moving section(s) leaves the source empty: ``` $ textnote open --copy 2021-01-17 -s NOTES -xx ``` The `--date` and `--copy` (or `-d` and `-c`) flags can be used in combination if such a workflow is desired. For convenience, the `-t` flag can be used to open tomorrow's note: ``` $ textnote open -t ``` For example, ``` $ textnote open -t -s TODO ``` creates a note for tomorrow with a copy of today's "TODO" section contents, assuming a note for today exits. Also for convenience, the latest (most recent) dated note can be opened using the `-l` flag: ``` $ textnote open -l ``` The most recently dated note is typically from the previous day or a few days ago, but this command will return the note for the current date if it already exists. It will ignore notes dated in the future. When opening/copying requires searching for the latest (most recently dated) note, textnote checks the number of template files that were required to be searched. If this number is above a threshold (as set in the [configuration](#configuration)), a message is displayed suggesting to run the [archive](#archive) command to reduce the number of template files. This message can be effectively disabled by configuring the `templateFileCountThresh` configuration parameter to be very large, but doing so is not recommended. The flag options are summarized by the command's help: ``` $ textnote open -h open or create a note template Usage: textnote open [flags] Flags: --copy string date of note for copying sections (defaults to date of most recent note, cannot be used with copy-back flag) -c, --copy-back uint number of days back from today for copying from a note (cannot be used with copy flag) --date string date for note to be opened (defaults to today) -d, --days-back uint number of days back from today for opening a note (cannot be used with date, tomorrow, or latest flags) -x, --delete count delete sections after copy (pass flag twice to also delete empty source note) -h, --help help for open -l, --latest specify the most recent dated note to be opened (cannot be used with date, days-back, or tomorrow flags) -s, --section strings section to copy (defaults to none) -t, --tomorrow specify tomorrow as the date for note to be opened (cannot be used with date, days-back, or latest flags) ```
### **`archive`** The `archive` command consolidates all daily notes into month archives, gathering together the contents for each section of a month in chronological order, labeled by the original date. Only notes older than a number of days specified in the configuration are archived. Running the archive command ``` $ textnote archive ``` generates an archive file for every month for which a note exists. For example, an archive of the January 2021 notes, assuming the default configuration, will have the form ``` ARCHIVE Jan2021 ___TODO___ [2021-01-03] ... [2021-01-04] ... ___DONE___ [2021-01-03] ... [2021-01-04] ... [2021-01-06] ... ___NOTES___ [2021-01-06] ... ``` with ellipses representing the daily notes' contents. By default, the `archive` command is non-destructive: it will create archive files and leave all notes in place. To delete the individual note files and retain only the generated archives, run the command with the `-x` flag: ``` $ textnote archive -x ``` This is the intended mode of operation, as it is desirable to "clean up" notes into archives, but must be intentionally enabled with `-x` for safety. Running with the `--dry-run` flag prints the file names to be deleted without performing any actions: ``` $ textnote archive --dry-run ``` If the `archive` command is run without the delete flag, archive files are written and the original notes are left in place. To "clean up" the original notes *after* archives have been generated, rerun the `archive` command with the `-x` flag as well as the `-n` flag to prevent duplicating the archive content: ``` $ textnote archive -x -n ``` The flag options are summarized by the command's help: ``` $ textnote archive -h consolidate notes into monthly archive files Usage: textnote archive [flags] Flags: -x, --delete delete individual files after archiving --dry-run print file names to be deleted instead of performing deletes (other flags are ignored) -h, --help help for archive -n, --no-write disable writing archive files (helpful for deleting previously archived files) ```
### **Additional Functionality** textnote is designed for simplicity. Because textnote writes files to a single directory on the local filesystem, most functionality outside of the scope described above can be easily accomplished using stanard command line tools (e.g., `grep` for search). A few simple command line functions for searching, listing, and printing notes are available in a [gist](https://gist.github.com/dkaslovsky/010fd26c4d0975639a5c286fa631d6c9).
## Configuration While textnote is intended to be extremely lightweight, it is also designed to be highly configurable. In particular, the template (sections, headers, date formats, and whitespace) for generating notes can be customized as desired. One might wish to configure headers and section titles for markdown compatibility or change date formats to match regional convention. Configuration is read from the `$TEXTNOTE_DIR/.config.yml` file. Changes to configuration parameters can be made by updating this file. Individual configuration parameters also can be overridden with [environment variables](#environment-variable-overrides). Importantly, if textnote's configuration is changed, notes created using a previous configuration might be incompatible with textnote's functionality. The configuration file can be displayed by running the `config` command with the `-f` flag: ``` $ textnote config -f ``` The configuration file path is displayed by using the `-p` flag: ``` $ textnote config -p ``` [Defaults](#defaults) are used for configuration parameters omitted from the configuration file or configuration [environment variables](#environment-variable-overrides). The `config` command with the `-a` flag displays the full "active" configuration used when the application runs, including default and environment parameters: ``` $ textnote config -a ``` To update the configuration file to match the active configuration, run ``` $ textnote config update ``` This command overwrites the existing configuration file. It can be used instead of manual updates to the configuration file by passing environment variables. For example, ``` $ TEXTNOTE_ARCHIVE_FILE_PREFIX="my_archive-" textnote config update ``` The `update` command is also helpful for writing configuration parameters that have been added with new versions of textnote. The `config` command options are summarized by the command's help: ``` $ textnote config -h manages the application's configuration Usage: textnote config [flags] textnote config [command] Available Commands: update update the configuration file with active configuration Flags: -a, --active display configuration the application actively uses (includes environment variable configuration) -f, --file display contents of configuration file (default) -h, --help help for config -p, --path display path to configuration file Use "textnote config [command] --help" for more information about a command. ```
### Defaults The default configuration file is automatically written the first time textnote is run: ``` header: prefix: "" # prefix to attach to header suffix: "" # suffix to attach to header trailingNewlines: 1 # number of newlines after header timeFormat: '[Mon] 02 Jan 2006' # Golang format for header dates section: prefix: ___ # prefix to attach to section name suffix: ___ # suffix to attach to section name trailingNewlines: 3 # number of newlines for empty section names: # section names - TODO - DONE - NOTES file: ext: txt # extension to use for note files timeFormat: "2006-01-02" # Golang format for note file names cursorLine: 4 # line to place cursor when opening a note archive: afterDays: 14 # number of days after which a note can be archived filePrefix: archive- # prefix to attach to archive file names headerPrefix: 'ARCHIVE ' # prefix to attach to header of archive notes headerSuffix: "" # suffix to attach to header of archive notes sectionContentPrefix: '[' # prefix to attach to section content date sectionContentSuffix: ']' # suffix to attach to section content date sectionContentTimeFormat: "2006-01-02" # Golang format for section content dates monthTimeFormat: Jan2006 # Golang format for month archive file and header dates cli: timeFormat: "2006-01-02" # Golang format for CLI date input templateFileCountThresh: 90 # threshold for displaying a warning for too many template files ``` ### Environment Variable Overrides Any configuration parameter can be overridden by setting a corresponding environment variable. Note that setting an environment variable does not change the value specified in the configuration file. The full list of environment variables is listed below and is always available by running `textnote --help`: ``` TEXTNOTE_TEMPLATE_FILE_COUNT_THRESH int threshold for warning too many template files TEXTNOTE_HEADER_PREFIX string prefix to attach to header TEXTNOTE_HEADER_SUFFIX string suffix to attach to header TEXTNOTE_HEADER_TRAILING_NEWLINES int number of newlines to attach to end of header TEXTNOTE_HEADER_TIME_FORMAT string formatting string to form headers from timestamps TEXTNOTE_SECTION_PREFIX string prefix to attach to section names TEXTNOTE_SECTION_SUFFIX string suffix to attach to section names TEXTNOTE_SECTION_TRAILING_NEWLINES int number of newlines to attach to end of each section TEXTNOTE_SECTION_NAMES slice section names TEXTNOTE_FILE_EXT string extension for all files written TEXTNOTE_FILE_TIME_FORMAT string formatting string to form file names from timestamps TEXTNOTE_FILE_CURSOR_LINE int line to place cursor when opening TEXTNOTE_ARCHIVE_AFTER_DAYS int number of days after which to archive a file TEXTNOTE_ARCHIVE_FILE_PREFIX string prefix attached to the file name of all archive files TEXTNOTE_ARCHIVE_HEADER_PREFIX string override header prefix for archive files TEXTNOTE_ARCHIVE_HEADER_SUFFIX string override header suffix for archive files TEXTNOTE_ARCHIVE_SECTION_CONTENT_PREFIX string prefix to attach to section content date TEXTNOTE_ARCHIVE_SECTION_CONTENT_SUFFIX string suffix to attach to section content date TEXTNOTE_ARCHIVE_SECTION_CONTENT_TIME_FORMAT string formatting string dated section content TEXTNOTE_ARCHIVE_MONTH_TIME_FORMAT string formatting string for month archive timestamps TEXTNOTE_CLI_TIME_FORMAT string formatting string for timestamp CLI flags ```
### Editor-Specific Configuration Currently, textnote supports the `file.cusorLine` and `TEXTNOTE_FILE_CURSOR_LINE` configuration for the following editors: * Vi/Vim * Emacs * Neovim * Nano textnote will work with all other editors but will not respect this configuration parameter.
## License textnote is released under the [MIT License](https://github.com/dkaslovsky/textnote/blob/main/LICENSE). Dependency licenses are available in this repository's [CREDITS](./CREDITS) file. ================================================ FILE: cmd/archive/archive.go ================================================ package archive import ( "fmt" "log" "os" "time" "github.com/dkaslovsky/textnote/pkg/archive" "github.com/dkaslovsky/textnote/pkg/config" "github.com/dkaslovsky/textnote/pkg/file" "github.com/dkaslovsky/textnote/pkg/template" "github.com/spf13/cobra" ) type commandOptions struct { delete bool noWrite bool dryRun bool } // CreateArchiveCmd creates the today subcommand func CreateArchiveCmd() *cobra.Command { cmdOpts := commandOptions{} cmd := &cobra.Command{ Use: "archive", Short: "consolidate notes into archive files", Long: "consolidate notes into monthly archive files", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { opts, err := config.Load() if err != nil { return err } return run(opts, cmdOpts) }, } attachOpts(cmd, &cmdOpts) return cmd } func attachOpts(cmd *cobra.Command, cmdOpts *commandOptions) { flags := cmd.Flags() flags.BoolVarP(&cmdOpts.delete, "delete", "x", false, "delete individual files after archiving") flags.BoolVarP(&cmdOpts.noWrite, "no-write", "n", false, "disable writing archive files (helpful for deleting previously archived files)") flags.BoolVar(&cmdOpts.dryRun, "dry-run", false, "print file names to be deleted instead of performing deletes (other flags are ignored)") } func run(templateOpts config.Opts, cmdOpts commandOptions) error { archiver := archive.NewArchiver(templateOpts, file.NewReadWriter(), time.Now()) files, err := os.ReadDir(templateOpts.AppDir) if err != nil { return err } // add template files to archiver for _, f := range files { if f.IsDir() { continue } // parse date from template file name, skipping non-template files templateDate, ok := template.ParseTemplateFileName(f.Name(), templateOpts.File) if !ok { continue } err := archiver.Add(templateDate) if err != nil { log.Printf("skipping unarchivable file [%s]: %s", f.Name(), err) continue } } // print file names for dry-run if cmdOpts.dryRun { files := archiver.GetArchivedFiles() fmt.Printf("running \"archive --delete\" will remove [%d] files\n", len(files)) for _, fileName := range files { fmt.Printf("- %s\n", fileName) } return nil } // write archive files if !cmdOpts.noWrite { err = archiver.Write() if err != nil { return err } } // return if not deleting archived files if !cmdOpts.delete { return nil } // delete individual archived files numDeleted := 0 for _, fileName := range archiver.GetArchivedFiles() { err = os.Remove(fileName) if err != nil { log.Printf("unable to remove file [%s]: %s", fileName, err) continue } numDeleted++ } log.Printf("removed [%d] files after archiving", numDeleted) return nil } ================================================ FILE: cmd/config/config.go ================================================ package config import ( "fmt" "io" "log" "os" "github.com/dkaslovsky/textnote/pkg/config" "github.com/spf13/cobra" "gopkg.in/yaml.v3" ) type commandOptions struct { path bool active bool file bool } // CreateConfigCmd creates the config subcommand func CreateConfigCmd() *cobra.Command { cmdOpts := commandOptions{} cmd := &cobra.Command{ Use: "config", Short: "manage configuration", Long: "manages the application's configuration", RunE: func(cmd *cobra.Command, args []string) error { configPath := config.GetConfigFilePath() if cmdOpts.path { log.Printf("configuration file path: [%s]", configPath) return nil } if cmdOpts.active { return displayActiveConfig() } // default return displayConfigFile(configPath) }, } attachOpts(cmd, &cmdOpts) cmd.AddCommand(CreateConfigUpdateCmd()) return cmd } func attachOpts(cmd *cobra.Command, cmdOpts *commandOptions) { flags := cmd.Flags() flags.BoolVarP(&cmdOpts.path, "path", "p", false, "display path to configuration file") flags.BoolVarP(&cmdOpts.active, "active", "a", false, "display configuration the application actively uses (includes environment variable configuration)") flags.BoolVarP(&cmdOpts.file, "file", "f", false, "display contents of configuration file (default)") } // CreateConfigUpdateCmd creates the config update subcommand func CreateConfigUpdateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "update", Short: "update the configuration file with active configuration", Long: "update the configuration file to match the active configuration", RunE: func(cmd *cobra.Command, args []string) error { active, err := getActiveConfigYaml() if err != nil { return err } return os.WriteFile(config.GetConfigFilePath(), active, 0o644) }, } return cmd } func displayConfigFile(configPath string) error { _, err := os.Stat(configPath) if os.IsNotExist(err) { return fmt.Errorf("cannot find configuration file [%s]", configPath) } f, err := os.Open(configPath) if err != nil { return fmt.Errorf("unable to open configuration file [%s]: %w", configPath, err) } c, err := io.ReadAll(f) if err != nil { return fmt.Errorf("unable to read configuration file [%s]: %w", configPath, err) } log.Print(string(c)) return nil } func displayActiveConfig() error { yml, err := getActiveConfigYaml() if err != nil { return err } log.Print(string(yml)) return nil } func getActiveConfigYaml() ([]byte, error) { opts, err := config.Load() if err != nil { return []byte{}, err } return yaml.Marshal(opts) } ================================================ FILE: cmd/initialize/initialize.go ================================================ package initialize import ( "github.com/spf13/cobra" "github.com/dkaslovsky/textnote/pkg/config" ) // CreateInitCmd creates the init subcommand func CreateInitCmd() *cobra.Command { cmd := &cobra.Command{ Use: "init", Short: "initialize the application", Long: "initialize the application's required directories and files", RunE: func(cmd *cobra.Command, args []string) error { return config.InitApp() }, } return cmd } ================================================ FILE: cmd/open/open.go ================================================ package open import ( "fmt" "log" "math" "os" "strings" "time" "github.com/dkaslovsky/textnote/pkg/config" "github.com/dkaslovsky/textnote/pkg/editor" "github.com/dkaslovsky/textnote/pkg/file" "github.com/dkaslovsky/textnote/pkg/template" "github.com/pkg/errors" "github.com/spf13/cobra" ) const day = 24 * time.Hour type commandOptions struct { // mutually exclusive flags for date to open date string daysBack uint tomorrow bool latest bool // mutually exclusive flags for copy date copyDate string copyDaysBack uint deleteFlagVal int // count of number of times delete flag is passed deleteSections bool // delete sections on copy (deleteFlagVal > 0) deleteEmpty bool // delete file if empty after deleting sections (deleteFlagVal > 1) sections []string } // CreateOpenCmd creates the open subcommand func CreateOpenCmd() *cobra.Command { cmdOpts := commandOptions{} cmd := &cobra.Command{ Use: "open", Short: "open a note", Long: "open or create a note template", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { opts, err := config.Load() if err != nil { return err } now := time.Now() numFilesSearchedForDate, err := setDateOpt(&cmdOpts, opts, getDirFiles, now) if err != nil { return err } numFilesSearchedForCopy, err := setCopyDateOpt(&cmdOpts, opts, getDirFiles, now) if err != nil { return err } warnTooManyTemplateFiles(max(numFilesSearchedForDate, numFilesSearchedForCopy), opts.TemplateFileCountThresh) setDeleteOpts(&cmdOpts) return run(opts, cmdOpts) }, } attachOpts(cmd, &cmdOpts) return cmd } func attachOpts(cmd *cobra.Command, cmdOpts *commandOptions) { flags := cmd.Flags() // mutually exclusive flags for date to open flags.StringVar(&cmdOpts.date, "date", "", "date for note to be opened (defaults to today)") flags.UintVarP(&cmdOpts.daysBack, "days-back", "d", 0, "number of days back from today for opening a note (cannot be used with date, tomorrow, or latest flags)") flags.BoolVarP(&cmdOpts.tomorrow, "tomorrow", "t", false, "specify tomorrow as the date for note to be opened (cannot be used with date, days-back, or latest flags)") flags.BoolVarP(&cmdOpts.latest, "latest", "l", false, "specify the most recent dated note to be opened (cannot be used with date, days-back, or tomorrow flags)") // mutually exclusive flags for copy date flags.StringVar(&cmdOpts.copyDate, "copy", "", "date of note for copying sections (defaults to date of most recent note, cannot be used with copy-back flag)") flags.UintVarP(&cmdOpts.copyDaysBack, "copy-back", "c", 0, "number of days back from today for copying from a note (cannot be used with copy flag)") flags.StringSliceVarP(&cmdOpts.sections, "section", "s", []string{}, "section to copy (defaults to none)") flags.CountVarP(&cmdOpts.deleteFlagVal, "delete", "x", "delete sections after copy (pass flag twice to also delete empty source note)") } func setDateOpt(cmdOpts *commandOptions, templateOpts config.Opts, getFiles func(string) ([]string, error), now time.Time) (int, error) { var ( date string numFiles int errMutuallyExclusive = errors.New("only one of [date, days-back, tomorrow, latest] flags may be used") ) if cmdOpts.date != "" { date = cmdOpts.date } if cmdOpts.daysBack != 0 { if date != "" { return numFiles, errMutuallyExclusive } date = now.Add(-day * time.Duration(cmdOpts.daysBack)).Format(templateOpts.Cli.TimeFormat) } if cmdOpts.tomorrow { if date != "" { return numFiles, errMutuallyExclusive } date = now.Add(day).Format(templateOpts.Cli.TimeFormat) } if cmdOpts.latest { if date != "" { return numFiles, errMutuallyExclusive } files, err := getFiles(templateOpts.AppDir) if err != nil { return numFiles, err } var latest string latest, numFiles = getLatestTemplateFile(files, now, templateOpts.File) if latest == "" { return numFiles, fmt.Errorf("failed to find latest template file in [%s]", templateOpts.AppDir) } if templateOpts.File.Ext != "" { latest = strings.TrimSuffix(latest, fmt.Sprintf(".%s", templateOpts.File.Ext)) } date = latest } // default to today if date == "" { date = now.Format(templateOpts.Cli.TimeFormat) } cmdOpts.date = date return numFiles, nil } func setCopyDateOpt(cmdOpts *commandOptions, templateOpts config.Opts, getFiles func(string) ([]string, error), now time.Time) (int, error) { numFiles := 0 if cmdOpts.copyDate != "" && cmdOpts.copyDaysBack != 0 { return numFiles, errors.New("only one of [copy, copy-back] flags may be used") } if cmdOpts.copyDate != "" { if _, err := time.Parse(templateOpts.Cli.TimeFormat, cmdOpts.copyDate); err != nil { return numFiles, fmt.Errorf("cannot copy note from malformed date [%s]: %w", cmdOpts.copyDate, err) } return numFiles, nil } if cmdOpts.copyDaysBack != 0 { cmdOpts.copyDate = now.Add(-day * time.Duration(cmdOpts.copyDaysBack)).Format(templateOpts.Cli.TimeFormat) return numFiles, nil } // default to latest files, err := getFiles(templateOpts.AppDir) if err != nil { return numFiles, err } latest, numFiles := getLatestTemplateFile(files, now, templateOpts.File) if templateOpts.File.Ext != "" { latest = strings.TrimSuffix(latest, fmt.Sprintf(".%s", templateOpts.File.Ext)) } cmdOpts.copyDate = latest return numFiles, nil } func setDeleteOpts(cmdOpts *commandOptions) { cmdOpts.deleteSections = cmdOpts.deleteFlagVal > 0 cmdOpts.deleteEmpty = cmdOpts.deleteFlagVal > 1 } func run(templateOpts config.Opts, cmdOpts commandOptions) error { date, err := time.Parse(templateOpts.Cli.TimeFormat, cmdOpts.date) if err != nil { return fmt.Errorf("cannot create note for malformed date [%s]: %w", cmdOpts.date, err) } t := template.NewTemplate(templateOpts, date) rw := file.NewReadWriter() ed := editor.GetEditor(os.Getenv(editor.EnvEditor)) // open file if no sections to copy if len(cmdOpts.sections) == 0 { if !rw.Exists(t) { err := rw.Overwrite(t) if err != nil { return err } } return openInEditor(t, ed) } // load source for copy if cmdOpts.copyDate == "" { return fmt.Errorf("cannot find note to copy, [%s] might be empty", templateOpts.AppDir) } if cmdOpts.copyDate == cmdOpts.date { return fmt.Errorf("copying from note dated [%s] not allowed when writing to note for date [%s]", cmdOpts.copyDate, cmdOpts.date) } copyDate, err := time.Parse(templateOpts.Cli.TimeFormat, cmdOpts.copyDate) if err != nil { return fmt.Errorf("cannot copy note from malformed date [%s]: %w", cmdOpts.copyDate, err) } src := template.NewTemplate(templateOpts, copyDate) err = rw.Read(src) if err != nil { return fmt.Errorf("cannot read source file for copy: %w", err) } // load template contents if it exists if rw.Exists(t) { err := rw.Read(t) if err != nil { return fmt.Errorf("cannot load template file: %w", err) } } // copy from source to template err = copySections(src, t, cmdOpts.sections) if err != nil { return err } if cmdOpts.deleteSections { err = deleteSections(src, cmdOpts.sections) if err != nil { return fmt.Errorf("failed to remove section content from source file: %w", err) } if cmdOpts.deleteEmpty && src.IsEmpty() { err = os.Remove(src.GetFilePath()) if err != nil { return fmt.Errorf("failed to remove empty source file: %w", err) } } else { err = rw.Overwrite(src) if err != nil { return fmt.Errorf("failed to save changes to source file: %w", err) } } } err = rw.Overwrite(t) if err != nil { return fmt.Errorf("failed to write file: %w", err) } return openInEditor(t, ed) } func copySections(src *template.Template, tgt *template.Template, sectionNames []string) error { for _, sectionName := range sectionNames { err := tgt.CopySectionContents(src, sectionName) if err != nil { return fmt.Errorf("cannot copy section [%s] from source to target: %w", sectionName, err) } } return nil } func deleteSections(t *template.Template, sectionNames []string) error { for _, sectionName := range sectionNames { err := t.DeleteSectionContents(sectionName) if err != nil { return fmt.Errorf("cannot delete section [%s] from template: %w", sectionName, err) } } return nil } func openInEditor(t *template.Template, ed *editor.Editor) error { if t.GetFileCursorLine() > 1 && !ed.Supported { log.Printf("Editor [%s] only supported with its default arguments, additional configuration ignored", ed.Cmd) } if ed.Default { log.Printf("Environment variable [%s] not set, attempting to use default editor [%s]", editor.EnvEditor, ed.Cmd) } return ed.Open(t) } func getLatestTemplateFile(files []string, now time.Time, opts config.FileOpts) (string, int) { latest := "" delta := math.Inf(1) numTemplateFiles := 0 for _, f := range files { fileTime, ok := template.ParseTemplateFileName(f, opts) if !ok { // skip archive files and other non-template files that cannot be parsed continue } numTemplateFiles++ curdelta := now.Sub(fileTime).Hours() if curdelta < 0 { continue } if curdelta < delta { delta = curdelta latest = f } } return latest, numTemplateFiles } func getDirFiles(dir string) ([]string, error) { fileNames := []string{} dirItems, err := os.ReadDir(dir) if err != nil { return fileNames, err } for _, item := range dirItems { if item.IsDir() { continue } fileNames = append(fileNames, item.Name()) } return fileNames, nil } func warnTooManyTemplateFiles(n int, thresh int) { if n > thresh { log.Printf("searching for latest template found more than %d files, consider running archive command for more efficient performance", thresh) } } func max(i, j int) int { if i > j { return i } return j } ================================================ FILE: cmd/open/open_test.go ================================================ package open import ( "testing" "time" "github.com/dkaslovsky/textnote/pkg/template/templatetest" "github.com/stretchr/testify/require" ) func TestGetLatestTemplateFile(t *testing.T) { opts := templatetest.GetOpts() type testCase struct { files []string now time.Time expectedLatest string expectedNumFound int } tests := map[string]testCase{ "empty directory": { files: []string{}, now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), expectedLatest: "", expectedNumFound: 0, }, "no timestamped template files": { files: []string{ "archive-Dec2019.txt", "archive-2019-11-01.txt", }, now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), expectedLatest: "", expectedNumFound: 0, }, "single template file in future": { files: []string{ "2020-04-13.txt", }, now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), expectedLatest: "", expectedNumFound: 1, }, "single template file": { files: []string{ "2020-03-11.txt", }, now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), expectedLatest: "2020-03-11.txt", expectedNumFound: 1, }, "multiple template files": { files: []string{ "2020-03-11.txt", "2020-03-12.txt", "2020-03-13.txt", }, now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), expectedLatest: "2020-03-13.txt", expectedNumFound: 3, }, "multiple template files with one in future": { files: []string{ "2020-04-11.txt", "2020-04-12.txt", "2020-04-13.txt", }, now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), expectedLatest: "2020-04-12.txt", expectedNumFound: 3, }, "mix of timestamped template files and other files": { files: []string{ ".config", "foobar", "2020-03-11.txt", "2020-03-12.txt", "2020-03-13.txt", "archive_April2020", }, now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), expectedLatest: "2020-03-13.txt", expectedNumFound: 3, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { latest, numFound := getLatestTemplateFile(test.files, test.now, opts.File) require.Equal(t, test.expectedLatest, latest) require.Equal(t, test.expectedNumFound, numFound) }) } } func TestSetDateOpt(t *testing.T) { type testCase struct { cmdOpts *commandOptions files []string now time.Time expectedDate string expectedNumFiles int shouldErr bool } tests := map[string]testCase{ "multiple mutually exclusive flags: date and daysBack set": { cmdOpts: &commandOptions{ date: "2020-04-11", daysBack: 2, }, files: []string{ "2020-04-11.txt", "2020-04-10.txt", "2020-04-09.txt", }, now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), shouldErr: true, }, "multiple mutually exclusive flags: date and tomorrow set": { cmdOpts: &commandOptions{ date: "2020-04-11", tomorrow: true, }, files: []string{ "2020-04-11.txt", "2020-04-10.txt", "2020-04-09.txt", }, now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), shouldErr: true, }, "multiple mutually exclusive flags: date and latest set": { cmdOpts: &commandOptions{ date: "2020-04-11", latest: true, }, files: []string{ "2020-04-11.txt", "2020-04-10.txt", "2020-04-09.txt", }, now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), shouldErr: true, }, "multiple mutually exclusive flags: daysBack and tomorrow set": { cmdOpts: &commandOptions{ daysBack: 2, tomorrow: true, }, files: []string{ "2020-04-11.txt", "2020-04-10.txt", "2020-04-09.txt", }, now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), shouldErr: true, }, "multiple mutually exclusive flags: daysBack and latest set": { cmdOpts: &commandOptions{ daysBack: 2, latest: true, }, files: []string{ "2020-04-11.txt", "2020-04-10.txt", "2020-04-09.txt", }, now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), shouldErr: true, }, "multiple mutually exclusive flags: tomorrow and latest set": { cmdOpts: &commandOptions{ tomorrow: true, latest: true, }, files: []string{ "2020-04-11.txt", "2020-04-10.txt", "2020-04-09.txt", }, now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), shouldErr: true, }, "use date": { cmdOpts: &commandOptions{ date: "2020-04-11", }, files: []string{ "2020-04-11.txt", "2020-04-10.txt", "2020-04-09.txt", }, now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), expectedDate: "2020-04-11", expectedNumFiles: 0, shouldErr: false, }, "use daysBack": { cmdOpts: &commandOptions{ daysBack: 2, }, files: []string{ "2020-04-11.txt", "2020-04-10.txt", "2020-04-09.txt", }, now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), expectedDate: "2020-04-10", expectedNumFiles: 0, shouldErr: false, }, "use tomorrow": { cmdOpts: &commandOptions{ tomorrow: true, }, files: []string{ "2020-04-11.txt", "2020-04-10.txt", "2020-04-09.txt", }, now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), expectedDate: "2020-04-13", expectedNumFiles: 0, shouldErr: false, }, "use latest": { cmdOpts: &commandOptions{ latest: true, }, files: []string{ "2020-04-11.txt", "2020-04-10.txt", "2020-04-09.txt", }, now: time.Date(2020, 4, 15, 0, 0, 0, 0, time.UTC), expectedDate: "2020-04-11", expectedNumFiles: 3, shouldErr: false, }, "no latest found": { cmdOpts: &commandOptions{ latest: true, }, files: []string{}, now: time.Date(2020, 4, 15, 0, 0, 0, 0, time.UTC), shouldErr: true, }, "default to today": { cmdOpts: &commandOptions{}, files: []string{ "2020-04-11.txt", "2020-04-10.txt", "2020-04-09.txt", }, now: time.Date(2020, 4, 15, 0, 0, 0, 0, time.UTC), expectedDate: "2020-04-15", expectedNumFiles: 0, shouldErr: false, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { // setup getFiles := func(dir string) ([]string, error) { return test.files, nil } templateOpts := templatetest.GetOpts() // test numFiles, err := setDateOpt(test.cmdOpts, templateOpts, getFiles, test.now) if test.shouldErr { require.Error(t, err) return } require.Equal(t, test.expectedNumFiles, numFiles) require.NoError(t, err) require.Equal(t, test.expectedDate, test.cmdOpts.date) }) } } func TestSetCopyDateOpt(t *testing.T) { type testCase struct { cmdOpts *commandOptions files []string now time.Time expectedDate string expectedNumFiles int shouldErr bool } tests := map[string]testCase{ "multiple mutually exclusive flags: copyDate and copyDaysBack set": { cmdOpts: &commandOptions{ copyDate: "2020-04-11", copyDaysBack: 2, }, files: []string{ "2020-04-11.txt", "2020-04-10.txt", "2020-04-09.txt", }, now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), shouldErr: true, }, "use copyDate": { cmdOpts: &commandOptions{ copyDate: "2020-04-11", }, files: []string{ "2020-04-11.txt", "2020-04-10.txt", "2020-04-09.txt", }, now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), expectedDate: "2020-04-11", expectedNumFiles: 0, shouldErr: false, }, "use copyDaysBack": { cmdOpts: &commandOptions{ copyDaysBack: 2, }, files: []string{ "2020-04-11.txt", "2020-04-10.txt", "2020-04-09.txt", }, now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), expectedDate: "2020-04-10", expectedNumFiles: 0, shouldErr: false, }, "default to latest": { cmdOpts: &commandOptions{}, files: []string{ "2020-04-11.txt", "2020-04-10.txt", "2020-04-09.txt", }, now: time.Date(2020, 4, 15, 0, 0, 0, 0, time.UTC), expectedDate: "2020-04-11", expectedNumFiles: 3, shouldErr: false, }, "no latest found": { cmdOpts: &commandOptions{}, files: []string{}, now: time.Date(2020, 4, 15, 0, 0, 0, 0, time.UTC), expectedDate: "", expectedNumFiles: 0, shouldErr: false, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { // setup getFiles := func(dir string) ([]string, error) { return test.files, nil } templateOpts := templatetest.GetOpts() // test numFiles, err := setCopyDateOpt(test.cmdOpts, templateOpts, getFiles, test.now) if test.shouldErr { require.Error(t, err) return } require.Equal(t, test.expectedNumFiles, numFiles) require.NoError(t, err) require.Equal(t, test.expectedDate, test.cmdOpts.copyDate) }) } } func TestSetDeleteOpts(t *testing.T) { type testCase struct { cmdOpts *commandOptions expectedDeleteSections bool expectedDeleteEmpty bool } tests := map[string]testCase{ "deleteFlagVal = 0": { cmdOpts: &commandOptions{ deleteFlagVal: 0, }, expectedDeleteSections: false, expectedDeleteEmpty: false, }, "deleteFlagVal < 0": { cmdOpts: &commandOptions{ deleteFlagVal: -1, }, expectedDeleteSections: false, expectedDeleteEmpty: false, }, "deleteFlagVal = 1": { cmdOpts: &commandOptions{ deleteFlagVal: 1, }, expectedDeleteSections: true, expectedDeleteEmpty: false, }, "deleteFlagVal = 2": { cmdOpts: &commandOptions{ deleteFlagVal: 2, }, expectedDeleteSections: true, expectedDeleteEmpty: true, }, "deleteFlagVal > 2": { cmdOpts: &commandOptions{ deleteFlagVal: 3, }, expectedDeleteSections: true, expectedDeleteEmpty: true, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { setDeleteOpts(test.cmdOpts) require.Equal(t, test.expectedDeleteSections, test.cmdOpts.deleteSections) require.Equal(t, test.expectedDeleteEmpty, test.cmdOpts.deleteEmpty) }) } } ================================================ FILE: cmd/root.go ================================================ package cmd import ( "fmt" "strings" "github.com/dkaslovsky/textnote/cmd/archive" "github.com/dkaslovsky/textnote/cmd/config" "github.com/dkaslovsky/textnote/cmd/initialize" "github.com/dkaslovsky/textnote/cmd/open" pkgconf "github.com/dkaslovsky/textnote/pkg/config" "github.com/spf13/cobra" ) // Run executes the CLI func Run(name string, version string) error { cmd := &cobra.Command{ Use: name, Long: fmt.Sprintf("Name:\n %s - a simple tool for creating and organizing daily notes on the command line", name), SilenceUsage: true, SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { // run the open command with default options as the default application command return open.CreateOpenCmd().Execute() }, } cmd.AddCommand( open.CreateOpenCmd(), archive.CreateArchiveCmd(), config.CreateConfigCmd(), initialize.CreateInitCmd(), ) setVersion(cmd, version) setHelp(cmd, name) return cmd.Execute() } func setVersion(cmd *cobra.Command, version string) { if version != "" { cmd.Version = version return } cmd.Version = "unavailable" cmd.SetVersionTemplate( fmt.Sprintf("%s: built from source", strings.TrimSuffix(cmd.VersionTemplate(), "\n")), ) } func setHelp(cmd *cobra.Command, name string) { // set custom help message for the root command defaultHelpFunc := cmd.HelpFunc() cmd.SetHelpFunc(func(cmd *cobra.Command, s []string) { defaultHelpFunc(cmd, s) if cmd.Name() != name { return } if description := pkgconf.DescribeEnvVars(); description != "" { fmt.Printf("\nOverride configuration using environment variables:%s", description) } }) } ================================================ FILE: go.mod ================================================ module github.com/dkaslovsky/textnote go 1.21.4 require ( dario.cat/mergo v1.0.0 github.com/ilyakaznacheev/cleanenv v1.5.0 github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.8.0 github.com/stretchr/testify v1.8.4 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/BurntSushi/toml v1.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect ) ================================================ FILE: go.sum ================================================ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= ================================================ FILE: main.go ================================================ package main import ( "log" "github.com/dkaslovsky/textnote/cmd" "github.com/dkaslovsky/textnote/pkg/config" ) const name = "textnote" var version string // set by build ldflags func main() { log.SetFlags(0) err := config.InitApp() if err != nil { log.Fatal(err) } err = cmd.Run(name, version) if err != nil { log.Fatal(err) } } ================================================ FILE: pkg/archive/archive.go ================================================ package archive import ( "fmt" "log" "time" "github.com/dkaslovsky/textnote/pkg/config" "github.com/dkaslovsky/textnote/pkg/file" "github.com/dkaslovsky/textnote/pkg/template" ) // Archiver consolidates templates into archives type Archiver struct { opts config.Opts rw readWriter date time.Time // timestamp for calculating if a file is old enough to be archived // monthArchives maintains a map of formatted month timestamp to the corresponding archive monthArchives map[string]*template.MonthArchiveTemplate // archivedFiles maintains the file names that have been archived archivedFiles []string } // NewArchiver constructs a new Archiver func NewArchiver(opts config.Opts, rw readWriter, date time.Time) *Archiver { return &Archiver{ opts: opts, rw: rw, date: date, monthArchives: map[string]*template.MonthArchiveTemplate{}, archivedFiles: []string{}, } } // Add adds a template corresponding to a date to the archive func (a *Archiver) Add(date time.Time) error { // recent files are not archived if a.date.Sub(date).Hours() <= float64(a.opts.Archive.AfterDays*24) { return nil } t := template.NewTemplate(a.opts, date) err := a.rw.Read(t) if err != nil { return fmt.Errorf("cannot add unreadable file [%s] to archive: %w", t.GetFilePath(), err) } monthKey := date.Format(a.opts.Archive.MonthTimeFormat) if _, found := a.monthArchives[monthKey]; !found { a.monthArchives[monthKey] = template.NewMonthArchiveTemplate(a.opts, date) } archive := a.monthArchives[monthKey] for _, section := range a.opts.Section.Names { err := archive.ArchiveSectionContents(t, section) if err != nil { return fmt.Errorf("cannot add contents from [%s] to archive: %w", t.GetFilePath(), err) } } a.archivedFiles = append(a.archivedFiles, t.GetFilePath()) return nil } // Write writes all of the archive templates stored in the Archiver func (a *Archiver) Write() error { for _, t := range a.monthArchives { if a.rw.Exists(t) { existing := template.NewMonthArchiveTemplate(a.opts, t.GetDate()) err := a.rw.Read(existing) if err != nil { return fmt.Errorf("unable to open existing archive file [%s]: %w", existing.GetFilePath(), err) } err = t.Merge(existing) if err != nil { return fmt.Errorf("unable to from merge existing archive file [%s] %w", existing.GetFilePath(), err) } } err := a.rw.Overwrite(t) if err != nil { return fmt.Errorf("failed to write archive file [%s]: %w", t.GetFilePath(), err) } log.Printf("wrote archive file [%s]", t.GetFilePath()) } return nil } // GetArchivedFiles returns the files that have been archived func (a *Archiver) GetArchivedFiles() []string { return a.archivedFiles } // readWriter is the interface for executing file operations type readWriter interface { Read(file.ReadWriteable) error Overwrite(file.ReadWriteable) error Exists(file.ReadWriteable) bool } ================================================ FILE: pkg/archive/archive_test.go ================================================ package archive import ( "bytes" "path/filepath" "strings" "testing" "time" "github.com/dkaslovsky/textnote/pkg/file" "github.com/dkaslovsky/textnote/pkg/template" "github.com/dkaslovsky/textnote/pkg/template/templatetest" "github.com/stretchr/testify/require" ) // // mocks // type testReadWriter struct { exists bool toRead string written string } func newTestReadWriter(exists bool, toRead string) *testReadWriter { return &testReadWriter{ exists: exists, toRead: toRead, written: "", } } func (trw *testReadWriter) Read(rwable file.ReadWriteable) error { r := strings.NewReader(trw.toRead) return rwable.Load(r) } func (trw *testReadWriter) Overwrite(rwable file.ReadWriteable) error { buf := new(bytes.Buffer) err := rwable.Write(buf) if err != nil { return err } trw.written = buf.String() return nil } func (trw *testReadWriter) Exists(rwable file.ReadWriteable) bool { return trw.exists } // // Tests // func TestAdd(t *testing.T) { type testCase struct { date time.Time templateText string existing map[string]string expectedArchives map[string]string expectedFiles []string } tests := map[string]testCase{ "add template that should not be archived": { date: time.Date(2020, 12, 20, 0, 0, 0, 0, time.UTC), expectedArchives: map[string]string{}, expectedFiles: []string{}, }, "add template from last day that should not be archived": { date: time.Date(2020, 12, 14, 0, 0, 0, 0, time.UTC), expectedArchives: map[string]string{}, expectedFiles: []string{}, }, "add template from first day that should be archived": { date: time.Date(2020, 12, 13, 0, 0, 0, 0, time.UTC), templateText: `-^-[Sun] 13 Dec 2020-v- _p_TestSection1_q_ text1 text2 _p_TestSection2_q_ _p_TestSection3_q_ `, expectedArchives: map[string]string{ "Dec2020": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ [2020-12-13] text1 text2 _p_TestSection2_q_ _p_TestSection3_q_ `, }, expectedFiles: []string{ "2020-12-13.txt", }, }, "add template from current month": { date: time.Date(2020, 12, 1, 0, 0, 0, 0, time.UTC), templateText: `-^-[Tue] 01 Dec 2020-v- _p_TestSection1_q_ text1 text2 _p_TestSection2_q_ _p_TestSection3_q_ `, expectedArchives: map[string]string{ "Dec2020": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ [2020-12-01] text1 text2 _p_TestSection2_q_ _p_TestSection3_q_ `, }, expectedFiles: []string{ "2020-12-01.txt", }, }, "add template from different month": { date: time.Date(2020, 11, 1, 0, 0, 0, 0, time.UTC), templateText: `-^-[Sun] 01 Nov 2020-v- _p_TestSection1_q_ text1 text2 _p_TestSection2_q_ _p_TestSection3_q_ `, expectedArchives: map[string]string{ "Nov2020": `ARCHIVEPREFIX Nov2020 ARCHIVESUFFIX _p_TestSection1_q_ [2020-11-01] text1 text2 _p_TestSection2_q_ _p_TestSection3_q_ `, }, expectedFiles: []string{ "2020-11-01.txt", }, }, "add template from different year": { date: time.Date(2019, 11, 2, 0, 0, 0, 0, time.UTC), templateText: `-^-[Sat] 02 Nov 2019-v- _p_TestSection1_q_ text1 text2 _p_TestSection2_q_ _p_TestSection3_q_ `, expectedArchives: map[string]string{ "Nov2019": `ARCHIVEPREFIX Nov2019 ARCHIVESUFFIX _p_TestSection1_q_ [2019-11-02] text1 text2 _p_TestSection2_q_ _p_TestSection3_q_ `, }, expectedFiles: []string{ "2019-11-02.txt", }, }, "add template with earlier date to existing archive": { date: time.Date(2020, 12, 1, 0, 0, 0, 0, time.UTC), templateText: `-^-[Tue] 01 Dec 2020-v- _p_TestSection1_q_ text1 text2 _p_TestSection2_q_ _p_TestSection3_q_ `, existing: map[string]string{ "Dec2020": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ [2020-12-02] existingText1 existingText2 existingText3 _p_TestSection2_q_ _p_TestSection3_q_ `, }, expectedArchives: map[string]string{ "Dec2020": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ [2020-12-01] text1 text2 [2020-12-02] existingText1 existingText2 existingText3 _p_TestSection2_q_ _p_TestSection3_q_ `, }, expectedFiles: []string{ "2020-12-01.txt", }, }, "add template with later date to existing archive": { date: time.Date(2020, 12, 2, 0, 0, 0, 0, time.UTC), templateText: `-^-[Wed] 02 Dec 2020-v- _p_TestSection1_q_ text1 text2 _p_TestSection2_q_ _p_TestSection3_q_ `, existing: map[string]string{ "Dec2020": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ [2020-12-01] existingText1 existingText2 existingText3 _p_TestSection2_q_ _p_TestSection3_q_ `, }, expectedArchives: map[string]string{ "Dec2020": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ [2020-12-01] existingText1 existingText2 existingText3 [2020-12-02] text1 text2 _p_TestSection2_q_ _p_TestSection3_q_ `, }, expectedFiles: []string{ "2020-12-02.txt", }, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { opts := templatetest.GetOpts() trw := newTestReadWriter(true, test.templateText) a := NewArchiver(opts, trw, templatetest.Date) for key, text := range test.existing { existingDate, err := time.Parse(opts.Archive.MonthTimeFormat, key) require.NoError(t, err) m := template.NewMonthArchiveTemplate(opts, existingDate) err = m.Load(strings.NewReader(text)) require.NoError(t, err) a.monthArchives[key] = m } err := a.Add(test.date) require.NoError(t, err) require.Equal(t, len(test.expectedArchives), len(a.monthArchives)) for key, expectedText := range test.expectedArchives { buf := new(bytes.Buffer) monthArchive, found := a.monthArchives[key] require.True(t, found) err := monthArchive.Write(buf) require.NoError(t, err) require.Equal(t, expectedText, buf.String()) } expectedFilesWithFullPath := []string{} for _, f := range test.expectedFiles { fullPath := filepath.Join(opts.AppDir, f) expectedFilesWithFullPath = append(expectedFilesWithFullPath, fullPath) } require.ElementsMatch(t, expectedFilesWithFullPath, a.GetArchivedFiles()) }) } } func TestWrite(t *testing.T) { type testCase struct { text string exists bool existingText string expected string } tests := map[string]testCase{ "write with empty archive in archiver to new archive": { exists: false, expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ _p_TestSection2_q_ _p_TestSection3_q_ `, }, "write with empty archive in archiver to existing archive": { exists: true, existingText: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ [2020-12-15] existingText1a _p_TestSection2_q_ _p_TestSection3_q_ [2020-12-15] existingText3a [2020-12-22] existingText3b `, expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ [2020-12-15] existingText1a _p_TestSection2_q_ _p_TestSection3_q_ [2020-12-15] existingText3a [2020-12-22] existingText3b `, }, "write to new archive": { text: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ [2020-12-17] text1a [2020-12-19] text1b _p_TestSection2_q_ _p_TestSection3_q_ [2020-12-18] text3a [2020-12-19] text3b `, exists: false, expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ [2020-12-17] text1a [2020-12-19] text1b _p_TestSection2_q_ _p_TestSection3_q_ [2020-12-18] text3a [2020-12-19] text3b `, }, "write to existing archive": { text: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ [2020-12-17] text1a [2020-12-19] text1b _p_TestSection2_q_ _p_TestSection3_q_ [2020-12-18] text3a [2020-12-19] text3b `, exists: true, existingText: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ [2020-12-15] existingText1a _p_TestSection2_q_ _p_TestSection3_q_ [2020-12-15] existingText3a [2020-12-22] existingText3b `, expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ [2020-12-15] existingText1a [2020-12-17] text1a [2020-12-19] text1b _p_TestSection2_q_ _p_TestSection3_q_ [2020-12-15] existingText3a [2020-12-18] text3a [2020-12-19] text3b [2020-12-22] existingText3b `, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { opts := templatetest.GetOpts() date := templatetest.Date key := date.Format(opts.Archive.MonthTimeFormat) template := template.NewMonthArchiveTemplate(opts, date) err := template.Load(strings.NewReader(test.text)) require.NoError(t, err) trw := newTestReadWriter(test.exists, test.existingText) a := NewArchiver(opts, trw, date) a.monthArchives[key] = template err = a.Write() require.NoError(t, err) require.Equal(t, test.expected, trw.written) }) } } ================================================ FILE: pkg/config/config.go ================================================ package config import ( "fmt" "log" "os" "path/filepath" "strings" "dario.cat/mergo" "github.com/ilyakaznacheev/cleanenv" "github.com/pkg/errors" "gopkg.in/yaml.v3" ) const ( // envAppDir is the name of the environment variable specifying the application directory envAppDir = "TEXTNOTE_DIR" // fileName is the name of the configuration file fileName = ".config.yml" ) // appDir is the directory in which the application stores its files var appDir = os.Getenv(envAppDir) // Opts are options that configure the application type Opts struct { AppDir string `yaml:"-"` // AppDir is always read from the environment and is not written to file Header HeaderOpts `yaml:"header"` Section SectionOpts `yaml:"section"` File FileOpts `yaml:"file"` Archive ArchiveOpts `yaml:"archive"` Cli CliOpts `yaml:"cli"` TemplateFileCountThresh int `yaml:"templateFileCountThresh" env:"TEXTNOTE_TEMPLATE_FILE_COUNT_THRESH" env-description:"threshold for warning too many template files"` } // HeaderOpts are options for configuring the header of a note type HeaderOpts struct { Prefix string `yaml:"prefix" env:"TEXTNOTE_HEADER_PREFIX" env-description:"prefix to attach to header"` Suffix string `yaml:"suffix" env:"TEXTNOTE_HEADER_SUFFIX" env-description:"suffix to attach to header"` TrailingNewlines int `yaml:"trailingNewlines" env:"TEXTNOTE_HEADER_TRAILING_NEWLINES" env-description:"number of newlines to attach to end of header"` TimeFormat string `yaml:"timeFormat" env:"TEXTNOTE_HEADER_TIME_FORMAT" env-description:"formatting string to form headers from timestamps"` } // SectionOpts are options for configuring sections of a note type SectionOpts struct { Prefix string `yaml:"prefix" env:"TEXTNOTE_SECTION_PREFIX" env-description:"prefix to attach to section names"` Suffix string `yaml:"suffix" env:"TEXTNOTE_SECTION_SUFFIX" env-description:"suffix to attach to section names"` TrailingNewlines int `yaml:"trailingNewlines" env:"TEXTNOTE_SECTION_TRAILING_NEWLINES" env-description:"number of newlines to attach to end of each section"` Names []string `yaml:"names" env:"TEXTNOTE_SECTION_NAMES" env-description:"section names"` } // FileOpts are options for configuring file outputs type FileOpts struct { Ext string `yaml:"ext" env:"TEXTNOTE_FILE_EXT" env-description:"extension for all files written"` TimeFormat string `yaml:"timeFormat" env:"TEXTNOTE_FILE_TIME_FORMAT" env-description:"formatting string to form file names from timestamps"` CursorLine int `yaml:"cursorLine" env:"TEXTNOTE_FILE_CURSOR_LINE" env-description:"line to place cursor when opening"` } // ArchiveOpts are options for configuring note archives type ArchiveOpts struct { AfterDays int `yaml:"afterDays" env:"TEXTNOTE_ARCHIVE_AFTER_DAYS" env-description:"number of days after which to archive a file"` FilePrefix string `yaml:"filePrefix" env:"TEXTNOTE_ARCHIVE_FILE_PREFIX" env-description:"prefix attached to the file name of all archive files"` HeaderPrefix string `yaml:"headerPrefix" env:"TEXTNOTE_ARCHIVE_HEADER_PREFIX" env-description:"override header prefix for archive files"` HeaderSuffix string `yaml:"headerSuffix" env:"TEXTNOTE_ARCHIVE_HEADER_SUFFIX" env-description:"override header suffix for archive files"` SectionContentPrefix string `yaml:"sectionContentPrefix" env:"TEXTNOTE_ARCHIVE_SECTION_CONTENT_PREFIX" env-description:"prefix to attach to section content date"` SectionContentSuffix string `yaml:"sectionContentSuffix" env:"TEXTNOTE_ARCHIVE_SECTION_CONTENT_SUFFIX" env-description:"suffix to attach to section content date"` SectionContentTimeFormat string `yaml:"sectionContentTimeFormat" env:"TEXTNOTE_ARCHIVE_SECTION_CONTENT_TIME_FORMAT" env-description:"formatting string dated section content"` MonthTimeFormat string `yaml:"monthTimeFormat" env:"TEXTNOTE_ARCHIVE_MONTH_TIME_FORMAT" env-description:"formatting string for month archive timestamps"` } // CliOpts are options for configuring the CLI type CliOpts struct { TimeFormat string `yaml:"timeFormat" env:"TEXTNOTE_CLI_TIME_FORMAT" env-description:"formatting string for timestamp CLI flags"` } // OptsBackCompat are options maintained for backwards compatibility that will be honored in the absence (zero-value) of their // replacements as handled in loadBackCompat() type OptsBackCompat struct { // TemplateFileCountThresh holds the value of the field "templateFileCountTresh" (note the typo) in a yaml configuration file TemplateFileCountThresh int `yaml:"templateFileCountTresh"` } func getDefaultOpts() Opts { return Opts{ Header: HeaderOpts{ Prefix: "", Suffix: "", TrailingNewlines: 1, TimeFormat: "[Mon] 02 Jan 2006", }, Section: SectionOpts{ Prefix: "___", Suffix: "___", TrailingNewlines: 3, Names: []string{ "TODO", "DONE", "NOTES", }, }, File: FileOpts{ Ext: "txt", TimeFormat: "2006-01-02", CursorLine: 4, }, Archive: ArchiveOpts{ AfterDays: 14, FilePrefix: "archive-", HeaderPrefix: "ARCHIVE ", HeaderSuffix: "", SectionContentPrefix: "[", SectionContentSuffix: "]", SectionContentTimeFormat: "2006-01-02", MonthTimeFormat: "Jan2006", }, Cli: CliOpts{ TimeFormat: "2006-01-02", }, TemplateFileCountThresh: 90, } } // Load loads the configuration from file and/or evironment func Load() (Opts, error) { opts := Opts{} // parse config file allowing environment variable overrides err := loadFromEnv(GetConfigFilePath(), &opts) if err != nil { return opts, fmt.Errorf("unable to read config file: %w", err) } // overwrite defaults with opts from file/env defaults := getDefaultOpts() err = mergo.Merge(&opts, defaults) if err != nil { return opts, fmt.Errorf("unable to integrate configuration from file with defaults: %w", err) } // set AppDir as read from environment opts.AppDir = appDir err = ValidateOpts(opts) if err != nil { return opts, fmt.Errorf("configuration error in [%s]: %w", fileName, err) } return opts, nil } func loadFromEnv(path string, opts *Opts) error { err := cleanenv.ReadConfig(path, opts) if err != nil { return err } err = loadBackCompat(path, opts) if err != nil { return fmt.Errorf("unable to read config file for backwards compatibility fields: %w", err) } return nil } func loadBackCompat(path string, opts *Opts) error { // TemplateFileCountThresh backwards compatibility with previously typo'd field if opts.TemplateFileCountThresh != 0 { return nil } backcompat := OptsBackCompat{} err := cleanenv.ReadConfig(GetConfigFilePath(), &backcompat) if err != nil { return err } opts.TemplateFileCountThresh = backcompat.TemplateFileCountThresh return nil } // CreateIfNotExists writes defaults to the configuration file if it does not already exist func CreateIfNotExists() error { configPath := GetConfigFilePath() _, err := os.Stat(configPath) if !os.IsNotExist(err) { // config file exists, nothing to do return nil } defaults := getDefaultOpts() yml, err := yaml.Marshal(defaults) if err != nil { return fmt.Errorf("unable to generate config file: %w", err) } err = os.WriteFile(configPath, yml, 0o644) if err != nil { return fmt.Errorf("unable to create configuration file [%s]: %w", configPath, err) } log.Printf("created default configuration file: [%s]", configPath) return nil } // EnsureAppDir validates that the application directory exists or is created func EnsureAppDir() error { if appDir == "" { return fmt.Errorf("required environment variable [%s] is not set", envAppDir) } finfo, err := os.Stat(appDir) if os.IsNotExist(err) { err := os.MkdirAll(appDir, 0o755) if err != nil { return err } log.Printf("created directory [%s]", appDir) return nil } if !finfo.IsDir() { return fmt.Errorf("[%s=%s] must be a directory", envAppDir, appDir) } return nil } // ValidateOpts returns an error if the specified options are misconfigured func ValidateOpts(opts Opts) error { // validate appDir is not empty if opts.AppDir == "" { return fmt.Errorf("must include path to application directory in %s environment variable", envAppDir) } // validate at least one section if len(opts.Section.Names) == 0 { return errors.New("must include at least one section") } // validate section names are unique uniq := map[string]struct{}{} for _, name := range opts.Section.Names { uniq[name] = struct{}{} } if len(uniq) != len(opts.Section.Names) { return errors.New("section names must be unique") } // validate file archive prefix: this is needed for determining if a file is an archive if opts.Archive.FilePrefix == "" || strings.ReplaceAll(opts.Archive.FilePrefix, " ", "") == "" { return errors.New("file prefix for archives must not be empty") } // validate archive after days is at least 1 if opts.Archive.AfterDays < 1 { return errors.New("archive after days must be greater than or equal to 1") } // validate file extension does not contain leading dot if strings.HasPrefix(opts.File.Ext, ".") { return errors.New("file extension must not include leading dot") } // validate the file cursor line is not negative if opts.File.CursorLine < 0 { return errors.New("cursor line must not be negative") } // validate threshold for warning on too many template files is larger than archive after days if opts.TemplateFileCountThresh <= opts.Archive.AfterDays { return errors.New("template file count threshold must be larger than archive after days") } return nil } // DescribeEnvVars returns a description string for environment variables used to configure the application func DescribeEnvVars() string { header := "" description, err := cleanenv.GetDescription(&Opts{}, &header) if err != nil { return "" } return description } // GetConfigFilePath constructs the full path to the configuration file func GetConfigFilePath() string { return filepath.Join(appDir, fileName) } // InitApp initializes the application by ensuring the necessary directories and files exist func InitApp() error { err := EnsureAppDir() if err != nil { return err } err = CreateIfNotExists() if err != nil { return err } return nil } ================================================ FILE: pkg/config/config_test.go ================================================ package config import ( "testing" "github.com/stretchr/testify/require" ) func TestValidateOpts(t *testing.T) { t.Run("no appDir", func(t *testing.T) { opts := getTestOpts() opts.AppDir = "" err := ValidateOpts(opts) require.Error(t, err) }) t.Run("no section names", func(t *testing.T) { opts := getTestOpts() opts.Section.Names = []string{} err := ValidateOpts(opts) require.Error(t, err) }) t.Run("section names are not unique", func(t *testing.T) { opts := getTestOpts() opts.Section.Names = []string{ "section1", "section2", "section1", } err := ValidateOpts(opts) require.Error(t, err) }) t.Run("section names are unique", func(t *testing.T) { opts := getTestOpts() opts.Section.Names = []string{ "section1", "section2", "section3", } err := ValidateOpts(opts) require.NoError(t, err) }) t.Run("archive file prefix is empty string", func(t *testing.T) { opts := getTestOpts() opts.Archive.FilePrefix = "" err := ValidateOpts(opts) require.Error(t, err) }) t.Run("archive file prefix is blank", func(t *testing.T) { opts := getTestOpts() opts.Archive.FilePrefix = " " err := ValidateOpts(opts) require.Error(t, err) }) t.Run("archive file prefix is not empty or blank", func(t *testing.T) { opts := getTestOpts() opts.Archive.FilePrefix = "xyzarchivexyz" err := ValidateOpts(opts) require.NoError(t, err) }) t.Run("archive after days is negative", func(t *testing.T) { opts := getTestOpts() opts.Archive.AfterDays = -1 err := ValidateOpts(opts) require.Error(t, err) }) t.Run("archive after days is zero", func(t *testing.T) { opts := getTestOpts() opts.Archive.AfterDays = 0 err := ValidateOpts(opts) require.Error(t, err) }) t.Run("archive after days is one", func(t *testing.T) { opts := getTestOpts() opts.Archive.AfterDays = 1 err := ValidateOpts(opts) require.NoError(t, err) }) t.Run("empty file extension should not error", func(t *testing.T) { opts := getTestOpts() opts.File.Ext = "" err := ValidateOpts(opts) require.NoError(t, err) }) t.Run("file extension without dot should not error", func(t *testing.T) { opts := getTestOpts() opts.File.Ext = "txt" err := ValidateOpts(opts) require.NoError(t, err) }) t.Run("file extension with leading dot should not error", func(t *testing.T) { opts := getTestOpts() opts.File.Ext = ".txt" err := ValidateOpts(opts) require.Error(t, err) }) t.Run("file cursor line is negative", func(t *testing.T) { opts := getTestOpts() opts.File.CursorLine = -2 err := ValidateOpts(opts) require.Error(t, err) }) t.Run("file cursor line is zero", func(t *testing.T) { opts := getTestOpts() opts.File.CursorLine = 0 err := ValidateOpts(opts) require.NoError(t, err) }) t.Run("template file count threshold not greater than archive after days should error", func(t *testing.T) { opts := getTestOpts() opts.Archive.AfterDays = 100 opts.TemplateFileCountThresh = 100 err := ValidateOpts(opts) require.Error(t, err) }) t.Run("template file count threshold greater than archive after days should not error", func(t *testing.T) { opts := getTestOpts() opts.Archive.AfterDays = 100 opts.TemplateFileCountThresh = 101 err := ValidateOpts(opts) require.NoError(t, err) }) } func getTestOpts() Opts { opts := getDefaultOpts() opts.AppDir = "path/to/appDir" return opts } ================================================ FILE: pkg/editor/editor.go ================================================ package editor import ( "fmt" "os" "os/exec" ) // EnvEditor is the name of the environment variable specifying the editor for opening notes const EnvEditor = "EDITOR" const ( editorNameEmacs = "emacs" editorNameNano = "nano" editorNameNeovim = "nvim" editorNameVi = "vi" editorNameVim = "vim" ) // openable is the interface that an editor opens type openable interface { GetFilePath() string GetFileCursorLine() int } // Editor encapsulates the commands and args necessary to open an editor in a shell type Editor struct { Cmd string GetArgs func(int) []string Supported bool Default bool } // Open opens an object satisfying the openable interface in the editor // NOTE: it is recommended to use Go >= v.1.15.7 due to call to exec.Command() // See: https://blog.golang.org/path-security func (e *Editor) Open(o openable) error { args := append(e.GetArgs(o.GetFileCursorLine()), o.GetFilePath()) cmd := exec.Command(e.Cmd, args...) cmd.Stdout = os.Stdout cmd.Stdin = os.Stdin cmd.Stderr = os.Stderr return cmd.Run() } // GetEditor gets an Editor based on a provided name func GetEditor(name string) *Editor { switch name { case editorNameVi, editorNameVim: return &Editor{ Cmd: name, GetArgs: func(line int) []string { return []string{ fmt.Sprintf("+%d", line), } }, Supported: true, Default: false, } case editorNameEmacs: return &Editor{ Cmd: editorNameEmacs, GetArgs: func(line int) []string { return []string{ fmt.Sprintf("+%d", line), } }, Supported: true, Default: false, } case editorNameNano: return &Editor{ Cmd: editorNameNano, GetArgs: func(line int) []string { return []string{ fmt.Sprintf("+%d", line), } }, Supported: true, Default: false, } case editorNameNeovim: return &Editor{ Cmd: editorNameNeovim, GetArgs: func(line int) []string { return []string{ fmt.Sprintf("+%d", line), } }, Supported: true, Default: false, } // use Vim as the default editor case "": return &Editor{ Cmd: editorNameVim, GetArgs: func(line int) []string { return []string{ fmt.Sprintf("+%d", line), } }, Supported: true, Default: true, } // unrecognized editor will be passed no arguments default: return &Editor{ Cmd: name, GetArgs: func(line int) []string { return []string{} }, Supported: false, Default: false, } } } ================================================ FILE: pkg/file/file.go ================================================ package file import ( "io" "os" ) // ReadWriteable is the interface on which file operations are executed type ReadWriteable interface { Load(io.Reader) error Write(io.Writer) error GetFilePath() string } // ReadWriter executes file operations type ReadWriter struct{} // NewReadWriter constructs a new ReadWriter func NewReadWriter() *ReadWriter { return &ReadWriter{} } // Read reads from file func (rw *ReadWriter) Read(rwable ReadWriteable) error { r, err := os.Open(rwable.GetFilePath()) if err != nil { return err } defer r.Close() return rwable.Load(r) } // Overwrite writes a template to a file, overwriting existing file contents if any func (rw *ReadWriter) Overwrite(rwable ReadWriteable) error { f, err := os.OpenFile(rwable.GetFilePath(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) if err != nil { return err } defer f.Close() err = rwable.Write(f) if err != nil { return err } return nil } // Exists evaluates if a file exists func (rw *ReadWriter) Exists(rwable ReadWriteable) bool { fileName := rwable.GetFilePath() _, err := os.Stat(fileName) return !os.IsNotExist(err) } ================================================ FILE: pkg/template/archive.go ================================================ package template import ( "fmt" "io" "path/filepath" "regexp" "strings" "time" "github.com/dkaslovsky/textnote/pkg/config" ) // MonthArchiveTemplate contains the structure of a month archive type MonthArchiveTemplate struct { *Template } // NewMonthArchiveTemplate constructs a new MonthArchiveTemplate func NewMonthArchiveTemplate(opts config.Opts, date time.Time) *MonthArchiveTemplate { monthDate := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location()) return &MonthArchiveTemplate{ NewTemplate(opts, monthDate), } } // Write writes the template // This function is needed to ensure the string() method of the MonthArchiveTemplate is called func (t *MonthArchiveTemplate) Write(w io.Writer) error { _, err := w.Write([]byte(t.string())) return err } // GetFilePath generates a full path for a file based on the template date func (t *MonthArchiveTemplate) GetFilePath() string { name := filepath.Join( t.opts.AppDir, t.opts.Archive.FilePrefix+t.date.Format(t.opts.Archive.MonthTimeFormat), ) if t.opts.File.Ext == "" { return name } return fmt.Sprintf("%s.%s", name, t.opts.File.Ext) } // ArchiveSectionContents concatenates the contents of the specified section from a source template and // appends to the contents of the receiver's section with a header derived from the source template's date func (t *MonthArchiveTemplate) ArchiveSectionContents(src *Template, sectionName string) error { tgtSec, err := t.getSection(sectionName) if err != nil { return fmt.Errorf("failed to find section in target: %w", err) } srcSec, err := src.getSection(sectionName) if err != nil { return fmt.Errorf("failed to find section in source: %w", err) } // flatten text from contents into a single string txt := "" for _, content := range srcSec.contents { txt += content.text } if len(txt) == 0 { return nil } tgtSec.contents = append(tgtSec.contents, contentItem{ header: t.makeContentHeader(src.GetDate()), text: txt, }) return nil } // Merge merges a source MonthArchiveTemplate into the receiver // This is a convenience function that iterates and copies all sections in the receiver func (t *MonthArchiveTemplate) Merge(src *MonthArchiveTemplate) error { for sectionName := range t.sectionIdx { err := t.CopySectionContents(src, sectionName) if err != nil { return err } } return nil } func (t *MonthArchiveTemplate) string() string { str := t.makeHeader() for _, section := range t.sections { name := section.getNameString(t.opts.Section.Prefix, t.opts.Section.Suffix) section.sortContents() body := section.getContentString() body = regexp.MustCompile(`\n{2,}`).ReplaceAllString(body, "\n") // remove blank lines str += fmt.Sprintf("%s%s%s", name, body, strings.Repeat("\n", t.opts.Section.TrailingNewlines)) } return str } func (t *MonthArchiveTemplate) makeHeader() string { return fmt.Sprintf("%s%s%s\n%s", t.opts.Archive.HeaderPrefix, t.date.Format(t.opts.Archive.MonthTimeFormat), t.opts.Archive.HeaderSuffix, strings.Repeat("\n", t.opts.Header.TrailingNewlines), ) } func (t *MonthArchiveTemplate) makeContentHeader(date time.Time) string { return fmt.Sprintf("%s%s%s", t.opts.Archive.SectionContentPrefix, date.Format(t.opts.Archive.SectionContentTimeFormat), t.opts.Archive.SectionContentSuffix, ) } // isArchiveItemHeader evaluates if a line matches the pattern of a dated header in a section of an archive func isArchiveItemHeader(line string, prefix string, suffix string, format string) bool { if !strings.HasPrefix(line, prefix) { return false } if !strings.HasSuffix(line, suffix) { return false } _, err := time.Parse(format, stripPrefixSuffix(line, prefix, suffix)) return err == nil } ================================================ FILE: pkg/template/archive_test.go ================================================ package template import ( "fmt" "strings" "testing" "time" "github.com/dkaslovsky/textnote/pkg/template/templatetest" "github.com/stretchr/testify/require" ) func TestNewMonthArchiveTemplate(t *testing.T) { type testCase struct { date time.Time expected time.Time } tests := map[string]testCase{ "first of the month": { date: time.Date(2020, 12, 1, 2, 3, 4, 5, time.UTC), expected: time.Date(2020, 12, 1, 0, 0, 0, 0, time.UTC), }, "not first of the month": { date: time.Date(2020, 12, 15, 2, 3, 4, 5, time.UTC), expected: time.Date(2020, 12, 1, 0, 0, 0, 0, time.UTC), }, "non UTC location": { date: time.Date(2020, 12, 15, 2, 3, 4, 5, time.FixedZone("UTC-8", -8*60*60)), expected: time.Date(2020, 12, 1, 0, 0, 0, 0, time.FixedZone("UTC-8", -8*60*60)), }, } for name, test := range tests { t.Run(name, func(t *testing.T) { m := NewMonthArchiveTemplate(templatetest.GetOpts(), test.date) require.Equal(t, test.expected, m.date) }) } } func TestArchiveGetFilePath(t *testing.T) { t.Run("get file path with extension", func(t *testing.T) { opts := templatetest.GetOpts() opts.File.Ext = "txt" template := NewMonthArchiveTemplate(opts, templatetest.Date) filePath := template.GetFilePath() require.True(t, strings.HasPrefix(filePath, opts.AppDir)) require.True(t, strings.HasSuffix(filePath, ".txt")) require.Equal(t, opts.Archive.FilePrefix+templatetest.Date.Format(opts.Archive.MonthTimeFormat), stripPrefixSuffix(filePath, fmt.Sprintf("%s/", opts.AppDir), ".txt"), ) }) t.Run("get file path without extension", func(t *testing.T) { opts := templatetest.GetOpts() opts.File.Ext = "" template := NewMonthArchiveTemplate(opts, templatetest.Date) filePath := template.GetFilePath() require.True(t, strings.HasPrefix(filePath, opts.AppDir)) require.False(t, strings.HasSuffix(filePath, ".")) require.Equal(t, opts.Archive.FilePrefix+templatetest.Date.Format(opts.Archive.MonthTimeFormat), stripPrefixSuffix(filePath, fmt.Sprintf("%s/", opts.AppDir), ""), ) }) } func TestArchiveSectionContents(t *testing.T) { type testCase struct { sectionName string existingContents []contentItem sourceDate time.Time sourceContents []contentItem expectedContents []contentItem } tests := map[string]testCase{ "archive empty contents into empty section": { sectionName: "TestSection1", existingContents: []contentItem{}, sourceDate: templatetest.Date, sourceContents: []contentItem{}, expectedContents: []contentItem{}, }, "archive empty contents into populated section": { sectionName: "TestSection1", existingContents: []contentItem{ { header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), text: "existingText1", }, }, sourceDate: templatetest.Date.Add(24 * time.Hour), sourceContents: []contentItem{}, expectedContents: []contentItem{ { header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), text: "existingText1", }, }, }, "archive contents with single element into empty section": { sectionName: "TestSection1", existingContents: []contentItem{}, sourceDate: templatetest.Date.Add(24 * time.Hour), sourceContents: []contentItem{ { header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), text: "text1", }, }, expectedContents: []contentItem{ { header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), text: "text1", }, }, }, "archive contents with single element into populated section": { sectionName: "TestSection1", existingContents: []contentItem{ { header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), text: "existingText1", }, }, sourceDate: templatetest.Date.Add(24 * time.Hour), sourceContents: []contentItem{ { header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), text: "sourceText1", }, }, expectedContents: []contentItem{ { header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), text: "existingText1", }, { header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), text: "sourceText1", }, }, }, "archive contents with multiple element into empty section": { sectionName: "TestSection1", existingContents: []contentItem{}, sourceDate: templatetest.Date.Add(24 * time.Hour), sourceContents: []contentItem{ { header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), text: "text1\n", }, { header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), text: "text2\n\n", }, }, expectedContents: []contentItem{ { header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), text: "text1\ntext2\n\n", }, }, }, "archive contents with multiple elements into populated section": { sectionName: "TestSection1", existingContents: []contentItem{ { header: templatetest.MakeItemHeader(templatetest.Date.Add(-24*time.Hour), templatetest.GetOpts()), text: "existingText", }, }, sourceDate: templatetest.Date.Add(24 * time.Hour), sourceContents: []contentItem{ { header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), text: "text1\n", }, { header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), text: "text2\n\n", }, }, expectedContents: []contentItem{ { header: templatetest.MakeItemHeader(templatetest.Date.Add(-24*time.Hour), templatetest.GetOpts()), text: "existingText", }, { header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), text: "text1\ntext2\n\n", }, }, }, "archive contents from source with same date": { sectionName: "TestSection1", existingContents: []contentItem{ { header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), text: "existingText", }, }, sourceDate: templatetest.Date, sourceContents: []contentItem{ { header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), text: "text1\n", }, { header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), text: "text2\n\n", }, }, expectedContents: []contentItem{ { header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), text: "existingText", }, { header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), text: "text1\ntext2\n\n", }, }, }, "source header does not matter": { sectionName: "TestSection1", existingContents: []contentItem{}, sourceDate: templatetest.Date.Add(24 * time.Hour), sourceContents: []contentItem{ { header: "doesn't matter 1", text: "text1\n", }, { header: "doesn't matter 2", text: "text2\n\n", }, }, expectedContents: []contentItem{ { header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), text: "text1\ntext2\n\n", }, }, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { opts := templatetest.GetOpts() src := NewTemplate(opts, test.sourceDate) src.sections[src.sectionIdx[test.sectionName]].contents = test.sourceContents template := NewMonthArchiveTemplate(opts, templatetest.Date) template.sections[template.sectionIdx[test.sectionName]].contents = test.existingContents err := template.ArchiveSectionContents(src, test.sectionName) require.NoError(t, err) require.Equal(t, template.sections[template.sectionIdx[test.sectionName]].contents, test.expectedContents) }) } } func TestArchiveSectionContentsFail(t *testing.T) { t.Run("section does not exist in template", func(t *testing.T) { toCopy := "toBeArchived" opts := templatetest.GetOpts() template := NewMonthArchiveTemplate(opts, templatetest.Date) src := NewTemplate(opts, templatetest.Date) src.sections = append(src.sections, newSection(toCopy)) src.sectionIdx[toCopy] = len(src.sections) - 1 err := template.ArchiveSectionContents(src, toCopy) require.Error(t, err) }) t.Run("section does not exist in source", func(t *testing.T) { toCopy := "toBeArchived" opts := templatetest.GetOpts() template := NewMonthArchiveTemplate(opts, templatetest.Date) template.sections = append(template.sections, newSection(toCopy)) template.sectionIdx[toCopy] = len(template.sections) - 1 src := NewTemplate(opts, templatetest.Date) err := template.ArchiveSectionContents(src, toCopy) require.Error(t, err) }) } func TestArchiveString(t *testing.T) { type testCase struct { sections []*section expected string } tests := map[string]testCase{ "empty template": { sections: []*section{}, expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX `, }, "single empty section": { sections: []*section{ newSection("TestSection1"), }, expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ `, }, "single section": { sections: []*section{ newSection("TestSection1", contentItem{ header: "[2020-12-19]", text: "text", }, ), }, expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ [2020-12-19] text `, }, "single section with multiline text": { sections: []*section{ newSection("TestSection1", contentItem{ header: "[2020-12-19]", text: "text1\ntext2\n\n text3text4\n- text5\n\n -text6\n\n", }, ), }, expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ [2020-12-19] text1 text2 text3text4 - text5 -text6 `, }, "single section with multiple contents": { sections: []*section{ newSection("TestSection1", contentItem{ header: "[2020-12-18]", text: "text1\n", }, contentItem{ header: "[2020-12-19]", text: "text2\n", }, ), }, expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ [2020-12-18] text1 [2020-12-19] text2 `, }, "multiple empty sections": { sections: []*section{ newSection("TestSection1"), newSection("TestSection2"), newSection("TestSection3"), }, expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ _p_TestSection2_q_ _p_TestSection3_q_ `, }, "multiple sections with only first populated": { sections: []*section{ newSection("TestSection1", contentItem{ header: "[2020-12-18]", text: "text", }, ), newSection("TestSection2"), newSection("TestSection3"), }, expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ [2020-12-18] text _p_TestSection2_q_ _p_TestSection3_q_ `, }, "multiple sections with only middle populated": { sections: []*section{ newSection("TestSection1"), newSection("TestSection2", contentItem{ header: "[2020-12-18]", text: "text", }, ), newSection("TestSection3"), }, expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ _p_TestSection2_q_ [2020-12-18] text _p_TestSection3_q_ `, }, "multiple sections with only last populated": { sections: []*section{ newSection("TestSection1"), newSection("TestSection2"), newSection("TestSection3", contentItem{ header: "[2020-12-18]", text: "text", }, ), }, expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ _p_TestSection2_q_ _p_TestSection3_q_ [2020-12-18] text `, }, "sections with out of order items should be sorted": { sections: []*section{ newSection("TestSection1"), newSection("TestSection2"), newSection("TestSection3", contentItem{ header: "[2020-12-18]", text: "text 2020-12-18", }, contentItem{ header: "[2020-12-16]", text: "text 2020-12-16", }, contentItem{ header: "[2020-12-17]", text: "text 2020-12-17", }, ), }, expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX _p_TestSection1_q_ _p_TestSection2_q_ _p_TestSection3_q_ [2020-12-16] text 2020-12-16 [2020-12-17] text 2020-12-17 [2020-12-18] text 2020-12-18 `, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { opts := templatetest.GetOpts() names := []string{} for _, section := range test.sections { names = append(names, section.name) } opts.Section.Names = names template := NewMonthArchiveTemplate(opts, templatetest.Date) for i, section := range test.sections { template.sections[i] = section } require.Equal(t, test.expected, template.string()) }) } } func TestIsArchiveItemHeader(t *testing.T) { type testCase struct { header string prefix string suffix string format string expected bool } tests := map[string]testCase{ "valid header": { header: "[2020-07-28]", prefix: "[", suffix: "]", format: "2006-01-02", expected: true, }, "valid header with no prefix or suffix": { header: "2020-07-28", prefix: "", suffix: "", format: "2006-01-02", expected: true, }, "invalid header with wrong prefix": { header: "<2020-07-28]", prefix: "[", suffix: "]", format: "2006-01-02", expected: false, }, "invalid header with wrong suffix": { header: "[2020-07-28>", prefix: "[", suffix: "]", format: "2006-01-02", expected: false, }, "invalid header with wrong format": { header: "[2020-July-28]", prefix: "[", suffix: "]", format: "2006-01-02", expected: false, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { val := isArchiveItemHeader(test.header, test.prefix, test.suffix, test.format) require.Equal(t, test.expected, val) }) } } ================================================ FILE: pkg/template/section.go ================================================ package template import ( "fmt" "regexp" "sort" "strings" "github.com/dkaslovsky/textnote/pkg/config" "github.com/pkg/errors" ) // section is a named section of a Template type section struct { name string contents []contentItem } // newSection constructs a Section func newSection(name string, items ...contentItem) *section { return §ion{ name: name, contents: items, } } func (s *section) deleteContents() { s.contents = []contentItem{} } func (s *section) sortContents() { // stable sort to preserve order for empty header case sort.SliceStable(s.contents, func(i, j int) bool { return s.contents[i].header < s.contents[j].header }) } func (s *section) isEmpty() bool { for _, content := range s.contents { if !content.isEmpty() { return false } } return true } func (s *section) getNameString(prefix string, suffix string) string { return fmt.Sprintf("%s%s%s\n", prefix, s.name, suffix) } func (s *section) getContentString() string { str := "" for _, content := range s.contents { txt := content.string() if !strings.HasSuffix(txt, "\n") { txt += "\n" } str += txt } return str } type contentItem struct { header string text string } func (ci contentItem) string() string { if ci.header != "" { return fmt.Sprintf("%s\n%s", ci.header, ci.text) } return ci.text } func (ci contentItem) isEmpty() bool { // exclude trailing newlines for empty content check strippedTxt := strings.Replace(ci.text, "\n", "", -1) return len(strippedTxt) == 0 } func parseSection(text string, opts config.Opts) (*section, error) { if len(text) == 0 { return nil, errors.New("cannot parse Section from empty input") } lines := strings.Split(text, "\n") name := stripPrefixSuffix(lines[0], opts.Section.Prefix, opts.Section.Suffix) contents := parseSectionContents( lines[1:], opts.Archive.SectionContentPrefix, opts.Archive.SectionContentSuffix, opts.File.TimeFormat, ) // return section populated with contents if any contentItem is non-empty for _, content := range contents { if !content.isEmpty() { return newSection(name, contents...), nil } } // all contents are empty so return unpopulated section return newSection(name), nil } func parseSectionContents(lines []string, prefix string, suffix string, format string) []contentItem { contents := []contentItem{} if len(lines) == 0 { return contents } // parse first line line := lines[0] header := "" body := []string{} if isArchiveItemHeader(line, prefix, suffix, format) { header = line } else { body = append(body, line) } for _, line := range lines[1:] { // if the line is a header it indicates new contents, so "flush" (append) the current // header/body and start tracking the new contents if isArchiveItemHeader(line, prefix, suffix, format) { contents = append(contents, contentItem{ header: header, text: strings.Join(body, "\n"), }) header = line body = []string{} continue } body = append(body, line) } // ensure remaining content is appended if len(body) != 0 || header != "" { contents = append(contents, contentItem{ header: header, text: strings.Join(body, "\n"), }) } return contents } func stripPrefixSuffix(line string, prefix string, suffix string) string { return strings.TrimPrefix(strings.TrimSuffix(line, suffix), prefix) } func getSectionNameRegex(prefix string, suffix string) (*regexp.Regexp, error) { sectionPattern := fmt.Sprintf("%s.*%s", prefix, suffix) sectionNameRegex, err := regexp.Compile(sectionPattern) if err != nil { return sectionNameRegex, fmt.Errorf("invalid section prefix [%s] or suffix [%s]", prefix, suffix) } return sectionNameRegex, nil } ================================================ FILE: pkg/template/section_test.go ================================================ package template import ( "fmt" "strings" "testing" "time" "github.com/dkaslovsky/textnote/pkg/template/templatetest" "github.com/stretchr/testify/require" ) func TestGetNameString(t *testing.T) { type testCase struct { name string prefix string suffix string expected string } tests := map[string]testCase{ "empty name, empty prefix and suffix": { name: "", prefix: "", suffix: "", expected: "\n", }, "empty name, non-empty prefix and suffix": { name: "", prefix: "p ", suffix: " s", expected: "p s\n", }, "non-empty name, empty prefix and suffix": { name: "name", prefix: "", suffix: "", expected: "name\n", }, "non-empty name, non-empty prefix and suffix": { name: "name", prefix: "p ", suffix: " s", expected: "p name s\n", }, "non-empty name with spaces, non-empty prefix and suffix": { name: " na me ", prefix: "p ", suffix: " s", expected: "p na me s\n", }, "non-empty name with newlines, non-empty prefix and suffix": { name: " na \n me ", prefix: "p ", suffix: " s", expected: "p na \n me s\n", }, } for name, test := range tests { t.Run(name, func(t *testing.T) { s := newSection(test.name) str := s.getNameString(test.prefix, test.suffix) require.Equal(t, test.expected, str) }) } } func TestGetContentString(t *testing.T) { type testCase struct { contents []contentItem expected string } tests := map[string]testCase{ "empty contents": { contents: []contentItem{}, expected: "", }, "single empty string contents with no header": { contents: []contentItem{ { header: "", text: "", }, }, expected: "\n", }, "single empty string contents with header": { contents: []contentItem{ { header: "header", text: "", }, }, expected: "header\n", }, "multiple empty string contents with no header": { contents: []contentItem{ { header: "", text: "", }, { header: "", text: "", }, }, expected: "\n\n", }, "multiple empty string contents with header": { contents: []contentItem{ { header: "header1", text: "", }, { header: "header2", text: "", }, }, expected: "header1\nheader2\n", }, "single nonempty contents with no header missing trailing newline": { contents: []contentItem{ { header: "", text: "text\n goes\n here", }, }, expected: "text\n goes\n here\n", }, "single nonempty contents with no header": { contents: []contentItem{ { header: "", text: "text\n goes\n here\n", }, }, expected: "text\n goes\n here\n", }, "single nonempty contents with header": { contents: []contentItem{ { header: "header", text: "text\n goes\n here\n", }, }, expected: "header\ntext\n goes\n here\n", }, "multiple nonempty contents with no headers": { contents: []contentItem{ { header: "", text: "text\n goes\n here\n", }, { header: "", text: "text2\n goes2\n here2 \n", }, }, expected: "text\n goes\n here\ntext2\n goes2\n here2 \n", }, "multiple nonempty contents with headers": { contents: []contentItem{ { header: "header1 ", text: "text\n goes\n here\n", }, { header: " header2", text: "text2\n goes2\n here2 \n", }, }, expected: "header1 \ntext\n goes\n here\n header2\ntext2\n goes2\n here2 \n", }, } for name, test := range tests { t.Run(name, func(t *testing.T) { s := newSection("name", test.contents...) str := s.getContentString() require.Equal(t, test.expected, str) }) } } func TestParseSectionContents(t *testing.T) { type testCase struct { lines []string expected []contentItem } tests := map[string]testCase{ "empty lines": { lines: []string{}, expected: []contentItem{}, }, "single empty string line": { lines: []string{""}, expected: []contentItem{ {}, }, }, "lines with no header": { lines: strings.Split("hello\n world", "\n"), expected: []contentItem{ { header: "", text: "hello\n world", }, }, }, "lines with no header with newline at start and end": { lines: strings.Split("\n\nhello\n world\n\n", "\n"), expected: []contentItem{ { header: "", text: "\n\nhello\n world\n\n", }, }, }, "lines with single header": { lines: strings.Split( fmt.Sprintf("%s\nhello\n world", templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts())), "\n", ), expected: []contentItem{ { header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), text: "hello\n world", }, }, }, "lines with single header with newline at start and end": { lines: strings.Split( fmt.Sprintf("\n%s\n\nhello\n world\n", templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts())), "\n", ), expected: []contentItem{ {}, { header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), text: "\nhello\n world\n", }, }, }, "lines with multiple headers": { lines: strings.Split( fmt.Sprintf("%s\nhello\n world\n%s\nhello2\n world2", templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), ), "\n", ), expected: []contentItem{ { header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), text: "hello\n world", }, { header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), text: "hello2\n world2", }, }, }, "lines with multiple headers with newline at start and end": { lines: strings.Split( fmt.Sprintf("\n%s\nhello\n\n world\n\n\n\n%s\nhello2\n world2\n\n\n\n\n", templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), ), "\n", ), expected: []contentItem{ {}, { header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), text: "hello\n\n world\n\n\n", }, { header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), text: "hello2\n world2\n\n\n\n\n", }, }, }, "header with no text": { lines: strings.Split( templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), "\n", ), expected: []contentItem{ { header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), text: "", }, }, }, "multiple headers with no text": { lines: strings.Split( fmt.Sprintf("%s\n%s", templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), ), "\n", ), expected: []contentItem{ { header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), text: "", }, { header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), text: "", }, }, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { opts := templatetest.GetOpts() contents := parseSectionContents(test.lines, opts.Archive.SectionContentPrefix, opts.Archive.SectionContentSuffix, opts.File.TimeFormat) require.Equal(t, test.expected, contents) }) } } func TestSectionIsEmpty(t *testing.T) { type testCase struct { contents []contentItem expected bool } tests := map[string]testCase{ "empty contents": { contents: []contentItem{}, expected: true, }, "single content with only newlines and empty header": { contents: []contentItem{ { header: "", text: "\n\n\n", }, }, expected: true, }, "single content with only newlines and populated header": { contents: []contentItem{ { header: "header", text: "\n\n\n", }, }, expected: true, }, "multiple contents with only newlines and empty headers": { contents: []contentItem{ { header: "", text: "\n\n\n", }, { header: "", text: "\n", }, }, expected: true, }, "multiple contents with only newlines and populated headers": { contents: []contentItem{ { header: "header1", text: "\n\n\n", }, { header: "header2", text: "\n", }, }, expected: true, }, "single content with text and no header": { contents: []contentItem{ { header: "", text: "\n\nfoo\n", }, }, expected: false, }, "single content with text and populated header": { contents: []contentItem{ { header: "header", text: "\n\nfoo\n", }, }, expected: false, }, "multiple contents with text and no headers": { contents: []contentItem{ { header: "", text: "\n\nfoo\n", }, { header: "", text: "bar", }, }, expected: false, }, "multiple contents with text and populated headers": { contents: []contentItem{ { header: "header1", text: "\n\nfoo\n", }, { header: "header2", text: "bar", }, }, expected: false, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { s := newSection("name", test.contents...) val := s.isEmpty() require.Equal(t, test.expected, val) }) } } func TestContentItemIsEmpty(t *testing.T) { type testCase struct { item contentItem expected bool } tests := map[string]testCase{ "empty": { item: contentItem{}, expected: true, }, "only newlines and empty header": { item: contentItem{ header: "", text: "\n\n\n", }, expected: true, }, "only newlines and populated header": { item: contentItem{ header: "header", text: "\n\n\n", }, expected: true, }, "text and no header": { item: contentItem{ header: "", text: "\n\nfoo\n", }, expected: false, }, "text and populated header": { item: contentItem{ header: "header", text: "\n\nfoo\n", }, expected: false, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { val := test.item.isEmpty() require.Equal(t, test.expected, val) }) } } ================================================ FILE: pkg/template/template.go ================================================ package template import ( "fmt" "io" "path/filepath" "strings" "time" "github.com/dkaslovsky/textnote/pkg/config" ) // Template contains the structure of a note type Template struct { opts config.Opts date time.Time sections []*section sectionIdx map[string]int // map of section name to index in sections slice } // NewTemplate constructs a new Template func NewTemplate(opts config.Opts, date time.Time) *Template { t := &Template{ opts: opts, date: date, sections: []*section{}, sectionIdx: map[string]int{}, } for idx, sectionName := range opts.Section.Names { t.sections = append(t.sections, newSection(sectionName)) t.sectionIdx[sectionName] = idx } return t } // Write writes the template func (t *Template) Write(w io.Writer) error { _, err := w.Write([]byte(t.string())) return err } // GetDate returns the template's date func (t *Template) GetDate() time.Time { return t.date } // GetFileCursorLine returns the line at which to place the cursor when opening the template func (t *Template) GetFileCursorLine() int { return t.opts.File.CursorLine } // GetFilePath generates a full path for a file based on the template date func (t *Template) GetFilePath() string { name := filepath.Join(t.opts.AppDir, t.date.Format(t.opts.File.TimeFormat)) if t.opts.File.Ext == "" { return name } return fmt.Sprintf("%s.%s", name, t.opts.File.Ext) } // sectionGettable is the interface for getting a section type sectionGettable interface { getSection(string) (*section, error) } // CopySectionContents copies the contents of the specified section from a source template by // appending to the contents of the receiver's section func (t *Template) CopySectionContents(src sectionGettable, sectionName string) error { tgtSec, err := t.getSection(sectionName) if err != nil { return fmt.Errorf("failed to find section in target: %w", err) } srcSec, err := src.getSection(sectionName) if err != nil { return fmt.Errorf("failed to find section in source: %w", err) } tgtSec.contents = append(tgtSec.contents, srcSec.contents...) return nil } // DeleteSectionContents deletes the contents of a specified section func (t *Template) DeleteSectionContents(sectionName string) error { sec, err := t.getSection(sectionName) if err != nil { return fmt.Errorf("cannot delete section: %w", err) } sec.deleteContents() return nil } // IsEmpty evaluates if a template is empty (ignores whitespace) func (t *Template) IsEmpty() bool { for _, sec := range t.sections { if !sec.isEmpty() { return false } } return true } // Load populates a Template from the contents of a reader func (t *Template) Load(r io.Reader) error { raw, err := io.ReadAll(r) if err != nil { return fmt.Errorf("error loading template: %w", err) } sectionText := string(raw) sectionNameRegex, err := getSectionNameRegex(t.opts.Section.Prefix, t.opts.Section.Suffix) if err != nil { return fmt.Errorf("cannot parse sections: %w", err) } sectionBoundaries := sectionNameRegex.FindAllStringSubmatchIndex(sectionText, -1) numSections := len(sectionBoundaries) // extract sections from sectionText for i, idxs := range sectionBoundaries { var curSecEnd int // end of current section is marked by the beginning of the next section // if current section is not the last section if i != numSections-1 { curSecEnd = sectionBoundaries[i+1][0] } else { curSecEnd = len(sectionText) } section, err := parseSection(sectionText[idxs[0]:curSecEnd], t.opts) if err != nil { return fmt.Errorf("failed to parse section while reading textnote: %w", err) } idx, found := t.sectionIdx[section.name] if !found { return fmt.Errorf("cannot load undefined section [%s]", section.name) } t.sections[idx] = section } return nil } func (t *Template) string() string { str := t.makeHeader() for _, section := range t.sections { name := section.getNameString(t.opts.Section.Prefix, t.opts.Section.Suffix) body := section.getContentString() // default to trailing whitespace for empty body if len(body) == 0 { body = strings.Repeat("\n", t.opts.Section.TrailingNewlines) } str += fmt.Sprintf("%s%s", name, body) } return str } func (t *Template) makeHeader() string { return fmt.Sprintf("%s%s%s\n%s", t.opts.Header.Prefix, t.date.Format(t.opts.Header.TimeFormat), t.opts.Header.Suffix, strings.Repeat("\n", t.opts.Header.TrailingNewlines), ) } func (t *Template) getSection(name string) (*section, error) { idx, found := t.sectionIdx[name] if !found { return §ion{}, fmt.Errorf("section [%s] not found", name) } return t.sections[idx], nil } // ParseTemplateFileName extracts a time.Time from a file name and returns an additional // bool indicating if name corresponds to a valid template file name func ParseTemplateFileName(fileName string, opts config.FileOpts) (t time.Time, ok bool) { // ensure extension matches template file name convention ext := filepath.Ext(fileName) if ext == "." { return t, false } if strings.TrimPrefix(ext, ".") != opts.Ext { return t, false } baseName := strings.TrimSuffix(fileName, ext) t, err := time.Parse(opts.TimeFormat, baseName) if err != nil { return t, false } return t, true } ================================================ FILE: pkg/template/template_test.go ================================================ package template import ( "fmt" "strings" "testing" "time" "github.com/dkaslovsky/textnote/pkg/config" "github.com/dkaslovsky/textnote/pkg/template/templatetest" "github.com/stretchr/testify/require" ) func TestNewTemplate(t *testing.T) { type testCase struct { sections []string expectedSections []*section expectedSectionIdx map[string]int } tests := map[string]testCase{ "no sections": { sections: []string{}, expectedSections: []*section{}, expectedSectionIdx: map[string]int{}, }, "single section": { sections: []string{ "section1", }, expectedSections: []*section{ newSection("section1"), }, expectedSectionIdx: map[string]int{ "section1": 0, }, }, "multiple sections": { sections: []string{ "section1", "section3", "section2", }, expectedSections: []*section{ newSection("section1"), newSection("section3"), newSection("section2"), }, expectedSectionIdx: map[string]int{ "section1": 0, "section2": 2, "section3": 1, }, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { opts := templatetest.GetOpts() opts.Section.Names = test.sections template := NewTemplate(opts, templatetest.Date) require.Equal(t, templatetest.Date, template.date) require.Equal(t, test.expectedSections, template.sections) require.Equal(t, test.expectedSectionIdx, template.sectionIdx) }) } } func TestGetFilePath(t *testing.T) { t.Run("get file path with extension", func(t *testing.T) { opts := templatetest.GetOpts() opts.File.Ext = "txt" template := NewTemplate(opts, templatetest.Date) filePath := template.GetFilePath() require.True(t, strings.HasPrefix(filePath, opts.AppDir)) require.True(t, strings.HasSuffix(filePath, ".txt")) require.Equal(t, templatetest.Date.Format(opts.File.TimeFormat), stripPrefixSuffix(filePath, fmt.Sprintf("%s/", opts.AppDir), ".txt"), ) }) t.Run("get file path without extension", func(t *testing.T) { opts := templatetest.GetOpts() opts.File.Ext = "" template := NewTemplate(opts, templatetest.Date) filePath := template.GetFilePath() require.True(t, strings.HasPrefix(filePath, opts.AppDir)) require.False(t, strings.HasSuffix(filePath, ".")) require.Equal(t, templatetest.Date.Format(opts.File.TimeFormat), stripPrefixSuffix(filePath, fmt.Sprintf("%s/", opts.AppDir), ""), ) }) } func TestCopySectionContents(t *testing.T) { type testCase struct { sectionName string existingContents []contentItem incomingContents []contentItem } tests := map[string]testCase{ "copy empty contents into empty section": { sectionName: "TestSection1", existingContents: []contentItem{}, incomingContents: []contentItem{}, }, "copy empty contents into populated section": { sectionName: "TestSection1", existingContents: []contentItem{ { header: "existingHeader", text: "existingText1", }, }, incomingContents: []contentItem{}, }, "copy contents with single element into empty section": { sectionName: "TestSection1", existingContents: []contentItem{}, incomingContents: []contentItem{ { header: "header", text: "text1", }, }, }, "copy contents with single element into populated section": { sectionName: "TestSection1", existingContents: []contentItem{ { header: "existingHeader", text: "existingText1", }, }, incomingContents: []contentItem{ { header: "header", text: "text1", }, }, }, "copy contents with multiple element into empty section": { sectionName: "TestSection1", existingContents: []contentItem{}, incomingContents: []contentItem{ { header: "header1", text: "text1", }, { header: "header2", text: "text2", }, }, }, "copy contents with multiple elements into populated section": { sectionName: "TestSection1", existingContents: []contentItem{ { header: "existingHeader", text: "existingText1", }, }, incomingContents: []contentItem{ { header: "header1", text: "text1", }, { header: "header2", text: "text2", }, }, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { opts := templatetest.GetOpts() src := NewTemplate(opts, templatetest.Date) src.sections[src.sectionIdx[test.sectionName]].contents = test.incomingContents template := NewTemplate(opts, templatetest.Date) template.sections[template.sectionIdx[test.sectionName]].contents = test.existingContents err := template.CopySectionContents(src, test.sectionName) require.NoError(t, err) for _, content := range test.incomingContents { require.Contains(t, template.sections[template.sectionIdx[test.sectionName]].contents, content) } }) } } func TestCopySectionContentsFail(t *testing.T) { t.Run("section does not exist in template", func(t *testing.T) { toCopy := "toBeCopied" opts := templatetest.GetOpts() template := NewTemplate(opts, templatetest.Date) src := NewTemplate(opts, templatetest.Date) src.sections = append(src.sections, newSection(toCopy)) src.sectionIdx[toCopy] = len(src.sections) - 1 err := template.CopySectionContents(src, toCopy) require.Error(t, err) }) t.Run("section does not exist in source", func(t *testing.T) { toCopy := "toBeCopied" opts := templatetest.GetOpts() template := NewTemplate(opts, templatetest.Date) template.sections = append(template.sections, newSection(toCopy)) template.sectionIdx[toCopy] = len(template.sections) - 1 src := NewTemplate(opts, templatetest.Date) err := template.CopySectionContents(src, toCopy) require.Error(t, err) }) } func TestDeleteSectionContents(t *testing.T) { t.Run("delete section with no contents", func(t *testing.T) { toDelete := "sectionToBeDeleted" template := NewTemplate(templatetest.GetOpts(), templatetest.Date) template.sections = append(template.sections, newSection(toDelete)) template.sectionIdx[toDelete] = len(template.sections) - 1 err := template.DeleteSectionContents(toDelete) require.NoError(t, err) require.Empty(t, template.sections[len(template.sections)-1].contents) }) t.Run("delete section with contents", func(t *testing.T) { toDelete := "sectionToBeDeleted" template := NewTemplate(templatetest.GetOpts(), templatetest.Date) template.sections = append(template.sections, newSection(toDelete, contentItem{ header: "header", text: "text goes here", })) template.sectionIdx[toDelete] = len(template.sections) - 1 err := template.DeleteSectionContents(toDelete) require.NoError(t, err) require.Empty(t, template.sections[len(template.sections)-1].contents) }) t.Run("delete non-existent section", func(t *testing.T) { toDelete := "sectionToBeDeleted" opts := templatetest.GetOpts() template := NewTemplate(opts, templatetest.Date) err := template.DeleteSectionContents(toDelete) require.Error(t, err) }) } func TestLoad(t *testing.T) { type testCase struct { text string expectedSections []*section } tests := map[string]testCase{ "empty text": { text: ``, expectedSections: []*section{ newSection("TestSection1"), newSection("TestSection2"), newSection("TestSection3"), }, }, "no sections in text": { text: `-^-[Sun] 20 Dec 2020-v- `, expectedSections: []*section{ newSection("TestSection1"), newSection("TestSection2"), newSection("TestSection3"), }, }, "single empty section in text": { text: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_`, expectedSections: []*section{ newSection("TestSection1"), newSection("TestSection2"), newSection("TestSection3"), }, }, "single empty section with trainling newlines in text": { text: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_ `, expectedSections: []*section{ newSection("TestSection1"), newSection("TestSection2"), newSection("TestSection3"), }, }, "single empty section with too many trainling newlines in text": { text: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_ `, expectedSections: []*section{ newSection("TestSection1"), newSection("TestSection2"), newSection("TestSection3"), }, }, "single empty second section in text": { text: `-^-[Sun] 20 Dec 2020-v- _p_TestSection2_q_`, expectedSections: []*section{ newSection("TestSection1"), newSection("TestSection2"), newSection("TestSection3"), }, }, "multiple empty sections in text": { text: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_ _p_TestSection2_q_ _p_TestSection3_q_`, expectedSections: []*section{ newSection("TestSection1"), newSection("TestSection2"), newSection("TestSection3"), }, }, "multiple empty sections with trailing newlines in text": { text: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_ _p_TestSection2_q_ _p_TestSection3_q_ `, expectedSections: []*section{ newSection("TestSection1"), newSection("TestSection2"), newSection("TestSection3"), }, }, "single section with contents in text": { text: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_ text1 text2 _p_TestSection2_q_ _p_TestSection3_q_ `, expectedSections: []*section{ newSection("TestSection1", contentItem{ header: "", text: "text1\n text2\n\n\n", }, ), newSection("TestSection2"), newSection("TestSection3"), }, }, "multiple sections with contents in text": { text: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_ text1 text2 _p_TestSection2_q_ text3 _p_TestSection3_q_ text4 `, expectedSections: []*section{ newSection("TestSection1", contentItem{ header: "", text: "text1\n text2\n\n\n", }, ), newSection("TestSection2", contentItem{ header: "", text: " text3\n", }, ), newSection("TestSection3", contentItem{ header: "", text: "\ntext4\n\n", }), }, }, "section with single item header": { text: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_ [2020-12-18] text1a text1b _p_TestSection2_q_ _p_TestSection3_q_ `, expectedSections: []*section{ newSection("TestSection1", contentItem{ header: "[2020-12-18]", text: "text1a\n text1b\n\n\n", }, ), newSection("TestSection2"), newSection("TestSection3"), }, }, "section with multiple item headers": { text: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_ [2020-12-16] text1a text1b [2020-12-17] text1c [2020-12-18] _p_TestSection2_q_ _p_TestSection3_q_ `, expectedSections: []*section{ newSection("TestSection1", contentItem{ header: "[2020-12-16]", text: "text1a\n text1b\n\n", }, contentItem{ header: "[2020-12-17]", text: "text1c", }, contentItem{ header: "[2020-12-18]", text: "", }, ), newSection("TestSection2"), newSection("TestSection3"), }, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { template := NewTemplate(templatetest.GetOpts(), templatetest.Date) err := template.Load(strings.NewReader(test.text)) require.NoError(t, err) for _, expectedSection := range test.expectedSections { sec, err := template.getSection(expectedSection.name) require.NoError(t, err) require.Equal(t, expectedSection, sec) } }) } } func TestString(t *testing.T) { type testCase struct { sections []*section expected string } tests := map[string]testCase{ "empty template": { sections: []*section{}, expected: `-^-[Sun] 20 Dec 2020-v- `, }, "single empty section": { sections: []*section{ newSection("TestSection1"), }, expected: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_ `, }, "single section with text": { sections: []*section{ newSection("TestSection1", contentItem{ header: "", text: "text", }, ), }, expected: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_ text `, }, "single section with multiline text": { sections: []*section{ newSection("TestSection1", contentItem{ header: "", text: "text1\ntext2\n\n text3text4\n- text5\n\n -text6\n\n", }, ), }, expected: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_ text1 text2 text3text4 - text5 -text6 `, }, "single section with text and header": { sections: []*section{ newSection("TestSection1", contentItem{ // in practice a Template will not have sections with headers // and as such we expect no formatting to be applied header: "header", text: "text", }, ), }, expected: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_ header text `, }, "single section with multiple contents": { sections: []*section{ newSection("TestSection1", contentItem{ header: "", text: "text1", }, // in practice a Template will not have sections with multiple contents contentItem{ header: "", text: "text2", }, ), }, expected: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_ text1 text2 `, }, "multiple empty sections": { sections: []*section{ newSection("TestSection1"), newSection("TestSection2"), newSection("TestSection3"), }, expected: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_ _p_TestSection2_q_ _p_TestSection3_q_ `, }, "multiple sections with only first populated": { sections: []*section{ newSection("TestSection1", contentItem{ header: "", text: "text", }, ), newSection("TestSection2"), newSection("TestSection3"), }, expected: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_ text _p_TestSection2_q_ _p_TestSection3_q_ `, }, "multiple sections with only middle populated": { sections: []*section{ newSection("TestSection1"), newSection("TestSection2", contentItem{ header: "", text: "text", }, ), newSection("TestSection3"), }, expected: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_ _p_TestSection2_q_ text _p_TestSection3_q_ `, }, "multiple sections with only last populated": { sections: []*section{ newSection("TestSection1"), newSection("TestSection2"), newSection("TestSection3", contentItem{ header: "", text: "text", }, ), }, expected: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_ _p_TestSection2_q_ _p_TestSection3_q_ text `, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { opts := templatetest.GetOpts() names := []string{} for _, section := range test.sections { names = append(names, section.name) } opts.Section.Names = names template := NewTemplate(opts, templatetest.Date) for i, section := range test.sections { template.sections[i] = section } require.Equal(t, test.expected, template.string()) }) } } func TestParseTemplateFileName(t *testing.T) { type testCase struct { fileName string opts config.FileOpts expectedTime time.Time expectedOk bool } tests := map[string]testCase{ "parsable file name with extension": { fileName: "2020-12-29.txt", opts: config.FileOpts{ Ext: "txt", TimeFormat: "2006-01-02", }, expectedTime: time.Date(2020, 12, 29, 0, 0, 0, 0, time.UTC), expectedOk: true, }, "parsable file name with no extension": { fileName: "2020-12-29", opts: config.FileOpts{ Ext: "", TimeFormat: "2006-01-02", }, expectedTime: time.Date(2020, 12, 29, 0, 0, 0, 0, time.UTC), expectedOk: true, }, "unparsable file name with extension": { fileName: "2020Dec29.txt", opts: config.FileOpts{ Ext: "txt", TimeFormat: "2006-01-02", }, expectedOk: false, }, "unparsable file name with no extension": { fileName: "2020Dec29", opts: config.FileOpts{ Ext: "", TimeFormat: "2006-01-02", }, expectedOk: false, }, "parsable file name with mismatched extension": { fileName: "2020-12-29.foo", opts: config.FileOpts{ Ext: "txt", TimeFormat: "2006-01-02", }, expectedOk: false, }, "parsable file name with malformed extension and populated config ext": { fileName: "2020-12-29.", opts: config.FileOpts{ Ext: "txt", TimeFormat: "2006-01-02", }, expectedOk: false, }, "parsable file name with malformed extension and unpopulated config ext": { fileName: "2020-12-29.", opts: config.FileOpts{ Ext: "", TimeFormat: "2006-01-02", }, expectedOk: false, }, "parsable file name with archive prefix": { fileName: "archive-2020-12-29.txt", opts: config.FileOpts{ Ext: "txt", TimeFormat: "2006-01-02", }, expectedOk: false, }, "archive file name convention": { fileName: "archive-Dec2020.txt", opts: config.FileOpts{ Ext: "txt", TimeFormat: "2006-01-02", }, expectedOk: false, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { parsedTime, ok := ParseTemplateFileName(test.fileName, test.opts) require.Equal(t, test.expectedOk, ok) if test.expectedOk { require.Equal(t, test.expectedTime, parsedTime) } }) } } func TestIsEmpty(t *testing.T) { type testCase struct { templateFile string expected bool } tests := map[string]testCase{ "no text": { templateFile: ``, expected: true, }, "empty with one section": { templateFile: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_ `, expected: true, }, "empty with multiple section": { templateFile: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_ _p_TestSection2_q_ _p_TestSection3_q_ `, expected: true, }, "not empty with text": { templateFile: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_ _p_TestSection2_q_ foobar _p_TestSection3_q_ `, expected: false, }, "not empty with whitespace": { templateFile: `-^-[Sun] 20 Dec 2020-v- _p_TestSection1_q_ _p_TestSection2_q_ _p_TestSection3_q_ `, expected: false, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { template := NewTemplate(templatetest.GetOpts(), templatetest.Date) err := template.Load(strings.NewReader(test.templateFile)) require.NoError(t, err) require.Equal(t, test.expected, template.IsEmpty()) }) } } ================================================ FILE: pkg/template/templatetest/templatetest.go ================================================ // Package templatetest provides utilities for template testing package templatetest import ( "fmt" "log" "time" "github.com/dkaslovsky/textnote/pkg/config" ) // Date is a fixed date - changing this value will affect some tests var Date = time.Date(2020, 12, 20, 1, 1, 1, 1, time.UTC) // GetOpts returns a configuration struct for tests - changing these values will affect some tests func GetOpts() config.Opts { opts := config.Opts{ AppDir: "path/to/app/dir", Header: config.HeaderOpts{ Prefix: "-^-", Suffix: "-v-", TrailingNewlines: 1, TimeFormat: "[Mon] 02 Jan 2006", }, Section: config.SectionOpts{ Prefix: "_p_", Suffix: "_q_", TrailingNewlines: 3, Names: []string{ "TestSection1", "TestSection2", "TestSection3", }, }, File: config.FileOpts{ Ext: "txt", TimeFormat: "2006-01-02", CursorLine: 1, }, Archive: config.ArchiveOpts{ AfterDays: 7, FilePrefix: "archive-", HeaderPrefix: "ARCHIVEPREFIX ", HeaderSuffix: " ARCHIVESUFFIX", SectionContentPrefix: "[", SectionContentSuffix: "]", SectionContentTimeFormat: "2006-01-02", MonthTimeFormat: "Jan2006", }, Cli: config.CliOpts{ TimeFormat: "2006-01-02", }, TemplateFileCountThresh: 90, } err := config.ValidateOpts(opts) if err != nil { log.Fatal(err) } return opts } // MakeItemHeader is a helper to construct a header property of a contentItem struct func MakeItemHeader(date time.Time, opts config.Opts) string { return fmt.Sprintf("%s%s%s", opts.Archive.SectionContentPrefix, date.Format(opts.Archive.SectionContentTimeFormat), opts.Archive.SectionContentSuffix, ) }