Full Code of dkaslovsky/textnote for AI

main 154eeae2bc3d cached
31 files
179.7 KB
49.4k tokens
147 symbols
1 requests
Download .txt
Repository: dkaslovsky/textnote
Branch: main
Commit: 154eeae2bc3d
Files: 31
Total size: 179.7 KB

Directory structure:
gitextract_a03jodqx/

├── .github/
│   └── workflows/
│       └── release.yml
├── .gitignore
├── .goreleaser.yml
├── .travis.yml
├── CHANGELOG.md
├── CREDITS
├── LICENSE
├── Makefile
├── README.md
├── cmd/
│   ├── archive/
│   │   └── archive.go
│   ├── config/
│   │   └── config.go
│   ├── initialize/
│   │   └── initialize.go
│   ├── open/
│   │   ├── open.go
│   │   └── open_test.go
│   └── root.go
├── go.mod
├── go.sum
├── main.go
└── pkg/
    ├── archive/
    │   ├── archive.go
    │   └── archive_test.go
    ├── config/
    │   ├── config.go
    │   └── config_test.go
    ├── editor/
    │   └── editor.go
    ├── file/
    │   └── file.go
    └── template/
        ├── archive.go
        ├── archive_test.go
        ├── section.go
        ├── section_test.go
        ├── template.go
        ├── template_test.go
        └── templatetest/
            └── templatetest.go

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/workflows/release.yml
================================================
name: goreleaser

on:
    push:
        tags:
            - 'v*.*.*'

permissions:
  contents: write  

jobs:
  goreleaser:
    runs-on: ubuntu-latest
    steps:
      -
        name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
      -
        name: Set up Go
        uses: actions/setup-go@v4
        with:
            go-version-file: 'go.mod'
      -
        name: Run GoReleaser
        uses: goreleaser/goreleaser-action@v5
        with:
          distribution: goreleaser
          version: latest
          args: release --clean
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}


================================================
FILE: .gitignore
================================================
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib

# Test binary, built with `go test -c`
*.test

# Output of the go coverage tool, specifically when used with LiteIDE
*.out

# Dependency directories (remove the comment below to include it)
# vendor/

# Directory for binaries
dist/


================================================
FILE: .goreleaser.yml
================================================
builds:
  - env:
      - CGO_ENABLED=0
    ldflags:
      - -s -w -X main.version={{.Version}}
    goos:
      - darwin
      - linux
      - windows
    goarch:
      - amd64
      - arm64
    ignore:
      - goos: windows
        goarch: arm64
changelog:
    skip: true
archives:
  - format: binary
    name_template: "{{.Binary}}_{{.Os}}_{{.Arch}}"


================================================
FILE: .travis.yml
================================================
language: go

env:
  - GO111MODULE=on TEXTNOTE_DIR=/tmp

go:
  - 1.16.x

branches:
  except:
  - /^(?i:dev)\/.*$/

before_install:
  - go get github.com/modocache/gover
  - go get github.com/mattn/goveralls

script:
  - go test -v github.com/dkaslovsky/textnote/... -coverprofile=all.coverprofile
  - gover
  - goveralls -race -coverprofile gover.coverprofile -service travis-ci


================================================
FILE: CHANGELOG.md
================================================
## 1.3.0 / 2021-06-19

* [ADDED] Second `--delete`/`-x` flag deletes source file left empty after moving section(s)

## 1.2.0 / 2021-04-26

* [ADDED] Flag to open most recently dated ("latest") note
* [ADDED] Configurable threshold for warning user of too many template files
* [ADDED] Flags to display configuration file contents (`-f`) and active configuration (`-a`)
* [ADDED] `update` subcommand for `config` command to overwrite configuration file with active configuration
* [ADDED] `init` command to more cleanly initialize textnote application directories and files
* [FIXED] Copy command defaults to latest note instead of potentially nonexistent note from previous day
* [INTERNAL] Upgraded to Go 1.16
* [INTERNAL] Deprecated use of `io/ioutil`

## 1.1.1 / 2021-02-28

* [FIXED] Fall back on defaults for parameters missing from configuration file
* [FIXED] Warning for unsupported editor configuration for cursorLine > 1

## 1.1.0 / 2021-02-16

* [ADDED] Use $EDITOR environment variable for opening notes
* [ADDED] Add support for vi/vim, nano, neovim, and emacs for using `file.cursorLine` config parameter

## 1.0.0 / 2021-02-09

* Initial release


================================================
FILE: CREDITS
================================================
Go (the standard library)
https://golang.org/
----------------------------------------------------------------
Copyright (c) 2009 The Go Authors. All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:

   * Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
   * Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
   * Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

================================================================

dario.cat/mergo
https://dario.cat/mergo
----------------------------------------------------------------
Copyright (c) 2013 Dario Castañé. All rights reserved.
Copyright (c) 2012 The Go Authors. All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:

   * Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
   * Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
   * Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

================================================================

github.com/BurntSushi/toml
https://github.com/BurntSushi/toml
----------------------------------------------------------------
The MIT License (MIT)

Copyright (c) 2013 TOML authors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

================================================================

github.com/davecgh/go-spew
https://github.com/davecgh/go-spew
----------------------------------------------------------------
ISC License

Copyright (c) 2012-2016 Dave Collins <dave@davec.name>

Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.

THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

================================================================

github.com/ilyakaznacheev/cleanenv
https://github.com/ilyakaznacheev/cleanenv
----------------------------------------------------------------
MIT License

Copyright (c) 2019 Ilya Kaznacheev

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================

github.com/inconshreveable/mousetrap
https://github.com/inconshreveable/mousetrap
----------------------------------------------------------------
                                 Apache License
                           Version 2.0, January 2004
                        http://www.apache.org/licenses/

   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

   1. Definitions.

      "License" shall mean the terms and conditions for use, reproduction,
      and distribution as defined by Sections 1 through 9 of this document.

      "Licensor" shall mean the copyright owner or entity authorized by
      the copyright owner that is granting the License.

      "Legal Entity" shall mean the union of the acting entity and all
      other entities that control, are controlled by, or are under common
      control with that entity. For the purposes of this definition,
      "control" means (i) the power, direct or indirect, to cause the
      direction or management of such entity, whether by contract or
      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      outstanding shares, or (iii) beneficial ownership of such entity.

      "You" (or "Your") shall mean an individual or Legal Entity
      exercising permissions granted by this License.

      "Source" form shall mean the preferred form for making modifications,
      including but not limited to software source code, documentation
      source, and configuration files.

      "Object" form shall mean any form resulting from mechanical
      transformation or translation of a Source form, including but
      not limited to compiled object code, generated documentation,
      and conversions to other media types.

      "Work" shall mean the work of authorship, whether in Source or
      Object form, made available under the License, as indicated by a
      copyright notice that is included in or attached to the work
      (an example is provided in the Appendix below).

      "Derivative Works" shall mean any work, whether in Source or Object
      form, that is based on (or derived from) the Work and for which the
      editorial revisions, annotations, elaborations, or other modifications
      represent, as a whole, an original work of authorship. For the purposes
      of this License, Derivative Works shall not include works that remain
      separable from, or merely link (or bind by name) to the interfaces of,
      the Work and Derivative Works thereof.

      "Contribution" shall mean any work of authorship, including
      the original version of the Work and any modifications or additions
      to that Work or Derivative Works thereof, that is intentionally
      submitted to Licensor for inclusion in the Work by the copyright owner
      or by an individual or Legal Entity authorized to submit on behalf of
      the copyright owner. For the purposes of this definition, "submitted"
      means any form of electronic, verbal, or written communication sent
      to the Licensor or its representatives, including but not limited to
      communication on electronic mailing lists, source code control systems,
      and issue tracking systems that are managed by, or on behalf of, the
      Licensor for the purpose of discussing and improving the Work, but
      excluding communication that is conspicuously marked or otherwise
      designated in writing by the copyright owner as "Not a Contribution."

      "Contributor" shall mean Licensor and any individual or Legal Entity
      on behalf of whom a Contribution has been received by Licensor and
      subsequently incorporated within the Work.

   2. Grant of Copyright License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      copyright license to reproduce, prepare Derivative Works of,
      publicly display, publicly perform, sublicense, and distribute the
      Work and such Derivative Works in Source or Object form.

   3. Grant of Patent License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      (except as stated in this section) patent license to make, have made,
      use, offer to sell, sell, import, and otherwise transfer the Work,
      where such license applies only to those patent claims licensable
      by such Contributor that are necessarily infringed by their
      Contribution(s) alone or by combination of their Contribution(s)
      with the Work to which such Contribution(s) was submitted. If You
      institute patent litigation against any entity (including a
      cross-claim or counterclaim in a lawsuit) alleging that the Work
      or a Contribution incorporated within the Work constitutes direct
      or contributory patent infringement, then any patent licenses
      granted to You under this License for that Work shall terminate
      as of the date such litigation is filed.

   4. Redistribution. You may reproduce and distribute copies of the
      Work or Derivative Works thereof in any medium, with or without
      modifications, and in Source or Object form, provided that You
      meet the following conditions:

      (a) You must give any other recipients of the Work or
          Derivative Works a copy of this License; and

      (b) You must cause any modified files to carry prominent notices
          stating that You changed the files; and

      (c) You must retain, in the Source form of any Derivative Works
          that You distribute, all copyright, patent, trademark, and
          attribution notices from the Source form of the Work,
          excluding those notices that do not pertain to any part of
          the Derivative Works; and

      (d) If the Work includes a "NOTICE" text file as part of its
          distribution, then any Derivative Works that You distribute must
          include a readable copy of the attribution notices contained
          within such NOTICE file, excluding those notices that do not
          pertain to any part of the Derivative Works, in at least one
          of the following places: within a NOTICE text file distributed
          as part of the Derivative Works; within the Source form or
          documentation, if provided along with the Derivative Works; or,
          within a display generated by the Derivative Works, if and
          wherever such third-party notices normally appear. The contents
          of the NOTICE file are for informational purposes only and
          do not modify the License. You may add Your own attribution
          notices within Derivative Works that You distribute, alongside
          or as an addendum to the NOTICE text from the Work, provided
          that such additional attribution notices cannot be construed
          as modifying the License.

      You may add Your own copyright statement to Your modifications and
      may provide additional or different license terms and conditions
      for use, reproduction, or distribution of Your modifications, or
      for any such Derivative Works as a whole, provided Your use,
      reproduction, and distribution of the Work otherwise complies with
      the conditions stated in this License.

   5. Submission of Contributions. Unless You explicitly state otherwise,
      any Contribution intentionally submitted for inclusion in the Work
      by You to the Licensor shall be under the terms and conditions of
      this License, without any additional terms or conditions.
      Notwithstanding the above, nothing herein shall supersede or modify
      the terms of any separate license agreement you may have executed
      with Licensor regarding such Contributions.

   6. Trademarks. This License does not grant permission to use the trade
      names, trademarks, service marks, or product names of the Licensor,
      except as required for reasonable and customary use in describing the
      origin of the Work and reproducing the content of the NOTICE file.

   7. Disclaimer of Warranty. Unless required by applicable law or
      agreed to in writing, Licensor provides the Work (and each
      Contributor provides its Contributions) on an "AS IS" BASIS,
      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      implied, including, without limitation, any warranties or conditions
      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      PARTICULAR PURPOSE. You are solely responsible for determining the
      appropriateness of using or redistributing the Work and assume any
      risks associated with Your exercise of permissions under this License.

   8. Limitation of Liability. In no event and under no legal theory,
      whether in tort (including negligence), contract, or otherwise,
      unless required by applicable law (such as deliberate and grossly
      negligent acts) or agreed to in writing, shall any Contributor be
      liable to You for damages, including any direct, indirect, special,
      incidental, or consequential damages of any character arising as a
      result of this License or out of the use or inability to use the
      Work (including but not limited to damages for loss of goodwill,
      work stoppage, computer failure or malfunction, or any and all
      other commercial damages or losses), even if such Contributor
      has been advised of the possibility of such damages.

   9. Accepting Warranty or Additional Liability. While redistributing
      the Work or Derivative Works thereof, You may choose to offer,
      and charge a fee for, acceptance of support, warranty, indemnity,
      or other liability obligations and/or rights consistent with this
      License. However, in accepting such obligations, You may act only
      on Your own behalf and on Your sole responsibility, not on behalf
      of any other Contributor, and only if You agree to indemnify,
      defend, and hold each Contributor harmless for any liability
      incurred by, or claims asserted against, such Contributor by reason
      of your accepting any such warranty or additional liability.

   END OF TERMS AND CONDITIONS

   APPENDIX: How to apply the Apache License to your work.

      To apply the Apache License to your work, attach the following
      boilerplate notice, with the fields enclosed by brackets "[]"
      replaced with your own identifying information. (Don't include
      the brackets!)  The text should be enclosed in the appropriate
      comment syntax for the file format. We also recommend that a
      file or class name and description of purpose be included on the
      same "printed page" as the copyright notice for easier
      identification within third-party archives.

   Copyright 2022 Alan Shreve (@inconshreveable)

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.

================================================================

github.com/joho/godotenv
https://github.com/joho/godotenv
----------------------------------------------------------------
Copyright (c) 2013 John Barton

MIT License

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.


================================================================

github.com/pkg/errors
https://github.com/pkg/errors
----------------------------------------------------------------
Copyright (c) 2015, Dave Cheney <dave@cheney.net>
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

* Redistributions of source code must retain the above copyright notice, this
  list of conditions and the following disclaimer.

* Redistributions in binary form must reproduce the above copyright notice,
  this list of conditions and the following disclaimer in the documentation
  and/or other materials provided with the distribution.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

================================================================

github.com/pmezard/go-difflib
https://github.com/pmezard/go-difflib
----------------------------------------------------------------
Copyright (c) 2013, Patrick Mezard
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:

    Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
    Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
    The names of its contributors may not be used to endorse or promote
products derived from this software without specific prior written
permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

================================================================

github.com/spf13/cobra
https://github.com/spf13/cobra
----------------------------------------------------------------
                                Apache License
                           Version 2.0, January 2004
                        http://www.apache.org/licenses/

   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

   1. Definitions.

      "License" shall mean the terms and conditions for use, reproduction,
      and distribution as defined by Sections 1 through 9 of this document.

      "Licensor" shall mean the copyright owner or entity authorized by
      the copyright owner that is granting the License.

      "Legal Entity" shall mean the union of the acting entity and all
      other entities that control, are controlled by, or are under common
      control with that entity. For the purposes of this definition,
      "control" means (i) the power, direct or indirect, to cause the
      direction or management of such entity, whether by contract or
      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      outstanding shares, or (iii) beneficial ownership of such entity.

      "You" (or "Your") shall mean an individual or Legal Entity
      exercising permissions granted by this License.

      "Source" form shall mean the preferred form for making modifications,
      including but not limited to software source code, documentation
      source, and configuration files.

      "Object" form shall mean any form resulting from mechanical
      transformation or translation of a Source form, including but
      not limited to compiled object code, generated documentation,
      and conversions to other media types.

      "Work" shall mean the work of authorship, whether in Source or
      Object form, made available under the License, as indicated by a
      copyright notice that is included in or attached to the work
      (an example is provided in the Appendix below).

      "Derivative Works" shall mean any work, whether in Source or Object
      form, that is based on (or derived from) the Work and for which the
      editorial revisions, annotations, elaborations, or other modifications
      represent, as a whole, an original work of authorship. For the purposes
      of this License, Derivative Works shall not include works that remain
      separable from, or merely link (or bind by name) to the interfaces of,
      the Work and Derivative Works thereof.

      "Contribution" shall mean any work of authorship, including
      the original version of the Work and any modifications or additions
      to that Work or Derivative Works thereof, that is intentionally
      submitted to Licensor for inclusion in the Work by the copyright owner
      or by an individual or Legal Entity authorized to submit on behalf of
      the copyright owner. For the purposes of this definition, "submitted"
      means any form of electronic, verbal, or written communication sent
      to the Licensor or its representatives, including but not limited to
      communication on electronic mailing lists, source code control systems,
      and issue tracking systems that are managed by, or on behalf of, the
      Licensor for the purpose of discussing and improving the Work, but
      excluding communication that is conspicuously marked or otherwise
      designated in writing by the copyright owner as "Not a Contribution."

      "Contributor" shall mean Licensor and any individual or Legal Entity
      on behalf of whom a Contribution has been received by Licensor and
      subsequently incorporated within the Work.

   2. Grant of Copyright License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      copyright license to reproduce, prepare Derivative Works of,
      publicly display, publicly perform, sublicense, and distribute the
      Work and such Derivative Works in Source or Object form.

   3. Grant of Patent License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      (except as stated in this section) patent license to make, have made,
      use, offer to sell, sell, import, and otherwise transfer the Work,
      where such license applies only to those patent claims licensable
      by such Contributor that are necessarily infringed by their
      Contribution(s) alone or by combination of their Contribution(s)
      with the Work to which such Contribution(s) was submitted. If You
      institute patent litigation against any entity (including a
      cross-claim or counterclaim in a lawsuit) alleging that the Work
      or a Contribution incorporated within the Work constitutes direct
      or contributory patent infringement, then any patent licenses
      granted to You under this License for that Work shall terminate
      as of the date such litigation is filed.

   4. Redistribution. You may reproduce and distribute copies of the
      Work or Derivative Works thereof in any medium, with or without
      modifications, and in Source or Object form, provided that You
      meet the following conditions:

      (a) You must give any other recipients of the Work or
          Derivative Works a copy of this License; and

      (b) You must cause any modified files to carry prominent notices
          stating that You changed the files; and

      (c) You must retain, in the Source form of any Derivative Works
          that You distribute, all copyright, patent, trademark, and
          attribution notices from the Source form of the Work,
          excluding those notices that do not pertain to any part of
          the Derivative Works; and

      (d) If the Work includes a "NOTICE" text file as part of its
          distribution, then any Derivative Works that You distribute must
          include a readable copy of the attribution notices contained
          within such NOTICE file, excluding those notices that do not
          pertain to any part of the Derivative Works, in at least one
          of the following places: within a NOTICE text file distributed
          as part of the Derivative Works; within the Source form or
          documentation, if provided along with the Derivative Works; or,
          within a display generated by the Derivative Works, if and
          wherever such third-party notices normally appear. The contents
          of the NOTICE file are for informational purposes only and
          do not modify the License. You may add Your own attribution
          notices within Derivative Works that You distribute, alongside
          or as an addendum to the NOTICE text from the Work, provided
          that such additional attribution notices cannot be construed
          as modifying the License.

      You may add Your own copyright statement to Your modifications and
      may provide additional or different license terms and conditions
      for use, reproduction, or distribution of Your modifications, or
      for any such Derivative Works as a whole, provided Your use,
      reproduction, and distribution of the Work otherwise complies with
      the conditions stated in this License.

   5. Submission of Contributions. Unless You explicitly state otherwise,
      any Contribution intentionally submitted for inclusion in the Work
      by You to the Licensor shall be under the terms and conditions of
      this License, without any additional terms or conditions.
      Notwithstanding the above, nothing herein shall supersede or modify
      the terms of any separate license agreement you may have executed
      with Licensor regarding such Contributions.

   6. Trademarks. This License does not grant permission to use the trade
      names, trademarks, service marks, or product names of the Licensor,
      except as required for reasonable and customary use in describing the
      origin of the Work and reproducing the content of the NOTICE file.

   7. Disclaimer of Warranty. Unless required by applicable law or
      agreed to in writing, Licensor provides the Work (and each
      Contributor provides its Contributions) on an "AS IS" BASIS,
      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      implied, including, without limitation, any warranties or conditions
      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      PARTICULAR PURPOSE. You are solely responsible for determining the
      appropriateness of using or redistributing the Work and assume any
      risks associated with Your exercise of permissions under this License.

   8. Limitation of Liability. In no event and under no legal theory,
      whether in tort (including negligence), contract, or otherwise,
      unless required by applicable law (such as deliberate and grossly
      negligent acts) or agreed to in writing, shall any Contributor be
      liable to You for damages, including any direct, indirect, special,
      incidental, or consequential damages of any character arising as a
      result of this License or out of the use or inability to use the
      Work (including but not limited to damages for loss of goodwill,
      work stoppage, computer failure or malfunction, or any and all
      other commercial damages or losses), even if such Contributor
      has been advised of the possibility of such damages.

   9. Accepting Warranty or Additional Liability. While redistributing
      the Work or Derivative Works thereof, You may choose to offer,
      and charge a fee for, acceptance of support, warranty, indemnity,
      or other liability obligations and/or rights consistent with this
      License. However, in accepting such obligations, You may act only
      on Your own behalf and on Your sole responsibility, not on behalf
      of any other Contributor, and only if You agree to indemnify,
      defend, and hold each Contributor harmless for any liability
      incurred by, or claims asserted against, such Contributor by reason
      of your accepting any such warranty or additional liability.

================================================================

github.com/spf13/pflag
https://github.com/spf13/pflag
----------------------------------------------------------------
Copyright (c) 2012 Alex Ogier. All rights reserved.
Copyright (c) 2012 The Go Authors. All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:

   * Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
   * Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
   * Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

================================================================

github.com/stretchr/testify
https://github.com/stretchr/testify
----------------------------------------------------------------
MIT License

Copyright (c) 2012-2020 Mat Ryer, Tyler Bunnell and contributors.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

================================================================

gopkg.in/check.v1
https://gopkg.in/check.v1
----------------------------------------------------------------
Gocheck - A rich testing framework for Go
 
Copyright (c) 2010-2013 Gustavo Niemeyer <gustavo@niemeyer.net>

All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met: 

1. Redistributions of source code must retain the above copyright notice, this
   list of conditions and the following disclaimer. 
2. Redistributions in binary form must reproduce the above copyright notice,
   this list of conditions and the following disclaimer in the documentation
   and/or other materials provided with the distribution. 

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

================================================================

gopkg.in/yaml.v3
https://gopkg.in/yaml.v3
----------------------------------------------------------------

This project is covered by two different licenses: MIT and Apache.

#### MIT License ####

The following files were ported to Go from C files of libyaml, and thus
are still covered by their original MIT license, with the additional
copyright staring in 2011 when the project was ported over:

    apic.go emitterc.go parserc.go readerc.go scannerc.go
    writerc.go yamlh.go yamlprivateh.go

Copyright (c) 2006-2010 Kirill Simonov
Copyright (c) 2006-2011 Kirill Simonov

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

### Apache License ###

All the remaining project files are covered by the Apache license:

Copyright (c) 2011-2019 Canonical Ltd

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

