[
  {
    "path": ".github/workflows/release.yml",
    "content": "name: goreleaser\n\non:\n    push:\n        tags:\n            - 'v*.*.*'\n\npermissions:\n  contents: write  \n\njobs:\n  goreleaser:\n    runs-on: ubuntu-latest\n    steps:\n      -\n        name: Checkout\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n      -\n        name: Set up Go\n        uses: actions/setup-go@v4\n        with:\n            go-version-file: 'go.mod'\n      -\n        name: Run GoReleaser\n        uses: goreleaser/goreleaser-action@v5\n        with:\n          distribution: goreleaser\n          version: latest\n          args: release --clean\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# Binaries for programs and plugins\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\n\n# Test binary, built with `go test -c`\n*.test\n\n# Output of the go coverage tool, specifically when used with LiteIDE\n*.out\n\n# Dependency directories (remove the comment below to include it)\n# vendor/\n\n# Directory for binaries\ndist/\n"
  },
  {
    "path": ".goreleaser.yml",
    "content": "builds:\n  - env:\n      - CGO_ENABLED=0\n    ldflags:\n      - -s -w -X main.version={{.Version}}\n    goos:\n      - darwin\n      - linux\n      - windows\n    goarch:\n      - amd64\n      - arm64\n    ignore:\n      - goos: windows\n        goarch: arm64\nchangelog:\n    skip: true\narchives:\n  - format: binary\n    name_template: \"{{.Binary}}_{{.Os}}_{{.Arch}}\"\n"
  },
  {
    "path": ".travis.yml",
    "content": "language: go\n\nenv:\n  - GO111MODULE=on TEXTNOTE_DIR=/tmp\n\ngo:\n  - 1.16.x\n\nbranches:\n  except:\n  - /^(?i:dev)\\/.*$/\n\nbefore_install:\n  - go get github.com/modocache/gover\n  - go get github.com/mattn/goveralls\n\nscript:\n  - go test -v github.com/dkaslovsky/textnote/... -coverprofile=all.coverprofile\n  - gover\n  - goveralls -race -coverprofile gover.coverprofile -service travis-ci\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "## 1.3.0 / 2021-06-19\n\n* [ADDED] Second `--delete`/`-x` flag deletes source file left empty after moving section(s)\n\n## 1.2.0 / 2021-04-26\n\n* [ADDED] Flag to open most recently dated (\"latest\") note\n* [ADDED] Configurable threshold for warning user of too many template files\n* [ADDED] Flags to display configuration file contents (`-f`) and active configuration (`-a`)\n* [ADDED] `update` subcommand for `config` command to overwrite configuration file with active configuration\n* [ADDED] `init` command to more cleanly initialize textnote application directories and files\n* [FIXED] Copy command defaults to latest note instead of potentially nonexistent note from previous day\n* [INTERNAL] Upgraded to Go 1.16\n* [INTERNAL] Deprecated use of `io/ioutil`\n\n## 1.1.1 / 2021-02-28\n\n* [FIXED] Fall back on defaults for parameters missing from configuration file\n* [FIXED] Warning for unsupported editor configuration for cursorLine > 1\n\n## 1.1.0 / 2021-02-16\n\n* [ADDED] Use $EDITOR environment variable for opening notes\n* [ADDED] Add support for vi/vim, nano, neovim, and emacs for using `file.cursorLine` config parameter\n\n## 1.0.0 / 2021-02-09\n\n* Initial release\n"
  },
  {
    "path": "CREDITS",
    "content": "Go (the standard library)\nhttps://golang.org/\n----------------------------------------------------------------\nCopyright (c) 2009 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n   * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n   * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n   * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\n================================================================\n\ndario.cat/mergo\nhttps://dario.cat/mergo\n----------------------------------------------------------------\nCopyright (c) 2013 Dario Castañé. All rights reserved.\nCopyright (c) 2012 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n   * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n   * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n   * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\n================================================================\n\ngithub.com/BurntSushi/toml\nhttps://github.com/BurntSushi/toml\n----------------------------------------------------------------\nThe MIT License (MIT)\n\nCopyright (c) 2013 TOML authors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n\n================================================================\n\ngithub.com/davecgh/go-spew\nhttps://github.com/davecgh/go-spew\n----------------------------------------------------------------\nISC License\n\nCopyright (c) 2012-2016 Dave Collins <dave@davec.name>\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted, provided that the above\ncopyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\nWITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR\nANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\nACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF\nOR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.\n\n================================================================\n\ngithub.com/ilyakaznacheev/cleanenv\nhttps://github.com/ilyakaznacheev/cleanenv\n----------------------------------------------------------------\nMIT License\n\nCopyright (c) 2019 Ilya Kaznacheev\n\nPermission 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:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE 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.\n================================================================\n\ngithub.com/inconshreveable/mousetrap\nhttps://github.com/inconshreveable/mousetrap\n----------------------------------------------------------------\n                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright 2022 Alan Shreve (@inconshreveable)\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n\n================================================================\n\ngithub.com/joho/godotenv\nhttps://github.com/joho/godotenv\n----------------------------------------------------------------\nCopyright (c) 2013 John Barton\n\nMIT License\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n\n================================================================\n\ngithub.com/pkg/errors\nhttps://github.com/pkg/errors\n----------------------------------------------------------------\nCopyright (c) 2015, Dave Cheney <dave@cheney.net>\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n* Redistributions of source code must retain the above copyright notice, this\n  list of conditions and the following disclaimer.\n\n* Redistributions in binary form must reproduce the above copyright notice,\n  this list of conditions and the following disclaimer in the documentation\n  and/or other materials provided with the distribution.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\n================================================================\n\ngithub.com/pmezard/go-difflib\nhttps://github.com/pmezard/go-difflib\n----------------------------------------------------------------\nCopyright (c) 2013, Patrick Mezard\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n    Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n    Redistributions in binary form must reproduce the above copyright\nnotice, this list of conditions and the following disclaimer in the\ndocumentation and/or other materials provided with the distribution.\n    The names of its contributors may not be used to endorse or promote\nproducts derived from this software without specific prior written\npermission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS\nIS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED\nTO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A\nPARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nHOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED\nTO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR\nPROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF\nLIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING\nNEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS\nSOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\n================================================================\n\ngithub.com/spf13/cobra\nhttps://github.com/spf13/cobra\n----------------------------------------------------------------\n                                Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n================================================================\n\ngithub.com/spf13/pflag\nhttps://github.com/spf13/pflag\n----------------------------------------------------------------\nCopyright (c) 2012 Alex Ogier. All rights reserved.\nCopyright (c) 2012 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n   * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n   * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n   * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\n================================================================\n\ngithub.com/stretchr/testify\nhttps://github.com/stretchr/testify\n----------------------------------------------------------------\nMIT License\n\nCopyright (c) 2012-2020 Mat Ryer, Tyler Bunnell and contributors.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n================================================================\n\ngopkg.in/check.v1\nhttps://gopkg.in/check.v1\n----------------------------------------------------------------\nGocheck - A rich testing framework for Go\n \nCopyright (c) 2010-2013 Gustavo Niemeyer <gustavo@niemeyer.net>\n\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met: \n\n1. Redistributions of source code must retain the above copyright notice, this\n   list of conditions and the following disclaimer. \n2. Redistributions in binary form must reproduce the above copyright notice,\n   this list of conditions and the following disclaimer in the documentation\n   and/or other materials provided with the distribution. \n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND\nANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED\nWARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR\nANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES\n(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;\nLOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND\nON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS\nSOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\n================================================================\n\ngopkg.in/yaml.v3\nhttps://gopkg.in/yaml.v3\n----------------------------------------------------------------\n\nThis project is covered by two different licenses: MIT and Apache.\n\n#### MIT License ####\n\nThe following files were ported to Go from C files of libyaml, and thus\nare still covered by their original MIT license, with the additional\ncopyright staring in 2011 when the project was ported over:\n\n    apic.go emitterc.go parserc.go readerc.go scannerc.go\n    writerc.go yamlh.go yamlprivateh.go\n\nCopyright (c) 2006-2010 Kirill Simonov\nCopyright (c) 2006-2011 Kirill Simonov\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies\nof the Software, and to permit persons to whom the Software is furnished to do\nso, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n### Apache License ###\n\nAll the remaining project files are covered by the Apache license:\n\nCopyright (c) 2011-2019 Canonical Ltd\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n\n================================================================\n\nolympos.io/encoding/edn\nhttps://olympos.io/encoding/edn\n----------------------------------------------------------------\nCopyright (c) 2015, The Go Authors, Jean Niklas L'orange\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without modification,\nare permitted provided that the following conditions are met:\n\n  * Redistributions of source code must retain the above copyright notice, this\nlist of conditions and the following disclaimer.\n  * Redistributions in binary form must reproduce the above copyright notice,\nthis list of conditions and the following disclaimer in the documentation and/or\nother materials provided with the distribution.\n  * Neither the name of Google Inc., the copyright holder nor the names of its\ncontributors may be used to endorse or promote products derived from this\nsoftware without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND\nANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED\nWARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR\nANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES\n(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;\nLOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON\nANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS\nSOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\n================================================================\n\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2021 Daniel Kaslovsky\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "Makefile",
    "content": "PROJ := \"$(notdir $(shell pwd))\"\nBRANCH := \"$(shell git rev-parse --abbrev-ref HEAD)\"\nSTATUS := \"$(shell git status -s)\"\n\nBUILD_OUTDIR = \"dist\"\nBUILD_FILE_PATTERN := \"${PROJ}_{{.OS}}_{{.Arch}}\"\n\nBUILD_ARCH = \"amd64 arm64\"\nBUILD_OS = \"linux darwin windows\"\nBUILD_LDFLAGS := \"-s -w -X main.version=$(BRANCH)\"\n\nTAG_REGEX = \"^v[0-9]\\.[0-9]\\.[0-9]$$\"\n\nexport GO111MODULE=on\n\n.PHONY: test\ntest:\n\tgo test ./...\n\n.PHONY: tidy\ntidy:\n\t@go mod tidy\n\t@sleep 1\n\n.PHONY: credits\ncredits: tidy\n\t@gocredits -w\n\t@sleep 1\n\n.PHONY: prepare\nprepare: test tidy credits\n\n.PHONY: build\nbuild: test\n\tgox -ldflags=${BUILD_LDFLAGS} -os=${BUILD_OS} -arch=${BUILD_ARCH} -output=${BUILD_OUTDIR}/${BRANCH}/${BUILD_FILE_PATTERN}\n\n.PHONY: release\nrelease: checkbranch checkstatus build\n\tghr \"${BRANCH}\" \"${BUILD_OUTDIR}/${BRANCH}/\"\n\n.PHONY: checkbranch\ncheckbranch:\nifeq (${BRANCH}, \"$(shell echo ${BRANCH} | grep ${TAG_REGEX})\")\n\t@echo \"branch name ${BRANCH} successfully checked for release\"\nelse\n\t@echo \"branch name ${BRANCH} does not follow semver naming convention, will not release\"\n\t@exit 1\nendif\n\n.PHONY: checkstatus\ncheckstatus:\nifneq (${STATUS}, \"\")\n\t@echo \"dirty branch: check git status\"\n\t@exit 1\nendif\n\t@:\n\n"
  },
  {
    "path": "README.md",
    "content": "# textnote\nSimple tool for creating and organizing daily notes on the command line\n\n[![Build Status](https://travis-ci.com/dkaslovsky/textnote.svg?branch=main)](https://travis-ci.com/github/dkaslovsky/textnote)\n[![Coverage Status](https://coveralls.io/repos/github/dkaslovsky/textnote/badge.svg?branch=main)](https://coveralls.io/github/dkaslovsky/textnote?branch=main)\n[![Go Report Card](https://goreportcard.com/badge/github.com/dkaslovsky/textnote)](https://goreportcard.com/report/github.com/dkaslovsky/textnote)\n[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/dkaslovsky/textnote/blob/main/LICENSE)\n\n<br/>\n\n## Overview\ntextnote is a command line tool for quickly creating and managing daily plain text notes.\nIt is designed for ease of use to encourage the practice of daily, organized note taking.\ntextnote 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.\n\nKey features:\n- Configurable, sectioned note template\n- Easily bring content forward to the next day's note (for those to-dos that didn't quite get done today...)\n- Simple command to consolidate daily notes into monthly archive files\n- Create and open today's note with the default `textnote` command\n\nAll note files are stored locally on the file system in a single directory.\nNotes can easily be synced to a remote server or cloud service if so desired by ensuring the application directory is remotely synced.\n\ntextnote opens notes using the text editor specified by the environment variable `$EDITOR` and defaults to Vim if the environment variable is not set.\nSee the [Editor-Specific Configuration](#editor-specific-configuration) subsection for more details. \n\n<br/>\n\n## Table of Contents\n- [Overview](#overview)\n- [Quick Start](#quick-start)\n- [Installation](#installation)\n  - [Releases](#releases)\n  - [Installing from source](#installing-from-source)\n- [Usage](#usage)\n  - [`open`](#open)\n  - [`archive`](#archive)\n  - [Additional Functionality](#additional-functionality)\n- [Configuration](#configuration)\n  - [Defaults](#defaults)\n  - [Environment Variable Overrides](#environment-variable-overrides)\n  - [Editor-Specific Configuration](#editor-specific-configuration)\n- [License](#license)\n\n<br/>\n\n## Quick Start\n1. Install textnote (see [Installation](#installation))\n2. Set a single environment variable `TEXTNOTE_DIR` to specify the directory for textnote's files\n\nThat's it, textnote is ready to go!\n\nThe directory specified by `TEXTNOTE_DIR` and the default configuration file will be automatically created the first time textnote is run.\n\nStart writing notes for today with a single command\n```\n$ textnote\n```\n\nTo first configure textnote before creating notes, run\n```\n$ textnote init\n```\nand then edit the configuration file found at the displayed path.\n\n<br/>\n\n## Installation\ntextnote can be installed by downloading a prebuilt binary or by the `go get` command.\n\n<br/>\n\n### Releases\nThe recommended installation method is downloading the latest released binary.\nDownload the appropriate binary for your operating system from this repository's [releases](https://github.com/dkaslovsky/textnote/releases/latest) page or via `curl`:\n\nmacOS\n```\n$ curl -o textnote -L https://github.com/dkaslovsky/textnote/releases/latest/download/textnote_darwin_amd64\n```\n\nLinux\n```\n$ curl -o textnote -L https://github.com/dkaslovsky/textnote/releases/latest/download/textnote_linux_amd64\n```\n\nWindows\n```\n> curl.exe -o textnote.exe -L https://github.com/dkaslovsky/textnote/releases/latest/download/textnote_windows_amd64.exe\n```\n\n<br/>\n\n### Installing from source\n\ntextnote can also be installed using Go's built-in tooling:\n```\n$ go get -u github.com/dkaslovsky/textnote\n```\nBuild from source by cloning this repository and running `go build`.\n\nIt 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)).\n\n<br/>\n\n## Usage\ntextnote is intentionally simple to use and supports two main commands: `open` for creating/opening notes and `archive` for consolidating notes into monthly archive files.\n\n<br/>\n\n### **`open`**\nThe `open` command will open a dated note in an editor, creating it first if it does not exist.\n\nOpening or creating a note for the current day is the default action.\nSimply run the root command to open or create a note for the current day:\n```\n$ textnote\n```\nwhich, using the default configuration and assuming today is 2021-01-24, will create and open an empty note template:\n```\n[Sun] 24 Jan 2021\n\n___TODO___\n\n\n\n___DONE___\n\n\n\n___NOTES___\n\n\n\n```\nTo open a note for a specific date other than the current day, specify the date with the `--date` flag:\n```\n$ textnote open --date 2020-12-22\n```\nwhere the date format is specified in the configuration.\n\nAlternatively, a note can be opened by passing the number of days prior to the current day using the `-d` flag. For example,\n```\n$ textnote open -d 1\n```\nopens yesterday's note.\n\nSections from previous notes can be copied or moved into a current note.\nEach section to be copied is specified in a separate `-s` flag.\nThe 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.\nFor example,\n```\n$ textnote open -s TODO -s NOTES\n```\nwill create today's note with the \"TODO\" and \"NOTES\" sections copied from the most recently dated (often yesterday's) note, while\n```\n$ textnote open --copy 2021-01-17 -s TODO\n```\ncreates today's note with the \"TODO\" section copied from the 2021-01-17 note.\nUse the `-c` flag to instead specify the source by the number of days back from the current day.\nFor example,\n```\n$ textnote open -c 3 -s TODO\n```\ncreates today's note with the \"TODO\" section copied from 3 days ago.\n\nTo move instead of copy, add the `-x` flag to any copy command.\nFor example,\n```\n$ textnote open --copy 2021-01-17 -s NOTES -x\n```\nmoves the \"NOTES\" section contents from the 2021-01-17 note into the note for today.\n\nPass two delete flags (`-xx`) to also delete the source note if moving section(s) leaves the source empty:\n```\n$ textnote open --copy 2021-01-17 -s NOTES -xx\n```\n\nThe `--date` and `--copy` (or `-d` and `-c`) flags can be used in combination if such a workflow is desired.\n\nFor convenience, the `-t` flag can be used to open tomorrow's note:\n```\n$ textnote open -t\n```\nFor example,\n```\n$ textnote open -t -s TODO\n```\ncreates a note for tomorrow with a copy of today's \"TODO\" section contents, assuming a note for today exits.\n\nAlso for convenience, the latest (most recent) dated note can be opened using the `-l` flag:\n```\n$ textnote open -l\n```\nThe 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.\nIt will ignore notes dated in the future.\n\nWhen opening/copying requires searching for the latest (most recently dated) note, textnote checks the number of template files that were required to be searched.\nIf 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.\nThis message can be effectively disabled by configuring the `templateFileCountThresh` configuration parameter to be very large, but doing so is not recommended.\n\nThe flag options are summarized by the command's help:\n```\n$ textnote open -h\n\nopen or create a note template\n\nUsage:\n  textnote open [flags]\n\nFlags:\n      --copy string       date of note for copying sections (defaults to date of most recent note, cannot be used with copy-back flag)\n  -c, --copy-back uint    number of days back from today for copying from a note (cannot be used with copy flag)\n      --date string       date for note to be opened (defaults to today)\n  -d, --days-back uint    number of days back from today for opening a note (cannot be used with date, tomorrow, or latest flags)\n  -x, --delete count      delete sections after copy (pass flag twice to also delete empty source note)\n  -h, --help              help for open\n  -l, --latest            specify the most recent dated note to be opened (cannot be used with date, days-back, or tomorrow flags)\n  -s, --section strings   section to copy (defaults to none)\n  -t, --tomorrow          specify tomorrow as the date for note to be opened (cannot be used with date, days-back, or latest flags)\n```\n\n\n<br/>\n\n### **`archive`**\nThe `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.\nOnly notes older than a number of days specified in the configuration are archived.\n\nRunning the archive command\n```\n$ textnote archive\n```\ngenerates an archive file for every month for which a note exists.\nFor example, an archive of the January 2021 notes, assuming the default configuration, will have the form\n```\nARCHIVE Jan2021\n\n___TODO___\n[2021-01-03]\n...\n[2021-01-04]\n...\n\n\n\n___DONE___\n[2021-01-03]\n...\n[2021-01-04]\n...\n[2021-01-06]\n...\n\n\n___NOTES___\n[2021-01-06]\n...\n\n\n\n```\nwith ellipses representing the daily notes' contents.\n\nBy default, the `archive` command is non-destructive: it will create archive files and leave all notes in place.\nTo delete the individual note files and retain only the generated archives, run the command with the `-x` flag:\n```\n$ textnote archive -x\n```\nThis 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.\nRunning with the `--dry-run` flag prints the file names to be deleted without performing any actions:\n```\n$ textnote archive --dry-run\n```\n\nIf the `archive` command is run without the delete flag, archive files are written and the original notes are left in place.\nTo \"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:\n```\n$ textnote archive -x -n\n```\n\nThe flag options are summarized by the command's help:\n```\n$ textnote archive -h\n\nconsolidate notes into monthly archive files\n\nUsage:\n  textnote archive [flags]\n\nFlags:\n  -x, --delete     delete individual files after archiving\n      --dry-run    print file names to be deleted instead of performing deletes (other flags are ignored)\n  -h, --help       help for archive\n  -n, --no-write   disable writing archive files (helpful for deleting previously archived files)\n```\n\n<br/>\n\n### **Additional Functionality**\ntextnote is designed for simplicity. \nBecause 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).\n\nA few simple command line functions for searching, listing, and printing notes are available in a [gist](https://gist.github.com/dkaslovsky/010fd26c4d0975639a5c286fa631d6c9).\n\n<br/>\n\n## Configuration\nWhile textnote is intended to be extremely lightweight, it is also designed to be highly configurable.\nIn particular, the template (sections, headers, date formats, and whitespace) for generating notes can be customized as desired.\nOne might wish to configure headers and section titles for markdown compatibility or change date formats to match regional convention.\n\nConfiguration is read from the `$TEXTNOTE_DIR/.config.yml` file.\nChanges to configuration parameters can be made by updating this file.\nIndividual configuration parameters also can be overridden with [environment variables](#environment-variable-overrides).\n\nImportantly, if textnote's configuration is changed, notes created using a previous configuration might be incompatible with textnote's functionality.\n\nThe configuration file can be displayed by running the `config` command with the `-f` flag:\n```\n$ textnote config -f\n```\nThe configuration file path is displayed by using the `-p` flag:\n```\n$ textnote config -p\n```\n[Defaults](#defaults) are used for configuration parameters omitted from the configuration file or configuration [environment variables](#environment-variable-overrides).\nThe `config` command with the `-a` flag displays the full \"active\" configuration used when the application runs, including default and environment parameters:\n```\n$ textnote config -a\n```\nTo update the configuration file to match the active configuration, run\n```\n$ textnote config update\n```\nThis command overwrites the existing configuration file.\nIt can be used instead of manual updates to the configuration file by passing environment variables.\nFor example,\n```\n$ TEXTNOTE_ARCHIVE_FILE_PREFIX=\"my_archive-\" textnote config update\n```\nThe `update` command is also helpful for writing configuration parameters that have been added with new versions of textnote.\n\nThe `config` command options are summarized by the command's help:\n```\n$ textnote config -h\n\nmanages the application's configuration\n\nUsage:\n  textnote config [flags]\n  textnote config [command]\n\nAvailable Commands:\n  update      update the configuration file with active configuration\n\nFlags:\n  -a, --active   display configuration the application actively uses (includes environment variable configuration)\n  -f, --file     display contents of configuration file (default)\n  -h, --help     help for config\n  -p, --path     display path to configuration file\n\nUse \"textnote config [command] --help\" for more information about a command.\n```\n\n<br/>\n\n### Defaults\nThe default configuration file is automatically written the first time textnote is run:\n```\nheader:\n  prefix: \"\"                              # prefix to attach to header\n  suffix: \"\"                              # suffix to attach to header\n  trailingNewlines: 1                     # number of newlines after header\n  timeFormat: '[Mon] 02 Jan 2006'         # Golang format for header dates\nsection:\n  prefix: ___                             # prefix to attach to section name\n  suffix: ___                             # suffix to attach to section name\n  trailingNewlines: 3                     # number of newlines for empty section\n  names:                                  # section names\n  - TODO\n  - DONE\n  - NOTES\nfile:\n  ext: txt                                # extension to use for note files\n  timeFormat: \"2006-01-02\"                # Golang format for note file names\n  cursorLine: 4                           # line to place cursor when opening a note\narchive:\n  afterDays: 14                           # number of days after which a note can be archived\n  filePrefix: archive-                    # prefix to attach to archive file names\n  headerPrefix: 'ARCHIVE '                # prefix to attach to header of archive notes\n  headerSuffix: \"\"                        # suffix to attach to header of archive notes\n  sectionContentPrefix: '['               # prefix to attach to section content date\n  sectionContentSuffix: ']'               # suffix to attach to section content date\n  sectionContentTimeFormat: \"2006-01-02\"  # Golang format for section content dates\n  monthTimeFormat: Jan2006                # Golang format for month archive file and header dates\ncli:\n  timeFormat: \"2006-01-02\"                # Golang format for CLI date input\ntemplateFileCountThresh: 90               # threshold for displaying a warning for too many template files\n```\n\n### Environment Variable Overrides\nAny configuration parameter can be overridden by setting a corresponding environment variable.\nNote that setting an environment variable does not change the value specified in the configuration file.\nThe full list of environment variables is listed below and is always available by running `textnote --help`:\n```\n  TEXTNOTE_TEMPLATE_FILE_COUNT_THRESH int\n    \tthreshold for warning too many template files\n  TEXTNOTE_HEADER_PREFIX string\n    \tprefix to attach to header\n  TEXTNOTE_HEADER_SUFFIX string\n    \tsuffix to attach to header\n  TEXTNOTE_HEADER_TRAILING_NEWLINES int\n    \tnumber of newlines to attach to end of header\n  TEXTNOTE_HEADER_TIME_FORMAT string\n    \tformatting string to form headers from timestamps\n  TEXTNOTE_SECTION_PREFIX string\n    \tprefix to attach to section names\n  TEXTNOTE_SECTION_SUFFIX string\n    \tsuffix to attach to section names\n  TEXTNOTE_SECTION_TRAILING_NEWLINES int\n    \tnumber of newlines to attach to end of each section\n  TEXTNOTE_SECTION_NAMES slice\n    \tsection names\n  TEXTNOTE_FILE_EXT string\n    \textension for all files written\n  TEXTNOTE_FILE_TIME_FORMAT string\n    \tformatting string to form file names from timestamps\n  TEXTNOTE_FILE_CURSOR_LINE int\n    \tline to place cursor when opening\n  TEXTNOTE_ARCHIVE_AFTER_DAYS int\n    \tnumber of days after which to archive a file\n  TEXTNOTE_ARCHIVE_FILE_PREFIX string\n    \tprefix attached to the file name of all archive files\n  TEXTNOTE_ARCHIVE_HEADER_PREFIX string\n    \toverride header prefix for archive files\n  TEXTNOTE_ARCHIVE_HEADER_SUFFIX string\n    \toverride header suffix for archive files\n  TEXTNOTE_ARCHIVE_SECTION_CONTENT_PREFIX string\n    \tprefix to attach to section content date\n  TEXTNOTE_ARCHIVE_SECTION_CONTENT_SUFFIX string\n    \tsuffix to attach to section content date\n  TEXTNOTE_ARCHIVE_SECTION_CONTENT_TIME_FORMAT string\n    \tformatting string dated section content\n  TEXTNOTE_ARCHIVE_MONTH_TIME_FORMAT string\n    \tformatting string for month archive timestamps\n  TEXTNOTE_CLI_TIME_FORMAT string\n    \tformatting string for timestamp CLI flags\n```\n\n<br/>\n\n### Editor-Specific Configuration\nCurrently, textnote supports the `file.cusorLine` and `TEXTNOTE_FILE_CURSOR_LINE` configuration for the following editors:\n* Vi/Vim\n* Emacs\n* Neovim\n* Nano\n\ntextnote will work with all other editors but will not respect this configuration parameter.\n\n<br/>\n\n## License\ntextnote is released under the [MIT License](https://github.com/dkaslovsky/textnote/blob/main/LICENSE).\nDependency licenses are available in this repository's [CREDITS](./CREDITS) file.\n"
  },
  {
    "path": "cmd/archive/archive.go",
    "content": "package archive\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/dkaslovsky/textnote/pkg/archive\"\n\t\"github.com/dkaslovsky/textnote/pkg/config\"\n\t\"github.com/dkaslovsky/textnote/pkg/file\"\n\t\"github.com/dkaslovsky/textnote/pkg/template\"\n\t\"github.com/spf13/cobra\"\n)\n\ntype commandOptions struct {\n\tdelete  bool\n\tnoWrite bool\n\tdryRun  bool\n}\n\n// CreateArchiveCmd creates the today subcommand\nfunc CreateArchiveCmd() *cobra.Command {\n\tcmdOpts := commandOptions{}\n\tcmd := &cobra.Command{\n\t\tUse:          \"archive\",\n\t\tShort:        \"consolidate notes into archive files\",\n\t\tLong:         \"consolidate notes into monthly archive files\",\n\t\tSilenceUsage: true,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\topts, err := config.Load()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn run(opts, cmdOpts)\n\t\t},\n\t}\n\tattachOpts(cmd, &cmdOpts)\n\treturn cmd\n}\n\nfunc attachOpts(cmd *cobra.Command, cmdOpts *commandOptions) {\n\tflags := cmd.Flags()\n\tflags.BoolVarP(&cmdOpts.delete, \"delete\", \"x\", false, \"delete individual files after archiving\")\n\tflags.BoolVarP(&cmdOpts.noWrite, \"no-write\", \"n\", false, \"disable writing archive files (helpful for deleting previously archived files)\")\n\tflags.BoolVar(&cmdOpts.dryRun, \"dry-run\", false, \"print file names to be deleted instead of performing deletes (other flags are ignored)\")\n}\n\nfunc run(templateOpts config.Opts, cmdOpts commandOptions) error {\n\tarchiver := archive.NewArchiver(templateOpts, file.NewReadWriter(), time.Now())\n\n\tfiles, err := os.ReadDir(templateOpts.AppDir)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// add template files to archiver\n\tfor _, f := range files {\n\t\tif f.IsDir() {\n\t\t\tcontinue\n\t\t}\n\n\t\t// parse date from template file name, skipping non-template files\n\t\ttemplateDate, ok := template.ParseTemplateFileName(f.Name(), templateOpts.File)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\terr := archiver.Add(templateDate)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"skipping unarchivable file [%s]: %s\", f.Name(), err)\n\t\t\tcontinue\n\t\t}\n\t}\n\n\t// print file names for dry-run\n\tif cmdOpts.dryRun {\n\t\tfiles := archiver.GetArchivedFiles()\n\t\tfmt.Printf(\"running \\\"archive --delete\\\" will remove [%d] files\\n\", len(files))\n\t\tfor _, fileName := range files {\n\t\t\tfmt.Printf(\"- %s\\n\", fileName)\n\t\t}\n\t\treturn nil\n\t}\n\n\t// write archive files\n\tif !cmdOpts.noWrite {\n\t\terr = archiver.Write()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// return if not deleting archived files\n\tif !cmdOpts.delete {\n\t\treturn nil\n\t}\n\n\t// delete individual archived files\n\tnumDeleted := 0\n\tfor _, fileName := range archiver.GetArchivedFiles() {\n\t\terr = os.Remove(fileName)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"unable to remove file [%s]: %s\", fileName, err)\n\t\t\tcontinue\n\t\t}\n\t\tnumDeleted++\n\t}\n\tlog.Printf(\"removed [%d] files after archiving\", numDeleted)\n\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/config/config.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"os\"\n\n\t\"github.com/dkaslovsky/textnote/pkg/config\"\n\t\"github.com/spf13/cobra\"\n\t\"gopkg.in/yaml.v3\"\n)\n\ntype commandOptions struct {\n\tpath   bool\n\tactive bool\n\tfile   bool\n}\n\n// CreateConfigCmd creates the config subcommand\nfunc CreateConfigCmd() *cobra.Command {\n\tcmdOpts := commandOptions{}\n\tcmd := &cobra.Command{\n\t\tUse:   \"config\",\n\t\tShort: \"manage configuration\",\n\t\tLong:  \"manages the application's configuration\",\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tconfigPath := config.GetConfigFilePath()\n\n\t\t\tif cmdOpts.path {\n\t\t\t\tlog.Printf(\"configuration file path: [%s]\", configPath)\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tif cmdOpts.active {\n\t\t\t\treturn displayActiveConfig()\n\t\t\t}\n\n\t\t\t// default\n\t\t\treturn displayConfigFile(configPath)\n\t\t},\n\t}\n\tattachOpts(cmd, &cmdOpts)\n\tcmd.AddCommand(CreateConfigUpdateCmd())\n\treturn cmd\n}\n\nfunc attachOpts(cmd *cobra.Command, cmdOpts *commandOptions) {\n\tflags := cmd.Flags()\n\tflags.BoolVarP(&cmdOpts.path, \"path\", \"p\", false, \"display path to configuration file\")\n\tflags.BoolVarP(&cmdOpts.active, \"active\", \"a\", false, \"display configuration the application actively uses (includes environment variable configuration)\")\n\tflags.BoolVarP(&cmdOpts.file, \"file\", \"f\", false, \"display contents of configuration file (default)\")\n}\n\n// CreateConfigUpdateCmd creates the config update subcommand\nfunc CreateConfigUpdateCmd() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"update\",\n\t\tShort: \"update the configuration file with active configuration\",\n\t\tLong:  \"update the configuration file to match the active configuration\",\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tactive, err := getActiveConfigYaml()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn os.WriteFile(config.GetConfigFilePath(), active, 0o644)\n\t\t},\n\t}\n\treturn cmd\n}\n\nfunc displayConfigFile(configPath string) error {\n\t_, err := os.Stat(configPath)\n\tif os.IsNotExist(err) {\n\t\treturn fmt.Errorf(\"cannot find configuration file [%s]\", configPath)\n\t}\n\tf, err := os.Open(configPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to open configuration file [%s]: %w\", configPath, err)\n\t}\n\tc, err := io.ReadAll(f)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to read configuration file [%s]: %w\", configPath, err)\n\t}\n\tlog.Print(string(c))\n\treturn nil\n}\n\nfunc displayActiveConfig() error {\n\tyml, err := getActiveConfigYaml()\n\tif err != nil {\n\t\treturn err\n\t}\n\tlog.Print(string(yml))\n\treturn nil\n}\n\nfunc getActiveConfigYaml() ([]byte, error) {\n\topts, err := config.Load()\n\tif err != nil {\n\t\treturn []byte{}, err\n\t}\n\treturn yaml.Marshal(opts)\n}\n"
  },
  {
    "path": "cmd/initialize/initialize.go",
    "content": "package initialize\n\nimport (\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/dkaslovsky/textnote/pkg/config\"\n)\n\n// CreateInitCmd creates the init subcommand\nfunc CreateInitCmd() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"init\",\n\t\tShort: \"initialize the application\",\n\t\tLong:  \"initialize the application's required directories and files\",\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\treturn config.InitApp()\n\t\t},\n\t}\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/open/open.go",
    "content": "package open\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"math\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/dkaslovsky/textnote/pkg/config\"\n\t\"github.com/dkaslovsky/textnote/pkg/editor\"\n\t\"github.com/dkaslovsky/textnote/pkg/file\"\n\t\"github.com/dkaslovsky/textnote/pkg/template\"\n\t\"github.com/pkg/errors\"\n\t\"github.com/spf13/cobra\"\n)\n\nconst day = 24 * time.Hour\n\ntype commandOptions struct {\n\t// mutually exclusive flags for date to open\n\tdate     string\n\tdaysBack uint\n\ttomorrow bool\n\tlatest   bool\n\n\t// mutually exclusive flags for copy date\n\tcopyDate     string\n\tcopyDaysBack uint\n\n\tdeleteFlagVal  int  // count of number of times delete flag is passed\n\tdeleteSections bool // delete sections on copy (deleteFlagVal > 0)\n\tdeleteEmpty    bool // delete file if empty after deleting sections (deleteFlagVal > 1)\n\n\tsections []string\n}\n\n// CreateOpenCmd creates the open subcommand\nfunc CreateOpenCmd() *cobra.Command {\n\tcmdOpts := commandOptions{}\n\tcmd := &cobra.Command{\n\t\tUse:          \"open\",\n\t\tShort:        \"open a note\",\n\t\tLong:         \"open or create a note template\",\n\t\tSilenceUsage: true,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\topts, err := config.Load()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tnow := time.Now()\n\t\t\tnumFilesSearchedForDate, err := setDateOpt(&cmdOpts, opts, getDirFiles, now)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tnumFilesSearchedForCopy, err := setCopyDateOpt(&cmdOpts, opts, getDirFiles, now)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\twarnTooManyTemplateFiles(max(numFilesSearchedForDate, numFilesSearchedForCopy), opts.TemplateFileCountThresh)\n\t\t\tsetDeleteOpts(&cmdOpts)\n\t\t\treturn run(opts, cmdOpts)\n\t\t},\n\t}\n\tattachOpts(cmd, &cmdOpts)\n\treturn cmd\n}\n\nfunc attachOpts(cmd *cobra.Command, cmdOpts *commandOptions) {\n\tflags := cmd.Flags()\n\n\t// mutually exclusive flags for date to open\n\tflags.StringVar(&cmdOpts.date, \"date\", \"\", \"date for note to be opened (defaults to today)\")\n\tflags.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)\")\n\tflags.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)\")\n\tflags.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)\")\n\n\t// mutually exclusive flags for copy date\n\tflags.StringVar(&cmdOpts.copyDate, \"copy\", \"\", \"date of note for copying sections (defaults to date of most recent note, cannot be used with copy-back flag)\")\n\tflags.UintVarP(&cmdOpts.copyDaysBack, \"copy-back\", \"c\", 0, \"number of days back from today for copying from a note (cannot be used with copy flag)\")\n\n\tflags.StringSliceVarP(&cmdOpts.sections, \"section\", \"s\", []string{}, \"section to copy (defaults to none)\")\n\tflags.CountVarP(&cmdOpts.deleteFlagVal, \"delete\", \"x\", \"delete sections after copy (pass flag twice to also delete empty source note)\")\n}\n\nfunc setDateOpt(cmdOpts *commandOptions, templateOpts config.Opts, getFiles func(string) ([]string, error), now time.Time) (int, error) {\n\tvar (\n\t\tdate                 string\n\t\tnumFiles             int\n\t\terrMutuallyExclusive = errors.New(\"only one of [date, days-back, tomorrow, latest] flags may be used\")\n\t)\n\n\tif cmdOpts.date != \"\" {\n\t\tdate = cmdOpts.date\n\t}\n\n\tif cmdOpts.daysBack != 0 {\n\t\tif date != \"\" {\n\t\t\treturn numFiles, errMutuallyExclusive\n\t\t}\n\t\tdate = now.Add(-day * time.Duration(cmdOpts.daysBack)).Format(templateOpts.Cli.TimeFormat)\n\t}\n\n\tif cmdOpts.tomorrow {\n\t\tif date != \"\" {\n\t\t\treturn numFiles, errMutuallyExclusive\n\t\t}\n\t\tdate = now.Add(day).Format(templateOpts.Cli.TimeFormat)\n\t}\n\n\tif cmdOpts.latest {\n\t\tif date != \"\" {\n\t\t\treturn numFiles, errMutuallyExclusive\n\t\t}\n\n\t\tfiles, err := getFiles(templateOpts.AppDir)\n\t\tif err != nil {\n\t\t\treturn numFiles, err\n\t\t}\n\t\tvar latest string\n\t\tlatest, numFiles = getLatestTemplateFile(files, now, templateOpts.File)\n\t\tif latest == \"\" {\n\t\t\treturn numFiles, fmt.Errorf(\"failed to find latest template file in [%s]\", templateOpts.AppDir)\n\t\t}\n\t\tif templateOpts.File.Ext != \"\" {\n\t\t\tlatest = strings.TrimSuffix(latest, fmt.Sprintf(\".%s\", templateOpts.File.Ext))\n\t\t}\n\t\tdate = latest\n\t}\n\n\t// default to today\n\tif date == \"\" {\n\t\tdate = now.Format(templateOpts.Cli.TimeFormat)\n\t}\n\n\tcmdOpts.date = date\n\treturn numFiles, nil\n}\n\nfunc setCopyDateOpt(cmdOpts *commandOptions, templateOpts config.Opts, getFiles func(string) ([]string, error), now time.Time) (int, error) {\n\tnumFiles := 0\n\n\tif cmdOpts.copyDate != \"\" && cmdOpts.copyDaysBack != 0 {\n\t\treturn numFiles, errors.New(\"only one of [copy, copy-back] flags may be used\")\n\t}\n\n\tif cmdOpts.copyDate != \"\" {\n\t\tif _, err := time.Parse(templateOpts.Cli.TimeFormat, cmdOpts.copyDate); err != nil {\n\t\t\treturn numFiles, fmt.Errorf(\"cannot copy note from malformed date [%s]: %w\", cmdOpts.copyDate, err)\n\t\t}\n\t\treturn numFiles, nil\n\t}\n\tif cmdOpts.copyDaysBack != 0 {\n\t\tcmdOpts.copyDate = now.Add(-day * time.Duration(cmdOpts.copyDaysBack)).Format(templateOpts.Cli.TimeFormat)\n\t\treturn numFiles, nil\n\t}\n\n\t// default to latest\n\tfiles, err := getFiles(templateOpts.AppDir)\n\tif err != nil {\n\t\treturn numFiles, err\n\t}\n\tlatest, numFiles := getLatestTemplateFile(files, now, templateOpts.File)\n\tif templateOpts.File.Ext != \"\" {\n\t\tlatest = strings.TrimSuffix(latest, fmt.Sprintf(\".%s\", templateOpts.File.Ext))\n\t}\n\tcmdOpts.copyDate = latest\n\n\treturn numFiles, nil\n}\n\nfunc setDeleteOpts(cmdOpts *commandOptions) {\n\tcmdOpts.deleteSections = cmdOpts.deleteFlagVal > 0\n\tcmdOpts.deleteEmpty = cmdOpts.deleteFlagVal > 1\n}\n\nfunc run(templateOpts config.Opts, cmdOpts commandOptions) error {\n\tdate, err := time.Parse(templateOpts.Cli.TimeFormat, cmdOpts.date)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot create note for malformed date [%s]: %w\", cmdOpts.date, err)\n\t}\n\n\tt := template.NewTemplate(templateOpts, date)\n\trw := file.NewReadWriter()\n\ted := editor.GetEditor(os.Getenv(editor.EnvEditor))\n\n\t// open file if no sections to copy\n\tif len(cmdOpts.sections) == 0 {\n\t\tif !rw.Exists(t) {\n\t\t\terr := rw.Overwrite(t)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn openInEditor(t, ed)\n\t}\n\n\t// load source for copy\n\tif cmdOpts.copyDate == \"\" {\n\t\treturn fmt.Errorf(\"cannot find note to copy, [%s] might be empty\", templateOpts.AppDir)\n\t}\n\tif cmdOpts.copyDate == cmdOpts.date {\n\t\treturn fmt.Errorf(\"copying from note dated [%s] not allowed when writing to note for date [%s]\", cmdOpts.copyDate, cmdOpts.date)\n\t}\n\tcopyDate, err := time.Parse(templateOpts.Cli.TimeFormat, cmdOpts.copyDate)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot copy note from malformed date [%s]: %w\", cmdOpts.copyDate, err)\n\t}\n\tsrc := template.NewTemplate(templateOpts, copyDate)\n\terr = rw.Read(src)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot read source file for copy: %w\", err)\n\t}\n\t// load template contents if it exists\n\tif rw.Exists(t) {\n\t\terr := rw.Read(t)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"cannot load template file: %w\", err)\n\t\t}\n\t}\n\t// copy from source to template\n\terr = copySections(src, t, cmdOpts.sections)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif cmdOpts.deleteSections {\n\t\terr = deleteSections(src, cmdOpts.sections)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to remove section content from source file: %w\", err)\n\t\t}\n\n\t\tif cmdOpts.deleteEmpty && src.IsEmpty() {\n\t\t\terr = os.Remove(src.GetFilePath())\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to remove empty source file: %w\", err)\n\t\t\t}\n\t\t} else {\n\t\t\terr = rw.Overwrite(src)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to save changes to source file: %w\", err)\n\t\t\t}\n\n\t\t}\n\t}\n\n\terr = rw.Overwrite(t)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to write file: %w\", err)\n\t}\n\treturn openInEditor(t, ed)\n}\n\nfunc copySections(src *template.Template, tgt *template.Template, sectionNames []string) error {\n\tfor _, sectionName := range sectionNames {\n\t\terr := tgt.CopySectionContents(src, sectionName)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"cannot copy section [%s] from source to target: %w\", sectionName, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc deleteSections(t *template.Template, sectionNames []string) error {\n\tfor _, sectionName := range sectionNames {\n\t\terr := t.DeleteSectionContents(sectionName)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"cannot delete section [%s] from template: %w\", sectionName, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc openInEditor(t *template.Template, ed *editor.Editor) error {\n\tif t.GetFileCursorLine() > 1 && !ed.Supported {\n\t\tlog.Printf(\"Editor [%s] only supported with its default arguments, additional configuration ignored\", ed.Cmd)\n\t}\n\tif ed.Default {\n\t\tlog.Printf(\"Environment variable [%s] not set, attempting to use default editor [%s]\", editor.EnvEditor, ed.Cmd)\n\t}\n\treturn ed.Open(t)\n}\n\nfunc getLatestTemplateFile(files []string, now time.Time, opts config.FileOpts) (string, int) {\n\tlatest := \"\"\n\tdelta := math.Inf(1)\n\tnumTemplateFiles := 0\n\n\tfor _, f := range files {\n\t\tfileTime, ok := template.ParseTemplateFileName(f, opts)\n\t\tif !ok {\n\t\t\t// skip archive files and other non-template files that cannot be parsed\n\t\t\tcontinue\n\t\t}\n\t\tnumTemplateFiles++\n\t\tcurdelta := now.Sub(fileTime).Hours()\n\t\tif curdelta < 0 {\n\t\t\tcontinue\n\t\t}\n\t\tif curdelta < delta {\n\t\t\tdelta = curdelta\n\t\t\tlatest = f\n\t\t}\n\t}\n\n\treturn latest, numTemplateFiles\n}\n\nfunc getDirFiles(dir string) ([]string, error) {\n\tfileNames := []string{}\n\n\tdirItems, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn fileNames, err\n\t}\n\n\tfor _, item := range dirItems {\n\t\tif item.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tfileNames = append(fileNames, item.Name())\n\t}\n\n\treturn fileNames, nil\n}\n\nfunc warnTooManyTemplateFiles(n int, thresh int) {\n\tif n > thresh {\n\t\tlog.Printf(\"searching for latest template found more than %d files, consider running archive command for more efficient performance\", thresh)\n\t}\n}\n\nfunc max(i, j int) int {\n\tif i > j {\n\t\treturn i\n\t}\n\treturn j\n}\n"
  },
  {
    "path": "cmd/open/open_test.go",
    "content": "package open\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/dkaslovsky/textnote/pkg/template/templatetest\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestGetLatestTemplateFile(t *testing.T) {\n\topts := templatetest.GetOpts()\n\n\ttype testCase struct {\n\t\tfiles            []string\n\t\tnow              time.Time\n\t\texpectedLatest   string\n\t\texpectedNumFound int\n\t}\n\n\ttests := map[string]testCase{\n\t\t\"empty directory\": {\n\t\t\tfiles:            []string{},\n\t\t\tnow:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),\n\t\t\texpectedLatest:   \"\",\n\t\t\texpectedNumFound: 0,\n\t\t},\n\t\t\"no timestamped template files\": {\n\t\t\tfiles: []string{\n\t\t\t\t\"archive-Dec2019.txt\",\n\t\t\t\t\"archive-2019-11-01.txt\",\n\t\t\t},\n\t\t\tnow:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),\n\t\t\texpectedLatest:   \"\",\n\t\t\texpectedNumFound: 0,\n\t\t},\n\t\t\"single template file in future\": {\n\t\t\tfiles: []string{\n\t\t\t\t\"2020-04-13.txt\",\n\t\t\t},\n\t\t\tnow:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),\n\t\t\texpectedLatest:   \"\",\n\t\t\texpectedNumFound: 1,\n\t\t},\n\t\t\"single template file\": {\n\t\t\tfiles: []string{\n\t\t\t\t\"2020-03-11.txt\",\n\t\t\t},\n\t\t\tnow:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),\n\t\t\texpectedLatest:   \"2020-03-11.txt\",\n\t\t\texpectedNumFound: 1,\n\t\t},\n\t\t\"multiple template files\": {\n\t\t\tfiles: []string{\n\t\t\t\t\"2020-03-11.txt\",\n\t\t\t\t\"2020-03-12.txt\",\n\t\t\t\t\"2020-03-13.txt\",\n\t\t\t},\n\t\t\tnow:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),\n\t\t\texpectedLatest:   \"2020-03-13.txt\",\n\t\t\texpectedNumFound: 3,\n\t\t},\n\t\t\"multiple template files with one in future\": {\n\t\t\tfiles: []string{\n\t\t\t\t\"2020-04-11.txt\",\n\t\t\t\t\"2020-04-12.txt\",\n\t\t\t\t\"2020-04-13.txt\",\n\t\t\t},\n\t\t\tnow:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),\n\t\t\texpectedLatest:   \"2020-04-12.txt\",\n\t\t\texpectedNumFound: 3,\n\t\t},\n\t\t\"mix of timestamped template files and other files\": {\n\t\t\tfiles: []string{\n\t\t\t\t\".config\",\n\t\t\t\t\"foobar\",\n\t\t\t\t\"2020-03-11.txt\",\n\t\t\t\t\"2020-03-12.txt\",\n\t\t\t\t\"2020-03-13.txt\",\n\t\t\t\t\"archive_April2020\",\n\t\t\t},\n\t\t\tnow:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),\n\t\t\texpectedLatest:   \"2020-03-13.txt\",\n\t\t\texpectedNumFound: 3,\n\t\t},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\tlatest, numFound := getLatestTemplateFile(test.files, test.now, opts.File)\n\t\t\trequire.Equal(t, test.expectedLatest, latest)\n\t\t\trequire.Equal(t, test.expectedNumFound, numFound)\n\t\t})\n\t}\n}\n\nfunc TestSetDateOpt(t *testing.T) {\n\ttype testCase struct {\n\t\tcmdOpts          *commandOptions\n\t\tfiles            []string\n\t\tnow              time.Time\n\t\texpectedDate     string\n\t\texpectedNumFiles int\n\t\tshouldErr        bool\n\t}\n\n\ttests := map[string]testCase{\n\t\t\"multiple mutually exclusive flags: date and daysBack set\": {\n\t\t\tcmdOpts: &commandOptions{\n\t\t\t\tdate:     \"2020-04-11\",\n\t\t\t\tdaysBack: 2,\n\t\t\t},\n\t\t\tfiles: []string{\n\t\t\t\t\"2020-04-11.txt\",\n\t\t\t\t\"2020-04-10.txt\",\n\t\t\t\t\"2020-04-09.txt\",\n\t\t\t},\n\t\t\tnow:       time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),\n\t\t\tshouldErr: true,\n\t\t},\n\t\t\"multiple mutually exclusive flags: date and tomorrow set\": {\n\t\t\tcmdOpts: &commandOptions{\n\t\t\t\tdate:     \"2020-04-11\",\n\t\t\t\ttomorrow: true,\n\t\t\t},\n\t\t\tfiles: []string{\n\t\t\t\t\"2020-04-11.txt\",\n\t\t\t\t\"2020-04-10.txt\",\n\t\t\t\t\"2020-04-09.txt\",\n\t\t\t},\n\t\t\tnow:       time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),\n\t\t\tshouldErr: true,\n\t\t},\n\t\t\"multiple mutually exclusive flags: date and latest set\": {\n\t\t\tcmdOpts: &commandOptions{\n\t\t\t\tdate:   \"2020-04-11\",\n\t\t\t\tlatest: true,\n\t\t\t},\n\t\t\tfiles: []string{\n\t\t\t\t\"2020-04-11.txt\",\n\t\t\t\t\"2020-04-10.txt\",\n\t\t\t\t\"2020-04-09.txt\",\n\t\t\t},\n\t\t\tnow:       time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),\n\t\t\tshouldErr: true,\n\t\t},\n\t\t\"multiple mutually exclusive flags: daysBack and tomorrow set\": {\n\t\t\tcmdOpts: &commandOptions{\n\t\t\t\tdaysBack: 2,\n\t\t\t\ttomorrow: true,\n\t\t\t},\n\t\t\tfiles: []string{\n\t\t\t\t\"2020-04-11.txt\",\n\t\t\t\t\"2020-04-10.txt\",\n\t\t\t\t\"2020-04-09.txt\",\n\t\t\t},\n\t\t\tnow:       time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),\n\t\t\tshouldErr: true,\n\t\t},\n\t\t\"multiple mutually exclusive flags: daysBack and latest set\": {\n\t\t\tcmdOpts: &commandOptions{\n\t\t\t\tdaysBack: 2,\n\t\t\t\tlatest:   true,\n\t\t\t},\n\t\t\tfiles: []string{\n\t\t\t\t\"2020-04-11.txt\",\n\t\t\t\t\"2020-04-10.txt\",\n\t\t\t\t\"2020-04-09.txt\",\n\t\t\t},\n\t\t\tnow:       time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),\n\t\t\tshouldErr: true,\n\t\t},\n\t\t\"multiple mutually exclusive flags: tomorrow and latest set\": {\n\t\t\tcmdOpts: &commandOptions{\n\t\t\t\ttomorrow: true,\n\t\t\t\tlatest:   true,\n\t\t\t},\n\t\t\tfiles: []string{\n\t\t\t\t\"2020-04-11.txt\",\n\t\t\t\t\"2020-04-10.txt\",\n\t\t\t\t\"2020-04-09.txt\",\n\t\t\t},\n\t\t\tnow:       time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),\n\t\t\tshouldErr: true,\n\t\t},\n\t\t\"use date\": {\n\t\t\tcmdOpts: &commandOptions{\n\t\t\t\tdate: \"2020-04-11\",\n\t\t\t},\n\t\t\tfiles: []string{\n\t\t\t\t\"2020-04-11.txt\",\n\t\t\t\t\"2020-04-10.txt\",\n\t\t\t\t\"2020-04-09.txt\",\n\t\t\t},\n\t\t\tnow:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),\n\t\t\texpectedDate:     \"2020-04-11\",\n\t\t\texpectedNumFiles: 0,\n\t\t\tshouldErr:        false,\n\t\t},\n\t\t\"use daysBack\": {\n\t\t\tcmdOpts: &commandOptions{\n\t\t\t\tdaysBack: 2,\n\t\t\t},\n\t\t\tfiles: []string{\n\t\t\t\t\"2020-04-11.txt\",\n\t\t\t\t\"2020-04-10.txt\",\n\t\t\t\t\"2020-04-09.txt\",\n\t\t\t},\n\t\t\tnow:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),\n\t\t\texpectedDate:     \"2020-04-10\",\n\t\t\texpectedNumFiles: 0,\n\t\t\tshouldErr:        false,\n\t\t},\n\t\t\"use tomorrow\": {\n\t\t\tcmdOpts: &commandOptions{\n\t\t\t\ttomorrow: true,\n\t\t\t},\n\t\t\tfiles: []string{\n\t\t\t\t\"2020-04-11.txt\",\n\t\t\t\t\"2020-04-10.txt\",\n\t\t\t\t\"2020-04-09.txt\",\n\t\t\t},\n\t\t\tnow:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),\n\t\t\texpectedDate:     \"2020-04-13\",\n\t\t\texpectedNumFiles: 0,\n\t\t\tshouldErr:        false,\n\t\t},\n\t\t\"use latest\": {\n\t\t\tcmdOpts: &commandOptions{\n\t\t\t\tlatest: true,\n\t\t\t},\n\t\t\tfiles: []string{\n\t\t\t\t\"2020-04-11.txt\",\n\t\t\t\t\"2020-04-10.txt\",\n\t\t\t\t\"2020-04-09.txt\",\n\t\t\t},\n\t\t\tnow:              time.Date(2020, 4, 15, 0, 0, 0, 0, time.UTC),\n\t\t\texpectedDate:     \"2020-04-11\",\n\t\t\texpectedNumFiles: 3,\n\t\t\tshouldErr:        false,\n\t\t},\n\t\t\"no latest found\": {\n\t\t\tcmdOpts: &commandOptions{\n\t\t\t\tlatest: true,\n\t\t\t},\n\t\t\tfiles:     []string{},\n\t\t\tnow:       time.Date(2020, 4, 15, 0, 0, 0, 0, time.UTC),\n\t\t\tshouldErr: true,\n\t\t},\n\t\t\"default to today\": {\n\t\t\tcmdOpts: &commandOptions{},\n\t\t\tfiles: []string{\n\t\t\t\t\"2020-04-11.txt\",\n\t\t\t\t\"2020-04-10.txt\",\n\t\t\t\t\"2020-04-09.txt\",\n\t\t\t},\n\t\t\tnow:              time.Date(2020, 4, 15, 0, 0, 0, 0, time.UTC),\n\t\t\texpectedDate:     \"2020-04-15\",\n\t\t\texpectedNumFiles: 0,\n\t\t\tshouldErr:        false,\n\t\t},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\t// setup\n\t\t\tgetFiles := func(dir string) ([]string, error) {\n\t\t\t\treturn test.files, nil\n\t\t\t}\n\t\t\ttemplateOpts := templatetest.GetOpts()\n\n\t\t\t// test\n\t\t\tnumFiles, err := setDateOpt(test.cmdOpts, templateOpts, getFiles, test.now)\n\t\t\tif test.shouldErr {\n\t\t\t\trequire.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\trequire.Equal(t, test.expectedNumFiles, numFiles)\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.Equal(t, test.expectedDate, test.cmdOpts.date)\n\t\t})\n\t}\n}\n\nfunc TestSetCopyDateOpt(t *testing.T) {\n\ttype testCase struct {\n\t\tcmdOpts          *commandOptions\n\t\tfiles            []string\n\t\tnow              time.Time\n\t\texpectedDate     string\n\t\texpectedNumFiles int\n\t\tshouldErr        bool\n\t}\n\n\ttests := map[string]testCase{\n\t\t\"multiple mutually exclusive flags: copyDate and copyDaysBack set\": {\n\t\t\tcmdOpts: &commandOptions{\n\t\t\t\tcopyDate:     \"2020-04-11\",\n\t\t\t\tcopyDaysBack: 2,\n\t\t\t},\n\t\t\tfiles: []string{\n\t\t\t\t\"2020-04-11.txt\",\n\t\t\t\t\"2020-04-10.txt\",\n\t\t\t\t\"2020-04-09.txt\",\n\t\t\t},\n\t\t\tnow:       time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),\n\t\t\tshouldErr: true,\n\t\t},\n\t\t\"use copyDate\": {\n\t\t\tcmdOpts: &commandOptions{\n\t\t\t\tcopyDate: \"2020-04-11\",\n\t\t\t},\n\t\t\tfiles: []string{\n\t\t\t\t\"2020-04-11.txt\",\n\t\t\t\t\"2020-04-10.txt\",\n\t\t\t\t\"2020-04-09.txt\",\n\t\t\t},\n\t\t\tnow:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),\n\t\t\texpectedDate:     \"2020-04-11\",\n\t\t\texpectedNumFiles: 0,\n\t\t\tshouldErr:        false,\n\t\t},\n\t\t\"use copyDaysBack\": {\n\t\t\tcmdOpts: &commandOptions{\n\t\t\t\tcopyDaysBack: 2,\n\t\t\t},\n\t\t\tfiles: []string{\n\t\t\t\t\"2020-04-11.txt\",\n\t\t\t\t\"2020-04-10.txt\",\n\t\t\t\t\"2020-04-09.txt\",\n\t\t\t},\n\t\t\tnow:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),\n\t\t\texpectedDate:     \"2020-04-10\",\n\t\t\texpectedNumFiles: 0,\n\t\t\tshouldErr:        false,\n\t\t},\n\t\t\"default to latest\": {\n\t\t\tcmdOpts: &commandOptions{},\n\t\t\tfiles: []string{\n\t\t\t\t\"2020-04-11.txt\",\n\t\t\t\t\"2020-04-10.txt\",\n\t\t\t\t\"2020-04-09.txt\",\n\t\t\t},\n\t\t\tnow:              time.Date(2020, 4, 15, 0, 0, 0, 0, time.UTC),\n\t\t\texpectedDate:     \"2020-04-11\",\n\t\t\texpectedNumFiles: 3,\n\t\t\tshouldErr:        false,\n\t\t},\n\t\t\"no latest found\": {\n\t\t\tcmdOpts:          &commandOptions{},\n\t\t\tfiles:            []string{},\n\t\t\tnow:              time.Date(2020, 4, 15, 0, 0, 0, 0, time.UTC),\n\t\t\texpectedDate:     \"\",\n\t\t\texpectedNumFiles: 0,\n\t\t\tshouldErr:        false,\n\t\t},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\t// setup\n\t\t\tgetFiles := func(dir string) ([]string, error) {\n\t\t\t\treturn test.files, nil\n\t\t\t}\n\t\t\ttemplateOpts := templatetest.GetOpts()\n\n\t\t\t// test\n\t\t\tnumFiles, err := setCopyDateOpt(test.cmdOpts, templateOpts, getFiles, test.now)\n\t\t\tif test.shouldErr {\n\t\t\t\trequire.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\trequire.Equal(t, test.expectedNumFiles, numFiles)\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.Equal(t, test.expectedDate, test.cmdOpts.copyDate)\n\t\t})\n\t}\n}\n\nfunc TestSetDeleteOpts(t *testing.T) {\n\ttype testCase struct {\n\t\tcmdOpts                *commandOptions\n\t\texpectedDeleteSections bool\n\t\texpectedDeleteEmpty    bool\n\t}\n\n\ttests := map[string]testCase{\n\t\t\"deleteFlagVal = 0\": {\n\t\t\tcmdOpts: &commandOptions{\n\t\t\t\tdeleteFlagVal: 0,\n\t\t\t},\n\t\t\texpectedDeleteSections: false,\n\t\t\texpectedDeleteEmpty:    false,\n\t\t},\n\t\t\"deleteFlagVal < 0\": {\n\t\t\tcmdOpts: &commandOptions{\n\t\t\t\tdeleteFlagVal: -1,\n\t\t\t},\n\t\t\texpectedDeleteSections: false,\n\t\t\texpectedDeleteEmpty:    false,\n\t\t},\n\t\t\"deleteFlagVal = 1\": {\n\t\t\tcmdOpts: &commandOptions{\n\t\t\t\tdeleteFlagVal: 1,\n\t\t\t},\n\t\t\texpectedDeleteSections: true,\n\t\t\texpectedDeleteEmpty:    false,\n\t\t},\n\t\t\"deleteFlagVal = 2\": {\n\t\t\tcmdOpts: &commandOptions{\n\t\t\t\tdeleteFlagVal: 2,\n\t\t\t},\n\t\t\texpectedDeleteSections: true,\n\t\t\texpectedDeleteEmpty:    true,\n\t\t},\n\t\t\"deleteFlagVal > 2\": {\n\t\t\tcmdOpts: &commandOptions{\n\t\t\t\tdeleteFlagVal: 3,\n\t\t\t},\n\t\t\texpectedDeleteSections: true,\n\t\t\texpectedDeleteEmpty:    true,\n\t\t},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\tsetDeleteOpts(test.cmdOpts)\n\t\t\trequire.Equal(t, test.expectedDeleteSections, test.cmdOpts.deleteSections)\n\t\t\trequire.Equal(t, test.expectedDeleteEmpty, test.cmdOpts.deleteEmpty)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "cmd/root.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/dkaslovsky/textnote/cmd/archive\"\n\t\"github.com/dkaslovsky/textnote/cmd/config\"\n\t\"github.com/dkaslovsky/textnote/cmd/initialize\"\n\t\"github.com/dkaslovsky/textnote/cmd/open\"\n\tpkgconf \"github.com/dkaslovsky/textnote/pkg/config\"\n\t\"github.com/spf13/cobra\"\n)\n\n// Run executes the CLI\nfunc Run(name string, version string) error {\n\tcmd := &cobra.Command{\n\t\tUse:           name,\n\t\tLong:          fmt.Sprintf(\"Name:\\n  %s - a simple tool for creating and organizing daily notes on the command line\", name),\n\t\tSilenceUsage:  true,\n\t\tSilenceErrors: true,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\t// run the open command with default options as the default application command\n\t\t\treturn open.CreateOpenCmd().Execute()\n\t\t},\n\t}\n\n\tcmd.AddCommand(\n\t\topen.CreateOpenCmd(),\n\t\tarchive.CreateArchiveCmd(),\n\t\tconfig.CreateConfigCmd(),\n\t\tinitialize.CreateInitCmd(),\n\t)\n\n\tsetVersion(cmd, version)\n\tsetHelp(cmd, name)\n\n\treturn cmd.Execute()\n}\n\nfunc setVersion(cmd *cobra.Command, version string) {\n\tif version != \"\" {\n\t\tcmd.Version = version\n\t\treturn\n\t}\n\n\tcmd.Version = \"unavailable\"\n\tcmd.SetVersionTemplate(\n\t\tfmt.Sprintf(\"%s: built from source\", strings.TrimSuffix(cmd.VersionTemplate(), \"\\n\")),\n\t)\n}\n\nfunc setHelp(cmd *cobra.Command, name string) {\n\t// set custom help message for the root command\n\tdefaultHelpFunc := cmd.HelpFunc()\n\tcmd.SetHelpFunc(func(cmd *cobra.Command, s []string) {\n\t\tdefaultHelpFunc(cmd, s)\n\t\tif cmd.Name() != name {\n\t\t\treturn\n\t\t}\n\t\tif description := pkgconf.DescribeEnvVars(); description != \"\" {\n\t\t\tfmt.Printf(\"\\nOverride configuration using environment variables:%s\", description)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/dkaslovsky/textnote\n\ngo 1.21.4\n\nrequire (\n\tdario.cat/mergo v1.0.0\n\tgithub.com/ilyakaznacheev/cleanenv v1.5.0\n\tgithub.com/pkg/errors v0.9.1\n\tgithub.com/spf13/cobra v1.8.0\n\tgithub.com/stretchr/testify v1.8.4\n\tgopkg.in/yaml.v3 v3.0.1\n)\n\nrequire (\n\tgithub.com/BurntSushi/toml v1.2.1 // indirect\n\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n\tgithub.com/inconshreveable/mousetrap v1.1.0 // indirect\n\tgithub.com/joho/godotenv v1.5.1 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.0 // indirect\n\tgithub.com/spf13/pflag v1.0.5 // indirect\n\tolympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=\ndario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=\ngithub.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=\ngithub.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4=\ngithub.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk=\ngithub.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=\ngithub.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=\ngithub.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=\ngithub.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=\ngithub.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=\ngithub.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=\ngithub.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\nolympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ=\nolympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw=\n"
  },
  {
    "path": "main.go",
    "content": "package main\n\nimport (\n\t\"log\"\n\n\t\"github.com/dkaslovsky/textnote/cmd\"\n\t\"github.com/dkaslovsky/textnote/pkg/config\"\n)\n\nconst name = \"textnote\"\n\nvar version string // set by build ldflags\n\nfunc main() {\n\tlog.SetFlags(0)\n\n\terr := config.InitApp()\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\terr = cmd.Run(name, version)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n}\n"
  },
  {
    "path": "pkg/archive/archive.go",
    "content": "package archive\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"time\"\n\n\t\"github.com/dkaslovsky/textnote/pkg/config\"\n\t\"github.com/dkaslovsky/textnote/pkg/file\"\n\t\"github.com/dkaslovsky/textnote/pkg/template\"\n)\n\n// Archiver consolidates templates into archives\ntype Archiver struct {\n\topts config.Opts\n\trw   readWriter\n\tdate time.Time // timestamp for calculating if a file is old enough to be archived\n\n\t// monthArchives maintains a map of formatted month timestamp to the corresponding archive\n\tmonthArchives map[string]*template.MonthArchiveTemplate\n\t// archivedFiles maintains the file names that have been archived\n\tarchivedFiles []string\n}\n\n// NewArchiver constructs a new Archiver\nfunc NewArchiver(opts config.Opts, rw readWriter, date time.Time) *Archiver {\n\treturn &Archiver{\n\t\topts: opts,\n\t\trw:   rw,\n\t\tdate: date,\n\n\t\tmonthArchives: map[string]*template.MonthArchiveTemplate{},\n\t\tarchivedFiles: []string{},\n\t}\n}\n\n// Add adds a template corresponding to a date to the archive\nfunc (a *Archiver) Add(date time.Time) error {\n\t// recent files are not archived\n\tif a.date.Sub(date).Hours() <= float64(a.opts.Archive.AfterDays*24) {\n\t\treturn nil\n\t}\n\n\tt := template.NewTemplate(a.opts, date)\n\terr := a.rw.Read(t)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot add unreadable file [%s] to archive: %w\", t.GetFilePath(), err)\n\t}\n\n\tmonthKey := date.Format(a.opts.Archive.MonthTimeFormat)\n\tif _, found := a.monthArchives[monthKey]; !found {\n\t\ta.monthArchives[monthKey] = template.NewMonthArchiveTemplate(a.opts, date)\n\t}\n\n\tarchive := a.monthArchives[monthKey]\n\tfor _, section := range a.opts.Section.Names {\n\t\terr := archive.ArchiveSectionContents(t, section)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"cannot add contents from [%s] to archive: %w\", t.GetFilePath(), err)\n\t\t}\n\t}\n\n\ta.archivedFiles = append(a.archivedFiles, t.GetFilePath())\n\treturn nil\n}\n\n// Write writes all of the archive templates stored in the Archiver\nfunc (a *Archiver) Write() error {\n\tfor _, t := range a.monthArchives {\n\t\tif a.rw.Exists(t) {\n\t\t\texisting := template.NewMonthArchiveTemplate(a.opts, t.GetDate())\n\t\t\terr := a.rw.Read(existing)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"unable to open existing archive file [%s]: %w\", existing.GetFilePath(), err)\n\t\t\t}\n\t\t\terr = t.Merge(existing)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"unable to from merge existing archive file [%s] %w\", existing.GetFilePath(), err)\n\t\t\t}\n\t\t}\n\n\t\terr := a.rw.Overwrite(t)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to write archive file [%s]: %w\", t.GetFilePath(), err)\n\t\t}\n\t\tlog.Printf(\"wrote archive file [%s]\", t.GetFilePath())\n\t}\n\treturn nil\n}\n\n// GetArchivedFiles returns the files that have been archived\nfunc (a *Archiver) GetArchivedFiles() []string {\n\treturn a.archivedFiles\n}\n\n// readWriter is the interface for executing file operations\ntype readWriter interface {\n\tRead(file.ReadWriteable) error\n\tOverwrite(file.ReadWriteable) error\n\tExists(file.ReadWriteable) bool\n}\n"
  },
  {
    "path": "pkg/archive/archive_test.go",
    "content": "package archive\n\nimport (\n\t\"bytes\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/dkaslovsky/textnote/pkg/file\"\n\t\"github.com/dkaslovsky/textnote/pkg/template\"\n\t\"github.com/dkaslovsky/textnote/pkg/template/templatetest\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\n//\n// mocks\n//\n\ntype testReadWriter struct {\n\texists  bool\n\ttoRead  string\n\twritten string\n}\n\nfunc newTestReadWriter(exists bool, toRead string) *testReadWriter {\n\treturn &testReadWriter{\n\t\texists:  exists,\n\t\ttoRead:  toRead,\n\t\twritten: \"\",\n\t}\n}\n\nfunc (trw *testReadWriter) Read(rwable file.ReadWriteable) error {\n\tr := strings.NewReader(trw.toRead)\n\treturn rwable.Load(r)\n}\n\nfunc (trw *testReadWriter) Overwrite(rwable file.ReadWriteable) error {\n\tbuf := new(bytes.Buffer)\n\terr := rwable.Write(buf)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttrw.written = buf.String()\n\treturn nil\n}\n\nfunc (trw *testReadWriter) Exists(rwable file.ReadWriteable) bool {\n\treturn trw.exists\n}\n\n//\n// Tests\n//\n\nfunc TestAdd(t *testing.T) {\n\ttype testCase struct {\n\t\tdate             time.Time\n\t\ttemplateText     string\n\t\texisting         map[string]string\n\t\texpectedArchives map[string]string\n\t\texpectedFiles    []string\n\t}\n\n\ttests := map[string]testCase{\n\t\t\"add template that should not be archived\": {\n\t\t\tdate:             time.Date(2020, 12, 20, 0, 0, 0, 0, time.UTC),\n\t\t\texpectedArchives: map[string]string{},\n\t\t\texpectedFiles:    []string{},\n\t\t},\n\t\t\"add template from last day that should not be archived\": {\n\t\t\tdate:             time.Date(2020, 12, 14, 0, 0, 0, 0, time.UTC),\n\t\t\texpectedArchives: map[string]string{},\n\t\t\texpectedFiles:    []string{},\n\t\t},\n\t\t\"add template from first day that should be archived\": {\n\t\t\tdate: time.Date(2020, 12, 13, 0, 0, 0, 0, time.UTC),\n\t\t\ttemplateText: `-^-[Sun] 13 Dec 2020-v-\n\n_p_TestSection1_q_\ntext1\n  text2\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n\n\n\n`,\n\t\t\texpectedArchives: map[string]string{\n\t\t\t\t\"Dec2020\": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n[2020-12-13]\ntext1\n  text2\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n\n\n\n`,\n\t\t\t},\n\t\t\texpectedFiles: []string{\n\t\t\t\t\"2020-12-13.txt\",\n\t\t\t},\n\t\t},\n\t\t\"add template from current month\": {\n\t\t\tdate: time.Date(2020, 12, 1, 0, 0, 0, 0, time.UTC),\n\t\t\ttemplateText: `-^-[Tue] 01 Dec 2020-v-\n\n_p_TestSection1_q_\ntext1\n  text2\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n\n\n\n`,\n\t\t\texpectedArchives: map[string]string{\n\t\t\t\t\"Dec2020\": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n[2020-12-01]\ntext1\n  text2\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n\n\n\n`,\n\t\t\t},\n\t\t\texpectedFiles: []string{\n\t\t\t\t\"2020-12-01.txt\",\n\t\t\t},\n\t\t},\n\t\t\"add template from different month\": {\n\t\t\tdate: time.Date(2020, 11, 1, 0, 0, 0, 0, time.UTC),\n\t\t\ttemplateText: `-^-[Sun] 01 Nov 2020-v-\n\n_p_TestSection1_q_\ntext1\n  text2\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n\n\n\n`,\n\t\t\texpectedArchives: map[string]string{\n\t\t\t\t\"Nov2020\": `ARCHIVEPREFIX Nov2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n[2020-11-01]\ntext1\n  text2\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n\n\n\n`,\n\t\t\t},\n\t\t\texpectedFiles: []string{\n\t\t\t\t\"2020-11-01.txt\",\n\t\t\t},\n\t\t},\n\t\t\"add template from different year\": {\n\t\t\tdate: time.Date(2019, 11, 2, 0, 0, 0, 0, time.UTC),\n\t\t\ttemplateText: `-^-[Sat] 02 Nov 2019-v-\n\n_p_TestSection1_q_\ntext1\n  text2\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n\n\n\n`,\n\t\t\texpectedArchives: map[string]string{\n\t\t\t\t\"Nov2019\": `ARCHIVEPREFIX Nov2019 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n[2019-11-02]\ntext1\n  text2\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n\n\n\n`,\n\t\t\t},\n\t\t\texpectedFiles: []string{\n\t\t\t\t\"2019-11-02.txt\",\n\t\t\t},\n\t\t},\n\t\t\"add template with earlier date to existing archive\": {\n\t\t\tdate: time.Date(2020, 12, 1, 0, 0, 0, 0, time.UTC),\n\t\t\ttemplateText: `-^-[Tue] 01 Dec 2020-v-\n\n_p_TestSection1_q_\ntext1\n  text2\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n\n\n\n`,\n\t\t\texisting: map[string]string{\n\t\t\t\t\"Dec2020\": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n[2020-12-02]\nexistingText1\n  existingText2\nexistingText3\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n\n\n\n`,\n\t\t\t},\n\t\t\texpectedArchives: map[string]string{\n\t\t\t\t\"Dec2020\": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n[2020-12-01]\ntext1\n  text2\n[2020-12-02]\nexistingText1\n  existingText2\nexistingText3\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n\n\n\n`,\n\t\t\t},\n\t\t\texpectedFiles: []string{\n\t\t\t\t\"2020-12-01.txt\",\n\t\t\t},\n\t\t},\n\t\t\"add template with later date to existing archive\": {\n\t\t\tdate: time.Date(2020, 12, 2, 0, 0, 0, 0, time.UTC),\n\t\t\ttemplateText: `-^-[Wed] 02 Dec 2020-v-\n\n_p_TestSection1_q_\ntext1\n  text2\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n\n\n\n`,\n\t\t\texisting: map[string]string{\n\t\t\t\t\"Dec2020\": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n[2020-12-01]\nexistingText1\n  existingText2\nexistingText3\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n\n\n\n`,\n\t\t\t},\n\t\t\texpectedArchives: map[string]string{\n\t\t\t\t\"Dec2020\": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n[2020-12-01]\nexistingText1\n  existingText2\nexistingText3\n[2020-12-02]\ntext1\n  text2\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n\n\n\n`,\n\t\t\t},\n\t\t\texpectedFiles: []string{\n\t\t\t\t\"2020-12-02.txt\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\topts := templatetest.GetOpts()\n\t\t\ttrw := newTestReadWriter(true, test.templateText)\n\t\t\ta := NewArchiver(opts, trw, templatetest.Date)\n\t\t\tfor key, text := range test.existing {\n\t\t\t\texistingDate, err := time.Parse(opts.Archive.MonthTimeFormat, key)\n\t\t\t\trequire.NoError(t, err)\n\n\t\t\t\tm := template.NewMonthArchiveTemplate(opts, existingDate)\n\t\t\t\terr = m.Load(strings.NewReader(text))\n\t\t\t\trequire.NoError(t, err)\n\n\t\t\t\ta.monthArchives[key] = m\n\t\t\t}\n\n\t\t\terr := a.Add(test.date)\n\t\t\trequire.NoError(t, err)\n\n\t\t\trequire.Equal(t, len(test.expectedArchives), len(a.monthArchives))\n\t\t\tfor key, expectedText := range test.expectedArchives {\n\t\t\t\tbuf := new(bytes.Buffer)\n\t\t\t\tmonthArchive, found := a.monthArchives[key]\n\t\t\t\trequire.True(t, found)\n\t\t\t\terr := monthArchive.Write(buf)\n\t\t\t\trequire.NoError(t, err)\n\t\t\t\trequire.Equal(t, expectedText, buf.String())\n\t\t\t}\n\n\t\t\texpectedFilesWithFullPath := []string{}\n\t\t\tfor _, f := range test.expectedFiles {\n\t\t\t\tfullPath := filepath.Join(opts.AppDir, f)\n\t\t\t\texpectedFilesWithFullPath = append(expectedFilesWithFullPath, fullPath)\n\t\t\t}\n\t\t\trequire.ElementsMatch(t, expectedFilesWithFullPath, a.GetArchivedFiles())\n\t\t})\n\t}\n}\n\nfunc TestWrite(t *testing.T) {\n\ttype testCase struct {\n\t\ttext         string\n\t\texists       bool\n\t\texistingText string\n\t\texpected     string\n\t}\n\n\ttests := map[string]testCase{\n\t\t\"write with empty archive in archiver to new archive\": {\n\t\t\texists: false,\n\t\t\texpected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n\n\n\n`,\n\t\t},\n\t\t\"write with empty archive in archiver to existing archive\": {\n\t\t\texists: true,\n\t\t\texistingText: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n[2020-12-15]\nexistingText1a\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n[2020-12-15]\nexistingText3a\n[2020-12-22]\nexistingText3b\n\n\n\n`,\n\t\t\texpected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n[2020-12-15]\nexistingText1a\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n[2020-12-15]\nexistingText3a\n[2020-12-22]\nexistingText3b\n\n\n\n`,\n\t\t},\n\t\t\"write to new archive\": {\n\t\t\ttext: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n[2020-12-17]\ntext1a\n[2020-12-19]\ntext1b\n\n_p_TestSection2_q_\n\n_p_TestSection3_q_\n[2020-12-18]\ntext3a\n[2020-12-19]\ntext3b\n\n`,\n\t\t\texists: false,\n\t\t\texpected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n[2020-12-17]\ntext1a\n[2020-12-19]\ntext1b\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n[2020-12-18]\ntext3a\n[2020-12-19]\ntext3b\n\n\n\n`,\n\t\t},\n\t\t\"write to existing archive\": {\n\t\t\ttext: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n[2020-12-17]\ntext1a\n[2020-12-19]\ntext1b\n\n_p_TestSection2_q_\n\n_p_TestSection3_q_\n[2020-12-18]\ntext3a\n[2020-12-19]\ntext3b\n\n`,\n\t\t\texists: true,\n\t\t\texistingText: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n[2020-12-15]\nexistingText1a\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n[2020-12-15]\nexistingText3a\n[2020-12-22]\nexistingText3b\n\n\n\n`,\n\t\t\texpected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n[2020-12-15]\nexistingText1a\n[2020-12-17]\ntext1a\n[2020-12-19]\ntext1b\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n[2020-12-15]\nexistingText3a\n[2020-12-18]\ntext3a\n[2020-12-19]\ntext3b\n[2020-12-22]\nexistingText3b\n\n\n\n`,\n\t\t},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\topts := templatetest.GetOpts()\n\t\t\tdate := templatetest.Date\n\t\t\tkey := date.Format(opts.Archive.MonthTimeFormat)\n\n\t\t\ttemplate := template.NewMonthArchiveTemplate(opts, date)\n\t\t\terr := template.Load(strings.NewReader(test.text))\n\t\t\trequire.NoError(t, err)\n\n\t\t\ttrw := newTestReadWriter(test.exists, test.existingText)\n\t\t\ta := NewArchiver(opts, trw, date)\n\t\t\ta.monthArchives[key] = template\n\n\t\t\terr = a.Write()\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.Equal(t, test.expected, trw.written)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/config/config.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"dario.cat/mergo\"\n\t\"github.com/ilyakaznacheev/cleanenv\"\n\t\"github.com/pkg/errors\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst (\n\t// envAppDir is the name of the environment variable specifying the application directory\n\tenvAppDir = \"TEXTNOTE_DIR\"\n\t// fileName is the name of the configuration file\n\tfileName = \".config.yml\"\n)\n\n// appDir is the directory in which the application stores its files\nvar appDir = os.Getenv(envAppDir)\n\n// Opts are options that configure the application\ntype Opts struct {\n\tAppDir                  string      `yaml:\"-\"` // AppDir is always read from the environment and is not written to file\n\tHeader                  HeaderOpts  `yaml:\"header\"`\n\tSection                 SectionOpts `yaml:\"section\"`\n\tFile                    FileOpts    `yaml:\"file\"`\n\tArchive                 ArchiveOpts `yaml:\"archive\"`\n\tCli                     CliOpts     `yaml:\"cli\"`\n\tTemplateFileCountThresh int         `yaml:\"templateFileCountThresh\" env:\"TEXTNOTE_TEMPLATE_FILE_COUNT_THRESH\" env-description:\"threshold for warning too many template files\"`\n}\n\n// HeaderOpts are options for configuring the header of a note\ntype HeaderOpts struct {\n\tPrefix           string `yaml:\"prefix\" env:\"TEXTNOTE_HEADER_PREFIX\" env-description:\"prefix to attach to header\"`\n\tSuffix           string `yaml:\"suffix\" env:\"TEXTNOTE_HEADER_SUFFIX\" env-description:\"suffix to attach to header\"`\n\tTrailingNewlines int    `yaml:\"trailingNewlines\" env:\"TEXTNOTE_HEADER_TRAILING_NEWLINES\" env-description:\"number of newlines to attach to end of header\"`\n\tTimeFormat       string `yaml:\"timeFormat\" env:\"TEXTNOTE_HEADER_TIME_FORMAT\" env-description:\"formatting string to form headers from timestamps\"`\n}\n\n// SectionOpts are options for configuring sections of a note\ntype SectionOpts struct {\n\tPrefix           string   `yaml:\"prefix\" env:\"TEXTNOTE_SECTION_PREFIX\" env-description:\"prefix to attach to section names\"`\n\tSuffix           string   `yaml:\"suffix\" env:\"TEXTNOTE_SECTION_SUFFIX\" env-description:\"suffix to attach to section names\"`\n\tTrailingNewlines int      `yaml:\"trailingNewlines\" env:\"TEXTNOTE_SECTION_TRAILING_NEWLINES\" env-description:\"number of newlines to attach to end of each section\"`\n\tNames            []string `yaml:\"names\" env:\"TEXTNOTE_SECTION_NAMES\" env-description:\"section names\"`\n}\n\n// FileOpts are options for configuring file outputs\ntype FileOpts struct {\n\tExt        string `yaml:\"ext\" env:\"TEXTNOTE_FILE_EXT\" env-description:\"extension for all files written\"`\n\tTimeFormat string `yaml:\"timeFormat\" env:\"TEXTNOTE_FILE_TIME_FORMAT\" env-description:\"formatting string to form file names from timestamps\"`\n\tCursorLine int    `yaml:\"cursorLine\" env:\"TEXTNOTE_FILE_CURSOR_LINE\" env-description:\"line to place cursor when opening\"`\n}\n\n// ArchiveOpts are options for configuring note archives\ntype ArchiveOpts struct {\n\tAfterDays                int    `yaml:\"afterDays\" env:\"TEXTNOTE_ARCHIVE_AFTER_DAYS\" env-description:\"number of days after which to archive a file\"`\n\tFilePrefix               string `yaml:\"filePrefix\" env:\"TEXTNOTE_ARCHIVE_FILE_PREFIX\" env-description:\"prefix attached to the file name of all archive files\"`\n\tHeaderPrefix             string `yaml:\"headerPrefix\" env:\"TEXTNOTE_ARCHIVE_HEADER_PREFIX\" env-description:\"override header prefix for archive files\"`\n\tHeaderSuffix             string `yaml:\"headerSuffix\" env:\"TEXTNOTE_ARCHIVE_HEADER_SUFFIX\" env-description:\"override header suffix for archive files\"`\n\tSectionContentPrefix     string `yaml:\"sectionContentPrefix\" env:\"TEXTNOTE_ARCHIVE_SECTION_CONTENT_PREFIX\" env-description:\"prefix to attach to section content date\"`\n\tSectionContentSuffix     string `yaml:\"sectionContentSuffix\" env:\"TEXTNOTE_ARCHIVE_SECTION_CONTENT_SUFFIX\" env-description:\"suffix to attach to section content date\"`\n\tSectionContentTimeFormat string `yaml:\"sectionContentTimeFormat\" env:\"TEXTNOTE_ARCHIVE_SECTION_CONTENT_TIME_FORMAT\" env-description:\"formatting string dated section content\"`\n\tMonthTimeFormat          string `yaml:\"monthTimeFormat\" env:\"TEXTNOTE_ARCHIVE_MONTH_TIME_FORMAT\" env-description:\"formatting string for month archive timestamps\"`\n}\n\n// CliOpts are options for configuring the CLI\ntype CliOpts struct {\n\tTimeFormat string `yaml:\"timeFormat\" env:\"TEXTNOTE_CLI_TIME_FORMAT\" env-description:\"formatting string for timestamp CLI flags\"`\n}\n\n// OptsBackCompat are options maintained for backwards compatibility that will be honored in the absence (zero-value) of their\n// replacements as handled in loadBackCompat()\ntype OptsBackCompat struct {\n\t// TemplateFileCountThresh holds the value of the field \"templateFileCountTresh\" (note the typo) in a yaml configuration file\n\tTemplateFileCountThresh int `yaml:\"templateFileCountTresh\"`\n}\n\nfunc getDefaultOpts() Opts {\n\treturn Opts{\n\t\tHeader: HeaderOpts{\n\t\t\tPrefix:           \"\",\n\t\t\tSuffix:           \"\",\n\t\t\tTrailingNewlines: 1,\n\t\t\tTimeFormat:       \"[Mon] 02 Jan 2006\",\n\t\t},\n\t\tSection: SectionOpts{\n\t\t\tPrefix:           \"___\",\n\t\t\tSuffix:           \"___\",\n\t\t\tTrailingNewlines: 3,\n\t\t\tNames: []string{\n\t\t\t\t\"TODO\",\n\t\t\t\t\"DONE\",\n\t\t\t\t\"NOTES\",\n\t\t\t},\n\t\t},\n\t\tFile: FileOpts{\n\t\t\tExt:        \"txt\",\n\t\t\tTimeFormat: \"2006-01-02\",\n\t\t\tCursorLine: 4,\n\t\t},\n\t\tArchive: ArchiveOpts{\n\t\t\tAfterDays:                14,\n\t\t\tFilePrefix:               \"archive-\",\n\t\t\tHeaderPrefix:             \"ARCHIVE \",\n\t\t\tHeaderSuffix:             \"\",\n\t\t\tSectionContentPrefix:     \"[\",\n\t\t\tSectionContentSuffix:     \"]\",\n\t\t\tSectionContentTimeFormat: \"2006-01-02\",\n\t\t\tMonthTimeFormat:          \"Jan2006\",\n\t\t},\n\t\tCli: CliOpts{\n\t\t\tTimeFormat: \"2006-01-02\",\n\t\t},\n\t\tTemplateFileCountThresh: 90,\n\t}\n}\n\n// Load loads the configuration from file and/or evironment\nfunc Load() (Opts, error) {\n\topts := Opts{}\n\n\t// parse config file allowing environment variable overrides\n\terr := loadFromEnv(GetConfigFilePath(), &opts)\n\tif err != nil {\n\t\treturn opts, fmt.Errorf(\"unable to read config file: %w\", err)\n\t}\n\n\t// overwrite defaults with opts from file/env\n\tdefaults := getDefaultOpts()\n\terr = mergo.Merge(&opts, defaults)\n\tif err != nil {\n\t\treturn opts, fmt.Errorf(\"unable to integrate configuration from file with defaults: %w\", err)\n\t}\n\n\t// set AppDir as read from environment\n\topts.AppDir = appDir\n\n\terr = ValidateOpts(opts)\n\tif err != nil {\n\t\treturn opts, fmt.Errorf(\"configuration error in [%s]: %w\", fileName, err)\n\t}\n\n\treturn opts, nil\n}\n\nfunc loadFromEnv(path string, opts *Opts) error {\n\terr := cleanenv.ReadConfig(path, opts)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = loadBackCompat(path, opts)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to read config file for backwards compatibility fields: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc loadBackCompat(path string, opts *Opts) error {\n\t// TemplateFileCountThresh backwards compatibility with previously typo'd field\n\tif opts.TemplateFileCountThresh != 0 {\n\t\treturn nil\n\t}\n\tbackcompat := OptsBackCompat{}\n\terr := cleanenv.ReadConfig(GetConfigFilePath(), &backcompat)\n\tif err != nil {\n\t\treturn err\n\t}\n\topts.TemplateFileCountThresh = backcompat.TemplateFileCountThresh\n\treturn nil\n}\n\n// CreateIfNotExists writes defaults to the configuration file if it does not already exist\nfunc CreateIfNotExists() error {\n\tconfigPath := GetConfigFilePath()\n\t_, err := os.Stat(configPath)\n\tif !os.IsNotExist(err) {\n\t\t// config file exists, nothing to do\n\t\treturn nil\n\t}\n\n\tdefaults := getDefaultOpts()\n\tyml, err := yaml.Marshal(defaults)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to generate config file: %w\", err)\n\t}\n\terr = os.WriteFile(configPath, yml, 0o644)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to create configuration file [%s]: %w\", configPath, err)\n\t}\n\tlog.Printf(\"created default configuration file: [%s]\", configPath)\n\treturn nil\n}\n\n// EnsureAppDir validates that the application directory exists or is created\nfunc EnsureAppDir() error {\n\tif appDir == \"\" {\n\t\treturn fmt.Errorf(\"required environment variable [%s] is not set\", envAppDir)\n\t}\n\n\tfinfo, err := os.Stat(appDir)\n\tif os.IsNotExist(err) {\n\t\terr := os.MkdirAll(appDir, 0o755)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tlog.Printf(\"created directory [%s]\", appDir)\n\t\treturn nil\n\t}\n\n\tif !finfo.IsDir() {\n\t\treturn fmt.Errorf(\"[%s=%s] must be a directory\", envAppDir, appDir)\n\t}\n\treturn nil\n}\n\n// ValidateOpts returns an error if the specified options are misconfigured\nfunc ValidateOpts(opts Opts) error {\n\t// validate appDir is not empty\n\tif opts.AppDir == \"\" {\n\t\treturn fmt.Errorf(\"must include path to application directory in %s environment variable\", envAppDir)\n\t}\n\n\t// validate at least one section\n\tif len(opts.Section.Names) == 0 {\n\t\treturn errors.New(\"must include at least one section\")\n\t}\n\n\t// validate section names are unique\n\tuniq := map[string]struct{}{}\n\tfor _, name := range opts.Section.Names {\n\t\tuniq[name] = struct{}{}\n\t}\n\tif len(uniq) != len(opts.Section.Names) {\n\t\treturn errors.New(\"section names must be unique\")\n\t}\n\n\t// validate file archive prefix: this is needed for determining if a file is an archive\n\tif opts.Archive.FilePrefix == \"\" || strings.ReplaceAll(opts.Archive.FilePrefix, \" \", \"\") == \"\" {\n\t\treturn errors.New(\"file prefix for archives must not be empty\")\n\t}\n\n\t// validate archive after days is at least 1\n\tif opts.Archive.AfterDays < 1 {\n\t\treturn errors.New(\"archive after days must be greater than or equal to 1\")\n\t}\n\n\t// validate file extension does not contain leading dot\n\tif strings.HasPrefix(opts.File.Ext, \".\") {\n\t\treturn errors.New(\"file extension must not include leading dot\")\n\t}\n\n\t// validate the file cursor line is not negative\n\tif opts.File.CursorLine < 0 {\n\t\treturn errors.New(\"cursor line must not be negative\")\n\t}\n\n\t// validate threshold for warning on too many template files is larger than archive after days\n\tif opts.TemplateFileCountThresh <= opts.Archive.AfterDays {\n\t\treturn errors.New(\"template file count threshold must be larger than archive after days\")\n\t}\n\n\treturn nil\n}\n\n// DescribeEnvVars returns a description string for environment variables used to configure the application\nfunc DescribeEnvVars() string {\n\theader := \"\"\n\tdescription, err := cleanenv.GetDescription(&Opts{}, &header)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn description\n}\n\n// GetConfigFilePath constructs the full path to the configuration file\nfunc GetConfigFilePath() string {\n\treturn filepath.Join(appDir, fileName)\n}\n\n// InitApp initializes the application by ensuring the necessary directories and files exist\nfunc InitApp() error {\n\terr := EnsureAppDir()\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = CreateIfNotExists()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/config/config_test.go",
    "content": "package config\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestValidateOpts(t *testing.T) {\n\tt.Run(\"no appDir\", func(t *testing.T) {\n\t\topts := getTestOpts()\n\t\topts.AppDir = \"\"\n\t\terr := ValidateOpts(opts)\n\t\trequire.Error(t, err)\n\t})\n\n\tt.Run(\"no section names\", func(t *testing.T) {\n\t\topts := getTestOpts()\n\t\topts.Section.Names = []string{}\n\t\terr := ValidateOpts(opts)\n\t\trequire.Error(t, err)\n\t})\n\n\tt.Run(\"section names are not unique\", func(t *testing.T) {\n\t\topts := getTestOpts()\n\t\topts.Section.Names = []string{\n\t\t\t\"section1\",\n\t\t\t\"section2\",\n\t\t\t\"section1\",\n\t\t}\n\t\terr := ValidateOpts(opts)\n\t\trequire.Error(t, err)\n\t})\n\n\tt.Run(\"section names are unique\", func(t *testing.T) {\n\t\topts := getTestOpts()\n\t\topts.Section.Names = []string{\n\t\t\t\"section1\",\n\t\t\t\"section2\",\n\t\t\t\"section3\",\n\t\t}\n\t\terr := ValidateOpts(opts)\n\t\trequire.NoError(t, err)\n\t})\n\n\tt.Run(\"archive file prefix is empty string\", func(t *testing.T) {\n\t\topts := getTestOpts()\n\t\topts.Archive.FilePrefix = \"\"\n\t\terr := ValidateOpts(opts)\n\t\trequire.Error(t, err)\n\t})\n\n\tt.Run(\"archive file prefix is blank\", func(t *testing.T) {\n\t\topts := getTestOpts()\n\t\topts.Archive.FilePrefix = \"     \"\n\t\terr := ValidateOpts(opts)\n\t\trequire.Error(t, err)\n\t})\n\n\tt.Run(\"archive file prefix is not empty or blank\", func(t *testing.T) {\n\t\topts := getTestOpts()\n\t\topts.Archive.FilePrefix = \"xyzarchivexyz\"\n\t\terr := ValidateOpts(opts)\n\t\trequire.NoError(t, err)\n\t})\n\n\tt.Run(\"archive after days is negative\", func(t *testing.T) {\n\t\topts := getTestOpts()\n\t\topts.Archive.AfterDays = -1\n\t\terr := ValidateOpts(opts)\n\t\trequire.Error(t, err)\n\t})\n\n\tt.Run(\"archive after days is zero\", func(t *testing.T) {\n\t\topts := getTestOpts()\n\t\topts.Archive.AfterDays = 0\n\t\terr := ValidateOpts(opts)\n\t\trequire.Error(t, err)\n\t})\n\n\tt.Run(\"archive after days is one\", func(t *testing.T) {\n\t\topts := getTestOpts()\n\t\topts.Archive.AfterDays = 1\n\t\terr := ValidateOpts(opts)\n\t\trequire.NoError(t, err)\n\t})\n\n\tt.Run(\"empty file extension should not error\", func(t *testing.T) {\n\t\topts := getTestOpts()\n\t\topts.File.Ext = \"\"\n\t\terr := ValidateOpts(opts)\n\t\trequire.NoError(t, err)\n\t})\n\n\tt.Run(\"file extension without dot should not error\", func(t *testing.T) {\n\t\topts := getTestOpts()\n\t\topts.File.Ext = \"txt\"\n\t\terr := ValidateOpts(opts)\n\t\trequire.NoError(t, err)\n\t})\n\n\tt.Run(\"file extension with leading dot should not error\", func(t *testing.T) {\n\t\topts := getTestOpts()\n\t\topts.File.Ext = \".txt\"\n\t\terr := ValidateOpts(opts)\n\t\trequire.Error(t, err)\n\t})\n\n\tt.Run(\"file cursor line is negative\", func(t *testing.T) {\n\t\topts := getTestOpts()\n\t\topts.File.CursorLine = -2\n\t\terr := ValidateOpts(opts)\n\t\trequire.Error(t, err)\n\t})\n\n\tt.Run(\"file cursor line is zero\", func(t *testing.T) {\n\t\topts := getTestOpts()\n\t\topts.File.CursorLine = 0\n\t\terr := ValidateOpts(opts)\n\t\trequire.NoError(t, err)\n\t})\n\n\tt.Run(\"template file count threshold not greater than archive after days should error\", func(t *testing.T) {\n\t\topts := getTestOpts()\n\t\topts.Archive.AfterDays = 100\n\t\topts.TemplateFileCountThresh = 100\n\t\terr := ValidateOpts(opts)\n\t\trequire.Error(t, err)\n\t})\n\n\tt.Run(\"template file count threshold greater than archive after days should not error\", func(t *testing.T) {\n\t\topts := getTestOpts()\n\t\topts.Archive.AfterDays = 100\n\t\topts.TemplateFileCountThresh = 101\n\t\terr := ValidateOpts(opts)\n\t\trequire.NoError(t, err)\n\t})\n}\n\nfunc getTestOpts() Opts {\n\topts := getDefaultOpts()\n\topts.AppDir = \"path/to/appDir\"\n\treturn opts\n}\n"
  },
  {
    "path": "pkg/editor/editor.go",
    "content": "package editor\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n)\n\n// EnvEditor is the name of the environment variable specifying the editor for opening notes\nconst EnvEditor = \"EDITOR\"\n\nconst (\n\teditorNameEmacs  = \"emacs\"\n\teditorNameNano   = \"nano\"\n\teditorNameNeovim = \"nvim\"\n\teditorNameVi     = \"vi\"\n\teditorNameVim    = \"vim\"\n)\n\n// openable is the interface that an editor opens\ntype openable interface {\n\tGetFilePath() string\n\tGetFileCursorLine() int\n}\n\n// Editor encapsulates the commands and args necessary to open an editor in a shell\ntype Editor struct {\n\tCmd       string\n\tGetArgs   func(int) []string\n\tSupported bool\n\tDefault   bool\n}\n\n// Open opens an object satisfying the openable interface in the editor\n// NOTE: it is recommended to use Go >= v.1.15.7 due to call to exec.Command()\n// See: https://blog.golang.org/path-security\nfunc (e *Editor) Open(o openable) error {\n\targs := append(e.GetArgs(o.GetFileCursorLine()), o.GetFilePath())\n\tcmd := exec.Command(e.Cmd, args...)\n\tcmd.Stdout = os.Stdout\n\tcmd.Stdin = os.Stdin\n\tcmd.Stderr = os.Stderr\n\treturn cmd.Run()\n}\n\n// GetEditor gets an Editor based on a provided name\nfunc GetEditor(name string) *Editor {\n\tswitch name {\n\tcase editorNameVi, editorNameVim:\n\t\treturn &Editor{\n\t\t\tCmd: name,\n\t\t\tGetArgs: func(line int) []string {\n\t\t\t\treturn []string{\n\t\t\t\t\tfmt.Sprintf(\"+%d\", line),\n\t\t\t\t}\n\t\t\t},\n\t\t\tSupported: true,\n\t\t\tDefault:   false,\n\t\t}\n\tcase editorNameEmacs:\n\t\treturn &Editor{\n\t\t\tCmd: editorNameEmacs,\n\t\t\tGetArgs: func(line int) []string {\n\t\t\t\treturn []string{\n\t\t\t\t\tfmt.Sprintf(\"+%d\", line),\n\t\t\t\t}\n\t\t\t},\n\t\t\tSupported: true,\n\t\t\tDefault:   false,\n\t\t}\n\tcase editorNameNano:\n\t\treturn &Editor{\n\t\t\tCmd: editorNameNano,\n\t\t\tGetArgs: func(line int) []string {\n\t\t\t\treturn []string{\n\t\t\t\t\tfmt.Sprintf(\"+%d\", line),\n\t\t\t\t}\n\t\t\t},\n\t\t\tSupported: true,\n\t\t\tDefault:   false,\n\t\t}\n\tcase editorNameNeovim:\n\t\treturn &Editor{\n\t\t\tCmd: editorNameNeovim,\n\t\t\tGetArgs: func(line int) []string {\n\t\t\t\treturn []string{\n\t\t\t\t\tfmt.Sprintf(\"+%d\", line),\n\t\t\t\t}\n\t\t\t},\n\t\t\tSupported: true,\n\t\t\tDefault:   false,\n\t\t}\n\t// use Vim as the default editor\n\tcase \"\":\n\t\treturn &Editor{\n\t\t\tCmd: editorNameVim,\n\t\t\tGetArgs: func(line int) []string {\n\t\t\t\treturn []string{\n\t\t\t\t\tfmt.Sprintf(\"+%d\", line),\n\t\t\t\t}\n\t\t\t},\n\t\t\tSupported: true,\n\t\t\tDefault:   true,\n\t\t}\n\t// unrecognized editor will be passed no arguments\n\tdefault:\n\t\treturn &Editor{\n\t\t\tCmd: name,\n\t\t\tGetArgs: func(line int) []string {\n\t\t\t\treturn []string{}\n\t\t\t},\n\t\t\tSupported: false,\n\t\t\tDefault:   false,\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/file/file.go",
    "content": "package file\n\nimport (\n\t\"io\"\n\t\"os\"\n)\n\n// ReadWriteable is the interface on which file operations are executed\ntype ReadWriteable interface {\n\tLoad(io.Reader) error\n\tWrite(io.Writer) error\n\tGetFilePath() string\n}\n\n// ReadWriter executes file operations\ntype ReadWriter struct{}\n\n// NewReadWriter constructs a new ReadWriter\nfunc NewReadWriter() *ReadWriter {\n\treturn &ReadWriter{}\n}\n\n// Read reads from file\nfunc (rw *ReadWriter) Read(rwable ReadWriteable) error {\n\tr, err := os.Open(rwable.GetFilePath())\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer r.Close()\n\treturn rwable.Load(r)\n}\n\n// Overwrite writes a template to a file, overwriting existing file contents if any\nfunc (rw *ReadWriter) Overwrite(rwable ReadWriteable) error {\n\tf, err := os.OpenFile(rwable.GetFilePath(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\terr = rwable.Write(f)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// Exists evaluates if a file exists\nfunc (rw *ReadWriter) Exists(rwable ReadWriteable) bool {\n\tfileName := rwable.GetFilePath()\n\t_, err := os.Stat(fileName)\n\treturn !os.IsNotExist(err)\n}\n"
  },
  {
    "path": "pkg/template/archive.go",
    "content": "package template\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/dkaslovsky/textnote/pkg/config\"\n)\n\n// MonthArchiveTemplate contains the structure of a month archive\ntype MonthArchiveTemplate struct {\n\t*Template\n}\n\n// NewMonthArchiveTemplate constructs a new MonthArchiveTemplate\nfunc NewMonthArchiveTemplate(opts config.Opts, date time.Time) *MonthArchiveTemplate {\n\tmonthDate := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location())\n\treturn &MonthArchiveTemplate{\n\t\tNewTemplate(opts, monthDate),\n\t}\n}\n\n// Write writes the template\n// This function is needed to ensure the string() method of the MonthArchiveTemplate is called\nfunc (t *MonthArchiveTemplate) Write(w io.Writer) error {\n\t_, err := w.Write([]byte(t.string()))\n\treturn err\n}\n\n// GetFilePath generates a full path for a file based on the template date\nfunc (t *MonthArchiveTemplate) GetFilePath() string {\n\tname := filepath.Join(\n\t\tt.opts.AppDir,\n\t\tt.opts.Archive.FilePrefix+t.date.Format(t.opts.Archive.MonthTimeFormat),\n\t)\n\tif t.opts.File.Ext == \"\" {\n\t\treturn name\n\t}\n\treturn fmt.Sprintf(\"%s.%s\", name, t.opts.File.Ext)\n}\n\n// ArchiveSectionContents concatenates the contents of the specified section from a source template and\n// appends to the contents of the receiver's section with a header derived from the source template's date\nfunc (t *MonthArchiveTemplate) ArchiveSectionContents(src *Template, sectionName string) error {\n\ttgtSec, err := t.getSection(sectionName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to find section in target: %w\", err)\n\t}\n\tsrcSec, err := src.getSection(sectionName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to find section in source: %w\", err)\n\t}\n\n\t// flatten text from contents into a single string\n\ttxt := \"\"\n\tfor _, content := range srcSec.contents {\n\t\ttxt += content.text\n\t}\n\tif len(txt) == 0 {\n\t\treturn nil\n\t}\n\n\ttgtSec.contents = append(tgtSec.contents, contentItem{\n\t\theader: t.makeContentHeader(src.GetDate()),\n\t\ttext:   txt,\n\t})\n\treturn nil\n}\n\n// Merge merges a source MonthArchiveTemplate into the receiver\n// This is a convenience function that iterates and copies all sections in the receiver\nfunc (t *MonthArchiveTemplate) Merge(src *MonthArchiveTemplate) error {\n\tfor sectionName := range t.sectionIdx {\n\t\terr := t.CopySectionContents(src, sectionName)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (t *MonthArchiveTemplate) string() string {\n\tstr := t.makeHeader()\n\tfor _, section := range t.sections {\n\t\tname := section.getNameString(t.opts.Section.Prefix, t.opts.Section.Suffix)\n\n\t\tsection.sortContents()\n\t\tbody := section.getContentString()\n\t\tbody = regexp.MustCompile(`\\n{2,}`).ReplaceAllString(body, \"\\n\") // remove blank lines\n\n\t\tstr += fmt.Sprintf(\"%s%s%s\", name, body, strings.Repeat(\"\\n\", t.opts.Section.TrailingNewlines))\n\t}\n\treturn str\n}\n\nfunc (t *MonthArchiveTemplate) makeHeader() string {\n\treturn fmt.Sprintf(\"%s%s%s\\n%s\",\n\t\tt.opts.Archive.HeaderPrefix,\n\t\tt.date.Format(t.opts.Archive.MonthTimeFormat),\n\t\tt.opts.Archive.HeaderSuffix,\n\t\tstrings.Repeat(\"\\n\", t.opts.Header.TrailingNewlines),\n\t)\n}\n\nfunc (t *MonthArchiveTemplate) makeContentHeader(date time.Time) string {\n\treturn fmt.Sprintf(\"%s%s%s\",\n\t\tt.opts.Archive.SectionContentPrefix,\n\t\tdate.Format(t.opts.Archive.SectionContentTimeFormat),\n\t\tt.opts.Archive.SectionContentSuffix,\n\t)\n}\n\n// isArchiveItemHeader evaluates if a line matches the pattern of a dated header in a section of an archive\nfunc isArchiveItemHeader(line string, prefix string, suffix string, format string) bool {\n\tif !strings.HasPrefix(line, prefix) {\n\t\treturn false\n\t}\n\tif !strings.HasSuffix(line, suffix) {\n\t\treturn false\n\t}\n\t_, err := time.Parse(format, stripPrefixSuffix(line, prefix, suffix))\n\treturn err == nil\n}\n"
  },
  {
    "path": "pkg/template/archive_test.go",
    "content": "package template\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/dkaslovsky/textnote/pkg/template/templatetest\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewMonthArchiveTemplate(t *testing.T) {\n\ttype testCase struct {\n\t\tdate     time.Time\n\t\texpected time.Time\n\t}\n\n\ttests := map[string]testCase{\n\t\t\"first of the month\": {\n\t\t\tdate:     time.Date(2020, 12, 1, 2, 3, 4, 5, time.UTC),\n\t\t\texpected: time.Date(2020, 12, 1, 0, 0, 0, 0, time.UTC),\n\t\t},\n\t\t\"not first of the month\": {\n\t\t\tdate:     time.Date(2020, 12, 15, 2, 3, 4, 5, time.UTC),\n\t\t\texpected: time.Date(2020, 12, 1, 0, 0, 0, 0, time.UTC),\n\t\t},\n\t\t\"non UTC location\": {\n\t\t\tdate:     time.Date(2020, 12, 15, 2, 3, 4, 5, time.FixedZone(\"UTC-8\", -8*60*60)),\n\t\t\texpected: time.Date(2020, 12, 1, 0, 0, 0, 0, time.FixedZone(\"UTC-8\", -8*60*60)),\n\t\t},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\tm := NewMonthArchiveTemplate(templatetest.GetOpts(), test.date)\n\t\t\trequire.Equal(t, test.expected, m.date)\n\t\t})\n\t}\n}\n\nfunc TestArchiveGetFilePath(t *testing.T) {\n\tt.Run(\"get file path with extension\", func(t *testing.T) {\n\t\topts := templatetest.GetOpts()\n\t\topts.File.Ext = \"txt\"\n\t\ttemplate := NewMonthArchiveTemplate(opts, templatetest.Date)\n\t\tfilePath := template.GetFilePath()\n\t\trequire.True(t, strings.HasPrefix(filePath, opts.AppDir))\n\t\trequire.True(t, strings.HasSuffix(filePath, \".txt\"))\n\t\trequire.Equal(t,\n\t\t\topts.Archive.FilePrefix+templatetest.Date.Format(opts.Archive.MonthTimeFormat),\n\t\t\tstripPrefixSuffix(filePath, fmt.Sprintf(\"%s/\", opts.AppDir), \".txt\"),\n\t\t)\n\t})\n\n\tt.Run(\"get file path without extension\", func(t *testing.T) {\n\t\topts := templatetest.GetOpts()\n\t\topts.File.Ext = \"\"\n\t\ttemplate := NewMonthArchiveTemplate(opts, templatetest.Date)\n\t\tfilePath := template.GetFilePath()\n\t\trequire.True(t, strings.HasPrefix(filePath, opts.AppDir))\n\t\trequire.False(t, strings.HasSuffix(filePath, \".\"))\n\t\trequire.Equal(t,\n\t\t\topts.Archive.FilePrefix+templatetest.Date.Format(opts.Archive.MonthTimeFormat),\n\t\t\tstripPrefixSuffix(filePath, fmt.Sprintf(\"%s/\", opts.AppDir), \"\"),\n\t\t)\n\t})\n}\n\nfunc TestArchiveSectionContents(t *testing.T) {\n\ttype testCase struct {\n\t\tsectionName      string\n\t\texistingContents []contentItem\n\t\tsourceDate       time.Time\n\t\tsourceContents   []contentItem\n\t\texpectedContents []contentItem\n\t}\n\n\ttests := map[string]testCase{\n\t\t\"archive empty contents into empty section\": {\n\t\t\tsectionName:      \"TestSection1\",\n\t\t\texistingContents: []contentItem{},\n\t\t\tsourceDate:       templatetest.Date,\n\t\t\tsourceContents:   []contentItem{},\n\t\t\texpectedContents: []contentItem{},\n\t\t},\n\t\t\"archive empty contents into populated section\": {\n\t\t\tsectionName: \"TestSection1\",\n\t\t\texistingContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"existingText1\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tsourceDate:     templatetest.Date.Add(24 * time.Hour),\n\t\t\tsourceContents: []contentItem{},\n\t\t\texpectedContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"existingText1\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"archive contents with single element into empty section\": {\n\t\t\tsectionName:      \"TestSection1\",\n\t\t\texistingContents: []contentItem{},\n\t\t\tsourceDate:       templatetest.Date.Add(24 * time.Hour),\n\t\t\tsourceContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"text1\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"text1\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"archive contents with single element into populated section\": {\n\t\t\tsectionName: \"TestSection1\",\n\t\t\texistingContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"existingText1\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tsourceDate: templatetest.Date.Add(24 * time.Hour),\n\t\t\tsourceContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"sourceText1\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"existingText1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"sourceText1\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"archive contents with multiple element into empty section\": {\n\t\t\tsectionName:      \"TestSection1\",\n\t\t\texistingContents: []contentItem{},\n\t\t\tsourceDate:       templatetest.Date.Add(24 * time.Hour),\n\t\t\tsourceContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"text1\\n\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"text2\\n\\n\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"text1\\ntext2\\n\\n\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"archive contents with multiple elements into populated section\": {\n\t\t\tsectionName: \"TestSection1\",\n\t\t\texistingContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date.Add(-24*time.Hour), templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"existingText\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tsourceDate: templatetest.Date.Add(24 * time.Hour),\n\t\t\tsourceContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"text1\\n\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"text2\\n\\n\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date.Add(-24*time.Hour), templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"existingText\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"text1\\ntext2\\n\\n\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"archive contents from source with same date\": {\n\t\t\tsectionName: \"TestSection1\",\n\t\t\texistingContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"existingText\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tsourceDate: templatetest.Date,\n\t\t\tsourceContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"text1\\n\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"text2\\n\\n\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"existingText\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"text1\\ntext2\\n\\n\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"source header does not matter\": {\n\t\t\tsectionName:      \"TestSection1\",\n\t\t\texistingContents: []contentItem{},\n\t\t\tsourceDate:       templatetest.Date.Add(24 * time.Hour),\n\t\t\tsourceContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"doesn't matter 1\",\n\t\t\t\t\ttext:   \"text1\\n\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\theader: \"doesn't matter 2\",\n\t\t\t\t\ttext:   \"text2\\n\\n\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"text1\\ntext2\\n\\n\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\topts := templatetest.GetOpts()\n\t\t\tsrc := NewTemplate(opts, test.sourceDate)\n\t\t\tsrc.sections[src.sectionIdx[test.sectionName]].contents = test.sourceContents\n\t\t\ttemplate := NewMonthArchiveTemplate(opts, templatetest.Date)\n\t\t\ttemplate.sections[template.sectionIdx[test.sectionName]].contents = test.existingContents\n\n\t\t\terr := template.ArchiveSectionContents(src, test.sectionName)\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.Equal(t, template.sections[template.sectionIdx[test.sectionName]].contents, test.expectedContents)\n\t\t})\n\t}\n}\n\nfunc TestArchiveSectionContentsFail(t *testing.T) {\n\tt.Run(\"section does not exist in template\", func(t *testing.T) {\n\t\ttoCopy := \"toBeArchived\"\n\t\topts := templatetest.GetOpts()\n\t\ttemplate := NewMonthArchiveTemplate(opts, templatetest.Date)\n\t\tsrc := NewTemplate(opts, templatetest.Date)\n\t\tsrc.sections = append(src.sections, newSection(toCopy))\n\t\tsrc.sectionIdx[toCopy] = len(src.sections) - 1\n\n\t\terr := template.ArchiveSectionContents(src, toCopy)\n\t\trequire.Error(t, err)\n\t})\n\n\tt.Run(\"section does not exist in source\", func(t *testing.T) {\n\t\ttoCopy := \"toBeArchived\"\n\t\topts := templatetest.GetOpts()\n\t\ttemplate := NewMonthArchiveTemplate(opts, templatetest.Date)\n\t\ttemplate.sections = append(template.sections, newSection(toCopy))\n\t\ttemplate.sectionIdx[toCopy] = len(template.sections) - 1\n\t\tsrc := NewTemplate(opts, templatetest.Date)\n\n\t\terr := template.ArchiveSectionContents(src, toCopy)\n\t\trequire.Error(t, err)\n\t})\n}\n\nfunc TestArchiveString(t *testing.T) {\n\ttype testCase struct {\n\t\tsections []*section\n\t\texpected string\n\t}\n\n\ttests := map[string]testCase{\n\t\t\"empty template\": {\n\t\t\tsections: []*section{},\n\t\t\texpected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n`,\n\t\t},\n\t\t\"single empty section\": {\n\t\t\tsections: []*section{\n\t\t\t\tnewSection(\"TestSection1\"),\n\t\t\t},\n\t\t\texpected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n\n\n\n`,\n\t\t},\n\t\t\"single section\": {\n\t\t\tsections: []*section{\n\t\t\t\tnewSection(\"TestSection1\",\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"[2020-12-19]\",\n\t\t\t\t\t\ttext:   \"text\",\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t},\n\t\t\texpected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n[2020-12-19]\ntext\n\n\n\n`,\n\t\t},\n\t\t\"single section with multiline text\": {\n\t\t\tsections: []*section{\n\t\t\t\tnewSection(\"TestSection1\",\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"[2020-12-19]\",\n\t\t\t\t\t\ttext:   \"text1\\ntext2\\n\\n text3text4\\n- text5\\n\\n  -text6\\n\\n\",\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t},\n\t\t\texpected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n[2020-12-19]\ntext1\ntext2\n text3text4\n- text5\n  -text6\n\n\n\n`,\n\t\t},\n\t\t\"single section with multiple contents\": {\n\t\t\tsections: []*section{\n\t\t\t\tnewSection(\"TestSection1\",\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"[2020-12-18]\",\n\t\t\t\t\t\ttext:   \"text1\\n\",\n\t\t\t\t\t},\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"[2020-12-19]\",\n\t\t\t\t\t\ttext:   \"text2\\n\",\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t},\n\t\t\texpected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n[2020-12-18]\ntext1\n[2020-12-19]\ntext2\n\n\n\n`,\n\t\t},\n\t\t\"multiple empty sections\": {\n\t\t\tsections: []*section{\n\t\t\t\tnewSection(\"TestSection1\"),\n\t\t\t\tnewSection(\"TestSection2\"),\n\t\t\t\tnewSection(\"TestSection3\"),\n\t\t\t},\n\t\t\texpected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n\n\n\n`,\n\t\t},\n\t\t\"multiple sections with only first populated\": {\n\t\t\tsections: []*section{\n\t\t\t\tnewSection(\"TestSection1\",\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"[2020-12-18]\",\n\t\t\t\t\t\ttext:   \"text\",\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t\tnewSection(\"TestSection2\"),\n\t\t\t\tnewSection(\"TestSection3\"),\n\t\t\t},\n\t\t\texpected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n[2020-12-18]\ntext\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n\n\n\n`,\n\t\t},\n\t\t\"multiple sections with only middle populated\": {\n\t\t\tsections: []*section{\n\t\t\t\tnewSection(\"TestSection1\"),\n\t\t\t\tnewSection(\"TestSection2\",\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"[2020-12-18]\",\n\t\t\t\t\t\ttext:   \"text\",\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t\tnewSection(\"TestSection3\"),\n\t\t\t},\n\t\t\texpected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n\n\n\n_p_TestSection2_q_\n[2020-12-18]\ntext\n\n\n\n_p_TestSection3_q_\n\n\n\n`,\n\t\t},\n\t\t\"multiple sections with only last populated\": {\n\t\t\tsections: []*section{\n\t\t\t\tnewSection(\"TestSection1\"),\n\t\t\t\tnewSection(\"TestSection2\"),\n\t\t\t\tnewSection(\"TestSection3\",\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"[2020-12-18]\",\n\t\t\t\t\t\ttext:   \"text\",\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t},\n\t\t\texpected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n[2020-12-18]\ntext\n\n\n\n`,\n\t\t},\n\t\t\"sections with out of order items should be sorted\": {\n\t\t\tsections: []*section{\n\t\t\t\tnewSection(\"TestSection1\"),\n\t\t\t\tnewSection(\"TestSection2\"),\n\t\t\t\tnewSection(\"TestSection3\",\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"[2020-12-18]\",\n\t\t\t\t\t\ttext:   \"text 2020-12-18\",\n\t\t\t\t\t},\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"[2020-12-16]\",\n\t\t\t\t\t\ttext:   \"text 2020-12-16\",\n\t\t\t\t\t},\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"[2020-12-17]\",\n\t\t\t\t\t\ttext:   \"text 2020-12-17\",\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t},\n\t\t\texpected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX\n\n_p_TestSection1_q_\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n[2020-12-16]\ntext 2020-12-16\n[2020-12-17]\ntext 2020-12-17\n[2020-12-18]\ntext 2020-12-18\n\n\n\n`,\n\t\t},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\topts := templatetest.GetOpts()\n\t\t\tnames := []string{}\n\t\t\tfor _, section := range test.sections {\n\t\t\t\tnames = append(names, section.name)\n\t\t\t}\n\t\t\topts.Section.Names = names\n\n\t\t\ttemplate := NewMonthArchiveTemplate(opts, templatetest.Date)\n\t\t\tfor i, section := range test.sections {\n\t\t\t\ttemplate.sections[i] = section\n\t\t\t}\n\n\t\t\trequire.Equal(t, test.expected, template.string())\n\t\t})\n\t}\n}\n\nfunc TestIsArchiveItemHeader(t *testing.T) {\n\ttype testCase struct {\n\t\theader   string\n\t\tprefix   string\n\t\tsuffix   string\n\t\tformat   string\n\t\texpected bool\n\t}\n\n\ttests := map[string]testCase{\n\t\t\"valid header\": {\n\t\t\theader:   \"[2020-07-28]\",\n\t\t\tprefix:   \"[\",\n\t\t\tsuffix:   \"]\",\n\t\t\tformat:   \"2006-01-02\",\n\t\t\texpected: true,\n\t\t},\n\t\t\"valid header with no prefix or suffix\": {\n\t\t\theader:   \"2020-07-28\",\n\t\t\tprefix:   \"\",\n\t\t\tsuffix:   \"\",\n\t\t\tformat:   \"2006-01-02\",\n\t\t\texpected: true,\n\t\t},\n\t\t\"invalid header with wrong prefix\": {\n\t\t\theader:   \"<2020-07-28]\",\n\t\t\tprefix:   \"[\",\n\t\t\tsuffix:   \"]\",\n\t\t\tformat:   \"2006-01-02\",\n\t\t\texpected: false,\n\t\t},\n\t\t\"invalid header with wrong suffix\": {\n\t\t\theader:   \"[2020-07-28>\",\n\t\t\tprefix:   \"[\",\n\t\t\tsuffix:   \"]\",\n\t\t\tformat:   \"2006-01-02\",\n\t\t\texpected: false,\n\t\t},\n\t\t\"invalid header with wrong format\": {\n\t\t\theader:   \"[2020-July-28]\",\n\t\t\tprefix:   \"[\",\n\t\t\tsuffix:   \"]\",\n\t\t\tformat:   \"2006-01-02\",\n\t\t\texpected: false,\n\t\t},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\tval := isArchiveItemHeader(test.header, test.prefix, test.suffix, test.format)\n\t\t\trequire.Equal(t, test.expected, val)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/template/section.go",
    "content": "package template\n\nimport (\n\t\"fmt\"\n\t\"regexp\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/dkaslovsky/textnote/pkg/config\"\n\t\"github.com/pkg/errors\"\n)\n\n// section is a named section of a Template\ntype section struct {\n\tname     string\n\tcontents []contentItem\n}\n\n// newSection constructs a Section\nfunc newSection(name string, items ...contentItem) *section {\n\treturn &section{\n\t\tname:     name,\n\t\tcontents: items,\n\t}\n}\n\nfunc (s *section) deleteContents() {\n\ts.contents = []contentItem{}\n}\n\nfunc (s *section) sortContents() {\n\t// stable sort to preserve order for empty header case\n\tsort.SliceStable(s.contents, func(i, j int) bool {\n\t\treturn s.contents[i].header < s.contents[j].header\n\t})\n}\n\nfunc (s *section) isEmpty() bool {\n\tfor _, content := range s.contents {\n\t\tif !content.isEmpty() {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc (s *section) getNameString(prefix string, suffix string) string {\n\treturn fmt.Sprintf(\"%s%s%s\\n\", prefix, s.name, suffix)\n}\n\nfunc (s *section) getContentString() string {\n\tstr := \"\"\n\tfor _, content := range s.contents {\n\t\ttxt := content.string()\n\t\tif !strings.HasSuffix(txt, \"\\n\") {\n\t\t\ttxt += \"\\n\"\n\t\t}\n\t\tstr += txt\n\t}\n\treturn str\n}\n\ntype contentItem struct {\n\theader string\n\ttext   string\n}\n\nfunc (ci contentItem) string() string {\n\tif ci.header != \"\" {\n\t\treturn fmt.Sprintf(\"%s\\n%s\", ci.header, ci.text)\n\t}\n\treturn ci.text\n}\n\nfunc (ci contentItem) isEmpty() bool {\n\t// exclude trailing newlines for empty content check\n\tstrippedTxt := strings.Replace(ci.text, \"\\n\", \"\", -1)\n\treturn len(strippedTxt) == 0\n}\n\nfunc parseSection(text string, opts config.Opts) (*section, error) {\n\tif len(text) == 0 {\n\t\treturn nil, errors.New(\"cannot parse Section from empty input\")\n\t}\n\n\tlines := strings.Split(text, \"\\n\")\n\tname := stripPrefixSuffix(lines[0], opts.Section.Prefix, opts.Section.Suffix)\n\tcontents := parseSectionContents(\n\t\tlines[1:],\n\t\topts.Archive.SectionContentPrefix,\n\t\topts.Archive.SectionContentSuffix,\n\t\topts.File.TimeFormat,\n\t)\n\n\t// return section populated with contents if any contentItem is non-empty\n\tfor _, content := range contents {\n\t\tif !content.isEmpty() {\n\t\t\treturn newSection(name, contents...), nil\n\t\t}\n\t}\n\t// all contents are empty so return unpopulated section\n\treturn newSection(name), nil\n}\n\nfunc parseSectionContents(lines []string, prefix string, suffix string, format string) []contentItem {\n\tcontents := []contentItem{}\n\tif len(lines) == 0 {\n\t\treturn contents\n\t}\n\n\t// parse first line\n\tline := lines[0]\n\theader := \"\"\n\tbody := []string{}\n\tif isArchiveItemHeader(line, prefix, suffix, format) {\n\t\theader = line\n\t} else {\n\t\tbody = append(body, line)\n\t}\n\n\tfor _, line := range lines[1:] {\n\t\t// if the line is a header it indicates new contents, so \"flush\" (append) the current\n\t\t// header/body and start tracking the new contents\n\t\tif isArchiveItemHeader(line, prefix, suffix, format) {\n\t\t\tcontents = append(contents, contentItem{\n\t\t\t\theader: header,\n\t\t\t\ttext:   strings.Join(body, \"\\n\"),\n\t\t\t})\n\n\t\t\theader = line\n\t\t\tbody = []string{}\n\t\t\tcontinue\n\t\t}\n\n\t\tbody = append(body, line)\n\t}\n\n\t// ensure remaining content is appended\n\tif len(body) != 0 || header != \"\" {\n\t\tcontents = append(contents, contentItem{\n\t\t\theader: header,\n\t\t\ttext:   strings.Join(body, \"\\n\"),\n\t\t})\n\t}\n\treturn contents\n}\n\nfunc stripPrefixSuffix(line string, prefix string, suffix string) string {\n\treturn strings.TrimPrefix(strings.TrimSuffix(line, suffix), prefix)\n}\n\nfunc getSectionNameRegex(prefix string, suffix string) (*regexp.Regexp, error) {\n\tsectionPattern := fmt.Sprintf(\"%s.*%s\", prefix, suffix)\n\tsectionNameRegex, err := regexp.Compile(sectionPattern)\n\tif err != nil {\n\t\treturn sectionNameRegex, fmt.Errorf(\"invalid section prefix [%s] or suffix [%s]\", prefix, suffix)\n\t}\n\treturn sectionNameRegex, nil\n}\n"
  },
  {
    "path": "pkg/template/section_test.go",
    "content": "package template\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/dkaslovsky/textnote/pkg/template/templatetest\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestGetNameString(t *testing.T) {\n\ttype testCase struct {\n\t\tname     string\n\t\tprefix   string\n\t\tsuffix   string\n\t\texpected string\n\t}\n\n\ttests := map[string]testCase{\n\t\t\"empty name, empty prefix and suffix\": {\n\t\t\tname:     \"\",\n\t\t\tprefix:   \"\",\n\t\t\tsuffix:   \"\",\n\t\t\texpected: \"\\n\",\n\t\t},\n\t\t\"empty name, non-empty prefix and suffix\": {\n\t\t\tname:     \"\",\n\t\t\tprefix:   \"p \",\n\t\t\tsuffix:   \" s\",\n\t\t\texpected: \"p  s\\n\",\n\t\t},\n\t\t\"non-empty name, empty prefix and suffix\": {\n\t\t\tname:     \"name\",\n\t\t\tprefix:   \"\",\n\t\t\tsuffix:   \"\",\n\t\t\texpected: \"name\\n\",\n\t\t},\n\t\t\"non-empty name, non-empty prefix and suffix\": {\n\t\t\tname:     \"name\",\n\t\t\tprefix:   \"p \",\n\t\t\tsuffix:   \" s\",\n\t\t\texpected: \"p name s\\n\",\n\t\t},\n\t\t\"non-empty name with spaces, non-empty prefix and suffix\": {\n\t\t\tname:     \" na me \",\n\t\t\tprefix:   \"p \",\n\t\t\tsuffix:   \" s\",\n\t\t\texpected: \"p  na me  s\\n\",\n\t\t},\n\t\t\"non-empty name with newlines, non-empty prefix and suffix\": {\n\t\t\tname:     \" na \\n me \",\n\t\t\tprefix:   \"p \",\n\t\t\tsuffix:   \" s\",\n\t\t\texpected: \"p  na \\n me  s\\n\",\n\t\t},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\ts := newSection(test.name)\n\t\t\tstr := s.getNameString(test.prefix, test.suffix)\n\t\t\trequire.Equal(t, test.expected, str)\n\t\t})\n\t}\n}\n\nfunc TestGetContentString(t *testing.T) {\n\ttype testCase struct {\n\t\tcontents []contentItem\n\t\texpected string\n\t}\n\n\ttests := map[string]testCase{\n\t\t\"empty contents\": {\n\t\t\tcontents: []contentItem{},\n\t\t\texpected: \"\",\n\t\t},\n\t\t\"single empty string contents with no header\": {\n\t\t\tcontents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"\",\n\t\t\t\t\ttext:   \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: \"\\n\",\n\t\t},\n\t\t\"single empty string contents with header\": {\n\t\t\tcontents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"header\",\n\t\t\t\t\ttext:   \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: \"header\\n\",\n\t\t},\n\t\t\"multiple empty string contents with no header\": {\n\t\t\tcontents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"\",\n\t\t\t\t\ttext:   \"\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\theader: \"\",\n\t\t\t\t\ttext:   \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: \"\\n\\n\",\n\t\t},\n\t\t\"multiple empty string contents with header\": {\n\t\t\tcontents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"header1\",\n\t\t\t\t\ttext:   \"\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\theader: \"header2\",\n\t\t\t\t\ttext:   \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: \"header1\\nheader2\\n\",\n\t\t},\n\t\t\"single nonempty contents with no header missing trailing newline\": {\n\t\t\tcontents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"\",\n\t\t\t\t\ttext:   \"text\\n goes\\n  here\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: \"text\\n goes\\n  here\\n\",\n\t\t},\n\t\t\"single nonempty contents with no header\": {\n\t\t\tcontents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"\",\n\t\t\t\t\ttext:   \"text\\n goes\\n  here\\n\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: \"text\\n goes\\n  here\\n\",\n\t\t},\n\t\t\"single nonempty contents with header\": {\n\t\t\tcontents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"header\",\n\t\t\t\t\ttext:   \"text\\n goes\\n  here\\n\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: \"header\\ntext\\n goes\\n  here\\n\",\n\t\t},\n\t\t\"multiple nonempty contents with no headers\": {\n\t\t\tcontents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"\",\n\t\t\t\t\ttext:   \"text\\n goes\\n  here\\n\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\theader: \"\",\n\t\t\t\t\ttext:   \"text2\\n goes2\\n  here2 \\n\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: \"text\\n goes\\n  here\\ntext2\\n goes2\\n  here2 \\n\",\n\t\t},\n\t\t\"multiple nonempty contents with headers\": {\n\t\t\tcontents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"header1 \",\n\t\t\t\t\ttext:   \"text\\n goes\\n  here\\n\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\theader: \" header2\",\n\t\t\t\t\ttext:   \"text2\\n goes2\\n  here2 \\n\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: \"header1 \\ntext\\n goes\\n  here\\n header2\\ntext2\\n goes2\\n  here2 \\n\",\n\t\t},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\ts := newSection(\"name\", test.contents...)\n\t\t\tstr := s.getContentString()\n\t\t\trequire.Equal(t, test.expected, str)\n\t\t})\n\t}\n}\n\nfunc TestParseSectionContents(t *testing.T) {\n\ttype testCase struct {\n\t\tlines    []string\n\t\texpected []contentItem\n\t}\n\n\ttests := map[string]testCase{\n\t\t\"empty lines\": {\n\t\t\tlines:    []string{},\n\t\t\texpected: []contentItem{},\n\t\t},\n\t\t\"single empty string line\": {\n\t\t\tlines: []string{\"\"},\n\t\t\texpected: []contentItem{\n\t\t\t\t{},\n\t\t\t},\n\t\t},\n\t\t\"lines with no header\": {\n\t\t\tlines: strings.Split(\"hello\\n  world\", \"\\n\"),\n\t\t\texpected: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"\",\n\t\t\t\t\ttext:   \"hello\\n  world\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"lines with no header with newline at start and end\": {\n\t\t\tlines: strings.Split(\"\\n\\nhello\\n  world\\n\\n\", \"\\n\"),\n\t\t\texpected: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"\",\n\t\t\t\t\ttext:   \"\\n\\nhello\\n  world\\n\\n\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"lines with single header\": {\n\t\t\tlines: strings.Split(\n\t\t\t\tfmt.Sprintf(\"%s\\nhello\\n  world\", templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts())),\n\t\t\t\t\"\\n\",\n\t\t\t),\n\t\t\texpected: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"hello\\n  world\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"lines with single header with newline at start and end\": {\n\t\t\tlines: strings.Split(\n\t\t\t\tfmt.Sprintf(\"\\n%s\\n\\nhello\\n  world\\n\", templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts())),\n\t\t\t\t\"\\n\",\n\t\t\t),\n\t\t\texpected: []contentItem{\n\t\t\t\t{},\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"\\nhello\\n  world\\n\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"lines with multiple headers\": {\n\t\t\tlines: strings.Split(\n\t\t\t\tfmt.Sprintf(\"%s\\nhello\\n  world\\n%s\\nhello2\\n  world2\",\n\t\t\t\t\ttemplatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),\n\t\t\t\t\ttemplatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),\n\t\t\t\t),\n\t\t\t\t\"\\n\",\n\t\t\t),\n\t\t\texpected: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"hello\\n  world\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"hello2\\n  world2\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"lines with multiple headers with newline at start and end\": {\n\t\t\tlines: strings.Split(\n\t\t\t\tfmt.Sprintf(\"\\n%s\\nhello\\n\\n  world\\n\\n\\n\\n%s\\nhello2\\n  world2\\n\\n\\n\\n\\n\",\n\t\t\t\t\ttemplatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),\n\t\t\t\t\ttemplatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),\n\t\t\t\t),\n\t\t\t\t\"\\n\",\n\t\t\t),\n\t\t\texpected: []contentItem{\n\t\t\t\t{},\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"hello\\n\\n  world\\n\\n\\n\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"hello2\\n  world2\\n\\n\\n\\n\\n\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"header with no text\": {\n\t\t\tlines: strings.Split(\n\t\t\t\ttemplatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),\n\t\t\t\t\"\\n\",\n\t\t\t),\n\t\t\texpected: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"multiple headers with no text\": {\n\t\t\tlines: strings.Split(\n\t\t\t\tfmt.Sprintf(\"%s\\n%s\",\n\t\t\t\t\ttemplatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),\n\t\t\t\t\ttemplatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),\n\t\t\t\t),\n\t\t\t\t\"\\n\",\n\t\t\t),\n\t\t\texpected: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\theader: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),\n\t\t\t\t\ttext:   \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\topts := templatetest.GetOpts()\n\t\t\tcontents := parseSectionContents(test.lines, opts.Archive.SectionContentPrefix, opts.Archive.SectionContentSuffix, opts.File.TimeFormat)\n\t\t\trequire.Equal(t, test.expected, contents)\n\t\t})\n\t}\n}\n\nfunc TestSectionIsEmpty(t *testing.T) {\n\ttype testCase struct {\n\t\tcontents []contentItem\n\t\texpected bool\n\t}\n\n\ttests := map[string]testCase{\n\t\t\"empty contents\": {\n\t\t\tcontents: []contentItem{},\n\t\t\texpected: true,\n\t\t},\n\t\t\"single content with only newlines and empty header\": {\n\t\t\tcontents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"\",\n\t\t\t\t\ttext:   \"\\n\\n\\n\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t\"single content with only newlines and populated header\": {\n\t\t\tcontents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"header\",\n\t\t\t\t\ttext:   \"\\n\\n\\n\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t\"multiple contents with only newlines and empty headers\": {\n\t\t\tcontents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"\",\n\t\t\t\t\ttext:   \"\\n\\n\\n\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\theader: \"\",\n\t\t\t\t\ttext:   \"\\n\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t\"multiple contents with only newlines and populated headers\": {\n\t\t\tcontents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"header1\",\n\t\t\t\t\ttext:   \"\\n\\n\\n\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\theader: \"header2\",\n\t\t\t\t\ttext:   \"\\n\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t\"single content with text and no header\": {\n\t\t\tcontents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"\",\n\t\t\t\t\ttext:   \"\\n\\nfoo\\n\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t\"single content with text and populated header\": {\n\t\t\tcontents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"header\",\n\t\t\t\t\ttext:   \"\\n\\nfoo\\n\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t\"multiple contents with text and no headers\": {\n\t\t\tcontents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"\",\n\t\t\t\t\ttext:   \"\\n\\nfoo\\n\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\theader: \"\",\n\t\t\t\t\ttext:   \"bar\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t\"multiple contents with text and populated headers\": {\n\t\t\tcontents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"header1\",\n\t\t\t\t\ttext:   \"\\n\\nfoo\\n\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\theader: \"header2\",\n\t\t\t\t\ttext:   \"bar\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\ts := newSection(\"name\", test.contents...)\n\t\t\tval := s.isEmpty()\n\t\t\trequire.Equal(t, test.expected, val)\n\t\t})\n\t}\n}\n\nfunc TestContentItemIsEmpty(t *testing.T) {\n\ttype testCase struct {\n\t\titem     contentItem\n\t\texpected bool\n\t}\n\n\ttests := map[string]testCase{\n\t\t\"empty\": {\n\t\t\titem:     contentItem{},\n\t\t\texpected: true,\n\t\t},\n\t\t\"only newlines and empty header\": {\n\t\t\titem: contentItem{\n\t\t\t\theader: \"\",\n\t\t\t\ttext:   \"\\n\\n\\n\",\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t\"only newlines and populated header\": {\n\t\t\titem: contentItem{\n\t\t\t\theader: \"header\",\n\t\t\t\ttext:   \"\\n\\n\\n\",\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t\"text and no header\": {\n\t\t\titem: contentItem{\n\t\t\t\theader: \"\",\n\t\t\t\ttext:   \"\\n\\nfoo\\n\",\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t\"text and populated header\": {\n\t\t\titem: contentItem{\n\t\t\t\theader: \"header\",\n\t\t\t\ttext:   \"\\n\\nfoo\\n\",\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\tval := test.item.isEmpty()\n\t\t\trequire.Equal(t, test.expected, val)\n\t\t})\n\t}\n\n}\n"
  },
  {
    "path": "pkg/template/template.go",
    "content": "package template\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/dkaslovsky/textnote/pkg/config\"\n)\n\n// Template contains the structure of a note\ntype Template struct {\n\topts       config.Opts\n\tdate       time.Time\n\tsections   []*section\n\tsectionIdx map[string]int // map of section name to index in sections slice\n}\n\n// NewTemplate constructs a new Template\nfunc NewTemplate(opts config.Opts, date time.Time) *Template {\n\tt := &Template{\n\t\topts:       opts,\n\t\tdate:       date,\n\t\tsections:   []*section{},\n\t\tsectionIdx: map[string]int{},\n\t}\n\tfor idx, sectionName := range opts.Section.Names {\n\t\tt.sections = append(t.sections, newSection(sectionName))\n\t\tt.sectionIdx[sectionName] = idx\n\t}\n\treturn t\n}\n\n// Write writes the template\nfunc (t *Template) Write(w io.Writer) error {\n\t_, err := w.Write([]byte(t.string()))\n\treturn err\n}\n\n// GetDate returns the template's date\nfunc (t *Template) GetDate() time.Time {\n\treturn t.date\n}\n\n// GetFileCursorLine returns the line at which to place the cursor when opening the template\nfunc (t *Template) GetFileCursorLine() int {\n\treturn t.opts.File.CursorLine\n}\n\n// GetFilePath generates a full path for a file based on the template date\nfunc (t *Template) GetFilePath() string {\n\tname := filepath.Join(t.opts.AppDir, t.date.Format(t.opts.File.TimeFormat))\n\tif t.opts.File.Ext == \"\" {\n\t\treturn name\n\t}\n\treturn fmt.Sprintf(\"%s.%s\", name, t.opts.File.Ext)\n}\n\n// sectionGettable is the interface for getting a section\ntype sectionGettable interface {\n\tgetSection(string) (*section, error)\n}\n\n// CopySectionContents copies the contents of the specified section from a source template by\n// appending to the contents of the receiver's section\nfunc (t *Template) CopySectionContents(src sectionGettable, sectionName string) error {\n\ttgtSec, err := t.getSection(sectionName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to find section in target: %w\", err)\n\t}\n\tsrcSec, err := src.getSection(sectionName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to find section in source: %w\", err)\n\t}\n\ttgtSec.contents = append(tgtSec.contents, srcSec.contents...)\n\treturn nil\n}\n\n// DeleteSectionContents deletes the contents of a specified section\nfunc (t *Template) DeleteSectionContents(sectionName string) error {\n\tsec, err := t.getSection(sectionName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot delete section: %w\", err)\n\t}\n\tsec.deleteContents()\n\treturn nil\n}\n\n// IsEmpty evaluates if a template is empty (ignores whitespace)\nfunc (t *Template) IsEmpty() bool {\n\tfor _, sec := range t.sections {\n\t\tif !sec.isEmpty() {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// Load populates a Template from the contents of a reader\nfunc (t *Template) Load(r io.Reader) error {\n\traw, err := io.ReadAll(r)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error loading template: %w\", err)\n\t}\n\tsectionText := string(raw)\n\n\tsectionNameRegex, err := getSectionNameRegex(t.opts.Section.Prefix, t.opts.Section.Suffix)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot parse sections: %w\", err)\n\t}\n\tsectionBoundaries := sectionNameRegex.FindAllStringSubmatchIndex(sectionText, -1)\n\tnumSections := len(sectionBoundaries)\n\n\t// extract sections from sectionText\n\tfor i, idxs := range sectionBoundaries {\n\t\tvar curSecEnd int\n\t\t// end of current section is marked by the beginning of the next section\n\t\t// if current section is not the last section\n\t\tif i != numSections-1 {\n\t\t\tcurSecEnd = sectionBoundaries[i+1][0]\n\t\t} else {\n\t\t\tcurSecEnd = len(sectionText)\n\t\t}\n\n\t\tsection, err := parseSection(sectionText[idxs[0]:curSecEnd], t.opts)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to parse section while reading textnote: %w\", err)\n\t\t}\n\n\t\tidx, found := t.sectionIdx[section.name]\n\t\tif !found {\n\t\t\treturn fmt.Errorf(\"cannot load undefined section [%s]\", section.name)\n\t\t}\n\t\tt.sections[idx] = section\n\t}\n\n\treturn nil\n}\n\nfunc (t *Template) string() string {\n\tstr := t.makeHeader()\n\tfor _, section := range t.sections {\n\t\tname := section.getNameString(t.opts.Section.Prefix, t.opts.Section.Suffix)\n\t\tbody := section.getContentString()\n\t\t// default to trailing whitespace for empty body\n\t\tif len(body) == 0 {\n\t\t\tbody = strings.Repeat(\"\\n\", t.opts.Section.TrailingNewlines)\n\t\t}\n\t\tstr += fmt.Sprintf(\"%s%s\", name, body)\n\t}\n\treturn str\n}\n\nfunc (t *Template) makeHeader() string {\n\treturn fmt.Sprintf(\"%s%s%s\\n%s\",\n\t\tt.opts.Header.Prefix,\n\t\tt.date.Format(t.opts.Header.TimeFormat),\n\t\tt.opts.Header.Suffix,\n\t\tstrings.Repeat(\"\\n\", t.opts.Header.TrailingNewlines),\n\t)\n}\n\nfunc (t *Template) getSection(name string) (*section, error) {\n\tidx, found := t.sectionIdx[name]\n\tif !found {\n\t\treturn &section{}, fmt.Errorf(\"section [%s] not found\", name)\n\t}\n\treturn t.sections[idx], nil\n}\n\n// ParseTemplateFileName extracts a time.Time from a file name and returns an additional\n// bool indicating if name corresponds to a valid template file name\nfunc ParseTemplateFileName(fileName string, opts config.FileOpts) (t time.Time, ok bool) {\n\t// ensure extension matches template file name convention\n\text := filepath.Ext(fileName)\n\tif ext == \".\" {\n\t\treturn t, false\n\t}\n\tif strings.TrimPrefix(ext, \".\") != opts.Ext {\n\t\treturn t, false\n\t}\n\n\tbaseName := strings.TrimSuffix(fileName, ext)\n\tt, err := time.Parse(opts.TimeFormat, baseName)\n\tif err != nil {\n\t\treturn t, false\n\t}\n\treturn t, true\n}\n"
  },
  {
    "path": "pkg/template/template_test.go",
    "content": "package template\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/dkaslovsky/textnote/pkg/config\"\n\t\"github.com/dkaslovsky/textnote/pkg/template/templatetest\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewTemplate(t *testing.T) {\n\ttype testCase struct {\n\t\tsections           []string\n\t\texpectedSections   []*section\n\t\texpectedSectionIdx map[string]int\n\t}\n\n\ttests := map[string]testCase{\n\t\t\"no sections\": {\n\t\t\tsections:           []string{},\n\t\t\texpectedSections:   []*section{},\n\t\t\texpectedSectionIdx: map[string]int{},\n\t\t},\n\t\t\"single section\": {\n\t\t\tsections: []string{\n\t\t\t\t\"section1\",\n\t\t\t},\n\t\t\texpectedSections: []*section{\n\t\t\t\tnewSection(\"section1\"),\n\t\t\t},\n\t\t\texpectedSectionIdx: map[string]int{\n\t\t\t\t\"section1\": 0,\n\t\t\t},\n\t\t},\n\t\t\"multiple sections\": {\n\t\t\tsections: []string{\n\t\t\t\t\"section1\",\n\t\t\t\t\"section3\",\n\t\t\t\t\"section2\",\n\t\t\t},\n\t\t\texpectedSections: []*section{\n\t\t\t\tnewSection(\"section1\"),\n\t\t\t\tnewSection(\"section3\"),\n\t\t\t\tnewSection(\"section2\"),\n\t\t\t},\n\t\t\texpectedSectionIdx: map[string]int{\n\t\t\t\t\"section1\": 0,\n\t\t\t\t\"section2\": 2,\n\t\t\t\t\"section3\": 1,\n\t\t\t},\n\t\t},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\topts := templatetest.GetOpts()\n\t\t\topts.Section.Names = test.sections\n\t\t\ttemplate := NewTemplate(opts, templatetest.Date)\n\n\t\t\trequire.Equal(t, templatetest.Date, template.date)\n\t\t\trequire.Equal(t, test.expectedSections, template.sections)\n\t\t\trequire.Equal(t, test.expectedSectionIdx, template.sectionIdx)\n\t\t})\n\t}\n}\n\nfunc TestGetFilePath(t *testing.T) {\n\tt.Run(\"get file path with extension\", func(t *testing.T) {\n\t\topts := templatetest.GetOpts()\n\t\topts.File.Ext = \"txt\"\n\t\ttemplate := NewTemplate(opts, templatetest.Date)\n\t\tfilePath := template.GetFilePath()\n\t\trequire.True(t, strings.HasPrefix(filePath, opts.AppDir))\n\t\trequire.True(t, strings.HasSuffix(filePath, \".txt\"))\n\t\trequire.Equal(t, templatetest.Date.Format(opts.File.TimeFormat), stripPrefixSuffix(filePath,\n\t\t\tfmt.Sprintf(\"%s/\", opts.AppDir), \".txt\"),\n\t\t)\n\t})\n\n\tt.Run(\"get file path without extension\", func(t *testing.T) {\n\t\topts := templatetest.GetOpts()\n\t\topts.File.Ext = \"\"\n\t\ttemplate := NewTemplate(opts, templatetest.Date)\n\t\tfilePath := template.GetFilePath()\n\t\trequire.True(t, strings.HasPrefix(filePath, opts.AppDir))\n\t\trequire.False(t, strings.HasSuffix(filePath, \".\"))\n\t\trequire.Equal(t, templatetest.Date.Format(opts.File.TimeFormat), stripPrefixSuffix(filePath,\n\t\t\tfmt.Sprintf(\"%s/\", opts.AppDir), \"\"),\n\t\t)\n\t})\n}\n\nfunc TestCopySectionContents(t *testing.T) {\n\ttype testCase struct {\n\t\tsectionName      string\n\t\texistingContents []contentItem\n\t\tincomingContents []contentItem\n\t}\n\n\ttests := map[string]testCase{\n\t\t\"copy empty contents into empty section\": {\n\t\t\tsectionName:      \"TestSection1\",\n\t\t\texistingContents: []contentItem{},\n\t\t\tincomingContents: []contentItem{},\n\t\t},\n\t\t\"copy empty contents into populated section\": {\n\t\t\tsectionName: \"TestSection1\",\n\t\t\texistingContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"existingHeader\",\n\t\t\t\t\ttext:   \"existingText1\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tincomingContents: []contentItem{},\n\t\t},\n\t\t\"copy contents with single element into empty section\": {\n\t\t\tsectionName:      \"TestSection1\",\n\t\t\texistingContents: []contentItem{},\n\t\t\tincomingContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"header\",\n\t\t\t\t\ttext:   \"text1\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"copy contents with single element into populated section\": {\n\t\t\tsectionName: \"TestSection1\",\n\t\t\texistingContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"existingHeader\",\n\t\t\t\t\ttext:   \"existingText1\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tincomingContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"header\",\n\t\t\t\t\ttext:   \"text1\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"copy contents with multiple element into empty section\": {\n\t\t\tsectionName:      \"TestSection1\",\n\t\t\texistingContents: []contentItem{},\n\t\t\tincomingContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"header1\",\n\t\t\t\t\ttext:   \"text1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\theader: \"header2\",\n\t\t\t\t\ttext:   \"text2\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"copy contents with multiple elements into populated section\": {\n\t\t\tsectionName: \"TestSection1\",\n\t\t\texistingContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"existingHeader\",\n\t\t\t\t\ttext:   \"existingText1\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tincomingContents: []contentItem{\n\t\t\t\t{\n\t\t\t\t\theader: \"header1\",\n\t\t\t\t\ttext:   \"text1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\theader: \"header2\",\n\t\t\t\t\ttext:   \"text2\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\topts := templatetest.GetOpts()\n\t\t\tsrc := NewTemplate(opts, templatetest.Date)\n\t\t\tsrc.sections[src.sectionIdx[test.sectionName]].contents = test.incomingContents\n\t\t\ttemplate := NewTemplate(opts, templatetest.Date)\n\t\t\ttemplate.sections[template.sectionIdx[test.sectionName]].contents = test.existingContents\n\n\t\t\terr := template.CopySectionContents(src, test.sectionName)\n\t\t\trequire.NoError(t, err)\n\t\t\tfor _, content := range test.incomingContents {\n\t\t\t\trequire.Contains(t, template.sections[template.sectionIdx[test.sectionName]].contents, content)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCopySectionContentsFail(t *testing.T) {\n\tt.Run(\"section does not exist in template\", func(t *testing.T) {\n\t\ttoCopy := \"toBeCopied\"\n\t\topts := templatetest.GetOpts()\n\t\ttemplate := NewTemplate(opts, templatetest.Date)\n\t\tsrc := NewTemplate(opts, templatetest.Date)\n\t\tsrc.sections = append(src.sections, newSection(toCopy))\n\t\tsrc.sectionIdx[toCopy] = len(src.sections) - 1\n\n\t\terr := template.CopySectionContents(src, toCopy)\n\t\trequire.Error(t, err)\n\t})\n\n\tt.Run(\"section does not exist in source\", func(t *testing.T) {\n\t\ttoCopy := \"toBeCopied\"\n\t\topts := templatetest.GetOpts()\n\t\ttemplate := NewTemplate(opts, templatetest.Date)\n\t\ttemplate.sections = append(template.sections, newSection(toCopy))\n\t\ttemplate.sectionIdx[toCopy] = len(template.sections) - 1\n\t\tsrc := NewTemplate(opts, templatetest.Date)\n\n\t\terr := template.CopySectionContents(src, toCopy)\n\t\trequire.Error(t, err)\n\t})\n}\n\nfunc TestDeleteSectionContents(t *testing.T) {\n\tt.Run(\"delete section with no contents\", func(t *testing.T) {\n\t\ttoDelete := \"sectionToBeDeleted\"\n\t\ttemplate := NewTemplate(templatetest.GetOpts(), templatetest.Date)\n\t\ttemplate.sections = append(template.sections, newSection(toDelete))\n\t\ttemplate.sectionIdx[toDelete] = len(template.sections) - 1\n\n\t\terr := template.DeleteSectionContents(toDelete)\n\t\trequire.NoError(t, err)\n\t\trequire.Empty(t, template.sections[len(template.sections)-1].contents)\n\t})\n\n\tt.Run(\"delete section with contents\", func(t *testing.T) {\n\t\ttoDelete := \"sectionToBeDeleted\"\n\t\ttemplate := NewTemplate(templatetest.GetOpts(), templatetest.Date)\n\t\ttemplate.sections = append(template.sections, newSection(toDelete, contentItem{\n\t\t\theader: \"header\",\n\t\t\ttext:   \"text goes here\",\n\t\t}))\n\t\ttemplate.sectionIdx[toDelete] = len(template.sections) - 1\n\n\t\terr := template.DeleteSectionContents(toDelete)\n\t\trequire.NoError(t, err)\n\t\trequire.Empty(t, template.sections[len(template.sections)-1].contents)\n\t})\n\n\tt.Run(\"delete non-existent section\", func(t *testing.T) {\n\t\ttoDelete := \"sectionToBeDeleted\"\n\t\topts := templatetest.GetOpts()\n\t\ttemplate := NewTemplate(opts, templatetest.Date)\n\n\t\terr := template.DeleteSectionContents(toDelete)\n\t\trequire.Error(t, err)\n\t})\n}\n\nfunc TestLoad(t *testing.T) {\n\ttype testCase struct {\n\t\ttext             string\n\t\texpectedSections []*section\n\t}\n\n\ttests := map[string]testCase{\n\t\t\"empty text\": {\n\t\t\ttext: ``,\n\t\t\texpectedSections: []*section{\n\t\t\t\tnewSection(\"TestSection1\"),\n\t\t\t\tnewSection(\"TestSection2\"),\n\t\t\t\tnewSection(\"TestSection3\"),\n\t\t\t},\n\t\t},\n\t\t\"no sections in text\": {\n\t\t\ttext: `-^-[Sun] 20 Dec 2020-v-\n\n`,\n\t\t\texpectedSections: []*section{\n\t\t\t\tnewSection(\"TestSection1\"),\n\t\t\t\tnewSection(\"TestSection2\"),\n\t\t\t\tnewSection(\"TestSection3\"),\n\t\t\t},\n\t\t},\n\t\t\"single empty section in text\": {\n\t\t\ttext: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_`,\n\t\t\texpectedSections: []*section{\n\t\t\t\tnewSection(\"TestSection1\"),\n\t\t\t\tnewSection(\"TestSection2\"),\n\t\t\t\tnewSection(\"TestSection3\"),\n\t\t\t},\n\t\t},\n\t\t\"single empty section with trainling newlines in text\": {\n\t\t\ttext: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_\n\n\n\n`,\n\t\t\texpectedSections: []*section{\n\t\t\t\tnewSection(\"TestSection1\"),\n\t\t\t\tnewSection(\"TestSection2\"),\n\t\t\t\tnewSection(\"TestSection3\"),\n\t\t\t},\n\t\t},\n\t\t\"single empty section with too many trainling newlines in text\": {\n\t\t\ttext: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_\n\n\n\n\n\n`,\n\t\t\texpectedSections: []*section{\n\t\t\t\tnewSection(\"TestSection1\"),\n\t\t\t\tnewSection(\"TestSection2\"),\n\t\t\t\tnewSection(\"TestSection3\"),\n\t\t\t},\n\t\t},\n\t\t\"single empty second section in text\": {\n\t\t\ttext: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection2_q_`,\n\t\t\texpectedSections: []*section{\n\t\t\t\tnewSection(\"TestSection1\"),\n\t\t\t\tnewSection(\"TestSection2\"),\n\t\t\t\tnewSection(\"TestSection3\"),\n\t\t\t},\n\t\t},\n\t\t\"multiple empty sections in text\": {\n\t\t\ttext: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_\n_p_TestSection2_q_\n_p_TestSection3_q_`,\n\t\t\texpectedSections: []*section{\n\t\t\t\tnewSection(\"TestSection1\"),\n\t\t\t\tnewSection(\"TestSection2\"),\n\t\t\t\tnewSection(\"TestSection3\"),\n\t\t\t},\n\t\t},\n\t\t\"multiple empty sections with trailing newlines in text\": {\n\t\t\ttext: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n\n\n\n`,\n\t\t\texpectedSections: []*section{\n\t\t\t\tnewSection(\"TestSection1\"),\n\t\t\t\tnewSection(\"TestSection2\"),\n\t\t\t\tnewSection(\"TestSection3\"),\n\t\t\t},\n\t\t},\n\t\t\"single section with contents in text\": {\n\t\t\ttext: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_\ntext1\n  text2\n\n\n_p_TestSection2_q_\n_p_TestSection3_q_\n`,\n\t\t\texpectedSections: []*section{\n\t\t\t\tnewSection(\"TestSection1\",\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"\",\n\t\t\t\t\t\ttext:   \"text1\\n  text2\\n\\n\\n\",\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t\tnewSection(\"TestSection2\"),\n\t\t\t\tnewSection(\"TestSection3\"),\n\t\t\t},\n\t\t},\n\t\t\"multiple sections with contents in text\": {\n\t\t\ttext: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_\ntext1\n  text2\n\n\n_p_TestSection2_q_\n  text3\n_p_TestSection3_q_\n\ntext4\n\n`,\n\t\t\texpectedSections: []*section{\n\t\t\t\tnewSection(\"TestSection1\",\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"\",\n\t\t\t\t\t\ttext:   \"text1\\n  text2\\n\\n\\n\",\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t\tnewSection(\"TestSection2\",\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"\",\n\t\t\t\t\t\ttext:   \"  text3\\n\",\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t\tnewSection(\"TestSection3\",\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"\",\n\t\t\t\t\t\ttext:   \"\\ntext4\\n\\n\",\n\t\t\t\t\t}),\n\t\t\t},\n\t\t},\n\t\t\"section with single item header\": {\n\t\t\ttext: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_\n[2020-12-18]\ntext1a\n  text1b\n\n\n_p_TestSection2_q_\n_p_TestSection3_q_\n`,\n\t\t\texpectedSections: []*section{\n\t\t\t\tnewSection(\"TestSection1\",\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"[2020-12-18]\",\n\t\t\t\t\t\ttext:   \"text1a\\n  text1b\\n\\n\\n\",\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t\tnewSection(\"TestSection2\"),\n\t\t\t\tnewSection(\"TestSection3\"),\n\t\t\t},\n\t\t},\n\t\t\"section with multiple item headers\": {\n\t\t\ttext: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_\n[2020-12-16]\ntext1a\n  text1b\n\n\n[2020-12-17]\ntext1c\n[2020-12-18]\n_p_TestSection2_q_\n_p_TestSection3_q_\n`,\n\t\t\texpectedSections: []*section{\n\t\t\t\tnewSection(\"TestSection1\",\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"[2020-12-16]\",\n\t\t\t\t\t\ttext:   \"text1a\\n  text1b\\n\\n\",\n\t\t\t\t\t},\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"[2020-12-17]\",\n\t\t\t\t\t\ttext:   \"text1c\",\n\t\t\t\t\t},\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"[2020-12-18]\",\n\t\t\t\t\t\ttext:   \"\",\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t\tnewSection(\"TestSection2\"),\n\t\t\t\tnewSection(\"TestSection3\"),\n\t\t\t},\n\t\t},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\ttemplate := NewTemplate(templatetest.GetOpts(), templatetest.Date)\n\t\t\terr := template.Load(strings.NewReader(test.text))\n\t\t\trequire.NoError(t, err)\n\t\t\tfor _, expectedSection := range test.expectedSections {\n\t\t\t\tsec, err := template.getSection(expectedSection.name)\n\t\t\t\trequire.NoError(t, err)\n\t\t\t\trequire.Equal(t, expectedSection, sec)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestString(t *testing.T) {\n\ttype testCase struct {\n\t\tsections []*section\n\t\texpected string\n\t}\n\n\ttests := map[string]testCase{\n\t\t\"empty template\": {\n\t\t\tsections: []*section{},\n\t\t\texpected: `-^-[Sun] 20 Dec 2020-v-\n\n`,\n\t\t},\n\t\t\"single empty section\": {\n\t\t\tsections: []*section{\n\t\t\t\tnewSection(\"TestSection1\"),\n\t\t\t},\n\t\t\texpected: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_\n\n\n\n`,\n\t\t},\n\t\t\"single section with text\": {\n\t\t\tsections: []*section{\n\t\t\t\tnewSection(\"TestSection1\",\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"\",\n\t\t\t\t\t\ttext:   \"text\",\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t},\n\t\t\texpected: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_\ntext\n`,\n\t\t},\n\t\t\"single section with multiline text\": {\n\t\t\tsections: []*section{\n\t\t\t\tnewSection(\"TestSection1\",\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"\",\n\t\t\t\t\t\ttext:   \"text1\\ntext2\\n\\n text3text4\\n- text5\\n\\n  -text6\\n\\n\",\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t},\n\t\t\texpected: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_\ntext1\ntext2\n\n text3text4\n- text5\n\n  -text6\n\n`,\n\t\t},\n\t\t\"single section with text and header\": {\n\t\t\tsections: []*section{\n\t\t\t\tnewSection(\"TestSection1\",\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\t// in practice a Template will not have sections with headers\n\t\t\t\t\t\t// and as such we expect no formatting to be applied\n\t\t\t\t\t\theader: \"header\",\n\t\t\t\t\t\ttext:   \"text\",\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t},\n\t\t\texpected: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_\nheader\ntext\n`,\n\t\t},\n\t\t\"single section with multiple contents\": {\n\t\t\tsections: []*section{\n\t\t\t\tnewSection(\"TestSection1\",\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"\",\n\t\t\t\t\t\ttext:   \"text1\",\n\t\t\t\t\t},\n\t\t\t\t\t// in practice a Template will not have sections with multiple contents\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"\",\n\t\t\t\t\t\ttext:   \"text2\",\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t},\n\t\t\texpected: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_\ntext1\ntext2\n`,\n\t\t},\n\t\t\"multiple empty sections\": {\n\t\t\tsections: []*section{\n\t\t\t\tnewSection(\"TestSection1\"),\n\t\t\t\tnewSection(\"TestSection2\"),\n\t\t\t\tnewSection(\"TestSection3\"),\n\t\t\t},\n\t\t\texpected: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n\n\n\n`,\n\t\t},\n\t\t\"multiple sections with only first populated\": {\n\t\t\tsections: []*section{\n\t\t\t\tnewSection(\"TestSection1\",\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"\",\n\t\t\t\t\t\ttext:   \"text\",\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t\tnewSection(\"TestSection2\"),\n\t\t\t\tnewSection(\"TestSection3\"),\n\t\t\t},\n\t\t\texpected: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_\ntext\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\n\n\n\n`,\n\t\t},\n\t\t\"multiple sections with only middle populated\": {\n\t\t\tsections: []*section{\n\t\t\t\tnewSection(\"TestSection1\"),\n\t\t\t\tnewSection(\"TestSection2\",\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"\",\n\t\t\t\t\t\ttext:   \"text\",\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t\tnewSection(\"TestSection3\"),\n\t\t\t},\n\t\t\texpected: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_\n\n\n\n_p_TestSection2_q_\ntext\n_p_TestSection3_q_\n\n\n\n`,\n\t\t},\n\t\t\"multiple sections with only last populated\": {\n\t\t\tsections: []*section{\n\t\t\t\tnewSection(\"TestSection1\"),\n\t\t\t\tnewSection(\"TestSection2\"),\n\t\t\t\tnewSection(\"TestSection3\",\n\t\t\t\t\tcontentItem{\n\t\t\t\t\t\theader: \"\",\n\t\t\t\t\t\ttext:   \"text\",\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t},\n\t\t\texpected: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_\n\n\n\n_p_TestSection2_q_\n\n\n\n_p_TestSection3_q_\ntext\n`,\n\t\t},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\topts := templatetest.GetOpts()\n\t\t\tnames := []string{}\n\t\t\tfor _, section := range test.sections {\n\t\t\t\tnames = append(names, section.name)\n\t\t\t}\n\t\t\topts.Section.Names = names\n\n\t\t\ttemplate := NewTemplate(opts, templatetest.Date)\n\t\t\tfor i, section := range test.sections {\n\t\t\t\ttemplate.sections[i] = section\n\t\t\t}\n\n\t\t\trequire.Equal(t, test.expected, template.string())\n\t\t})\n\t}\n}\n\nfunc TestParseTemplateFileName(t *testing.T) {\n\ttype testCase struct {\n\t\tfileName     string\n\t\topts         config.FileOpts\n\t\texpectedTime time.Time\n\t\texpectedOk   bool\n\t}\n\n\ttests := map[string]testCase{\n\t\t\"parsable file name with extension\": {\n\t\t\tfileName: \"2020-12-29.txt\",\n\t\t\topts: config.FileOpts{\n\t\t\t\tExt:        \"txt\",\n\t\t\t\tTimeFormat: \"2006-01-02\",\n\t\t\t},\n\t\t\texpectedTime: time.Date(2020, 12, 29, 0, 0, 0, 0, time.UTC),\n\t\t\texpectedOk:   true,\n\t\t},\n\t\t\"parsable file name with no extension\": {\n\t\t\tfileName: \"2020-12-29\",\n\t\t\topts: config.FileOpts{\n\t\t\t\tExt:        \"\",\n\t\t\t\tTimeFormat: \"2006-01-02\",\n\t\t\t},\n\t\t\texpectedTime: time.Date(2020, 12, 29, 0, 0, 0, 0, time.UTC),\n\t\t\texpectedOk:   true,\n\t\t},\n\t\t\"unparsable file name with extension\": {\n\t\t\tfileName: \"2020Dec29.txt\",\n\t\t\topts: config.FileOpts{\n\t\t\t\tExt:        \"txt\",\n\t\t\t\tTimeFormat: \"2006-01-02\",\n\t\t\t},\n\t\t\texpectedOk: false,\n\t\t},\n\t\t\"unparsable file name with no extension\": {\n\t\t\tfileName: \"2020Dec29\",\n\t\t\topts: config.FileOpts{\n\t\t\t\tExt:        \"\",\n\t\t\t\tTimeFormat: \"2006-01-02\",\n\t\t\t},\n\t\t\texpectedOk: false,\n\t\t},\n\t\t\"parsable file name with mismatched extension\": {\n\t\t\tfileName: \"2020-12-29.foo\",\n\t\t\topts: config.FileOpts{\n\t\t\t\tExt:        \"txt\",\n\t\t\t\tTimeFormat: \"2006-01-02\",\n\t\t\t},\n\t\t\texpectedOk: false,\n\t\t},\n\t\t\"parsable file name with malformed extension and populated config ext\": {\n\t\t\tfileName: \"2020-12-29.\",\n\t\t\topts: config.FileOpts{\n\t\t\t\tExt:        \"txt\",\n\t\t\t\tTimeFormat: \"2006-01-02\",\n\t\t\t},\n\t\t\texpectedOk: false,\n\t\t},\n\t\t\"parsable file name with malformed extension and unpopulated config ext\": {\n\t\t\tfileName: \"2020-12-29.\",\n\t\t\topts: config.FileOpts{\n\t\t\t\tExt:        \"\",\n\t\t\t\tTimeFormat: \"2006-01-02\",\n\t\t\t},\n\t\t\texpectedOk: false,\n\t\t},\n\t\t\"parsable file name with archive prefix\": {\n\t\t\tfileName: \"archive-2020-12-29.txt\",\n\t\t\topts: config.FileOpts{\n\t\t\t\tExt:        \"txt\",\n\t\t\t\tTimeFormat: \"2006-01-02\",\n\t\t\t},\n\t\t\texpectedOk: false,\n\t\t},\n\t\t\"archive file name convention\": {\n\t\t\tfileName: \"archive-Dec2020.txt\",\n\t\t\topts: config.FileOpts{\n\t\t\t\tExt:        \"txt\",\n\t\t\t\tTimeFormat: \"2006-01-02\",\n\t\t\t},\n\t\t\texpectedOk: false,\n\t\t},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\tparsedTime, ok := ParseTemplateFileName(test.fileName, test.opts)\n\t\t\trequire.Equal(t, test.expectedOk, ok)\n\t\t\tif test.expectedOk {\n\t\t\t\trequire.Equal(t, test.expectedTime, parsedTime)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIsEmpty(t *testing.T) {\n\ttype testCase struct {\n\t\ttemplateFile string\n\t\texpected     bool\n\t}\n\n\ttests := map[string]testCase{\n\t\t\"no text\": {\n\t\t\ttemplateFile: ``,\n\t\t\texpected:     true,\n\t\t},\n\t\t\"empty with one section\": {\n\t\t\ttemplateFile: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_\n\n`,\n\t\t\texpected: true,\n\t\t},\n\t\t\"empty with multiple section\": {\n\t\t\ttemplateFile: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_\n\n_p_TestSection2_q_\n\n_p_TestSection3_q_\n\n\n\n\n`,\n\t\t\texpected: true,\n\t\t},\n\t\t\"not empty with text\": {\n\t\t\ttemplateFile: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_\n\n_p_TestSection2_q_\nfoobar\n\n\n_p_TestSection3_q_\n\n`,\n\t\t\texpected: false,\n\t\t},\n\t\t\"not empty with whitespace\": {\n\t\t\ttemplateFile: `-^-[Sun] 20 Dec 2020-v-\n\n_p_TestSection1_q_\n\n_p_TestSection2_q_\n\n_p_TestSection3_q_\n    `,\n\t\t\texpected: false,\n\t\t},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\ttemplate := NewTemplate(templatetest.GetOpts(), templatetest.Date)\n\t\t\terr := template.Load(strings.NewReader(test.templateFile))\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.Equal(t, test.expected, template.IsEmpty())\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/template/templatetest/templatetest.go",
    "content": "// Package templatetest provides utilities for template testing\npackage templatetest\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"time\"\n\n\t\"github.com/dkaslovsky/textnote/pkg/config\"\n)\n\n// Date is a fixed date - changing this value will affect some tests\nvar Date = time.Date(2020, 12, 20, 1, 1, 1, 1, time.UTC)\n\n// GetOpts returns a configuration struct for tests - changing these values will affect some tests\nfunc GetOpts() config.Opts {\n\topts := config.Opts{\n\t\tAppDir: \"path/to/app/dir\",\n\t\tHeader: config.HeaderOpts{\n\t\t\tPrefix:           \"-^-\",\n\t\t\tSuffix:           \"-v-\",\n\t\t\tTrailingNewlines: 1,\n\t\t\tTimeFormat:       \"[Mon] 02 Jan 2006\",\n\t\t},\n\t\tSection: config.SectionOpts{\n\t\t\tPrefix:           \"_p_\",\n\t\t\tSuffix:           \"_q_\",\n\t\t\tTrailingNewlines: 3,\n\t\t\tNames: []string{\n\t\t\t\t\"TestSection1\",\n\t\t\t\t\"TestSection2\",\n\t\t\t\t\"TestSection3\",\n\t\t\t},\n\t\t},\n\t\tFile: config.FileOpts{\n\t\t\tExt:        \"txt\",\n\t\t\tTimeFormat: \"2006-01-02\",\n\t\t\tCursorLine: 1,\n\t\t},\n\t\tArchive: config.ArchiveOpts{\n\t\t\tAfterDays:                7,\n\t\t\tFilePrefix:               \"archive-\",\n\t\t\tHeaderPrefix:             \"ARCHIVEPREFIX \",\n\t\t\tHeaderSuffix:             \" ARCHIVESUFFIX\",\n\t\t\tSectionContentPrefix:     \"[\",\n\t\t\tSectionContentSuffix:     \"]\",\n\t\t\tSectionContentTimeFormat: \"2006-01-02\",\n\t\t\tMonthTimeFormat:          \"Jan2006\",\n\t\t},\n\t\tCli: config.CliOpts{\n\t\t\tTimeFormat: \"2006-01-02\",\n\t\t},\n\t\tTemplateFileCountThresh: 90,\n\t}\n\n\terr := config.ValidateOpts(opts)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\treturn opts\n}\n\n// MakeItemHeader is a helper to construct a header property of a contentItem struct\nfunc MakeItemHeader(date time.Time, opts config.Opts) string {\n\treturn fmt.Sprintf(\"%s%s%s\",\n\t\topts.Archive.SectionContentPrefix,\n\t\tdate.Format(opts.Archive.SectionContentTimeFormat),\n\t\topts.Archive.SectionContentSuffix,\n\t)\n}\n"
  }
]