================================================================

olympos.io/encoding/edn
https://olympos.io/encoding/edn
----------------------------------------------------------------
Copyright (c) 2015, The Go Authors, Jean Niklas L'orange
All rights reserved.

Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:

  * Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
  * Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation and/or
other materials provided with the distribution.
  * Neither the name of Google Inc., the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from this
software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

================================================================



================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2021 Daniel Kaslovsky

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: Makefile
================================================
PROJ := "$(notdir $(shell pwd))"
BRANCH := "$(shell git rev-parse --abbrev-ref HEAD)"
STATUS := "$(shell git status -s)"

BUILD_OUTDIR = "dist"
BUILD_FILE_PATTERN := "${PROJ}_{{.OS}}_{{.Arch}}"

BUILD_ARCH = "amd64 arm64"
BUILD_OS = "linux darwin windows"
BUILD_LDFLAGS := "-s -w -X main.version=$(BRANCH)"

TAG_REGEX = "^v[0-9]\.[0-9]\.[0-9]$$"

export GO111MODULE=on

.PHONY: test
test:
	go test ./...

.PHONY: tidy
tidy:
	@go mod tidy
	@sleep 1

.PHONY: credits
credits: tidy
	@gocredits -w
	@sleep 1

.PHONY: prepare
prepare: test tidy credits

.PHONY: build
build: test
	gox -ldflags=${BUILD_LDFLAGS} -os=${BUILD_OS} -arch=${BUILD_ARCH} -output=${BUILD_OUTDIR}/${BRANCH}/${BUILD_FILE_PATTERN}

.PHONY: release
release: checkbranch checkstatus build
	ghr "${BRANCH}" "${BUILD_OUTDIR}/${BRANCH}/"

.PHONY: checkbranch
checkbranch:
ifeq (${BRANCH}, "$(shell echo ${BRANCH} | grep ${TAG_REGEX})")
	@echo "branch name ${BRANCH} successfully checked for release"
else
	@echo "branch name ${BRANCH} does not follow semver naming convention, will not release"
	@exit 1
endif

.PHONY: checkstatus
checkstatus:
ifneq (${STATUS}, "")
	@echo "dirty branch: check git status"
	@exit 1
endif
	@:



================================================
FILE: README.md
================================================
# textnote
Simple tool for creating and organizing daily notes on the command line

[![Build Status](https://travis-ci.com/dkaslovsky/textnote.svg?branch=main)](https://travis-ci.com/github/dkaslovsky/textnote)
[![Coverage Status](https://coveralls.io/repos/github/dkaslovsky/textnote/badge.svg?branch=main)](https://coveralls.io/github/dkaslovsky/textnote?branch=main)
[![Go Report Card](https://goreportcard.com/badge/github.com/dkaslovsky/textnote)](https://goreportcard.com/report/github.com/dkaslovsky/textnote)
[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/dkaslovsky/textnote/blob/main/LICENSE)

<br/>

## Overview
textnote is a command line tool for quickly creating and managing daily plain text notes.
It is designed for ease of use to encourage the practice of daily, organized note taking.
textnote intentionally facilitates only the management (creation, opening, organizing, and consolidated archiving) of notes, following the philosophy that notes are best written in a text editor and not via a CLI.

Key features:
- Configurable, sectioned note template
- Easily bring content forward to the next day's note (for those to-dos that didn't quite get done today...)
- Simple command to consolidate daily notes into monthly archive files
- Create and open today's note with the default `textnote` command

All note files are stored locally on the file system in a single directory.
Notes can easily be synced to a remote server or cloud service if so desired by ensuring the application directory is remotely synced.

textnote opens notes using the text editor specified by the environment variable `$EDITOR` and defaults to Vim if the environment variable is not set.
See the [Editor-Specific Configuration](#editor-specific-configuration) subsection for more details. 

<br/>

## Table of Contents
- [Overview](#overview)
- [Quick Start](#quick-start)
- [Installation](#installation)
  - [Releases](#releases)
  - [Installing from source](#installing-from-source)
- [Usage](#usage)
  - [`open`](#open)
  - [`archive`](#archive)
  - [Additional Functionality](#additional-functionality)
- [Configuration](#configuration)
  - [Defaults](#defaults)
  - [Environment Variable Overrides](#environment-variable-overrides)
  - [Editor-Specific Configuration](#editor-specific-configuration)
- [License](#license)

<br/>

## Quick Start
1. Install textnote (see [Installation](#installation))
2. Set a single environment variable `TEXTNOTE_DIR` to specify the directory for textnote's files

That's it, textnote is ready to go!

The directory specified by `TEXTNOTE_DIR` and the default configuration file will be automatically created the first time textnote is run.

Start writing notes for today with a single command
```
$ textnote
```

To first configure textnote before creating notes, run
```
$ textnote init
```
and then edit the configuration file found at the displayed path.

<br/>

## Installation
textnote can be installed by downloading a prebuilt binary or by the `go get` command.

<br/>

### Releases
The recommended installation method is downloading the latest released binary.
Download the appropriate binary for your operating system from this repository's [releases](https://github.com/dkaslovsky/textnote/releases/latest) page or via `curl`:

macOS
```
$ curl -o textnote -L https://github.com/dkaslovsky/textnote/releases/latest/download/textnote_darwin_amd64
```

Linux
```
$ curl -o textnote -L https://github.com/dkaslovsky/textnote/releases/latest/download/textnote_linux_amd64
```

Windows
```
> curl.exe -o textnote.exe -L https://github.com/dkaslovsky/textnote/releases/latest/download/textnote_windows_amd64.exe
```

<br/>

### Installing from source

textnote can also be installed using Go's built-in tooling:
```
$ go get -u github.com/dkaslovsky/textnote
```
Build from source by cloning this repository and running `go build`.

It is recommended to build using Go 1.15.7 or greater to avoid a potential security issue when looking for the desired editor in the `$PATH` ([details](https://blog.golang.org/path-security)).

<br/>

## Usage
textnote is intentionally simple to use and supports two main commands: `open` for creating/opening notes and `archive` for consolidating notes into monthly archive files.

<br/>

### **`open`**
The `open` command will open a dated note in an editor, creating it first if it does not exist.

Opening or creating a note for the current day is the default action.
Simply run the root command to open or create a note for the current day:
```
$ textnote
```
which, using the default configuration and assuming today is 2021-01-24, will create and open an empty note template:
```
[Sun] 24 Jan 2021

___TODO___



___DONE___



___NOTES___



```
To open a note for a specific date other than the current day, specify the date with the `--date` flag:
```
$ textnote open --date 2020-12-22
```
where the date format is specified in the configuration.

Alternatively, a note can be opened by passing the number of days prior to the current day using the `-d` flag. For example,
```
$ textnote open -d 1
```
opens yesterday's note.

Sections from previous notes can be copied or moved into a current note.
Each section to be copied is specified in a separate `-s` flag.
The most recent dated note is used as the source by default and a specific date for a source note can be provided through the `--copy` flag.
For example,
```
$ textnote open -s TODO -s NOTES
```
will create today's note with the "TODO" and "NOTES" sections copied from the most recently dated (often yesterday's) note, while
```
$ textnote open --copy 2021-01-17 -s TODO
```
creates today's note with the "TODO" section copied from the 2021-01-17 note.
Use the `-c` flag to instead specify the source by the number of days back from the current day.
For example,
```
$ textnote open -c 3 -s TODO
```
creates today's note with the "TODO" section copied from 3 days ago.

To move instead of copy, add the `-x` flag to any copy command.
For example,
```
$ textnote open --copy 2021-01-17 -s NOTES -x
```
moves the "NOTES" section contents from the 2021-01-17 note into the note for today.

Pass two delete flags (`-xx`) to also delete the source note if moving section(s) leaves the source empty:
```
$ textnote open --copy 2021-01-17 -s NOTES -xx
```

The `--date` and `--copy` (or `-d` and `-c`) flags can be used in combination if such a workflow is desired.

For convenience, the `-t` flag can be used to open tomorrow's note:
```
$ textnote open -t
```
For example,
```
$ textnote open -t -s TODO
```
creates a note for tomorrow with a copy of today's "TODO" section contents, assuming a note for today exits.

Also for convenience, the latest (most recent) dated note can be opened using the `-l` flag:
```
$ textnote open -l
```
The most recently dated note is typically from the previous day or a few days ago, but this command will return the note for the current date if it already exists.
It will ignore notes dated in the future.

When opening/copying requires searching for the latest (most recently dated) note, textnote checks the number of template files that were required to be searched.
If this number is above a threshold (as set in the [configuration](#configuration)), a message is displayed suggesting to run the [archive](#archive) command to reduce the number of template files.
This message can be effectively disabled by configuring the `templateFileCountThresh` configuration parameter to be very large, but doing so is not recommended.

The flag options are summarized by the command's help:
```
$ textnote open -h

open or create a note template

Usage:
  textnote open [flags]

Flags:
      --copy string       date of note for copying sections (defaults to date of most recent note, cannot be used with copy-back flag)
  -c, --copy-back uint    number of days back from today for copying from a note (cannot be used with copy flag)
      --date string       date for note to be opened (defaults to today)
  -d, --days-back uint    number of days back from today for opening a note (cannot be used with date, tomorrow, or latest flags)
  -x, --delete count      delete sections after copy (pass flag twice to also delete empty source note)
  -h, --help              help for open
  -l, --latest            specify the most recent dated note to be opened (cannot be used with date, days-back, or tomorrow flags)
  -s, --section strings   section to copy (defaults to none)
  -t, --tomorrow          specify tomorrow as the date for note to be opened (cannot be used with date, days-back, or latest flags)
```


<br/>

### **`archive`**
The `archive` command consolidates all daily notes into month archives, gathering together the contents for each section of a month in chronological order, labeled by the original date.
Only notes older than a number of days specified in the configuration are archived.

Running the archive command
```
$ textnote archive
```
generates an archive file for every month for which a note exists.
For example, an archive of the January 2021 notes, assuming the default configuration, will have the form
```
ARCHIVE Jan2021

___TODO___
[2021-01-03]
...
[2021-01-04]
...



___DONE___
[2021-01-03]
...
[2021-01-04]
...
[2021-01-06]
...


___NOTES___
[2021-01-06]
...



```
with ellipses representing the daily notes' contents.

By default, the `archive` command is non-destructive: it will create archive files and leave all notes in place.
To delete the individual note files and retain only the generated archives, run the command with the `-x` flag:
```
$ textnote archive -x
```
This is the intended mode of operation, as it is desirable to "clean up" notes into archives, but must be intentionally enabled with `-x` for safety.
Running with the `--dry-run` flag prints the file names to be deleted without performing any actions:
```
$ textnote archive --dry-run
```

If the `archive` command is run without the delete flag, archive files are written and the original notes are left in place.
To "clean up" the original notes *after* archives have been generated, rerun the `archive` command with the `-x` flag as well as the `-n` flag to prevent duplicating the archive content:
```
$ textnote archive -x -n
```

The flag options are summarized by the command's help:
```
$ textnote archive -h

consolidate notes into monthly archive files

Usage:
  textnote archive [flags]

Flags:
  -x, --delete     delete individual files after archiving
      --dry-run    print file names to be deleted instead of performing deletes (other flags are ignored)
  -h, --help       help for archive
  -n, --no-write   disable writing archive files (helpful for deleting previously archived files)
```

<br/>

### **Additional Functionality**
textnote is designed for simplicity. 
Because textnote writes files to a single directory on the local filesystem, most functionality outside of the scope described above can be easily accomplished using stanard command line tools (e.g., `grep` for search).

A few simple command line functions for searching, listing, and printing notes are available in a [gist](https://gist.github.com/dkaslovsky/010fd26c4d0975639a5c286fa631d6c9).

<br/>

## Configuration
While textnote is intended to be extremely lightweight, it is also designed to be highly configurable.
In particular, the template (sections, headers, date formats, and whitespace) for generating notes can be customized as desired.
One might wish to configure headers and section titles for markdown compatibility or change date formats to match regional convention.

Configuration is read from the `$TEXTNOTE_DIR/.config.yml` file.
Changes to configuration parameters can be made by updating this file.
Individual configuration parameters also can be overridden with [environment variables](#environment-variable-overrides).

Importantly, if textnote's configuration is changed, notes created using a previous configuration might be incompatible with textnote's functionality.

The configuration file can be displayed by running the `config` command with the `-f` flag:
```
$ textnote config -f
```
The configuration file path is displayed by using the `-p` flag:
```
$ textnote config -p
```
[Defaults](#defaults) are used for configuration parameters omitted from the configuration file or configuration [environment variables](#environment-variable-overrides).
The `config` command with the `-a` flag displays the full "active" configuration used when the application runs, including default and environment parameters:
```
$ textnote config -a
```
To update the configuration file to match the active configuration, run
```
$ textnote config update
```
This command overwrites the existing configuration file.
It can be used instead of manual updates to the configuration file by passing environment variables.
For example,
```
$ TEXTNOTE_ARCHIVE_FILE_PREFIX="my_archive-" textnote config update
```
The `update` command is also helpful for writing configuration parameters that have been added with new versions of textnote.

The `config` command options are summarized by the command's help:
```
$ textnote config -h

manages the application's configuration

Usage:
  textnote config [flags]
  textnote config [command]

Available Commands:
  update      update the configuration file with active configuration

Flags:
  -a, --active   display configuration the application actively uses (includes environment variable configuration)
  -f, --file     display contents of configuration file (default)
  -h, --help     help for config
  -p, --path     display path to configuration file

Use "textnote config [command] --help" for more information about a command.
```

<br/>

### Defaults
The default configuration file is automatically written the first time textnote is run:
```
header:
  prefix: ""                              # prefix to attach to header
  suffix: ""                              # suffix to attach to header
  trailingNewlines: 1                     # number of newlines after header
  timeFormat: '[Mon] 02 Jan 2006'         # Golang format for header dates
section:
  prefix: ___                             # prefix to attach to section name
  suffix: ___                             # suffix to attach to section name
  trailingNewlines: 3                     # number of newlines for empty section
  names:                                  # section names
  - TODO
  - DONE
  - NOTES
file:
  ext: txt                                # extension to use for note files
  timeFormat: "2006-01-02"                # Golang format for note file names
  cursorLine: 4                           # line to place cursor when opening a note
archive:
  afterDays: 14                           # number of days after which a note can be archived
  filePrefix: archive-                    # prefix to attach to archive file names
  headerPrefix: 'ARCHIVE '                # prefix to attach to header of archive notes
  headerSuffix: ""                        # suffix to attach to header of archive notes
  sectionContentPrefix: '['               # prefix to attach to section content date
  sectionContentSuffix: ']'               # suffix to attach to section content date
  sectionContentTimeFormat: "2006-01-02"  # Golang format for section content dates
  monthTimeFormat: Jan2006                # Golang format for month archive file and header dates
cli:
  timeFormat: "2006-01-02"                # Golang format for CLI date input
templateFileCountThresh: 90               # threshold for displaying a warning for too many template files
```

### Environment Variable Overrides
Any configuration parameter can be overridden by setting a corresponding environment variable.
Note that setting an environment variable does not change the value specified in the configuration file.
The full list of environment variables is listed below and is always available by running `textnote --help`:
```
  TEXTNOTE_TEMPLATE_FILE_COUNT_THRESH int
    	threshold for warning too many template files
  TEXTNOTE_HEADER_PREFIX string
    	prefix to attach to header
  TEXTNOTE_HEADER_SUFFIX string
    	suffix to attach to header
  TEXTNOTE_HEADER_TRAILING_NEWLINES int
    	number of newlines to attach to end of header
  TEXTNOTE_HEADER_TIME_FORMAT string
    	formatting string to form headers from timestamps
  TEXTNOTE_SECTION_PREFIX string
    	prefix to attach to section names
  TEXTNOTE_SECTION_SUFFIX string
    	suffix to attach to section names
  TEXTNOTE_SECTION_TRAILING_NEWLINES int
    	number of newlines to attach to end of each section
  TEXTNOTE_SECTION_NAMES slice
    	section names
  TEXTNOTE_FILE_EXT string
    	extension for all files written
  TEXTNOTE_FILE_TIME_FORMAT string
    	formatting string to form file names from timestamps
  TEXTNOTE_FILE_CURSOR_LINE int
    	line to place cursor when opening
  TEXTNOTE_ARCHIVE_AFTER_DAYS int
    	number of days after which to archive a file
  TEXTNOTE_ARCHIVE_FILE_PREFIX string
    	prefix attached to the file name of all archive files
  TEXTNOTE_ARCHIVE_HEADER_PREFIX string
    	override header prefix for archive files
  TEXTNOTE_ARCHIVE_HEADER_SUFFIX string
    	override header suffix for archive files
  TEXTNOTE_ARCHIVE_SECTION_CONTENT_PREFIX string
    	prefix to attach to section content date
  TEXTNOTE_ARCHIVE_SECTION_CONTENT_SUFFIX string
    	suffix to attach to section content date
  TEXTNOTE_ARCHIVE_SECTION_CONTENT_TIME_FORMAT string
    	formatting string dated section content
  TEXTNOTE_ARCHIVE_MONTH_TIME_FORMAT string
    	formatting string for month archive timestamps
  TEXTNOTE_CLI_TIME_FORMAT string
    	formatting string for timestamp CLI flags
```

<br/>

### Editor-Specific Configuration
Currently, textnote supports the `file.cusorLine` and `TEXTNOTE_FILE_CURSOR_LINE` configuration for the following editors:
* Vi/Vim
* Emacs
* Neovim
* Nano

textnote will work with all other editors but will not respect this configuration parameter.

<br/>

## License
textnote is released under the [MIT License](https://github.com/dkaslovsky/textnote/blob/main/LICENSE).
Dependency licenses are available in this repository's [CREDITS](./CREDITS) file.


================================================
FILE: cmd/archive/archive.go
================================================
package archive

import (
	"fmt"
	"log"
	"os"
	"time"

	"github.com/dkaslovsky/textnote/pkg/archive"
	"github.com/dkaslovsky/textnote/pkg/config"
	"github.com/dkaslovsky/textnote/pkg/file"
	"github.com/dkaslovsky/textnote/pkg/template"
	"github.com/spf13/cobra"
)

type commandOptions struct {
	delete  bool
	noWrite bool
	dryRun  bool
}

// CreateArchiveCmd creates the today subcommand
func CreateArchiveCmd() *cobra.Command {
	cmdOpts := commandOptions{}
	cmd := &cobra.Command{
		Use:          "archive",
		Short:        "consolidate notes into archive files",
		Long:         "consolidate notes into monthly archive files",
		SilenceUsage: true,
		RunE: func(cmd *cobra.Command, args []string) error {
			opts, err := config.Load()
			if err != nil {
				return err
			}
			return run(opts, cmdOpts)
		},
	}
	attachOpts(cmd, &cmdOpts)
	return cmd
}

func attachOpts(cmd *cobra.Command, cmdOpts *commandOptions) {
	flags := cmd.Flags()
	flags.BoolVarP(&cmdOpts.delete, "delete", "x", false, "delete individual files after archiving")
	flags.BoolVarP(&cmdOpts.noWrite, "no-write", "n", false, "disable writing archive files (helpful for deleting previously archived files)")
	flags.BoolVar(&cmdOpts.dryRun, "dry-run", false, "print file names to be deleted instead of performing deletes (other flags are ignored)")
}

func run(templateOpts config.Opts, cmdOpts commandOptions) error {
	archiver := archive.NewArchiver(templateOpts, file.NewReadWriter(), time.Now())

	files, err := os.ReadDir(templateOpts.AppDir)
	if err != nil {
		return err
	}

	// add template files to archiver
	for _, f := range files {
		if f.IsDir() {
			continue
		}

		// parse date from template file name, skipping non-template files
		templateDate, ok := template.ParseTemplateFileName(f.Name(), templateOpts.File)
		if !ok {
			continue
		}

		err := archiver.Add(templateDate)
		if err != nil {
			log.Printf("skipping unarchivable file [%s]: %s", f.Name(), err)
			continue
		}
	}

	// print file names for dry-run
	if cmdOpts.dryRun {
		files := archiver.GetArchivedFiles()
		fmt.Printf("running \"archive --delete\" will remove [%d] files\n", len(files))
		for _, fileName := range files {
			fmt.Printf("- %s\n", fileName)
		}
		return nil
	}

	// write archive files
	if !cmdOpts.noWrite {
		err = archiver.Write()
		if err != nil {
			return err
		}
	}

	// return if not deleting archived files
	if !cmdOpts.delete {
		return nil
	}

	// delete individual archived files
	numDeleted := 0
	for _, fileName := range archiver.GetArchivedFiles() {
		err = os.Remove(fileName)
		if err != nil {
			log.Printf("unable to remove file [%s]: %s", fileName, err)
			continue
		}
		numDeleted++
	}
	log.Printf("removed [%d] files after archiving", numDeleted)

	return nil
}


================================================
FILE: cmd/config/config.go
================================================
package config

import (
	"fmt"
	"io"
	"log"
	"os"

	"github.com/dkaslovsky/textnote/pkg/config"
	"github.com/spf13/cobra"
	"gopkg.in/yaml.v3"
)

type commandOptions struct {
	path   bool
	active bool
	file   bool
}

// CreateConfigCmd creates the config subcommand
func CreateConfigCmd() *cobra.Command {
	cmdOpts := commandOptions{}
	cmd := &cobra.Command{
		Use:   "config",
		Short: "manage configuration",
		Long:  "manages the application's configuration",
		RunE: func(cmd *cobra.Command, args []string) error {
			configPath := config.GetConfigFilePath()

			if cmdOpts.path {
				log.Printf("configuration file path: [%s]", configPath)
				return nil
			}

			if cmdOpts.active {
				return displayActiveConfig()
			}

			// default
			return displayConfigFile(configPath)
		},
	}
	attachOpts(cmd, &cmdOpts)
	cmd.AddCommand(CreateConfigUpdateCmd())
	return cmd
}

func attachOpts(cmd *cobra.Command, cmdOpts *commandOptions) {
	flags := cmd.Flags()
	flags.BoolVarP(&cmdOpts.path, "path", "p", false, "display path to configuration file")
	flags.BoolVarP(&cmdOpts.active, "active", "a", false, "display configuration the application actively uses (includes environment variable configuration)")
	flags.BoolVarP(&cmdOpts.file, "file", "f", false, "display contents of configuration file (default)")
}

// CreateConfigUpdateCmd creates the config update subcommand
func CreateConfigUpdateCmd() *cobra.Command {
	cmd := &cobra.Command{
		Use:   "update",
		Short: "update the configuration file with active configuration",
		Long:  "update the configuration file to match the active configuration",
		RunE: func(cmd *cobra.Command, args []string) error {
			active, err := getActiveConfigYaml()
			if err != nil {
				return err
			}
			return os.WriteFile(config.GetConfigFilePath(), active, 0o644)
		},
	}
	return cmd
}

func displayConfigFile(configPath string) error {
	_, err := os.Stat(configPath)
	if os.IsNotExist(err) {
		return fmt.Errorf("cannot find configuration file [%s]", configPath)
	}
	f, err := os.Open(configPath)
	if err != nil {
		return fmt.Errorf("unable to open configuration file [%s]: %w", configPath, err)
	}
	c, err := io.ReadAll(f)
	if err != nil {
		return fmt.Errorf("unable to read configuration file [%s]: %w", configPath, err)
	}
	log.Print(string(c))
	return nil
}

func displayActiveConfig() error {
	yml, err := getActiveConfigYaml()
	if err != nil {
		return err
	}
	log.Print(string(yml))
	return nil
}

func getActiveConfigYaml() ([]byte, error) {
	opts, err := config.Load()
	if err != nil {
		return []byte{}, err
	}
	return yaml.Marshal(opts)
}


================================================
FILE: cmd/initialize/initialize.go
================================================
package initialize

import (
	"github.com/spf13/cobra"

	"github.com/dkaslovsky/textnote/pkg/config"
)

// CreateInitCmd creates the init subcommand
func CreateInitCmd() *cobra.Command {
	cmd := &cobra.Command{
		Use:   "init",
		Short: "initialize the application",
		Long:  "initialize the application's required directories and files",
		RunE: func(cmd *cobra.Command, args []string) error {
			return config.InitApp()
		},
	}
	return cmd
}


================================================
FILE: cmd/open/open.go
================================================
package open

import (
	"fmt"
	"log"
	"math"
	"os"
	"strings"
	"time"

	"github.com/dkaslovsky/textnote/pkg/config"
	"github.com/dkaslovsky/textnote/pkg/editor"
	"github.com/dkaslovsky/textnote/pkg/file"
	"github.com/dkaslovsky/textnote/pkg/template"
	"github.com/pkg/errors"
	"github.com/spf13/cobra"
)

const day = 24 * time.Hour

type commandOptions struct {
	// mutually exclusive flags for date to open
	date     string
	daysBack uint
	tomorrow bool
	latest   bool

	// mutually exclusive flags for copy date
	copyDate     string
	copyDaysBack uint

	deleteFlagVal  int  // count of number of times delete flag is passed
	deleteSections bool // delete sections on copy (deleteFlagVal > 0)
	deleteEmpty    bool // delete file if empty after deleting sections (deleteFlagVal > 1)

	sections []string
}

// CreateOpenCmd creates the open subcommand
func CreateOpenCmd() *cobra.Command {
	cmdOpts := commandOptions{}
	cmd := &cobra.Command{
		Use:          "open",
		Short:        "open a note",
		Long:         "open or create a note template",
		SilenceUsage: true,
		RunE: func(cmd *cobra.Command, args []string) error {
			opts, err := config.Load()
			if err != nil {
				return err
			}
			now := time.Now()
			numFilesSearchedForDate, err := setDateOpt(&cmdOpts, opts, getDirFiles, now)
			if err != nil {
				return err
			}
			numFilesSearchedForCopy, err := setCopyDateOpt(&cmdOpts, opts, getDirFiles, now)
			if err != nil {
				return err
			}
			warnTooManyTemplateFiles(max(numFilesSearchedForDate, numFilesSearchedForCopy), opts.TemplateFileCountThresh)
			setDeleteOpts(&cmdOpts)
			return run(opts, cmdOpts)
		},
	}
	attachOpts(cmd, &cmdOpts)
	return cmd
}

func attachOpts(cmd *cobra.Command, cmdOpts *commandOptions) {
	flags := cmd.Flags()

	// mutually exclusive flags for date to open
	flags.StringVar(&cmdOpts.date, "date", "", "date for note to be opened (defaults to today)")
	flags.UintVarP(&cmdOpts.daysBack, "days-back", "d", 0, "number of days back from today for opening a note (cannot be used with date, tomorrow, or latest flags)")
	flags.BoolVarP(&cmdOpts.tomorrow, "tomorrow", "t", false, "specify tomorrow as the date for note to be opened (cannot be used with date, days-back, or latest flags)")
	flags.BoolVarP(&cmdOpts.latest, "latest", "l", false, "specify the most recent dated note to be opened (cannot be used with date, days-back, or tomorrow flags)")

	// mutually exclusive flags for copy date
	flags.StringVar(&cmdOpts.copyDate, "copy", "", "date of note for copying sections (defaults to date of most recent note, cannot be used with copy-back flag)")
	flags.UintVarP(&cmdOpts.copyDaysBack, "copy-back", "c", 0, "number of days back from today for copying from a note (cannot be used with copy flag)")

	flags.StringSliceVarP(&cmdOpts.sections, "section", "s", []string{}, "section to copy (defaults to none)")
	flags.CountVarP(&cmdOpts.deleteFlagVal, "delete", "x", "delete sections after copy (pass flag twice to also delete empty source note)")
}

func setDateOpt(cmdOpts *commandOptions, templateOpts config.Opts, getFiles func(string) ([]string, error), now time.Time) (int, error) {
	var (
		date                 string
		numFiles             int
		errMutuallyExclusive = errors.New("only one of [date, days-back, tomorrow, latest] flags may be used")
	)

	if cmdOpts.date != "" {
		date = cmdOpts.date
	}

	if cmdOpts.daysBack != 0 {
		if date != "" {
			return numFiles, errMutuallyExclusive
		}
		date = now.Add(-day * time.Duration(cmdOpts.daysBack)).Format(templateOpts.Cli.TimeFormat)
	}

	if cmdOpts.tomorrow {
		if date != "" {
			return numFiles, errMutuallyExclusive
		}
		date = now.Add(day).Format(templateOpts.Cli.TimeFormat)
	}

	if cmdOpts.latest {
		if date != "" {
			return numFiles, errMutuallyExclusive
		}

		files, err := getFiles(templateOpts.AppDir)
		if err != nil {
			return numFiles, err
		}
		var latest string
		latest, numFiles = getLatestTemplateFile(files, now, templateOpts.File)
		if latest == "" {
			return numFiles, fmt.Errorf("failed to find latest template file in [%s]", templateOpts.AppDir)
		}
		if templateOpts.File.Ext != "" {
			latest = strings.TrimSuffix(latest, fmt.Sprintf(".%s", templateOpts.File.Ext))
		}
		date = latest
	}

	// default to today
	if date == "" {
		date = now.Format(templateOpts.Cli.TimeFormat)
	}

	cmdOpts.date = date
	return numFiles, nil
}

func setCopyDateOpt(cmdOpts *commandOptions, templateOpts config.Opts, getFiles func(string) ([]string, error), now time.Time) (int, error) {
	numFiles := 0

	if cmdOpts.copyDate != "" && cmdOpts.copyDaysBack != 0 {
		return numFiles, errors.New("only one of [copy, copy-back] flags may be used")
	}

	if cmdOpts.copyDate != "" {
		if _, err := time.Parse(templateOpts.Cli.TimeFormat, cmdOpts.copyDate); err != nil {
			return numFiles, fmt.Errorf("cannot copy note from malformed date [%s]: %w", cmdOpts.copyDate, err)
		}
		return numFiles, nil
	}
	if cmdOpts.copyDaysBack != 0 {
		cmdOpts.copyDate = now.Add(-day * time.Duration(cmdOpts.copyDaysBack)).Format(templateOpts.Cli.TimeFormat)
		return numFiles, nil
	}

	// default to latest
	files, err := getFiles(templateOpts.AppDir)
	if err != nil {
		return numFiles, err
	}
	latest, numFiles := getLatestTemplateFile(files, now, templateOpts.File)
	if templateOpts.File.Ext != "" {
		latest = strings.TrimSuffix(latest, fmt.Sprintf(".%s", templateOpts.File.Ext))
	}
	cmdOpts.copyDate = latest

	return numFiles, nil
}

func setDeleteOpts(cmdOpts *commandOptions) {
	cmdOpts.deleteSections = cmdOpts.deleteFlagVal > 0
	cmdOpts.deleteEmpty = cmdOpts.deleteFlagVal > 1
}

func run(templateOpts config.Opts, cmdOpts commandOptions) error {
	date, err := time.Parse(templateOpts.Cli.TimeFormat, cmdOpts.date)
	if err != nil {
		return fmt.Errorf("cannot create note for malformed date [%s]: %w", cmdOpts.date, err)
	}

	t := template.NewTemplate(templateOpts, date)
	rw := file.NewReadWriter()
	ed := editor.GetEditor(os.Getenv(editor.EnvEditor))

	// open file if no sections to copy
	if len(cmdOpts.sections) == 0 {
		if !rw.Exists(t) {
			err := rw.Overwrite(t)
			if err != nil {
				return err
			}
		}
		return openInEditor(t, ed)
	}

	// load source for copy
	if cmdOpts.copyDate == "" {
		return fmt.Errorf("cannot find note to copy, [%s] might be empty", templateOpts.AppDir)
	}
	if cmdOpts.copyDate == cmdOpts.date {
		return fmt.Errorf("copying from note dated [%s] not allowed when writing to note for date [%s]", cmdOpts.copyDate, cmdOpts.date)
	}
	copyDate, err := time.Parse(templateOpts.Cli.TimeFormat, cmdOpts.copyDate)
	if err != nil {
		return fmt.Errorf("cannot copy note from malformed date [%s]: %w", cmdOpts.copyDate, err)
	}
	src := template.NewTemplate(templateOpts, copyDate)
	err = rw.Read(src)
	if err != nil {
		return fmt.Errorf("cannot read source file for copy: %w", err)
	}
	// load template contents if it exists
	if rw.Exists(t) {
		err := rw.Read(t)
		if err != nil {
			return fmt.Errorf("cannot load template file: %w", err)
		}
	}
	// copy from source to template
	err = copySections(src, t, cmdOpts.sections)
	if err != nil {
		return err
	}

	if cmdOpts.deleteSections {
		err = deleteSections(src, cmdOpts.sections)
		if err != nil {
			return fmt.Errorf("failed to remove section content from source file: %w", err)
		}

		if cmdOpts.deleteEmpty && src.IsEmpty() {
			err = os.Remove(src.GetFilePath())
			if err != nil {
				return fmt.Errorf("failed to remove empty source file: %w", err)
			}
		} else {
			err = rw.Overwrite(src)
			if err != nil {
				return fmt.Errorf("failed to save changes to source file: %w", err)
			}

		}
	}

	err = rw.Overwrite(t)
	if err != nil {
		return fmt.Errorf("failed to write file: %w", err)
	}
	return openInEditor(t, ed)
}

func copySections(src *template.Template, tgt *template.Template, sectionNames []string) error {
	for _, sectionName := range sectionNames {
		err := tgt.CopySectionContents(src, sectionName)
		if err != nil {
			return fmt.Errorf("cannot copy section [%s] from source to target: %w", sectionName, err)
		}
	}
	return nil
}

func deleteSections(t *template.Template, sectionNames []string) error {
	for _, sectionName := range sectionNames {
		err := t.DeleteSectionContents(sectionName)
		if err != nil {
			return fmt.Errorf("cannot delete section [%s] from template: %w", sectionName, err)
		}
	}
	return nil
}

func openInEditor(t *template.Template, ed *editor.Editor) error {
	if t.GetFileCursorLine() > 1 && !ed.Supported {
		log.Printf("Editor [%s] only supported with its default arguments, additional configuration ignored", ed.Cmd)
	}
	if ed.Default {
		log.Printf("Environment variable [%s] not set, attempting to use default editor [%s]", editor.EnvEditor, ed.Cmd)
	}
	return ed.Open(t)
}

func getLatestTemplateFile(files []string, now time.Time, opts config.FileOpts) (string, int) {
	latest := ""
	delta := math.Inf(1)
	numTemplateFiles := 0

	for _, f := range files {
		fileTime, ok := template.ParseTemplateFileName(f, opts)
		if !ok {
			// skip archive files and other non-template files that cannot be parsed
			continue
		}
		numTemplateFiles++
		curdelta := now.Sub(fileTime).Hours()
		if curdelta < 0 {
			continue
		}
		if curdelta < delta {
			delta = curdelta
			latest = f
		}
	}

	return latest, numTemplateFiles
}

func getDirFiles(dir string) ([]string, error) {
	fileNames := []string{}

	dirItems, err := os.ReadDir(dir)
	if err != nil {
		return fileNames, err
	}

	for _, item := range dirItems {
		if item.IsDir() {
			continue
		}
		fileNames = append(fileNames, item.Name())
	}

	return fileNames, nil
}

func warnTooManyTemplateFiles(n int, thresh int) {
	if n > thresh {
		log.Printf("searching for latest template found more than %d files, consider running archive command for more efficient performance", thresh)
	}
}

func max(i, j int) int {
	if i > j {
		return i
	}
	return j
}


================================================
FILE: cmd/open/open_test.go
================================================
package open

import (
	"testing"
	"time"

	"github.com/dkaslovsky/textnote/pkg/template/templatetest"
	"github.com/stretchr/testify/require"
)

func TestGetLatestTemplateFile(t *testing.T) {
	opts := templatetest.GetOpts()

	type testCase struct {
		files            []string
		now              time.Time
		expectedLatest   string
		expectedNumFound int
	}

	tests := map[string]testCase{
		"empty directory": {
			files:            []string{},
			now:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),
			expectedLatest:   "",
			expectedNumFound: 0,
		},
		"no timestamped template files": {
			files: []string{
				"archive-Dec2019.txt",
				"archive-2019-11-01.txt",
			},
			now:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),
			expectedLatest:   "",
			expectedNumFound: 0,
		},
		"single template file in future": {
			files: []string{
				"2020-04-13.txt",
			},
			now:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),
			expectedLatest:   "",
			expectedNumFound: 1,
		},
		"single template file": {
			files: []string{
				"2020-03-11.txt",
			},
			now:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),
			expectedLatest:   "2020-03-11.txt",
			expectedNumFound: 1,
		},
		"multiple template files": {
			files: []string{
				"2020-03-11.txt",
				"2020-03-12.txt",
				"2020-03-13.txt",
			},
			now:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),
			expectedLatest:   "2020-03-13.txt",
			expectedNumFound: 3,
		},
		"multiple template files with one in future": {
			files: []string{
				"2020-04-11.txt",
				"2020-04-12.txt",
				"2020-04-13.txt",
			},
			now:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),
			expectedLatest:   "2020-04-12.txt",
			expectedNumFound: 3,
		},
		"mix of timestamped template files and other files": {
			files: []string{
				".config",
				"foobar",
				"2020-03-11.txt",
				"2020-03-12.txt",
				"2020-03-13.txt",
				"archive_April2020",
			},
			now:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),
			expectedLatest:   "2020-03-13.txt",
			expectedNumFound: 3,
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			latest, numFound := getLatestTemplateFile(test.files, test.now, opts.File)
			require.Equal(t, test.expectedLatest, latest)
			require.Equal(t, test.expectedNumFound, numFound)
		})
	}
}

func TestSetDateOpt(t *testing.T) {
	type testCase struct {
		cmdOpts          *commandOptions
		files            []string
		now              time.Time
		expectedDate     string
		expectedNumFiles int
		shouldErr        bool
	}

	tests := map[string]testCase{
		"multiple mutually exclusive flags: date and daysBack set": {
			cmdOpts: &commandOptions{
				date:     "2020-04-11",
				daysBack: 2,
			},
			files: []string{
				"2020-04-11.txt",
				"2020-04-10.txt",
				"2020-04-09.txt",
			},
			now:       time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),
			shouldErr: true,
		},
		"multiple mutually exclusive flags: date and tomorrow set": {
			cmdOpts: &commandOptions{
				date:     "2020-04-11",
				tomorrow: true,
			},
			files: []string{
				"2020-04-11.txt",
				"2020-04-10.txt",
				"2020-04-09.txt",
			},
			now:       time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),
			shouldErr: true,
		},
		"multiple mutually exclusive flags: date and latest set": {
			cmdOpts: &commandOptions{
				date:   "2020-04-11",
				latest: true,
			},
			files: []string{
				"2020-04-11.txt",
				"2020-04-10.txt",
				"2020-04-09.txt",
			},
			now:       time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),
			shouldErr: true,
		},
		"multiple mutually exclusive flags: daysBack and tomorrow set": {
			cmdOpts: &commandOptions{
				daysBack: 2,
				tomorrow: true,
			},
			files: []string{
				"2020-04-11.txt",
				"2020-04-10.txt",
				"2020-04-09.txt",
			},
			now:       time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),
			shouldErr: true,
		},
		"multiple mutually exclusive flags: daysBack and latest set": {
			cmdOpts: &commandOptions{
				daysBack: 2,
				latest:   true,
			},
			files: []string{
				"2020-04-11.txt",
				"2020-04-10.txt",
				"2020-04-09.txt",
			},
			now:       time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),
			shouldErr: true,
		},
		"multiple mutually exclusive flags: tomorrow and latest set": {
			cmdOpts: &commandOptions{
				tomorrow: true,
				latest:   true,
			},
			files: []string{
				"2020-04-11.txt",
				"2020-04-10.txt",
				"2020-04-09.txt",
			},
			now:       time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),
			shouldErr: true,
		},
		"use date": {
			cmdOpts: &commandOptions{
				date: "2020-04-11",
			},
			files: []string{
				"2020-04-11.txt",
				"2020-04-10.txt",
				"2020-04-09.txt",
			},
			now:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),
			expectedDate:     "2020-04-11",
			expectedNumFiles: 0,
			shouldErr:        false,
		},
		"use daysBack": {
			cmdOpts: &commandOptions{
				daysBack: 2,
			},
			files: []string{
				"2020-04-11.txt",
				"2020-04-10.txt",
				"2020-04-09.txt",
			},
			now:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),
			expectedDate:     "2020-04-10",
			expectedNumFiles: 0,
			shouldErr:        false,
		},
		"use tomorrow": {
			cmdOpts: &commandOptions{
				tomorrow: true,
			},
			files: []string{
				"2020-04-11.txt",
				"2020-04-10.txt",
				"2020-04-09.txt",
			},
			now:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),
			expectedDate:     "2020-04-13",
			expectedNumFiles: 0,
			shouldErr:        false,
		},
		"use latest": {
			cmdOpts: &commandOptions{
				latest: true,
			},
			files: []string{
				"2020-04-11.txt",
				"2020-04-10.txt",
				"2020-04-09.txt",
			},
			now:              time.Date(2020, 4, 15, 0, 0, 0, 0, time.UTC),
			expectedDate:     "2020-04-11",
			expectedNumFiles: 3,
			shouldErr:        false,
		},
		"no latest found": {
			cmdOpts: &commandOptions{
				latest: true,
			},
			files:     []string{},
			now:       time.Date(2020, 4, 15, 0, 0, 0, 0, time.UTC),
			shouldErr: true,
		},
		"default to today": {
			cmdOpts: &commandOptions{},
			files: []string{
				"2020-04-11.txt",
				"2020-04-10.txt",
				"2020-04-09.txt",
			},
			now:              time.Date(2020, 4, 15, 0, 0, 0, 0, time.UTC),
			expectedDate:     "2020-04-15",
			expectedNumFiles: 0,
			shouldErr:        false,
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			// setup
			getFiles := func(dir string) ([]string, error) {
				return test.files, nil
			}
			templateOpts := templatetest.GetOpts()

			// test
			numFiles, err := setDateOpt(test.cmdOpts, templateOpts, getFiles, test.now)
			if test.shouldErr {
				require.Error(t, err)
				return
			}
			require.Equal(t, test.expectedNumFiles, numFiles)
			require.NoError(t, err)
			require.Equal(t, test.expectedDate, test.cmdOpts.date)
		})
	}
}

func TestSetCopyDateOpt(t *testing.T) {
	type testCase struct {
		cmdOpts          *commandOptions
		files            []string
		now              time.Time
		expectedDate     string
		expectedNumFiles int
		shouldErr        bool
	}

	tests := map[string]testCase{
		"multiple mutually exclusive flags: copyDate and copyDaysBack set": {
			cmdOpts: &commandOptions{
				copyDate:     "2020-04-11",
				copyDaysBack: 2,
			},
			files: []string{
				"2020-04-11.txt",
				"2020-04-10.txt",
				"2020-04-09.txt",
			},
			now:       time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),
			shouldErr: true,
		},
		"use copyDate": {
			cmdOpts: &commandOptions{
				copyDate: "2020-04-11",
			},
			files: []string{
				"2020-04-11.txt",
				"2020-04-10.txt",
				"2020-04-09.txt",
			},
			now:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),
			expectedDate:     "2020-04-11",
			expectedNumFiles: 0,
			shouldErr:        false,
		},
		"use copyDaysBack": {
			cmdOpts: &commandOptions{
				copyDaysBack: 2,
			},
			files: []string{
				"2020-04-11.txt",
				"2020-04-10.txt",
				"2020-04-09.txt",
			},
			now:              time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC),
			expectedDate:     "2020-04-10",
			expectedNumFiles: 0,
			shouldErr:        false,
		},
		"default to latest": {
			cmdOpts: &commandOptions{},
			files: []string{
				"2020-04-11.txt",
				"2020-04-10.txt",
				"2020-04-09.txt",
			},
			now:              time.Date(2020, 4, 15, 0, 0, 0, 0, time.UTC),
			expectedDate:     "2020-04-11",
			expectedNumFiles: 3,
			shouldErr:        false,
		},
		"no latest found": {
			cmdOpts:          &commandOptions{},
			files:            []string{},
			now:              time.Date(2020, 4, 15, 0, 0, 0, 0, time.UTC),
			expectedDate:     "",
			expectedNumFiles: 0,
			shouldErr:        false,
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			// setup
			getFiles := func(dir string) ([]string, error) {
				return test.files, nil
			}
			templateOpts := templatetest.GetOpts()

			// test
			numFiles, err := setCopyDateOpt(test.cmdOpts, templateOpts, getFiles, test.now)
			if test.shouldErr {
				require.Error(t, err)
				return
			}
			require.Equal(t, test.expectedNumFiles, numFiles)
			require.NoError(t, err)
			require.Equal(t, test.expectedDate, test.cmdOpts.copyDate)
		})
	}
}

func TestSetDeleteOpts(t *testing.T) {
	type testCase struct {
		cmdOpts                *commandOptions
		expectedDeleteSections bool
		expectedDeleteEmpty    bool
	}

	tests := map[string]testCase{
		"deleteFlagVal = 0": {
			cmdOpts: &commandOptions{
				deleteFlagVal: 0,
			},
			expectedDeleteSections: false,
			expectedDeleteEmpty:    false,
		},
		"deleteFlagVal < 0": {
			cmdOpts: &commandOptions{
				deleteFlagVal: -1,
			},
			expectedDeleteSections: false,
			expectedDeleteEmpty:    false,
		},
		"deleteFlagVal = 1": {
			cmdOpts: &commandOptions{
				deleteFlagVal: 1,
			},
			expectedDeleteSections: true,
			expectedDeleteEmpty:    false,
		},
		"deleteFlagVal = 2": {
			cmdOpts: &commandOptions{
				deleteFlagVal: 2,
			},
			expectedDeleteSections: true,
			expectedDeleteEmpty:    true,
		},
		"deleteFlagVal > 2": {
			cmdOpts: &commandOptions{
				deleteFlagVal: 3,
			},
			expectedDeleteSections: true,
			expectedDeleteEmpty:    true,
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			setDeleteOpts(test.cmdOpts)
			require.Equal(t, test.expectedDeleteSections, test.cmdOpts.deleteSections)
			require.Equal(t, test.expectedDeleteEmpty, test.cmdOpts.deleteEmpty)
		})
	}
}


================================================
FILE: cmd/root.go
================================================
package cmd

import (
	"fmt"
	"strings"

	"github.com/dkaslovsky/textnote/cmd/archive"
	"github.com/dkaslovsky/textnote/cmd/config"
	"github.com/dkaslovsky/textnote/cmd/initialize"
	"github.com/dkaslovsky/textnote/cmd/open"
	pkgconf "github.com/dkaslovsky/textnote/pkg/config"
	"github.com/spf13/cobra"
)

// Run executes the CLI
func Run(name string, version string) error {
	cmd := &cobra.Command{
		Use:           name,
		Long:          fmt.Sprintf("Name:\n  %s - a simple tool for creating and organizing daily notes on the command line", name),
		SilenceUsage:  true,
		SilenceErrors: true,
		RunE: func(cmd *cobra.Command, args []string) error {
			// run the open command with default options as the default application command
			return open.CreateOpenCmd().Execute()
		},
	}

	cmd.AddCommand(
		open.CreateOpenCmd(),
		archive.CreateArchiveCmd(),
		config.CreateConfigCmd(),
		initialize.CreateInitCmd(),
	)

	setVersion(cmd, version)
	setHelp(cmd, name)

	return cmd.Execute()
}

func setVersion(cmd *cobra.Command, version string) {
	if version != "" {
		cmd.Version = version
		return
	}

	cmd.Version = "unavailable"
	cmd.SetVersionTemplate(
		fmt.Sprintf("%s: built from source", strings.TrimSuffix(cmd.VersionTemplate(), "\n")),
	)
}

func setHelp(cmd *cobra.Command, name string) {
	// set custom help message for the root command
	defaultHelpFunc := cmd.HelpFunc()
	cmd.SetHelpFunc(func(cmd *cobra.Command, s []string) {
		defaultHelpFunc(cmd, s)
		if cmd.Name() != name {
			return
		}
		if description := pkgconf.DescribeEnvVars(); description != "" {
			fmt.Printf("\nOverride configuration using environment variables:%s", description)
		}
	})
}


================================================
FILE: go.mod
================================================
module github.com/dkaslovsky/textnote

go 1.21.4

require (
	dario.cat/mergo v1.0.0
	github.com/ilyakaznacheev/cleanenv v1.5.0
	github.com/pkg/errors v0.9.1
	github.com/spf13/cobra v1.8.0
	github.com/stretchr/testify v1.8.4
	gopkg.in/yaml.v3 v3.0.1
)

require (
	github.com/BurntSushi/toml v1.2.1 // indirect
	github.com/davecgh/go-spew v1.1.1 // indirect
	github.com/inconshreveable/mousetrap v1.1.0 // indirect
	github.com/joho/godotenv v1.5.1 // indirect
	github.com/pmezard/go-difflib v1.0.0 // indirect
	github.com/spf13/pflag v1.0.5 // indirect
	olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect
)


================================================
FILE: go.sum
================================================
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4=
github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ=
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw=


================================================
FILE: main.go
================================================
package main

import (
	"log"

	"github.com/dkaslovsky/textnote/cmd"
	"github.com/dkaslovsky/textnote/pkg/config"
)

const name = "textnote"

var version string // set by build ldflags

func main() {
	log.SetFlags(0)

	err := config.InitApp()
	if err != nil {
		log.Fatal(err)
	}

	err = cmd.Run(name, version)
	if err != nil {
		log.Fatal(err)
	}
}


================================================
FILE: pkg/archive/archive.go
================================================
package archive

import (
	"fmt"
	"log"
	"time"

	"github.com/dkaslovsky/textnote/pkg/config"
	"github.com/dkaslovsky/textnote/pkg/file"
	"github.com/dkaslovsky/textnote/pkg/template"
)

// Archiver consolidates templates into archives
type Archiver struct {
	opts config.Opts
	rw   readWriter
	date time.Time // timestamp for calculating if a file is old enough to be archived

	// monthArchives maintains a map of formatted month timestamp to the corresponding archive
	monthArchives map[string]*template.MonthArchiveTemplate
	// archivedFiles maintains the file names that have been archived
	archivedFiles []string
}

// NewArchiver constructs a new Archiver
func NewArchiver(opts config.Opts, rw readWriter, date time.Time) *Archiver {
	return &Archiver{
		opts: opts,
		rw:   rw,
		date: date,

		monthArchives: map[string]*template.MonthArchiveTemplate{},
		archivedFiles: []string{},
	}
}

// Add adds a template corresponding to a date to the archive
func (a *Archiver) Add(date time.Time) error {
	// recent files are not archived
	if a.date.Sub(date).Hours() <= float64(a.opts.Archive.AfterDays*24) {
		return nil
	}

	t := template.NewTemplate(a.opts, date)
	err := a.rw.Read(t)
	if err != nil {
		return fmt.Errorf("cannot add unreadable file [%s] to archive: %w", t.GetFilePath(), err)
	}

	monthKey := date.Format(a.opts.Archive.MonthTimeFormat)
	if _, found := a.monthArchives[monthKey]; !found {
		a.monthArchives[monthKey] = template.NewMonthArchiveTemplate(a.opts, date)
	}

	archive := a.monthArchives[monthKey]
	for _, section := range a.opts.Section.Names {
		err := archive.ArchiveSectionContents(t, section)
		if err != nil {
			return fmt.Errorf("cannot add contents from [%s] to archive: %w", t.GetFilePath(), err)
		}
	}

	a.archivedFiles = append(a.archivedFiles, t.GetFilePath())
	return nil
}

// Write writes all of the archive templates stored in the Archiver
func (a *Archiver) Write() error {
	for _, t := range a.monthArchives {
		if a.rw.Exists(t) {
			existing := template.NewMonthArchiveTemplate(a.opts, t.GetDate())
			err := a.rw.Read(existing)
			if err != nil {
				return fmt.Errorf("unable to open existing archive file [%s]: %w", existing.GetFilePath(), err)
			}
			err = t.Merge(existing)
			if err != nil {
				return fmt.Errorf("unable to from merge existing archive file [%s] %w", existing.GetFilePath(), err)
			}
		}

		err := a.rw.Overwrite(t)
		if err != nil {
			return fmt.Errorf("failed to write archive file [%s]: %w", t.GetFilePath(), err)
		}
		log.Printf("wrote archive file [%s]", t.GetFilePath())
	}
	return nil
}

// GetArchivedFiles returns the files that have been archived
func (a *Archiver) GetArchivedFiles() []string {
	return a.archivedFiles
}

// readWriter is the interface for executing file operations
type readWriter interface {
	Read(file.ReadWriteable) error
	Overwrite(file.ReadWriteable) error
	Exists(file.ReadWriteable) bool
}


================================================
FILE: pkg/archive/archive_test.go
================================================
package archive

import (
	"bytes"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/dkaslovsky/textnote/pkg/file"
	"github.com/dkaslovsky/textnote/pkg/template"
	"github.com/dkaslovsky/textnote/pkg/template/templatetest"

	"github.com/stretchr/testify/require"
)

//
// mocks
//

type testReadWriter struct {
	exists  bool
	toRead  string
	written string
}

func newTestReadWriter(exists bool, toRead string) *testReadWriter {
	return &testReadWriter{
		exists:  exists,
		toRead:  toRead,
		written: "",
	}
}

func (trw *testReadWriter) Read(rwable file.ReadWriteable) error {
	r := strings.NewReader(trw.toRead)
	return rwable.Load(r)
}

func (trw *testReadWriter) Overwrite(rwable file.ReadWriteable) error {
	buf := new(bytes.Buffer)
	err := rwable.Write(buf)
	if err != nil {
		return err
	}
	trw.written = buf.String()
	return nil
}

func (trw *testReadWriter) Exists(rwable file.ReadWriteable) bool {
	return trw.exists
}

//
// Tests
//

func TestAdd(t *testing.T) {
	type testCase struct {
		date             time.Time
		templateText     string
		existing         map[string]string
		expectedArchives map[string]string
		expectedFiles    []string
	}

	tests := map[string]testCase{
		"add template that should not be archived": {
			date:             time.Date(2020, 12, 20, 0, 0, 0, 0, time.UTC),
			expectedArchives: map[string]string{},
			expectedFiles:    []string{},
		},
		"add template from last day that should not be archived": {
			date:             time.Date(2020, 12, 14, 0, 0, 0, 0, time.UTC),
			expectedArchives: map[string]string{},
			expectedFiles:    []string{},
		},
		"add template from first day that should be archived": {
			date: time.Date(2020, 12, 13, 0, 0, 0, 0, time.UTC),
			templateText: `-^-[Sun] 13 Dec 2020-v-

_p_TestSection1_q_
text1
  text2



_p_TestSection2_q_



_p_TestSection3_q_



`,
			expectedArchives: map[string]string{
				"Dec2020": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_
[2020-12-13]
text1
  text2



_p_TestSection2_q_



_p_TestSection3_q_



`,
			},
			expectedFiles: []string{
				"2020-12-13.txt",
			},
		},
		"add template from current month": {
			date: time.Date(2020, 12, 1, 0, 0, 0, 0, time.UTC),
			templateText: `-^-[Tue] 01 Dec 2020-v-

_p_TestSection1_q_
text1
  text2



_p_TestSection2_q_



_p_TestSection3_q_



`,
			expectedArchives: map[string]string{
				"Dec2020": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_
[2020-12-01]
text1
  text2



_p_TestSection2_q_



_p_TestSection3_q_



`,
			},
			expectedFiles: []string{
				"2020-12-01.txt",
			},
		},
		"add template from different month": {
			date: time.Date(2020, 11, 1, 0, 0, 0, 0, time.UTC),
			templateText: `-^-[Sun] 01 Nov 2020-v-

_p_TestSection1_q_
text1
  text2



_p_TestSection2_q_



_p_TestSection3_q_



`,
			expectedArchives: map[string]string{
				"Nov2020": `ARCHIVEPREFIX Nov2020 ARCHIVESUFFIX

_p_TestSection1_q_
[2020-11-01]
text1
  text2



_p_TestSection2_q_



_p_TestSection3_q_



`,
			},
			expectedFiles: []string{
				"2020-11-01.txt",
			},
		},
		"add template from different year": {
			date: time.Date(2019, 11, 2, 0, 0, 0, 0, time.UTC),
			templateText: `-^-[Sat] 02 Nov 2019-v-

_p_TestSection1_q_
text1
  text2



_p_TestSection2_q_



_p_TestSection3_q_



`,
			expectedArchives: map[string]string{
				"Nov2019": `ARCHIVEPREFIX Nov2019 ARCHIVESUFFIX

_p_TestSection1_q_
[2019-11-02]
text1
  text2



_p_TestSection2_q_



_p_TestSection3_q_



`,
			},
			expectedFiles: []string{
				"2019-11-02.txt",
			},
		},
		"add template with earlier date to existing archive": {
			date: time.Date(2020, 12, 1, 0, 0, 0, 0, time.UTC),
			templateText: `-^-[Tue] 01 Dec 2020-v-

_p_TestSection1_q_
text1
  text2



_p_TestSection2_q_



_p_TestSection3_q_



`,
			existing: map[string]string{
				"Dec2020": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_
[2020-12-02]
existingText1
  existingText2
existingText3

_p_TestSection2_q_



_p_TestSection3_q_



`,
			},
			expectedArchives: map[string]string{
				"Dec2020": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_
[2020-12-01]
text1
  text2
[2020-12-02]
existingText1
  existingText2
existingText3



_p_TestSection2_q_



_p_TestSection3_q_



`,
			},
			expectedFiles: []string{
				"2020-12-01.txt",
			},
		},
		"add template with later date to existing archive": {
			date: time.Date(2020, 12, 2, 0, 0, 0, 0, time.UTC),
			templateText: `-^-[Wed] 02 Dec 2020-v-

_p_TestSection1_q_
text1
  text2



_p_TestSection2_q_



_p_TestSection3_q_



`,
			existing: map[string]string{
				"Dec2020": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_
[2020-12-01]
existingText1
  existingText2
existingText3

_p_TestSection2_q_



_p_TestSection3_q_



`,
			},
			expectedArchives: map[string]string{
				"Dec2020": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_
[2020-12-01]
existingText1
  existingText2
existingText3
[2020-12-02]
text1
  text2



_p_TestSection2_q_



_p_TestSection3_q_



`,
			},
			expectedFiles: []string{
				"2020-12-02.txt",
			},
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			opts := templatetest.GetOpts()
			trw := newTestReadWriter(true, test.templateText)
			a := NewArchiver(opts, trw, templatetest.Date)
			for key, text := range test.existing {
				existingDate, err := time.Parse(opts.Archive.MonthTimeFormat, key)
				require.NoError(t, err)

				m := template.NewMonthArchiveTemplate(opts, existingDate)
				err = m.Load(strings.NewReader(text))
				require.NoError(t, err)

				a.monthArchives[key] = m
			}

			err := a.Add(test.date)
			require.NoError(t, err)

			require.Equal(t, len(test.expectedArchives), len(a.monthArchives))
			for key, expectedText := range test.expectedArchives {
				buf := new(bytes.Buffer)
				monthArchive, found := a.monthArchives[key]
				require.True(t, found)
				err := monthArchive.Write(buf)
				require.NoError(t, err)
				require.Equal(t, expectedText, buf.String())
			}

			expectedFilesWithFullPath := []string{}
			for _, f := range test.expectedFiles {
				fullPath := filepath.Join(opts.AppDir, f)
				expectedFilesWithFullPath = append(expectedFilesWithFullPath, fullPath)
			}
			require.ElementsMatch(t, expectedFilesWithFullPath, a.GetArchivedFiles())
		})
	}
}

func TestWrite(t *testing.T) {
	type testCase struct {
		text         string
		exists       bool
		existingText string
		expected     string
	}

	tests := map[string]testCase{
		"write with empty archive in archiver to new archive": {
			exists: false,
			expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_



_p_TestSection2_q_



_p_TestSection3_q_



`,
		},
		"write with empty archive in archiver to existing archive": {
			exists: true,
			existingText: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_
[2020-12-15]
existingText1a



_p_TestSection2_q_



_p_TestSection3_q_
[2020-12-15]
existingText3a
[2020-12-22]
existingText3b



`,
			expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_
[2020-12-15]
existingText1a



_p_TestSection2_q_



_p_TestSection3_q_
[2020-12-15]
existingText3a
[2020-12-22]
existingText3b



`,
		},
		"write to new archive": {
			text: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_
[2020-12-17]
text1a
[2020-12-19]
text1b

_p_TestSection2_q_

_p_TestSection3_q_
[2020-12-18]
text3a
[2020-12-19]
text3b

`,
			exists: false,
			expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_
[2020-12-17]
text1a
[2020-12-19]
text1b



_p_TestSection2_q_



_p_TestSection3_q_
[2020-12-18]
text3a
[2020-12-19]
text3b



`,
		},
		"write to existing archive": {
			text: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_
[2020-12-17]
text1a
[2020-12-19]
text1b

_p_TestSection2_q_

_p_TestSection3_q_
[2020-12-18]
text3a
[2020-12-19]
text3b

`,
			exists: true,
			existingText: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_
[2020-12-15]
existingText1a



_p_TestSection2_q_



_p_TestSection3_q_
[2020-12-15]
existingText3a
[2020-12-22]
existingText3b



`,
			expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_
[2020-12-15]
existingText1a
[2020-12-17]
text1a
[2020-12-19]
text1b



_p_TestSection2_q_



_p_TestSection3_q_
[2020-12-15]
existingText3a
[2020-12-18]
text3a
[2020-12-19]
text3b
[2020-12-22]
existingText3b



`,
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			opts := templatetest.GetOpts()
			date := templatetest.Date
			key := date.Format(opts.Archive.MonthTimeFormat)

			template := template.NewMonthArchiveTemplate(opts, date)
			err := template.Load(strings.NewReader(test.text))
			require.NoError(t, err)

			trw := newTestReadWriter(test.exists, test.existingText)
			a := NewArchiver(opts, trw, date)
			a.monthArchives[key] = template

			err = a.Write()
			require.NoError(t, err)
			require.Equal(t, test.expected, trw.written)
		})
	}
}


================================================
FILE: pkg/config/config.go
================================================
package config

import (
	"fmt"
	"log"
	"os"
	"path/filepath"
	"strings"

	"dario.cat/mergo"
	"github.com/ilyakaznacheev/cleanenv"
	"github.com/pkg/errors"
	"gopkg.in/yaml.v3"
)

const (
	// envAppDir is the name of the environment variable specifying the application directory
	envAppDir = "TEXTNOTE_DIR"
	// fileName is the name of the configuration file
	fileName = ".config.yml"
)

// appDir is the directory in which the application stores its files
var appDir = os.Getenv(envAppDir)

// Opts are options that configure the application
type Opts struct {
	AppDir                  string      `yaml:"-"` // AppDir is always read from the environment and is not written to file
	Header                  HeaderOpts  `yaml:"header"`
	Section                 SectionOpts `yaml:"section"`
	File                    FileOpts    `yaml:"file"`
	Archive                 ArchiveOpts `yaml:"archive"`
	Cli                     CliOpts     `yaml:"cli"`
	TemplateFileCountThresh int         `yaml:"templateFileCountThresh" env:"TEXTNOTE_TEMPLATE_FILE_COUNT_THRESH" env-description:"threshold for warning too many template files"`
}

// HeaderOpts are options for configuring the header of a note
type HeaderOpts struct {
	Prefix           string `yaml:"prefix" env:"TEXTNOTE_HEADER_PREFIX" env-description:"prefix to attach to header"`
	Suffix           string `yaml:"suffix" env:"TEXTNOTE_HEADER_SUFFIX" env-description:"suffix to attach to header"`
	TrailingNewlines int    `yaml:"trailingNewlines" env:"TEXTNOTE_HEADER_TRAILING_NEWLINES" env-description:"number of newlines to attach to end of header"`
	TimeFormat       string `yaml:"timeFormat" env:"TEXTNOTE_HEADER_TIME_FORMAT" env-description:"formatting string to form headers from timestamps"`
}

// SectionOpts are options for configuring sections of a note
type SectionOpts struct {
	Prefix           string   `yaml:"prefix" env:"TEXTNOTE_SECTION_PREFIX" env-description:"prefix to attach to section names"`
	Suffix           string   `yaml:"suffix" env:"TEXTNOTE_SECTION_SUFFIX" env-description:"suffix to attach to section names"`
	TrailingNewlines int      `yaml:"trailingNewlines" env:"TEXTNOTE_SECTION_TRAILING_NEWLINES" env-description:"number of newlines to attach to end of each section"`
	Names            []string `yaml:"names" env:"TEXTNOTE_SECTION_NAMES" env-description:"section names"`
}

// FileOpts are options for configuring file outputs
type FileOpts struct {
	Ext        string `yaml:"ext" env:"TEXTNOTE_FILE_EXT" env-description:"extension for all files written"`
	TimeFormat string `yaml:"timeFormat" env:"TEXTNOTE_FILE_TIME_FORMAT" env-description:"formatting string to form file names from timestamps"`
	CursorLine int    `yaml:"cursorLine" env:"TEXTNOTE_FILE_CURSOR_LINE" env-description:"line to place cursor when opening"`
}

// ArchiveOpts are options for configuring note archives
type ArchiveOpts struct {
	AfterDays                int    `yaml:"afterDays" env:"TEXTNOTE_ARCHIVE_AFTER_DAYS" env-description:"number of days after which to archive a file"`
	FilePrefix               string `yaml:"filePrefix" env:"TEXTNOTE_ARCHIVE_FILE_PREFIX" env-description:"prefix attached to the file name of all archive files"`
	HeaderPrefix             string `yaml:"headerPrefix" env:"TEXTNOTE_ARCHIVE_HEADER_PREFIX" env-description:"override header prefix for archive files"`
	HeaderSuffix             string `yaml:"headerSuffix" env:"TEXTNOTE_ARCHIVE_HEADER_SUFFIX" env-description:"override header suffix for archive files"`
	SectionContentPrefix     string `yaml:"sectionContentPrefix" env:"TEXTNOTE_ARCHIVE_SECTION_CONTENT_PREFIX" env-description:"prefix to attach to section content date"`
	SectionContentSuffix     string `yaml:"sectionContentSuffix" env:"TEXTNOTE_ARCHIVE_SECTION_CONTENT_SUFFIX" env-description:"suffix to attach to section content date"`
	SectionContentTimeFormat string `yaml:"sectionContentTimeFormat" env:"TEXTNOTE_ARCHIVE_SECTION_CONTENT_TIME_FORMAT" env-description:"formatting string dated section content"`
	MonthTimeFormat          string `yaml:"monthTimeFormat" env:"TEXTNOTE_ARCHIVE_MONTH_TIME_FORMAT" env-description:"formatting string for month archive timestamps"`
}

// CliOpts are options for configuring the CLI
type CliOpts struct {
	TimeFormat string `yaml:"timeFormat" env:"TEXTNOTE_CLI_TIME_FORMAT" env-description:"formatting string for timestamp CLI flags"`
}

// OptsBackCompat are options maintained for backwards compatibility that will be honored in the absence (zero-value) of their
// replacements as handled in loadBackCompat()
type OptsBackCompat struct {
	// TemplateFileCountThresh holds the value of the field "templateFileCountTresh" (note the typo) in a yaml configuration file
	TemplateFileCountThresh int `yaml:"templateFileCountTresh"`
}

func getDefaultOpts() Opts {
	return Opts{
		Header: HeaderOpts{
			Prefix:           "",
			Suffix:           "",
			TrailingNewlines: 1,
			TimeFormat:       "[Mon] 02 Jan 2006",
		},
		Section: SectionOpts{
			Prefix:           "___",
			Suffix:           "___",
			TrailingNewlines: 3,
			Names: []string{
				"TODO",
				"DONE",
				"NOTES",
			},
		},
		File: FileOpts{
			Ext:        "txt",
			TimeFormat: "2006-01-02",
			CursorLine: 4,
		},
		Archive: ArchiveOpts{
			AfterDays:                14,
			FilePrefix:               "archive-",
			HeaderPrefix:             "ARCHIVE ",
			HeaderSuffix:             "",
			SectionContentPrefix:     "[",
			SectionContentSuffix:     "]",
			SectionContentTimeFormat: "2006-01-02",
			MonthTimeFormat:          "Jan2006",
		},
		Cli: CliOpts{
			TimeFormat: "2006-01-02",
		},
		TemplateFileCountThresh: 90,
	}
}

// Load loads the configuration from file and/or evironment
func Load() (Opts, error) {
	opts := Opts{}

	// parse config file allowing environment variable overrides
	err := loadFromEnv(GetConfigFilePath(), &opts)
	if err != nil {
		return opts, fmt.Errorf("unable to read config file: %w", err)
	}

	// overwrite defaults with opts from file/env
	defaults := getDefaultOpts()
	err = mergo.Merge(&opts, defaults)
	if err != nil {
		return opts, fmt.Errorf("unable to integrate configuration from file with defaults: %w", err)
	}

	// set AppDir as read from environment
	opts.AppDir = appDir

	err = ValidateOpts(opts)
	if err != nil {
		return opts, fmt.Errorf("configuration error in [%s]: %w", fileName, err)
	}

	return opts, nil
}

func loadFromEnv(path string, opts *Opts) error {
	err := cleanenv.ReadConfig(path, opts)
	if err != nil {
		return err
	}

	err = loadBackCompat(path, opts)
	if err != nil {
		return fmt.Errorf("unable to read config file for backwards compatibility fields: %w", err)
	}

	return nil
}

func loadBackCompat(path string, opts *Opts) error {
	// TemplateFileCountThresh backwards compatibility with previously typo'd field
	if opts.TemplateFileCountThresh != 0 {
		return nil
	}
	backcompat := OptsBackCompat{}
	err := cleanenv.ReadConfig(GetConfigFilePath(), &backcompat)
	if err != nil {
		return err
	}
	opts.TemplateFileCountThresh = backcompat.TemplateFileCountThresh
	return nil
}

// CreateIfNotExists writes defaults to the configuration file if it does not already exist
func CreateIfNotExists() error {
	configPath := GetConfigFilePath()
	_, err := os.Stat(configPath)
	if !os.IsNotExist(err) {
		// config file exists, nothing to do
		return nil
	}

	defaults := getDefaultOpts()
	yml, err := yaml.Marshal(defaults)
	if err != nil {
		return fmt.Errorf("unable to generate config file: %w", err)
	}
	err = os.WriteFile(configPath, yml, 0o644)
	if err != nil {
		return fmt.Errorf("unable to create configuration file [%s]: %w", configPath, err)
	}
	log.Printf("created default configuration file: [%s]", configPath)
	return nil
}

// EnsureAppDir validates that the application directory exists or is created
func EnsureAppDir() error {
	if appDir == "" {
		return fmt.Errorf("required environment variable [%s] is not set", envAppDir)
	}

	finfo, err := os.Stat(appDir)
	if os.IsNotExist(err) {
		err := os.MkdirAll(appDir, 0o755)
		if err != nil {
			return err
		}
		log.Printf("created directory [%s]", appDir)
		return nil
	}

	if !finfo.IsDir() {
		return fmt.Errorf("[%s=%s] must be a directory", envAppDir, appDir)
	}
	return nil
}

// ValidateOpts returns an error if the specified options are misconfigured
func ValidateOpts(opts Opts) error {
	// validate appDir is not empty
	if opts.AppDir == "" {
		return fmt.Errorf("must include path to application directory in %s environment variable", envAppDir)
	}

	// validate at least one section
	if len(opts.Section.Names) == 0 {
		return errors.New("must include at least one section")
	}

	// validate section names are unique
	uniq := map[string]struct{}{}
	for _, name := range opts.Section.Names {
		uniq[name] = struct{}{}
	}
	if len(uniq) != len(opts.Section.Names) {
		return errors.New("section names must be unique")
	}

	// validate file archive prefix: this is needed for determining if a file is an archive
	if opts.Archive.FilePrefix == "" || strings.ReplaceAll(opts.Archive.FilePrefix, " ", "") == "" {
		return errors.New("file prefix for archives must not be empty")
	}

	// validate archive after days is at least 1
	if opts.Archive.AfterDays < 1 {
		return errors.New("archive after days must be greater than or equal to 1")
	}

	// validate file extension does not contain leading dot
	if strings.HasPrefix(opts.File.Ext, ".") {
		return errors.New("file extension must not include leading dot")
	}

	// validate the file cursor line is not negative
	if opts.File.CursorLine < 0 {
		return errors.New("cursor line must not be negative")
	}

	// validate threshold for warning on too many template files is larger than archive after days
	if opts.TemplateFileCountThresh <= opts.Archive.AfterDays {
		return errors.New("template file count threshold must be larger than archive after days")
	}

	return nil
}

// DescribeEnvVars returns a description string for environment variables used to configure the application
func DescribeEnvVars() string {
	header := ""
	description, err := cleanenv.GetDescription(&Opts{}, &header)
	if err != nil {
		return ""
	}
	return description
}

// GetConfigFilePath constructs the full path to the configuration file
func GetConfigFilePath() string {
	return filepath.Join(appDir, fileName)
}

// InitApp initializes the application by ensuring the necessary directories and files exist
func InitApp() error {
	err := EnsureAppDir()
	if err != nil {
		return err
	}
	err = CreateIfNotExists()
	if err != nil {
		return err
	}
	return nil
}


================================================
FILE: pkg/config/config_test.go
================================================
package config

import (
	"testing"

	"github.com/stretchr/testify/require"
)

func TestValidateOpts(t *testing.T) {
	t.Run("no appDir", func(t *testing.T) {
		opts := getTestOpts()
		opts.AppDir = ""
		err := ValidateOpts(opts)
		require.Error(t, err)
	})

	t.Run("no section names", func(t *testing.T) {
		opts := getTestOpts()
		opts.Section.Names = []string{}
		err := ValidateOpts(opts)
		require.Error(t, err)
	})

	t.Run("section names are not unique", func(t *testing.T) {
		opts := getTestOpts()
		opts.Section.Names = []string{
			"section1",
			"section2",
			"section1",
		}
		err := ValidateOpts(opts)
		require.Error(t, err)
	})

	t.Run("section names are unique", func(t *testing.T) {
		opts := getTestOpts()
		opts.Section.Names = []string{
			"section1",
			"section2",
			"section3",
		}
		err := ValidateOpts(opts)
		require.NoError(t, err)
	})

	t.Run("archive file prefix is empty string", func(t *testing.T) {
		opts := getTestOpts()
		opts.Archive.FilePrefix = ""
		err := ValidateOpts(opts)
		require.Error(t, err)
	})

	t.Run("archive file prefix is blank", func(t *testing.T) {
		opts := getTestOpts()
		opts.Archive.FilePrefix = "     "
		err := ValidateOpts(opts)
		require.Error(t, err)
	})

	t.Run("archive file prefix is not empty or blank", func(t *testing.T) {
		opts := getTestOpts()
		opts.Archive.FilePrefix = "xyzarchivexyz"
		err := ValidateOpts(opts)
		require.NoError(t, err)
	})

	t.Run("archive after days is negative", func(t *testing.T) {
		opts := getTestOpts()
		opts.Archive.AfterDays = -1
		err := ValidateOpts(opts)
		require.Error(t, err)
	})

	t.Run("archive after days is zero", func(t *testing.T) {
		opts := getTestOpts()
		opts.Archive.AfterDays = 0
		err := ValidateOpts(opts)
		require.Error(t, err)
	})

	t.Run("archive after days is one", func(t *testing.T) {
		opts := getTestOpts()
		opts.Archive.AfterDays = 1
		err := ValidateOpts(opts)
		require.NoError(t, err)
	})

	t.Run("empty file extension should not error", func(t *testing.T) {
		opts := getTestOpts()
		opts.File.Ext = ""
		err := ValidateOpts(opts)
		require.NoError(t, err)
	})

	t.Run("file extension without dot should not error", func(t *testing.T) {
		opts := getTestOpts()
		opts.File.Ext = "txt"
		err := ValidateOpts(opts)
		require.NoError(t, err)
	})

	t.Run("file extension with leading dot should not error", func(t *testing.T) {
		opts := getTestOpts()
		opts.File.Ext = ".txt"
		err := ValidateOpts(opts)
		require.Error(t, err)
	})

	t.Run("file cursor line is negative", func(t *testing.T) {
		opts := getTestOpts()
		opts.File.CursorLine = -2
		err := ValidateOpts(opts)
		require.Error(t, err)
	})

	t.Run("file cursor line is zero", func(t *testing.T) {
		opts := getTestOpts()
		opts.File.CursorLine = 0
		err := ValidateOpts(opts)
		require.NoError(t, err)
	})

	t.Run("template file count threshold not greater than archive after days should error", func(t *testing.T) {
		opts := getTestOpts()
		opts.Archive.AfterDays = 100
		opts.TemplateFileCountThresh = 100
		err := ValidateOpts(opts)
		require.Error(t, err)
	})

	t.Run("template file count threshold greater than archive after days should not error", func(t *testing.T) {
		opts := getTestOpts()
		opts.Archive.AfterDays = 100
		opts.TemplateFileCountThresh = 101
		err := ValidateOpts(opts)
		require.NoError(t, err)
	})
}

func getTestOpts() Opts {
	opts := getDefaultOpts()
	opts.AppDir = "path/to/appDir"
	return opts
}


================================================
FILE: pkg/editor/editor.go
================================================
package editor

import (
	"fmt"
	"os"
	"os/exec"
)

// EnvEditor is the name of the environment variable specifying the editor for opening notes
const EnvEditor = "EDITOR"

const (
	editorNameEmacs  = "emacs"
	editorNameNano   = "nano"
	editorNameNeovim = "nvim"
	editorNameVi     = "vi"
	editorNameVim    = "vim"
)

// openable is the interface that an editor opens
type openable interface {
	GetFilePath() string
	GetFileCursorLine() int
}

// Editor encapsulates the commands and args necessary to open an editor in a shell
type Editor struct {
	Cmd       string
	GetArgs   func(int) []string
	Supported bool
	Default   bool
}

// Open opens an object satisfying the openable interface in the editor
// NOTE: it is recommended to use Go >= v.1.15.7 due to call to exec.Command()
// See: https://blog.golang.org/path-security
func (e *Editor) Open(o openable) error {
	args := append(e.GetArgs(o.GetFileCursorLine()), o.GetFilePath())
	cmd := exec.Command(e.Cmd, args...)
	cmd.Stdout = os.Stdout
	cmd.Stdin = os.Stdin
	cmd.Stderr = os.Stderr
	return cmd.Run()
}

// GetEditor gets an Editor based on a provided name
func GetEditor(name string) *Editor {
	switch name {
	case editorNameVi, editorNameVim:
		return &Editor{
			Cmd: name,
			GetArgs: func(line int) []string {
				return []string{
					fmt.Sprintf("+%d", line),
				}
			},
			Supported: true,
			Default:   false,
		}
	case editorNameEmacs:
		return &Editor{
			Cmd: editorNameEmacs,
			GetArgs: func(line int) []string {
				return []string{
					fmt.Sprintf("+%d", line),
				}
			},
			Supported: true,
			Default:   false,
		}
	case editorNameNano:
		return &Editor{
			Cmd: editorNameNano,
			GetArgs: func(line int) []string {
				return []string{
					fmt.Sprintf("+%d", line),
				}
			},
			Supported: true,
			Default:   false,
		}
	case editorNameNeovim:
		return &Editor{
			Cmd: editorNameNeovim,
			GetArgs: func(line int) []string {
				return []string{
					fmt.Sprintf("+%d", line),
				}
			},
			Supported: true,
			Default:   false,
		}
	// use Vim as the default editor
	case "":
		return &Editor{
			Cmd: editorNameVim,
			GetArgs: func(line int) []string {
				return []string{
					fmt.Sprintf("+%d", line),
				}
			},
			Supported: true,
			Default:   true,
		}
	// unrecognized editor will be passed no arguments
	default:
		return &Editor{
			Cmd: name,
			GetArgs: func(line int) []string {
				return []string{}
			},
			Supported: false,
			Default:   false,
		}
	}
}


================================================
FILE: pkg/file/file.go
================================================
package file

import (
	"io"
	"os"
)

// ReadWriteable is the interface on which file operations are executed
type ReadWriteable interface {
	Load(io.Reader) error
	Write(io.Writer) error
	GetFilePath() string
}

// ReadWriter executes file operations
type ReadWriter struct{}

// NewReadWriter constructs a new ReadWriter
func NewReadWriter() *ReadWriter {
	return &ReadWriter{}
}

// Read reads from file
func (rw *ReadWriter) Read(rwable ReadWriteable) error {
	r, err := os.Open(rwable.GetFilePath())
	if err != nil {
		return err
	}
	defer r.Close()
	return rwable.Load(r)
}

// Overwrite writes a template to a file, overwriting existing file contents if any
func (rw *ReadWriter) Overwrite(rwable ReadWriteable) error {
	f, err := os.OpenFile(rwable.GetFilePath(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
	if err != nil {
		return err
	}
	defer f.Close()

	err = rwable.Write(f)
	if err != nil {
		return err
	}
	return nil
}

// Exists evaluates if a file exists
func (rw *ReadWriter) Exists(rwable ReadWriteable) bool {
	fileName := rwable.GetFilePath()
	_, err := os.Stat(fileName)
	return !os.IsNotExist(err)
}


================================================
FILE: pkg/template/archive.go
================================================
package template

import (
	"fmt"
	"io"
	"path/filepath"
	"regexp"
	"strings"
	"time"

	"github.com/dkaslovsky/textnote/pkg/config"
)

// MonthArchiveTemplate contains the structure of a month archive
type MonthArchiveTemplate struct {
	*Template
}

// NewMonthArchiveTemplate constructs a new MonthArchiveTemplate
func NewMonthArchiveTemplate(opts config.Opts, date time.Time) *MonthArchiveTemplate {
	monthDate := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location())
	return &MonthArchiveTemplate{
		NewTemplate(opts, monthDate),
	}
}

// Write writes the template
// This function is needed to ensure the string() method of the MonthArchiveTemplate is called
func (t *MonthArchiveTemplate) Write(w io.Writer) error {
	_, err := w.Write([]byte(t.string()))
	return err
}

// GetFilePath generates a full path for a file based on the template date
func (t *MonthArchiveTemplate) GetFilePath() string {
	name := filepath.Join(
		t.opts.AppDir,
		t.opts.Archive.FilePrefix+t.date.Format(t.opts.Archive.MonthTimeFormat),
	)
	if t.opts.File.Ext == "" {
		return name
	}
	return fmt.Sprintf("%s.%s", name, t.opts.File.Ext)
}

// ArchiveSectionContents concatenates the contents of the specified section from a source template and
// appends to the contents of the receiver's section with a header derived from the source template's date
func (t *MonthArchiveTemplate) ArchiveSectionContents(src *Template, sectionName string) error {
	tgtSec, err := t.getSection(sectionName)
	if err != nil {
		return fmt.Errorf("failed to find section in target: %w", err)
	}
	srcSec, err := src.getSection(sectionName)
	if err != nil {
		return fmt.Errorf("failed to find section in source: %w", err)
	}

	// flatten text from contents into a single string
	txt := ""
	for _, content := range srcSec.contents {
		txt += content.text
	}
	if len(txt) == 0 {
		return nil
	}

	tgtSec.contents = append(tgtSec.contents, contentItem{
		header: t.makeContentHeader(src.GetDate()),
		text:   txt,
	})
	return nil
}

// Merge merges a source MonthArchiveTemplate into the receiver
// This is a convenience function that iterates and copies all sections in the receiver
func (t *MonthArchiveTemplate) Merge(src *MonthArchiveTemplate) error {
	for sectionName := range t.sectionIdx {
		err := t.CopySectionContents(src, sectionName)
		if err != nil {
			return err
		}
	}
	return nil
}

func (t *MonthArchiveTemplate) string() string {
	str := t.makeHeader()
	for _, section := range t.sections {
		name := section.getNameString(t.opts.Section.Prefix, t.opts.Section.Suffix)

		section.sortContents()
		body := section.getContentString()
		body = regexp.MustCompile(`\n{2,}`).ReplaceAllString(body, "\n") // remove blank lines

		str += fmt.Sprintf("%s%s%s", name, body, strings.Repeat("\n", t.opts.Section.TrailingNewlines))
	}
	return str
}

func (t *MonthArchiveTemplate) makeHeader() string {
	return fmt.Sprintf("%s%s%s\n%s",
		t.opts.Archive.HeaderPrefix,
		t.date.Format(t.opts.Archive.MonthTimeFormat),
		t.opts.Archive.HeaderSuffix,
		strings.Repeat("\n", t.opts.Header.TrailingNewlines),
	)
}

func (t *MonthArchiveTemplate) makeContentHeader(date time.Time) string {
	return fmt.Sprintf("%s%s%s",
		t.opts.Archive.SectionContentPrefix,
		date.Format(t.opts.Archive.SectionContentTimeFormat),
		t.opts.Archive.SectionContentSuffix,
	)
}

// isArchiveItemHeader evaluates if a line matches the pattern of a dated header in a section of an archive
func isArchiveItemHeader(line string, prefix string, suffix string, format string) bool {
	if !strings.HasPrefix(line, prefix) {
		return false
	}
	if !strings.HasSuffix(line, suffix) {
		return false
	}
	_, err := time.Parse(format, stripPrefixSuffix(line, prefix, suffix))
	return err == nil
}


================================================
FILE: pkg/template/archive_test.go
================================================
package template

import (
	"fmt"
	"strings"
	"testing"
	"time"

	"github.com/dkaslovsky/textnote/pkg/template/templatetest"
	"github.com/stretchr/testify/require"
)

func TestNewMonthArchiveTemplate(t *testing.T) {
	type testCase struct {
		date     time.Time
		expected time.Time
	}

	tests := map[string]testCase{
		"first of the month": {
			date:     time.Date(2020, 12, 1, 2, 3, 4, 5, time.UTC),
			expected: time.Date(2020, 12, 1, 0, 0, 0, 0, time.UTC),
		},
		"not first of the month": {
			date:     time.Date(2020, 12, 15, 2, 3, 4, 5, time.UTC),
			expected: time.Date(2020, 12, 1, 0, 0, 0, 0, time.UTC),
		},
		"non UTC location": {
			date:     time.Date(2020, 12, 15, 2, 3, 4, 5, time.FixedZone("UTC-8", -8*60*60)),
			expected: time.Date(2020, 12, 1, 0, 0, 0, 0, time.FixedZone("UTC-8", -8*60*60)),
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			m := NewMonthArchiveTemplate(templatetest.GetOpts(), test.date)
			require.Equal(t, test.expected, m.date)
		})
	}
}

func TestArchiveGetFilePath(t *testing.T) {
	t.Run("get file path with extension", func(t *testing.T) {
		opts := templatetest.GetOpts()
		opts.File.Ext = "txt"
		template := NewMonthArchiveTemplate(opts, templatetest.Date)
		filePath := template.GetFilePath()
		require.True(t, strings.HasPrefix(filePath, opts.AppDir))
		require.True(t, strings.HasSuffix(filePath, ".txt"))
		require.Equal(t,
			opts.Archive.FilePrefix+templatetest.Date.Format(opts.Archive.MonthTimeFormat),
			stripPrefixSuffix(filePath, fmt.Sprintf("%s/", opts.AppDir), ".txt"),
		)
	})

	t.Run("get file path without extension", func(t *testing.T) {
		opts := templatetest.GetOpts()
		opts.File.Ext = ""
		template := NewMonthArchiveTemplate(opts, templatetest.Date)
		filePath := template.GetFilePath()
		require.True(t, strings.HasPrefix(filePath, opts.AppDir))
		require.False(t, strings.HasSuffix(filePath, "."))
		require.Equal(t,
			opts.Archive.FilePrefix+templatetest.Date.Format(opts.Archive.MonthTimeFormat),
			stripPrefixSuffix(filePath, fmt.Sprintf("%s/", opts.AppDir), ""),
		)
	})
}

func TestArchiveSectionContents(t *testing.T) {
	type testCase struct {
		sectionName      string
		existingContents []contentItem
		sourceDate       time.Time
		sourceContents   []contentItem
		expectedContents []contentItem
	}

	tests := map[string]testCase{
		"archive empty contents into empty section": {
			sectionName:      "TestSection1",
			existingContents: []contentItem{},
			sourceDate:       templatetest.Date,
			sourceContents:   []contentItem{},
			expectedContents: []contentItem{},
		},
		"archive empty contents into populated section": {
			sectionName: "TestSection1",
			existingContents: []contentItem{
				{
					header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
					text:   "existingText1",
				},
			},
			sourceDate:     templatetest.Date.Add(24 * time.Hour),
			sourceContents: []contentItem{},
			expectedContents: []contentItem{
				{
					header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
					text:   "existingText1",
				},
			},
		},
		"archive contents with single element into empty section": {
			sectionName:      "TestSection1",
			existingContents: []contentItem{},
			sourceDate:       templatetest.Date.Add(24 * time.Hour),
			sourceContents: []contentItem{
				{
					header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
					text:   "text1",
				},
			},
			expectedContents: []contentItem{
				{
					header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
					text:   "text1",
				},
			},
		},
		"archive contents with single element into populated section": {
			sectionName: "TestSection1",
			existingContents: []contentItem{
				{
					header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
					text:   "existingText1",
				},
			},
			sourceDate: templatetest.Date.Add(24 * time.Hour),
			sourceContents: []contentItem{
				{
					header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
					text:   "sourceText1",
				},
			},
			expectedContents: []contentItem{
				{
					header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
					text:   "existingText1",
				},
				{
					header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
					text:   "sourceText1",
				},
			},
		},
		"archive contents with multiple element into empty section": {
			sectionName:      "TestSection1",
			existingContents: []contentItem{},
			sourceDate:       templatetest.Date.Add(24 * time.Hour),
			sourceContents: []contentItem{
				{
					header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
					text:   "text1\n",
				},
				{
					header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
					text:   "text2\n\n",
				},
			},
			expectedContents: []contentItem{
				{
					header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
					text:   "text1\ntext2\n\n",
				},
			},
		},
		"archive contents with multiple elements into populated section": {
			sectionName: "TestSection1",
			existingContents: []contentItem{
				{
					header: templatetest.MakeItemHeader(templatetest.Date.Add(-24*time.Hour), templatetest.GetOpts()),
					text:   "existingText",
				},
			},
			sourceDate: templatetest.Date.Add(24 * time.Hour),
			sourceContents: []contentItem{
				{
					header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
					text:   "text1\n",
				},
				{
					header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
					text:   "text2\n\n",
				},
			},
			expectedContents: []contentItem{
				{
					header: templatetest.MakeItemHeader(templatetest.Date.Add(-24*time.Hour), templatetest.GetOpts()),
					text:   "existingText",
				},
				{
					header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
					text:   "text1\ntext2\n\n",
				},
			},
		},
		"archive contents from source with same date": {
			sectionName: "TestSection1",
			existingContents: []contentItem{
				{
					header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
					text:   "existingText",
				},
			},
			sourceDate: templatetest.Date,
			sourceContents: []contentItem{
				{
					header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
					text:   "text1\n",
				},
				{
					header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
					text:   "text2\n\n",
				},
			},
			expectedContents: []contentItem{
				{
					header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
					text:   "existingText",
				},
				{
					header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
					text:   "text1\ntext2\n\n",
				},
			},
		},
		"source header does not matter": {
			sectionName:      "TestSection1",
			existingContents: []contentItem{},
			sourceDate:       templatetest.Date.Add(24 * time.Hour),
			sourceContents: []contentItem{
				{
					header: "doesn't matter 1",
					text:   "text1\n",
				},
				{
					header: "doesn't matter 2",
					text:   "text2\n\n",
				},
			},
			expectedContents: []contentItem{
				{
					header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
					text:   "text1\ntext2\n\n",
				},
			},
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			opts := templatetest.GetOpts()
			src := NewTemplate(opts, test.sourceDate)
			src.sections[src.sectionIdx[test.sectionName]].contents = test.sourceContents
			template := NewMonthArchiveTemplate(opts, templatetest.Date)
			template.sections[template.sectionIdx[test.sectionName]].contents = test.existingContents

			err := template.ArchiveSectionContents(src, test.sectionName)
			require.NoError(t, err)
			require.Equal(t, template.sections[template.sectionIdx[test.sectionName]].contents, test.expectedContents)
		})
	}
}

func TestArchiveSectionContentsFail(t *testing.T) {
	t.Run("section does not exist in template", func(t *testing.T) {
		toCopy := "toBeArchived"
		opts := templatetest.GetOpts()
		template := NewMonthArchiveTemplate(opts, templatetest.Date)
		src := NewTemplate(opts, templatetest.Date)
		src.sections = append(src.sections, newSection(toCopy))
		src.sectionIdx[toCopy] = len(src.sections) - 1

		err := template.ArchiveSectionContents(src, toCopy)
		require.Error(t, err)
	})

	t.Run("section does not exist in source", func(t *testing.T) {
		toCopy := "toBeArchived"
		opts := templatetest.GetOpts()
		template := NewMonthArchiveTemplate(opts, templatetest.Date)
		template.sections = append(template.sections, newSection(toCopy))
		template.sectionIdx[toCopy] = len(template.sections) - 1
		src := NewTemplate(opts, templatetest.Date)

		err := template.ArchiveSectionContents(src, toCopy)
		require.Error(t, err)
	})
}

func TestArchiveString(t *testing.T) {
	type testCase struct {
		sections []*section
		expected string
	}

	tests := map[string]testCase{
		"empty template": {
			sections: []*section{},
			expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

`,
		},
		"single empty section": {
			sections: []*section{
				newSection("TestSection1"),
			},
			expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_



`,
		},
		"single section": {
			sections: []*section{
				newSection("TestSection1",
					contentItem{
						header: "[2020-12-19]",
						text:   "text",
					},
				),
			},
			expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_
[2020-12-19]
text



`,
		},
		"single section with multiline text": {
			sections: []*section{
				newSection("TestSection1",
					contentItem{
						header: "[2020-12-19]",
						text:   "text1\ntext2\n\n text3text4\n- text5\n\n  -text6\n\n",
					},
				),
			},
			expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_
[2020-12-19]
text1
text2
 text3text4
- text5
  -text6



`,
		},
		"single section with multiple contents": {
			sections: []*section{
				newSection("TestSection1",
					contentItem{
						header: "[2020-12-18]",
						text:   "text1\n",
					},
					contentItem{
						header: "[2020-12-19]",
						text:   "text2\n",
					},
				),
			},
			expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_
[2020-12-18]
text1
[2020-12-19]
text2



`,
		},
		"multiple empty sections": {
			sections: []*section{
				newSection("TestSection1"),
				newSection("TestSection2"),
				newSection("TestSection3"),
			},
			expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_



_p_TestSection2_q_



_p_TestSection3_q_



`,
		},
		"multiple sections with only first populated": {
			sections: []*section{
				newSection("TestSection1",
					contentItem{
						header: "[2020-12-18]",
						text:   "text",
					},
				),
				newSection("TestSection2"),
				newSection("TestSection3"),
			},
			expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_
[2020-12-18]
text



_p_TestSection2_q_



_p_TestSection3_q_



`,
		},
		"multiple sections with only middle populated": {
			sections: []*section{
				newSection("TestSection1"),
				newSection("TestSection2",
					contentItem{
						header: "[2020-12-18]",
						text:   "text",
					},
				),
				newSection("TestSection3"),
			},
			expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_



_p_TestSection2_q_
[2020-12-18]
text



_p_TestSection3_q_



`,
		},
		"multiple sections with only last populated": {
			sections: []*section{
				newSection("TestSection1"),
				newSection("TestSection2"),
				newSection("TestSection3",
					contentItem{
						header: "[2020-12-18]",
						text:   "text",
					},
				),
			},
			expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_



_p_TestSection2_q_



_p_TestSection3_q_
[2020-12-18]
text



`,
		},
		"sections with out of order items should be sorted": {
			sections: []*section{
				newSection("TestSection1"),
				newSection("TestSection2"),
				newSection("TestSection3",
					contentItem{
						header: "[2020-12-18]",
						text:   "text 2020-12-18",
					},
					contentItem{
						header: "[2020-12-16]",
						text:   "text 2020-12-16",
					},
					contentItem{
						header: "[2020-12-17]",
						text:   "text 2020-12-17",
					},
				),
			},
			expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX

_p_TestSection1_q_



_p_TestSection2_q_



_p_TestSection3_q_
[2020-12-16]
text 2020-12-16
[2020-12-17]
text 2020-12-17
[2020-12-18]
text 2020-12-18



`,
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			opts := templatetest.GetOpts()
			names := []string{}
			for _, section := range test.sections {
				names = append(names, section.name)
			}
			opts.Section.Names = names

			template := NewMonthArchiveTemplate(opts, templatetest.Date)
			for i, section := range test.sections {
				template.sections[i] = section
			}

			require.Equal(t, test.expected, template.string())
		})
	}
}

func TestIsArchiveItemHeader(t *testing.T) {
	type testCase struct {
		header   string
		prefix   string
		suffix   string
		format   string
		expected bool
	}

	tests := map[string]testCase{
		"valid header": {
			header:   "[2020-07-28]",
			prefix:   "[",
			suffix:   "]",
			format:   "2006-01-02",
			expected: true,
		},
		"valid header with no prefix or suffix": {
			header:   "2020-07-28",
			prefix:   "",
			suffix:   "",
			format:   "2006-01-02",
			expected: true,
		},
		"invalid header with wrong prefix": {
			header:   "<2020-07-28]",
			prefix:   "[",
			suffix:   "]",
			format:   "2006-01-02",
			expected: false,
		},
		"invalid header with wrong suffix": {
			header:   "[2020-07-28>",
			prefix:   "[",
			suffix:   "]",
			format:   "2006-01-02",
			expected: false,
		},
		"invalid header with wrong format": {
			header:   "[2020-July-28]",
			prefix:   "[",
			suffix:   "]",
			format:   "2006-01-02",
			expected: false,
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			val := isArchiveItemHeader(test.header, test.prefix, test.suffix, test.format)
			require.Equal(t, test.expected, val)
		})
	}
}


================================================
FILE: pkg/template/section.go
================================================
package template

import (
	"fmt"
	"regexp"
	"sort"
	"strings"

	"github.com/dkaslovsky/textnote/pkg/config"
	"github.com/pkg/errors"
)

// section is a named section of a Template
type section struct {
	name     string
	contents []contentItem
}

// newSection constructs a Section
func newSection(name string, items ...contentItem) *section {
	return &section{
		name:     name,
		contents: items,
	}
}

func (s *section) deleteContents() {
	s.contents = []contentItem{}
}

func (s *section) sortContents() {
	// stable sort to preserve order for empty header case
	sort.SliceStable(s.contents, func(i, j int) bool {
		return s.contents[i].header < s.contents[j].header
	})
}

func (s *section) isEmpty() bool {
	for _, content := range s.contents {
		if !content.isEmpty() {
			return false
		}
	}
	return true
}

func (s *section) getNameString(prefix string, suffix string) string {
	return fmt.Sprintf("%s%s%s\n", prefix, s.name, suffix)
}

func (s *section) getContentString() string {
	str := ""
	for _, content := range s.contents {
		txt := content.string()
		if !strings.HasSuffix(txt, "\n") {
			txt += "\n"
		}
		str += txt
	}
	return str
}

type contentItem struct {
	header string
	text   string
}

func (ci contentItem) string() string {
	if ci.header != "" {
		return fmt.Sprintf("%s\n%s", ci.header, ci.text)
	}
	return ci.text
}

func (ci contentItem) isEmpty() bool {
	// exclude trailing newlines for empty content check
	strippedTxt := strings.Replace(ci.text, "\n", "", -1)
	return len(strippedTxt) == 0
}

func parseSection(text string, opts config.Opts) (*section, error) {
	if len(text) == 0 {
		return nil, errors.New("cannot parse Section from empty input")
	}

	lines := strings.Split(text, "\n")
	name := stripPrefixSuffix(lines[0], opts.Section.Prefix, opts.Section.Suffix)
	contents := parseSectionContents(
		lines[1:],
		opts.Archive.SectionContentPrefix,
		opts.Archive.SectionContentSuffix,
		opts.File.TimeFormat,
	)

	// return section populated with contents if any contentItem is non-empty
	for _, content := range contents {
		if !content.isEmpty() {
			return newSection(name, contents...), nil
		}
	}
	// all contents are empty so return unpopulated section
	return newSection(name), nil
}

func parseSectionContents(lines []string, prefix string, suffix string, format string) []contentItem {
	contents := []contentItem{}
	if len(lines) == 0 {
		return contents
	}

	// parse first line
	line := lines[0]
	header := ""
	body := []string{}
	if isArchiveItemHeader(line, prefix, suffix, format) {
		header = line
	} else {
		body = append(body, line)
	}

	for _, line := range lines[1:] {
		// if the line is a header it indicates new contents, so "flush" (append) the current
		// header/body and start tracking the new contents
		if isArchiveItemHeader(line, prefix, suffix, format) {
			contents = append(contents, contentItem{
				header: header,
				text:   strings.Join(body, "\n"),
			})

			header = line
			body = []string{}
			continue
		}

		body = append(body, line)
	}

	// ensure remaining content is appended
	if len(body) != 0 || header != "" {
		contents = append(contents, contentItem{
			header: header,
			text:   strings.Join(body, "\n"),
		})
	}
	return contents
}

func stripPrefixSuffix(line string, prefix string, suffix string) string {
	return strings.TrimPrefix(strings.TrimSuffix(line, suffix), prefix)
}

func getSectionNameRegex(prefix string, suffix string) (*regexp.Regexp, error) {
	sectionPattern := fmt.Sprintf("%s.*%s", prefix, suffix)
	sectionNameRegex, err := regexp.Compile(sectionPattern)
	if err != nil {
		return sectionNameRegex, fmt.Errorf("invalid section prefix [%s] or suffix [%s]", prefix, suffix)
	}
	return sectionNameRegex, nil
}


================================================
FILE: pkg/template/section_test.go
================================================
package template

import (
	"fmt"
	"strings"
	"testing"
	"time"

	"github.com/dkaslovsky/textnote/pkg/template/templatetest"
	"github.com/stretchr/testify/require"
)

func TestGetNameString(t *testing.T) {
	type testCase struct {
		name     string
		prefix   string
		suffix   string
		expected string
	}

	tests := map[string]testCase{
		"empty name, empty prefix and suffix": {
			name:     "",
			prefix:   "",
			suffix:   "",
			expected: "\n",
		},
		"empty name, non-empty prefix and suffix": {
			name:     "",
			prefix:   "p ",
			suffix:   " s",
			expected: "p  s\n",
		},
		"non-empty name, empty prefix and suffix": {
			name:     "name",
			prefix:   "",
			suffix:   "",
			expected: "name\n",
		},
		"non-empty name, non-empty prefix and suffix": {
			name:     "name",
			prefix:   "p ",
			suffix:   " s",
			expected: "p name s\n",
		},
		"non-empty name with spaces, non-empty prefix and suffix": {
			name:     " na me ",
			prefix:   "p ",
			suffix:   " s",
			expected: "p  na me  s\n",
		},
		"non-empty name with newlines, non-empty prefix and suffix": {
			name:     " na \n me ",
			prefix:   "p ",
			suffix:   " s",
			expected: "p  na \n me  s\n",
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			s := newSection(test.name)
			str := s.getNameString(test.prefix, test.suffix)
			require.Equal(t, test.expected, str)
		})
	}
}

func TestGetContentString(t *testing.T) {
	type testCase struct {
		contents []contentItem
		expected string
	}

	tests := map[string]testCase{
		"empty contents": {
			contents: []contentItem{},
			expected: "",
		},
		"single empty string contents with no header": {
			contents: []contentItem{
				{
					header: "",
					text:   "",
				},
			},
			expected: "\n",
		},
		"single empty string contents with header": {
			contents: []contentItem{
				{
					header: "header",
					text:   "",
				},
			},
			expected: "header\n",
		},
		"multiple empty string contents with no header": {
			contents: []contentItem{
				{
					header: "",
					text:   "",
				},
				{
					header: "",
					text:   "",
				},
			},
			expected: "\n\n",
		},
		"multiple empty string contents with header": {
			contents: []contentItem{
				{
					header: "header1",
					text:   "",
				},
				{
					header: "header2",
					text:   "",
				},
			},
			expected: "header1\nheader2\n",
		},
		"single nonempty contents with no header missing trailing newline": {
			contents: []contentItem{
				{
					header: "",
					text:   "text\n goes\n  here",
				},
			},
			expected: "text\n goes\n  here\n",
		},
		"single nonempty contents with no header": {
			contents: []contentItem{
				{
					header: "",
					text:   "text\n goes\n  here\n",
				},
			},
			expected: "text\n goes\n  here\n",
		},
		"single nonempty contents with header": {
			contents: []contentItem{
				{
					header: "header",
					text:   "text\n goes\n  here\n",
				},
			},
			expected: "header\ntext\n goes\n  here\n",
		},
		"multiple nonempty contents with no headers": {
			contents: []contentItem{
				{
					header: "",
					text:   "text\n goes\n  here\n",
				},
				{
					header: "",
					text:   "text2\n goes2\n  here2 \n",
				},
			},
			expected: "text\n goes\n  here\ntext2\n goes2\n  here2 \n",
		},
		"multiple nonempty contents with headers": {
			contents: []contentItem{
				{
					header: "header1 ",
					text:   "text\n goes\n  here\n",
				},
				{
					header: " header2",
					text:   "text2\n goes2\n  here2 \n",
				},
			},
			expected: "header1 \ntext\n goes\n  here\n header2\ntext2\n goes2\n  here2 \n",
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			s := newSection("name", test.contents...)
			str := s.getContentString()
			require.Equal(t, test.expected, str)
		})
	}
}

func TestParseSectionContents(t *testing.T) {
	type testCase struct {
		lines    []string
		expected []contentItem
	}

	tests := map[string]testCase{
		"empty lines": {
			lines:    []string{},
			expected: []contentItem{},
		},
		"single empty string line": {
			lines: []string{""},
			expected: []contentItem{
				{},
			},
		},
		"lines with no header": {
			lines: strings.Split("hello\n  world", "\n"),
			expected: []contentItem{
				{
					header: "",
					text:   "hello\n  world",
				},
			},
		},
		"lines with no header with newline at start and end": {
			lines: strings.Split("\n\nhello\n  world\n\n", "\n"),
			expected: []contentItem{
				{
					header: "",
					text:   "\n\nhello\n  world\n\n",
				},
			},
		},
		"lines with single header": {
			lines: strings.Split(
				fmt.Sprintf("%s\nhello\n  world", templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts())),
				"\n",
			),
			expected: []contentItem{
				{
					header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
					text:   "hello\n  world",
				},
			},
		},
		"lines with single header with newline at start and end": {
			lines: strings.Split(
				fmt.Sprintf("\n%s\n\nhello\n  world\n", templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts())),
				"\n",
			),
			expected: []contentItem{
				{},
				{
					header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
					text:   "\nhello\n  world\n",
				},
			},
		},
		"lines with multiple headers": {
			lines: strings.Split(
				fmt.Sprintf("%s\nhello\n  world\n%s\nhello2\n  world2",
					templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
					templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
				),
				"\n",
			),
			expected: []contentItem{
				{
					header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
					text:   "hello\n  world",
				},
				{
					header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
					text:   "hello2\n  world2",
				},
			},
		},
		"lines with multiple headers with newline at start and end": {
			lines: strings.Split(
				fmt.Sprintf("\n%s\nhello\n\n  world\n\n\n\n%s\nhello2\n  world2\n\n\n\n\n",
					templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
					templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
				),
				"\n",
			),
			expected: []contentItem{
				{},
				{
					header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
					text:   "hello\n\n  world\n\n\n",
				},
				{
					header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
					text:   "hello2\n  world2\n\n\n\n\n",
				},
			},
		},
		"header with no text": {
			lines: strings.Split(
				templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
				"\n",
			),
			expected: []contentItem{
				{
					header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
					text:   "",
				},
			},
		},
		"multiple headers with no text": {
			lines: strings.Split(
				fmt.Sprintf("%s\n%s",
					templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
					templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
				),
				"\n",
			),
			expected: []contentItem{
				{
					header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
					text:   "",
				},
				{
					header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
					text:   "",
				},
			},
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			opts := templatetest.GetOpts()
			contents := parseSectionContents(test.lines, opts.Archive.SectionContentPrefix, opts.Archive.SectionContentSuffix, opts.File.TimeFormat)
			require.Equal(t, test.expected, contents)
		})
	}
}

func TestSectionIsEmpty(t *testing.T) {
	type testCase struct {
		contents []contentItem
		expected bool
	}

	tests := map[string]testCase{
		"empty contents": {
			contents: []contentItem{},
			expected: true,
		},
		"single content with only newlines and empty header": {
			contents: []contentItem{
				{
					header: "",
					text:   "\n\n\n",
				},
			},
			expected: true,
		},
		"single content with only newlines and populated header": {
			contents: []contentItem{
				{
					header: "header",
					text:   "\n\n\n",
				},
			},
			expected: true,
		},
		"multiple contents with only newlines and empty headers": {
			contents: []contentItem{
				{
					header: "",
					text:   "\n\n\n",
				},
				{
					header: "",
					text:   "\n",
				},
			},
			expected: true,
		},
		"multiple contents with only newlines and populated headers": {
			contents: []contentItem{
				{
					header: "header1",
					text:   "\n\n\n",
				},
				{
					header: "header2",
					text:   "\n",
				},
			},
			expected: true,
		},
		"single content with text and no header": {
			contents: []contentItem{
				{
					header: "",
					text:   "\n\nfoo\n",
				},
			},
			expected: false,
		},
		"single content with text and populated header": {
			contents: []contentItem{
				{
					header: "header",
					text:   "\n\nfoo\n",
				},
			},
			expected: false,
		},
		"multiple contents with text and no headers": {
			contents: []contentItem{
				{
					header: "",
					text:   "\n\nfoo\n",
				},
				{
					header: "",
					text:   "bar",
				},
			},
			expected: false,
		},
		"multiple contents with text and populated headers": {
			contents: []contentItem{
				{
					header: "header1",
					text:   "\n\nfoo\n",
				},
				{
					header: "header2",
					text:   "bar",
				},
			},
			expected: false,
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			s := newSection("name", test.contents...)
			val := s.isEmpty()
			require.Equal(t, test.expected, val)
		})
	}
}

func TestContentItemIsEmpty(t *testing.T) {
	type testCase struct {
		item     contentItem
		expected bool
	}

	tests := map[string]testCase{
		"empty": {
			item:     contentItem{},
			expected: true,
		},
		"only newlines and empty header": {
			item: contentItem{
				header: "",
				text:   "\n\n\n",
			},
			expected: true,
		},
		"only newlines and populated header": {
			item: contentItem{
				header: "header",
				text:   "\n\n\n",
			},
			expected: true,
		},
		"text and no header": {
			item: contentItem{
				header: "",
				text:   "\n\nfoo\n",
			},
			expected: false,
		},
		"text and populated header": {
			item: contentItem{
				header: "header",
				text:   "\n\nfoo\n",
			},
			expected: false,
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			val := test.item.isEmpty()
			require.Equal(t, test.expected, val)
		})
	}

}


================================================
FILE: pkg/template/template.go
================================================
package template

import (
	"fmt"
	"io"
	"path/filepath"
	"strings"
	"time"

	"github.com/dkaslovsky/textnote/pkg/config"
)

// Template contains the structure of a note
type Template struct {
	opts       config.Opts
	date       time.Time
	sections   []*section
	sectionIdx map[string]int // map of section name to index in sections slice
}

// NewTemplate constructs a new Template
func NewTemplate(opts config.Opts, date time.Time) *Template {
	t := &Template{
		opts:       opts,
		date:       date,
		sections:   []*section{},
		sectionIdx: map[string]int{},
	}
	for idx, sectionName := range opts.Section.Names {
		t.sections = append(t.sections, newSection(sectionName))
		t.sectionIdx[sectionName] = idx
	}
	return t
}

// Write writes the template
func (t *Template) Write(w io.Writer) error {
	_, err := w.Write([]byte(t.string()))
	return err
}

// GetDate returns the template's date
func (t *Template) GetDate() time.Time {
	return t.date
}

// GetFileCursorLine returns the line at which to place the cursor when opening the template
func (t *Template) GetFileCursorLine() int {
	return t.opts.File.CursorLine
}

// GetFilePath generates a full path for a file based on the template date
func (t *Template) GetFilePath() string {
	name := filepath.Join(t.opts.AppDir, t.date.Format(t.opts.File.TimeFormat))
	if t.opts.File.Ext == "" {
		return name
	}
	return fmt.Sprintf("%s.%s", name, t.opts.File.Ext)
}

// sectionGettable is the interface for getting a section
type sectionGettable interface {
	getSection(string) (*section, error)
}

// CopySectionContents copies the contents of the specified section from a source template by
// appending to the contents of the receiver's section
func (t *Template) CopySectionContents(src sectionGettable, sectionName string) error {
	tgtSec, err := t.getSection(sectionName)
	if err != nil {
		return fmt.Errorf("failed to find section in target: %w", err)
	}
	srcSec, err := src.getSection(sectionName)
	if err != nil {
		return fmt.Errorf("failed to find section in source: %w", err)
	}
	tgtSec.contents = append(tgtSec.contents, srcSec.contents...)
	return nil
}

// DeleteSectionContents deletes the contents of a specified section
func (t *Template) DeleteSectionContents(sectionName string) error {
	sec, err := t.getSection(sectionName)
	if err != nil {
		return fmt.Errorf("cannot delete section: %w", err)
	}
	sec.deleteContents()
	return nil
}

// IsEmpty evaluates if a template is empty (ignores whitespace)
func (t *Template) IsEmpty() bool {
	for _, sec := range t.sections {
		if !sec.isEmpty() {
			return false
		}
	}
	return true
}

// Load populates a Template from the contents of a reader
func (t *Template) Load(r io.Reader) error {
	raw, err := io.ReadAll(r)
	if err != nil {
		return fmt.Errorf("error loading template: %w", err)
	}
	sectionText := string(raw)

	sectionNameRegex, err := getSectionNameRegex(t.opts.Section.Prefix, t.opts.Section.Suffix)
	if err != nil {
		return fmt.Errorf("cannot parse sections: %w", err)
	}
	sectionBoundaries := sectionNameRegex.FindAllStringSubmatchIndex(sectionText, -1)
	numSections := len(sectionBoundaries)

	// extract sections from sectionText
	for i, idxs := range sectionBoundaries {
		var curSecEnd int
		// end of current section is marked by the beginning of the next section
		// if current section is not the last section
		if i != numSections-1 {
			curSecEnd = sectionBoundaries[i+1][0]
		} else {
			curSecEnd = len(sectionText)
		}

		section, err := parseSection(sectionText[idxs[0]:curSecEnd], t.opts)
		if err != nil {
			return fmt.Errorf("failed to parse section while reading textnote: %w", err)
		}

		idx, found := t.sectionIdx[section.name]
		if !found {
			return fmt.Errorf("cannot load undefined section [%s]", section.name)
		}
		t.sections[idx] = section
	}

	return nil
}

func (t *Template) string() string {
	str := t.makeHeader()
	for _, section := range t.sections {
		name := section.getNameString(t.opts.Section.Prefix, t.opts.Section.Suffix)
		body := section.getContentString()
		// default to trailing whitespace for empty body
		if len(body) == 0 {
			body = strings.Repeat("\n", t.opts.Section.TrailingNewlines)
		}
		str += fmt.Sprintf("%s%s", name, body)
	}
	return str
}

func (t *Template) makeHeader() string {
	return fmt.Sprintf("%s%s%s\n%s",
		t.opts.Header.Prefix,
		t.date.Format(t.opts.Header.TimeFormat),
		t.opts.Header.Suffix,
		strings.Repeat("\n", t.opts.Header.TrailingNewlines),
	)
}

func (t *Template) getSection(name string) (*section, error) {
	idx, found := t.sectionIdx[name]
	if !found {
		return &section{}, fmt.Errorf("section [%s] not found", name)
	}
	return t.sections[idx], nil
}

// ParseTemplateFileName extracts a time.Time from a file name and returns an additional
// bool indicating if name corresponds to a valid template file name
func ParseTemplateFileName(fileName string, opts config.FileOpts) (t time.Time, ok bool) {
	// ensure extension matches template file name convention
	ext := filepath.Ext(fileName)
	if ext == "." {
		return t, false
	}
	if strings.TrimPrefix(ext, ".") != opts.Ext {
		return t, false
	}

	baseName := strings.TrimSuffix(fileName, ext)
	t, err := time.Parse(opts.TimeFormat, baseName)
	if err != nil {
		return t, false
	}
	return t, true
}


================================================
FILE: pkg/template/template_test.go
================================================
package template

import (
	"fmt"
	"strings"
	"testing"
	"time"

	"github.com/dkaslovsky/textnote/pkg/config"
	"github.com/dkaslovsky/textnote/pkg/template/templatetest"
	"github.com/stretchr/testify/require"
)

func TestNewTemplate(t *testing.T) {
	type testCase struct {
		sections           []string
		expectedSections   []*section
		expectedSectionIdx map[string]int
	}

	tests := map[string]testCase{
		"no sections": {
			sections:           []string{},
			expectedSections:   []*section{},
			expectedSectionIdx: map[string]int{},
		},
		"single section": {
			sections: []string{
				"section1",
			},
			expectedSections: []*section{
				newSection("section1"),
			},
			expectedSectionIdx: map[string]int{
				"section1": 0,
			},
		},
		"multiple sections": {
			sections: []string{
				"section1",
				"section3",
				"section2",
			},
			expectedSections: []*section{
				newSection("section1"),
				newSection("section3"),
				newSection("section2"),
			},
			expectedSectionIdx: map[string]int{
				"section1": 0,
				"section2": 2,
				"section3": 1,
			},
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			opts := templatetest.GetOpts()
			opts.Section.Names = test.sections
			template := NewTemplate(opts, templatetest.Date)

			require.Equal(t, templatetest.Date, template.date)
			require.Equal(t, test.expectedSections, template.sections)
			require.Equal(t, test.expectedSectionIdx, template.sectionIdx)
		})
	}
}

func TestGetFilePath(t *testing.T) {
	t.Run("get file path with extension", func(t *testing.T) {
		opts := templatetest.GetOpts()
		opts.File.Ext = "txt"
		template := NewTemplate(opts, templatetest.Date)
		filePath := template.GetFilePath()
		require.True(t, strings.HasPrefix(filePath, opts.AppDir))
		require.True(t, strings.HasSuffix(filePath, ".txt"))
		require.Equal(t, templatetest.Date.Format(opts.File.TimeFormat), stripPrefixSuffix(filePath,
			fmt.Sprintf("%s/", opts.AppDir), ".txt"),
		)
	})

	t.Run("get file path without extension", func(t *testing.T) {
		opts := templatetest.GetOpts()
		opts.File.Ext = ""
		template := NewTemplate(opts, templatetest.Date)
		filePath := template.GetFilePath()
		require.True(t, strings.HasPrefix(filePath, opts.AppDir))
		require.False(t, strings.HasSuffix(filePath, "."))
		require.Equal(t, templatetest.Date.Format(opts.File.TimeFormat), stripPrefixSuffix(filePath,
			fmt.Sprintf("%s/", opts.AppDir), ""),
		)
	})
}

func TestCopySectionContents(t *testing.T) {
	type testCase struct {
		sectionName      string
		existingContents []contentItem
		incomingContents []contentItem
	}

	tests := map[string]testCase{
		"copy empty contents into empty section": {
			sectionName:      "TestSection1",
			existingContents: []contentItem{},
			incomingContents: []contentItem{},
		},
		"copy empty contents into populated section": {
			sectionName: "TestSection1",
			existingContents: []contentItem{
				{
					header: "existingHeader",
					text:   "existingText1",
				},
			},
			incomingContents: []contentItem{},
		},
		"copy contents with single element into empty section": {
			sectionName:      "TestSection1",
			existingContents: []contentItem{},
			incomingContents: []contentItem{
				{
					header: "header",
					text:   "text1",
				},
			},
		},
		"copy contents with single element into populated section": {
			sectionName: "TestSection1",
			existingContents: []contentItem{
				{
					header: "existingHeader",
					text:   "existingText1",
				},
			},
			incomingContents: []contentItem{
				{
					header: "header",
					text:   "text1",
				},
			},
		},
		"copy contents with multiple element into empty section": {
			sectionName:      "TestSection1",
			existingContents: []contentItem{},
			incomingContents: []contentItem{
				{
					header: "header1",
					text:   "text1",
				},
				{
					header: "header2",
					text:   "text2",
				},
			},
		},
		"copy contents with multiple elements into populated section": {
			sectionName: "TestSection1",
			existingContents: []contentItem{
				{
					header: "existingHeader",
					text:   "existingText1",
				},
			},
			incomingContents: []contentItem{
				{
					header: "header1",
					text:   "text1",
				},
				{
					header: "header2",
					text:   "text2",
				},
			},
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			opts := templatetest.GetOpts()
			src := NewTemplate(opts, templatetest.Date)
			src.sections[src.sectionIdx[test.sectionName]].contents = test.incomingContents
			template := NewTemplate(opts, templatetest.Date)
			template.sections[template.sectionIdx[test.sectionName]].contents = test.existingContents

			err := template.CopySectionContents(src, test.sectionName)
			require.NoError(t, err)
			for _, content := range test.incomingContents {
				require.Contains(t, template.sections[template.sectionIdx[test.sectionName]].contents, content)
			}
		})
	}
}

func TestCopySectionContentsFail(t *testing.T) {
	t.Run("section does not exist in template", func(t *testing.T) {
		toCopy := "toBeCopied"
		opts := templatetest.GetOpts()
		template := NewTemplate(opts, templatetest.Date)
		src := NewTemplate(opts, templatetest.Date)
		src.sections = append(src.sections, newSection(toCopy))
		src.sectionIdx[toCopy] = len(src.sections) - 1

		err := template.CopySectionContents(src, toCopy)
		require.Error(t, err)
	})

	t.Run("section does not exist in source", func(t *testing.T) {
		toCopy := "toBeCopied"
		opts := templatetest.GetOpts()
		template := NewTemplate(opts, templatetest.Date)
		template.sections = append(template.sections, newSection(toCopy))
		template.sectionIdx[toCopy] = len(template.sections) - 1
		src := NewTemplate(opts, templatetest.Date)

		err := template.CopySectionContents(src, toCopy)
		require.Error(t, err)
	})
}

func TestDeleteSectionContents(t *testing.T) {
	t.Run("delete section with no contents", func(t *testing.T) {
		toDelete := "sectionToBeDeleted"
		template := NewTemplate(templatetest.GetOpts(), templatetest.Date)
		template.sections = append(template.sections, newSection(toDelete))
		template.sectionIdx[toDelete] = len(template.sections) - 1

		err := template.DeleteSectionContents(toDelete)
		require.NoError(t, err)
		require.Empty(t, template.sections[len(template.sections)-1].contents)
	})

	t.Run("delete section with contents", func(t *testing.T) {
		toDelete := "sectionToBeDeleted"
		template := NewTemplate(templatetest.GetOpts(), templatetest.Date)
		template.sections = append(template.sections, newSection(toDelete, contentItem{
			header: "header",
			text:   "text goes here",
		}))
		template.sectionIdx[toDelete] = len(template.sections) - 1

		err := template.DeleteSectionContents(toDelete)
		require.NoError(t, err)
		require.Empty(t, template.sections[len(template.sections)-1].contents)
	})

	t.Run("delete non-existent section", func(t *testing.T) {
		toDelete := "sectionToBeDeleted"
		opts := templatetest.GetOpts()
		template := NewTemplate(opts, templatetest.Date)

		err := template.DeleteSectionContents(toDelete)
		require.Error(t, err)
	})
}

func TestLoad(t *testing.T) {
	type testCase struct {
		text             string
		expectedSections []*section
	}

	tests := map[string]testCase{
		"empty text": {
			text: ``,
			expectedSections: []*section{
				newSection("TestSection1"),
				newSection("TestSection2"),
				newSection("TestSection3"),
			},
		},
		"no sections in text": {
			text: `-^-[Sun] 20 Dec 2020-v-

`,
			expectedSections: []*section{
				newSection("TestSection1"),
				newSection("TestSection2"),
				newSection("TestSection3"),
			},
		},
		"single empty section in text": {
			text: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_`,
			expectedSections: []*section{
				newSection("TestSection1"),
				newSection("TestSection2"),
				newSection("TestSection3"),
			},
		},
		"single empty section with trainling newlines in text": {
			text: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_



`,
			expectedSections: []*section{
				newSection("TestSection1"),
				newSection("TestSection2"),
				newSection("TestSection3"),
			},
		},
		"single empty section with too many trainling newlines in text": {
			text: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_





`,
			expectedSections: []*section{
				newSection("TestSection1"),
				newSection("TestSection2"),
				newSection("TestSection3"),
			},
		},
		"single empty second section in text": {
			text: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection2_q_`,
			expectedSections: []*section{
				newSection("TestSection1"),
				newSection("TestSection2"),
				newSection("TestSection3"),
			},
		},
		"multiple empty sections in text": {
			text: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_
_p_TestSection2_q_
_p_TestSection3_q_`,
			expectedSections: []*section{
				newSection("TestSection1"),
				newSection("TestSection2"),
				newSection("TestSection3"),
			},
		},
		"multiple empty sections with trailing newlines in text": {
			text: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_



_p_TestSection2_q_



_p_TestSection3_q_



`,
			expectedSections: []*section{
				newSection("TestSection1"),
				newSection("TestSection2"),
				newSection("TestSection3"),
			},
		},
		"single section with contents in text": {
			text: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_
text1
  text2


_p_TestSection2_q_
_p_TestSection3_q_
`,
			expectedSections: []*section{
				newSection("TestSection1",
					contentItem{
						header: "",
						text:   "text1\n  text2\n\n\n",
					},
				),
				newSection("TestSection2"),
				newSection("TestSection3"),
			},
		},
		"multiple sections with contents in text": {
			text: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_
text1
  text2


_p_TestSection2_q_
  text3
_p_TestSection3_q_

text4

`,
			expectedSections: []*section{
				newSection("TestSection1",
					contentItem{
						header: "",
						text:   "text1\n  text2\n\n\n",
					},
				),
				newSection("TestSection2",
					contentItem{
						header: "",
						text:   "  text3\n",
					},
				),
				newSection("TestSection3",
					contentItem{
						header: "",
						text:   "\ntext4\n\n",
					}),
			},
		},
		"section with single item header": {
			text: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_
[2020-12-18]
text1a
  text1b


_p_TestSection2_q_
_p_TestSection3_q_
`,
			expectedSections: []*section{
				newSection("TestSection1",
					contentItem{
						header: "[2020-12-18]",
						text:   "text1a\n  text1b\n\n\n",
					},
				),
				newSection("TestSection2"),
				newSection("TestSection3"),
			},
		},
		"section with multiple item headers": {
			text: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_
[2020-12-16]
text1a
  text1b


[2020-12-17]
text1c
[2020-12-18]
_p_TestSection2_q_
_p_TestSection3_q_
`,
			expectedSections: []*section{
				newSection("TestSection1",
					contentItem{
						header: "[2020-12-16]",
						text:   "text1a\n  text1b\n\n",
					},
					contentItem{
						header: "[2020-12-17]",
						text:   "text1c",
					},
					contentItem{
						header: "[2020-12-18]",
						text:   "",
					},
				),
				newSection("TestSection2"),
				newSection("TestSection3"),
			},
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			template := NewTemplate(templatetest.GetOpts(), templatetest.Date)
			err := template.Load(strings.NewReader(test.text))
			require.NoError(t, err)
			for _, expectedSection := range test.expectedSections {
				sec, err := template.getSection(expectedSection.name)
				require.NoError(t, err)
				require.Equal(t, expectedSection, sec)
			}
		})
	}
}

func TestString(t *testing.T) {
	type testCase struct {
		sections []*section
		expected string
	}

	tests := map[string]testCase{
		"empty template": {
			sections: []*section{},
			expected: `-^-[Sun] 20 Dec 2020-v-

`,
		},
		"single empty section": {
			sections: []*section{
				newSection("TestSection1"),
			},
			expected: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_



`,
		},
		"single section with text": {
			sections: []*section{
				newSection("TestSection1",
					contentItem{
						header: "",
						text:   "text",
					},
				),
			},
			expected: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_
text
`,
		},
		"single section with multiline text": {
			sections: []*section{
				newSection("TestSection1",
					contentItem{
						header: "",
						text:   "text1\ntext2\n\n text3text4\n- text5\n\n  -text6\n\n",
					},
				),
			},
			expected: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_
text1
text2

 text3text4
- text5

  -text6

`,
		},
		"single section with text and header": {
			sections: []*section{
				newSection("TestSection1",
					contentItem{
						// in practice a Template will not have sections with headers
						// and as such we expect no formatting to be applied
						header: "header",
						text:   "text",
					},
				),
			},
			expected: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_
header
text
`,
		},
		"single section with multiple contents": {
			sections: []*section{
				newSection("TestSection1",
					contentItem{
						header: "",
						text:   "text1",
					},
					// in practice a Template will not have sections with multiple contents
					contentItem{
						header: "",
						text:   "text2",
					},
				),
			},
			expected: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_
text1
text2
`,
		},
		"multiple empty sections": {
			sections: []*section{
				newSection("TestSection1"),
				newSection("TestSection2"),
				newSection("TestSection3"),
			},
			expected: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_



_p_TestSection2_q_



_p_TestSection3_q_



`,
		},
		"multiple sections with only first populated": {
			sections: []*section{
				newSection("TestSection1",
					contentItem{
						header: "",
						text:   "text",
					},
				),
				newSection("TestSection2"),
				newSection("TestSection3"),
			},
			expected: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_
text
_p_TestSection2_q_



_p_TestSection3_q_



`,
		},
		"multiple sections with only middle populated": {
			sections: []*section{
				newSection("TestSection1"),
				newSection("TestSection2",
					contentItem{
						header: "",
						text:   "text",
					},
				),
				newSection("TestSection3"),
			},
			expected: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_



_p_TestSection2_q_
text
_p_TestSection3_q_



`,
		},
		"multiple sections with only last populated": {
			sections: []*section{
				newSection("TestSection1"),
				newSection("TestSection2"),
				newSection("TestSection3",
					contentItem{
						header: "",
						text:   "text",
					},
				),
			},
			expected: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_



_p_TestSection2_q_



_p_TestSection3_q_
text
`,
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			opts := templatetest.GetOpts()
			names := []string{}
			for _, section := range test.sections {
				names = append(names, section.name)
			}
			opts.Section.Names = names

			template := NewTemplate(opts, templatetest.Date)
			for i, section := range test.sections {
				template.sections[i] = section
			}

			require.Equal(t, test.expected, template.string())
		})
	}
}

func TestParseTemplateFileName(t *testing.T) {
	type testCase struct {
		fileName     string
		opts         config.FileOpts
		expectedTime time.Time
		expectedOk   bool
	}

	tests := map[string]testCase{
		"parsable file name with extension": {
			fileName: "2020-12-29.txt",
			opts: config.FileOpts{
				Ext:        "txt",
				TimeFormat: "2006-01-02",
			},
			expectedTime: time.Date(2020, 12, 29, 0, 0, 0, 0, time.UTC),
			expectedOk:   true,
		},
		"parsable file name with no extension": {
			fileName: "2020-12-29",
			opts: config.FileOpts{
				Ext:        "",
				TimeFormat: "2006-01-02",
			},
			expectedTime: time.Date(2020, 12, 29, 0, 0, 0, 0, time.UTC),
			expectedOk:   true,
		},
		"unparsable file name with extension": {
			fileName: "2020Dec29.txt",
			opts: config.FileOpts{
				Ext:        "txt",
				TimeFormat: "2006-01-02",
			},
			expectedOk: false,
		},
		"unparsable file name with no extension": {
			fileName: "2020Dec29",
			opts: config.FileOpts{
				Ext:        "",
				TimeFormat: "2006-01-02",
			},
			expectedOk: false,
		},
		"parsable file name with mismatched extension": {
			fileName: "2020-12-29.foo",
			opts: config.FileOpts{
				Ext:        "txt",
				TimeFormat: "2006-01-02",
			},
			expectedOk: false,
		},
		"parsable file name with malformed extension and populated config ext": {
			fileName: "2020-12-29.",
			opts: config.FileOpts{
				Ext:        "txt",
				TimeFormat: "2006-01-02",
			},
			expectedOk: false,
		},
		"parsable file name with malformed extension and unpopulated config ext": {
			fileName: "2020-12-29.",
			opts: config.FileOpts{
				Ext:        "",
				TimeFormat: "2006-01-02",
			},
			expectedOk: false,
		},
		"parsable file name with archive prefix": {
			fileName: "archive-2020-12-29.txt",
			opts: config.FileOpts{
				Ext:        "txt",
				TimeFormat: "2006-01-02",
			},
			expectedOk: false,
		},
		"archive file name convention": {
			fileName: "archive-Dec2020.txt",
			opts: config.FileOpts{
				Ext:        "txt",
				TimeFormat: "2006-01-02",
			},
			expectedOk: false,
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			parsedTime, ok := ParseTemplateFileName(test.fileName, test.opts)
			require.Equal(t, test.expectedOk, ok)
			if test.expectedOk {
				require.Equal(t, test.expectedTime, parsedTime)
			}
		})
	}
}

func TestIsEmpty(t *testing.T) {
	type testCase struct {
		templateFile string
		expected     bool
	}

	tests := map[string]testCase{
		"no text": {
			templateFile: ``,
			expected:     true,
		},
		"empty with one section": {
			templateFile: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_

`,
			expected: true,
		},
		"empty with multiple section": {
			templateFile: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_

_p_TestSection2_q_

_p_TestSection3_q_




`,
			expected: true,
		},
		"not empty with text": {
			templateFile: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_

_p_TestSection2_q_
foobar


_p_TestSection3_q_

`,
			expected: false,
		},
		"not empty with whitespace": {
			templateFile: `-^-[Sun] 20 Dec 2020-v-

_p_TestSection1_q_

_p_TestSection2_q_

_p_TestSection3_q_
    `,
			expected: false,
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			template := NewTemplate(templatetest.GetOpts(), templatetest.Date)
			err := template.Load(strings.NewReader(test.templateFile))
			require.NoError(t, err)
			require.Equal(t, test.expected, template.IsEmpty())
		})
	}
}


================================================
FILE: pkg/template/templatetest/templatetest.go
================================================
// Package templatetest provides utilities for template testing
package templatetest

import (
	"fmt"
	"log"
	"time"

	"github.com/dkaslovsky/textnote/pkg/config"
)

// Date is a fixed date - changing this value will affect some tests
var Date = time.Date(2020, 12, 20, 1, 1, 1, 1, time.UTC)

// GetOpts returns a configuration struct for tests - changing these values will affect some tests
func GetOpts() config.Opts {
	opts := config.Opts{
		AppDir: "path/to/app/dir",
		Header: config.HeaderOpts{
			Prefix:           "-^-",
			Suffix:           "-v-",
			TrailingNewlines: 1,
			TimeFormat:       "[Mon] 02 Jan 2006",
		},
		Section: config.SectionOpts{
			Prefix:           "_p_",
			Suffix:           "_q_",
			TrailingNewlines: 3,
			Names: []string{
				"TestSection1",
				"TestSection2",
				"TestSection3",
			},
		},
		File: config.FileOpts{
			Ext:        "txt",
			TimeFormat: "2006-01-02",
			CursorLine: 1,
		},
		Archive: config.ArchiveOpts{
			AfterDays:                7,
			FilePrefix:               "archive-",
			HeaderPrefix:             "ARCHIVEPREFIX ",
			HeaderSuffix:             " ARCHIVESUFFIX",
			SectionContentPrefix:     "[",
			SectionContentSuffix:     "]",
			SectionContentTimeFormat: "2006-01-02",
			MonthTimeFormat:          "Jan2006",
		},
		Cli: config.CliOpts{
			TimeFormat: "2006-01-02",
		},
		TemplateFileCountThresh: 90,
	}

	err := config.ValidateOpts(opts)
	if err != nil {
		log.Fatal(err)
	}
	return opts
}

// MakeItemHeader is a helper to construct a header property of a contentItem struct
func MakeItemHeader(date time.Time, opts config.Opts) string {
	return fmt.Sprintf("%s%s%s",
		opts.Archive.SectionContentPrefix,
		date.Format(opts.Archive.SectionContentTimeFormat),
		opts.Archive.SectionContentSuffix,
	)
}
Download .txt
gitextract_a03jodqx/

├── .github/
│   └── workflows/
│       └── release.yml
├── .gitignore
├── .goreleaser.yml
├── .travis.yml
├── CHANGELOG.md
├── CREDITS
├── LICENSE
├── Makefile
├── README.md
├── cmd/
│   ├── archive/
│   │   └── archive.go
│   ├── config/
│   │   └── config.go
│   ├── initialize/
│   │   └── initialize.go
│   ├── open/
│   │   ├── open.go
│   │   └── open_test.go
│   └── root.go
├── go.mod
├── go.sum
├── main.go
└── pkg/
    ├── archive/
    │   ├── archive.go
    │   └── archive_test.go
    ├── config/
    │   ├── config.go
    │   └── config_test.go
    ├── editor/
    │   └── editor.go
    ├── file/
    │   └── file.go
    └── template/
        ├── archive.go
        ├── archive_test.go
        ├── section.go
        ├── section_test.go
        ├── template.go
        ├── template_test.go
        └── templatetest/
            └── templatetest.go
Download .txt
SYMBOL INDEX (147 symbols across 20 files)

FILE: cmd/archive/archive.go
  type commandOptions (line 16) | type commandOptions struct
  function CreateArchiveCmd (line 23) | func CreateArchiveCmd() *cobra.Command {
  function attachOpts (line 42) | func attachOpts(cmd *cobra.Command, cmdOpts *commandOptions) {
  function run (line 49) | func run(templateOpts config.Opts, cmdOpts commandOptions) error {

FILE: cmd/config/config.go
  type commandOptions (line 14) | type commandOptions struct
  function CreateConfigCmd (line 21) | func CreateConfigCmd() *cobra.Command {
  function attachOpts (line 48) | func attachOpts(cmd *cobra.Command, cmdOpts *commandOptions) {
  function CreateConfigUpdateCmd (line 56) | func CreateConfigUpdateCmd() *cobra.Command {
  function displayConfigFile (line 72) | func displayConfigFile(configPath string) error {
  function displayActiveConfig (line 89) | func displayActiveConfig() error {
  function getActiveConfigYaml (line 98) | func getActiveConfigYaml() ([]byte, error) {

FILE: cmd/initialize/initialize.go
  function CreateInitCmd (line 10) | func CreateInitCmd() *cobra.Command {

FILE: cmd/open/open.go
  constant day (line 19) | day = 24 * time.Hour
  type commandOptions (line 21) | type commandOptions struct
  function CreateOpenCmd (line 40) | func CreateOpenCmd() *cobra.Command {
  function attachOpts (line 70) | func attachOpts(cmd *cobra.Command, cmdOpts *commandOptions) {
  function setDateOpt (line 87) | func setDateOpt(cmdOpts *commandOptions, templateOpts config.Opts, getFi...
  function setCopyDateOpt (line 141) | func setCopyDateOpt(cmdOpts *commandOptions, templateOpts config.Opts, g...
  function setDeleteOpts (line 173) | func setDeleteOpts(cmdOpts *commandOptions) {
  function run (line 178) | func run(templateOpts config.Opts, cmdOpts commandOptions) error {
  function copySections (line 255) | func copySections(src *template.Template, tgt *template.Template, sectio...
  function deleteSections (line 265) | func deleteSections(t *template.Template, sectionNames []string) error {
  function openInEditor (line 275) | func openInEditor(t *template.Template, ed *editor.Editor) error {
  function getLatestTemplateFile (line 285) | func getLatestTemplateFile(files []string, now time.Time, opts config.Fi...
  function getDirFiles (line 310) | func getDirFiles(dir string) ([]string, error) {
  function warnTooManyTemplateFiles (line 328) | func warnTooManyTemplateFiles(n int, thresh int) {
  function max (line 334) | func max(i, j int) int {

FILE: cmd/open/open_test.go
  function TestGetLatestTemplateFile (line 11) | func TestGetLatestTemplateFile(t *testing.T) {
  function TestSetDateOpt (line 97) | func TestSetDateOpt(t *testing.T) {
  function TestSetCopyDateOpt (line 285) | func TestSetCopyDateOpt(t *testing.T) {
  function TestSetDeleteOpts (line 380) | func TestSetDeleteOpts(t *testing.T) {

FILE: cmd/root.go
  function Run (line 16) | func Run(name string, version string) error {
  function setVersion (line 41) | func setVersion(cmd *cobra.Command, version string) {
  function setHelp (line 53) | func setHelp(cmd *cobra.Command, name string) {

FILE: main.go
  constant name (line 10) | name = "textnote"
  function main (line 14) | func main() {

FILE: pkg/archive/archive.go
  type Archiver (line 14) | type Archiver struct
    method Add (line 38) | func (a *Archiver) Add(date time.Time) error {
    method Write (line 68) | func (a *Archiver) Write() error {
    method GetArchivedFiles (line 92) | func (a *Archiver) GetArchivedFiles() []string {
  function NewArchiver (line 26) | func NewArchiver(opts config.Opts, rw readWriter, date time.Time) *Archi...
  type readWriter (line 97) | type readWriter interface

FILE: pkg/archive/archive_test.go
  type testReadWriter (line 21) | type testReadWriter struct
    method Read (line 35) | func (trw *testReadWriter) Read(rwable file.ReadWriteable) error {
    method Overwrite (line 40) | func (trw *testReadWriter) Overwrite(rwable file.ReadWriteable) error {
    method Exists (line 50) | func (trw *testReadWriter) Exists(rwable file.ReadWriteable) bool {
  function newTestReadWriter (line 27) | func newTestReadWriter(exists bool, toRead string) *testReadWriter {
  function TestAdd (line 58) | func TestAdd(t *testing.T) {
  function TestWrite (line 423) | func TestWrite(t *testing.T) {

FILE: pkg/config/config.go
  constant envAppDir (line 18) | envAppDir = "TEXTNOTE_DIR"
  constant fileName (line 20) | fileName = ".config.yml"
  type Opts (line 27) | type Opts struct
  type HeaderOpts (line 38) | type HeaderOpts struct
  type SectionOpts (line 46) | type SectionOpts struct
  type FileOpts (line 54) | type FileOpts struct
  type ArchiveOpts (line 61) | type ArchiveOpts struct
  type CliOpts (line 73) | type CliOpts struct
  type OptsBackCompat (line 79) | type OptsBackCompat struct
  function getDefaultOpts (line 84) | func getDefaultOpts() Opts {
  function Load (line 125) | func Load() (Opts, error) {
  function loadFromEnv (line 152) | func loadFromEnv(path string, opts *Opts) error {
  function loadBackCompat (line 166) | func loadBackCompat(path string, opts *Opts) error {
  function CreateIfNotExists (line 181) | func CreateIfNotExists() error {
  function EnsureAppDir (line 203) | func EnsureAppDir() error {
  function ValidateOpts (line 225) | func ValidateOpts(opts Opts) error {
  function DescribeEnvVars (line 274) | func DescribeEnvVars() string {
  function GetConfigFilePath (line 284) | func GetConfigFilePath() string {
  function InitApp (line 289) | func InitApp() error {

FILE: pkg/config/config_test.go
  function TestValidateOpts (line 9) | func TestValidateOpts(t *testing.T) {
  function getTestOpts (line 140) | func getTestOpts() Opts {

FILE: pkg/editor/editor.go
  constant EnvEditor (line 10) | EnvEditor = "EDITOR"
  constant editorNameEmacs (line 13) | editorNameEmacs  = "emacs"
  constant editorNameNano (line 14) | editorNameNano   = "nano"
  constant editorNameNeovim (line 15) | editorNameNeovim = "nvim"
  constant editorNameVi (line 16) | editorNameVi     = "vi"
  constant editorNameVim (line 17) | editorNameVim    = "vim"
  type openable (line 21) | type openable interface
  type Editor (line 27) | type Editor struct
    method Open (line 37) | func (e *Editor) Open(o openable) error {
  function GetEditor (line 47) | func GetEditor(name string) *Editor {

FILE: pkg/file/file.go
  type ReadWriteable (line 9) | type ReadWriteable interface
  type ReadWriter (line 16) | type ReadWriter struct
    method Read (line 24) | func (rw *ReadWriter) Read(rwable ReadWriteable) error {
    method Overwrite (line 34) | func (rw *ReadWriter) Overwrite(rwable ReadWriteable) error {
    method Exists (line 49) | func (rw *ReadWriter) Exists(rwable ReadWriteable) bool {
  function NewReadWriter (line 19) | func NewReadWriter() *ReadWriter {

FILE: pkg/template/archive.go
  type MonthArchiveTemplate (line 15) | type MonthArchiveTemplate struct
    method Write (line 29) | func (t *MonthArchiveTemplate) Write(w io.Writer) error {
    method GetFilePath (line 35) | func (t *MonthArchiveTemplate) GetFilePath() string {
    method ArchiveSectionContents (line 48) | func (t *MonthArchiveTemplate) ArchiveSectionContents(src *Template, s...
    method Merge (line 76) | func (t *MonthArchiveTemplate) Merge(src *MonthArchiveTemplate) error {
    method string (line 86) | func (t *MonthArchiveTemplate) string() string {
    method makeHeader (line 100) | func (t *MonthArchiveTemplate) makeHeader() string {
    method makeContentHeader (line 109) | func (t *MonthArchiveTemplate) makeContentHeader(date time.Time) string {
  function NewMonthArchiveTemplate (line 20) | func NewMonthArchiveTemplate(opts config.Opts, date time.Time) *MonthArc...
  function isArchiveItemHeader (line 118) | func isArchiveItemHeader(line string, prefix string, suffix string, form...

FILE: pkg/template/archive_test.go
  function TestNewMonthArchiveTemplate (line 13) | func TestNewMonthArchiveTemplate(t *testing.T) {
  function TestArchiveGetFilePath (line 42) | func TestArchiveGetFilePath(t *testing.T) {
  function TestArchiveSectionContents (line 70) | func TestArchiveSectionContents(t *testing.T) {
  function TestArchiveSectionContentsFail (line 266) | func TestArchiveSectionContentsFail(t *testing.T) {
  function TestArchiveString (line 292) | func TestArchiveString(t *testing.T) {
  function TestIsArchiveItemHeader (line 555) | func TestIsArchiveItemHeader(t *testing.T) {

FILE: pkg/template/section.go
  type section (line 14) | type section struct
    method deleteContents (line 27) | func (s *section) deleteContents() {
    method sortContents (line 31) | func (s *section) sortContents() {
    method isEmpty (line 38) | func (s *section) isEmpty() bool {
    method getNameString (line 47) | func (s *section) getNameString(prefix string, suffix string) string {
    method getContentString (line 51) | func (s *section) getContentString() string {
  function newSection (line 20) | func newSection(name string, items ...contentItem) *section {
  type contentItem (line 63) | type contentItem struct
    method string (line 68) | func (ci contentItem) string() string {
    method isEmpty (line 75) | func (ci contentItem) isEmpty() bool {
  function parseSection (line 81) | func parseSection(text string, opts config.Opts) (*section, error) {
  function parseSectionContents (line 105) | func parseSectionContents(lines []string, prefix string, suffix string, ...
  function stripPrefixSuffix (line 148) | func stripPrefixSuffix(line string, prefix string, suffix string) string {
  function getSectionNameRegex (line 152) | func getSectionNameRegex(prefix string, suffix string) (*regexp.Regexp, ...

FILE: pkg/template/section_test.go
  function TestGetNameString (line 13) | func TestGetNameString(t *testing.T) {
  function TestGetContentString (line 69) | func TestGetContentString(t *testing.T) {
  function TestParseSectionContents (line 188) | func TestParseSectionContents(t *testing.T) {
  function TestSectionIsEmpty (line 329) | func TestSectionIsEmpty(t *testing.T) {
  function TestContentItemIsEmpty (line 439) | func TestContentItemIsEmpty(t *testing.T) {

FILE: pkg/template/template.go
  type Template (line 14) | type Template struct
    method Write (line 37) | func (t *Template) Write(w io.Writer) error {
    method GetDate (line 43) | func (t *Template) GetDate() time.Time {
    method GetFileCursorLine (line 48) | func (t *Template) GetFileCursorLine() int {
    method GetFilePath (line 53) | func (t *Template) GetFilePath() string {
    method CopySectionContents (line 68) | func (t *Template) CopySectionContents(src sectionGettable, sectionNam...
    method DeleteSectionContents (line 82) | func (t *Template) DeleteSectionContents(sectionName string) error {
    method IsEmpty (line 92) | func (t *Template) IsEmpty() bool {
    method Load (line 102) | func (t *Template) Load(r io.Reader) error {
    method string (line 142) | func (t *Template) string() string {
    method makeHeader (line 156) | func (t *Template) makeHeader() string {
    method getSection (line 165) | func (t *Template) getSection(name string) (*section, error) {
  function NewTemplate (line 22) | func NewTemplate(opts config.Opts, date time.Time) *Template {
  type sectionGettable (line 62) | type sectionGettable interface
  function ParseTemplateFileName (line 175) | func ParseTemplateFileName(fileName string, opts config.FileOpts) (t tim...

FILE: pkg/template/template_test.go
  function TestNewTemplate (line 14) | func TestNewTemplate(t *testing.T) {
  function TestGetFilePath (line 70) | func TestGetFilePath(t *testing.T) {
  function TestCopySectionContents (line 96) | func TestCopySectionContents(t *testing.T) {
  function TestCopySectionContentsFail (line 196) | func TestCopySectionContentsFail(t *testing.T) {
  function TestDeleteSectionContents (line 222) | func TestDeleteSectionContents(t *testing.T) {
  function TestLoad (line 258) | func TestLoad(t *testing.T) {
  function TestString (line 497) | func TestString(t *testing.T) {
  function TestParseTemplateFileName (line 716) | func TestParseTemplateFileName(t *testing.T) {
  function TestIsEmpty (line 812) | func TestIsEmpty(t *testing.T) {

FILE: pkg/template/templatetest/templatetest.go
  function GetOpts (line 16) | func GetOpts() config.Opts {
  function MakeItemHeader (line 64) | func MakeItemHeader(date time.Time, opts config.Opts) string {
Condensed preview — 31 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (203K chars).
[
  {
    "path": ".github/workflows/release.yml",
    "chars": 639,
    "preview": "name: goreleaser\n\non:\n    push:\n        tags:\n            - 'v*.*.*'\n\npermissions:\n  contents: write  \n\njobs:\n  goreleas"
  },
  {
    "path": ".gitignore",
    "chars": 301,
    "preview": "# 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# Ou"
  },
  {
    "path": ".goreleaser.yml",
    "chars": 352,
    "preview": "builds:\n  - env:\n      - CGO_ENABLED=0\n    ldflags:\n      - -s -w -X main.version={{.Version}}\n    goos:\n      - darwin\n"
  },
  {
    "path": ".travis.yml",
    "chars": 379,
    "preview": "language: go\n\nenv:\n  - GO111MODULE=on TEXTNOTE_DIR=/tmp\n\ngo:\n  - 1.16.x\n\nbranches:\n  except:\n  - /^(?i:dev)\\/.*$/\n\nbefor"
  },
  {
    "path": "CHANGELOG.md",
    "chars": 1162,
    "preview": "## 1.3.0 / 2021-06-19\n\n* [ADDED] Second `--delete`/`-x` flag deletes source file left empty after moving section(s)\n\n## "
  },
  {
    "path": "CREDITS",
    "chars": 41780,
    "preview": "Go (the standard library)\nhttps://golang.org/\n----------------------------------------------------------------\nCopyright"
  },
  {
    "path": "LICENSE",
    "chars": 1073,
    "preview": "MIT License\n\nCopyright (c) 2021 Daniel Kaslovsky\n\nPermission is hereby granted, free of charge, to any person obtaining "
  },
  {
    "path": "Makefile",
    "chars": 1188,
    "preview": "PROJ := \"$(notdir $(shell pwd))\"\nBRANCH := \"$(shell git rev-parse --abbrev-ref HEAD)\"\nSTATUS := \"$(shell git status -s)\""
  },
  {
    "path": "README.md",
    "chars": 18199,
    "preview": "# textnote\nSimple tool for creating and organizing daily notes on the command line\n\n[![Build Status](https://travis-ci.c"
  },
  {
    "path": "cmd/archive/archive.go",
    "chars": 2756,
    "preview": "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/dkaslo"
  },
  {
    "path": "cmd/config/config.go",
    "chars": 2594,
    "preview": "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/cobr"
  },
  {
    "path": "cmd/initialize/initialize.go",
    "chars": 444,
    "preview": "package initialize\n\nimport (\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/dkaslovsky/textnote/pkg/config\"\n)\n\n// CreateInitCmd"
  },
  {
    "path": "cmd/open/open.go",
    "chars": 9852,
    "preview": "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\"gi"
  },
  {
    "path": "cmd/open/open_test.go",
    "chars": 10414,
    "preview": "package open\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/dkaslovsky/textnote/pkg/template/templatetest\"\n\t\"github.com/stre"
  },
  {
    "path": "cmd/root.go",
    "chars": 1667,
    "preview": "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/"
  },
  {
    "path": "go.mod",
    "chars": 625,
    "preview": "module github.com/dkaslovsky/textnote\n\ngo 1.21.4\n\nrequire (\n\tdario.cat/mergo v1.0.0\n\tgithub.com/ilyakaznacheev/cleanenv "
  },
  {
    "path": "go.sum",
    "chars": 2646,
    "preview": "dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=\ndario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobSt"
  },
  {
    "path": "main.go",
    "chars": 350,
    "preview": "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\ncon"
  },
  {
    "path": "pkg/archive/archive.go",
    "chars": 2909,
    "preview": "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/te"
  },
  {
    "path": "pkg/archive/archive_test.go",
    "chars": 8914,
    "preview": "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/"
  },
  {
    "path": "pkg/config/config.go",
    "chars": 10539,
    "preview": "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"
  },
  {
    "path": "pkg/config/config_test.go",
    "chars": 3428,
    "preview": "package config\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestValidateOpts(t *testing.T) {\n\tt."
  },
  {
    "path": "pkg/editor/editor.go",
    "chars": 2466,
    "preview": "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 "
  },
  {
    "path": "pkg/file/file.go",
    "chars": 1124,
    "preview": "package file\n\nimport (\n\t\"io\"\n\t\"os\"\n)\n\n// ReadWriteable is the interface on which file operations are executed\ntype ReadW"
  },
  {
    "path": "pkg/template/archive.go",
    "chars": 3740,
    "preview": "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/"
  },
  {
    "path": "pkg/template/archive_test.go",
    "chars": 14423,
    "preview": "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/templatet"
  },
  {
    "path": "pkg/template/section.go",
    "chars": 3723,
    "preview": "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.co"
  },
  {
    "path": "pkg/template/section_test.go",
    "chars": 10669,
    "preview": "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/templatet"
  },
  {
    "path": "pkg/template/template.go",
    "chars": 5272,
    "preview": "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"
  },
  {
    "path": "pkg/template/template_test.go",
    "chars": 18575,
    "preview": "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.c"
  },
  {
    "path": "pkg/template/templatetest/templatetest.go",
    "chars": 1773,
    "preview": "// Package templatetest provides utilities for template testing\npackage templatetest\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"time\"\n\n\t\""
  }
]

About this extraction

This page contains the full source code of the dkaslovsky/textnote GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 31 files (179.7 KB), approximately 49.4k tokens, and a symbol index with 147 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!