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
[](https://travis-ci.com/github/dkaslovsky/textnote)
[](https://coveralls.io/github/dkaslovsky/textnote?branch=main)
[](https://goreportcard.com/report/github.com/dkaslovsky/textnote)
[](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 §ion{
name: name,
contents: items,
}
}
func (s *section) deleteContents() {
s.contents = []contentItem{}
}
func (s *section) sortContents() {
// stable sort to preserve order for empty header case
sort.SliceStable(s.contents, func(i, j int) bool {
return s.contents[i].header < s.contents[j].header
})
}
func (s *section) isEmpty() bool {
for _, content := range s.contents {
if !content.isEmpty() {
return false
}
}
return true
}
func (s *section) getNameString(prefix string, suffix string) string {
return fmt.Sprintf("%s%s%s\n", prefix, s.name, suffix)
}
func (s *section) getContentString() string {
str := ""
for _, content := range s.contents {
txt := content.string()
if !strings.HasSuffix(txt, "\n") {
txt += "\n"
}
str += txt
}
return str
}
type contentItem struct {
header string
text string
}
func (ci contentItem) string() string {
if ci.header != "" {
return fmt.Sprintf("%s\n%s", ci.header, ci.text)
}
return ci.text
}
func (ci contentItem) isEmpty() bool {
// exclude trailing newlines for empty content check
strippedTxt := strings.Replace(ci.text, "\n", "", -1)
return len(strippedTxt) == 0
}
func parseSection(text string, opts config.Opts) (*section, error) {
if len(text) == 0 {
return nil, errors.New("cannot parse Section from empty input")
}
lines := strings.Split(text, "\n")
name := stripPrefixSuffix(lines[0], opts.Section.Prefix, opts.Section.Suffix)
contents := parseSectionContents(
lines[1:],
opts.Archive.SectionContentPrefix,
opts.Archive.SectionContentSuffix,
opts.File.TimeFormat,
)
// return section populated with contents if any contentItem is non-empty
for _, content := range contents {
if !content.isEmpty() {
return newSection(name, contents...), nil
}
}
// all contents are empty so return unpopulated section
return newSection(name), nil
}
func parseSectionContents(lines []string, prefix string, suffix string, format string) []contentItem {
contents := []contentItem{}
if len(lines) == 0 {
return contents
}
// parse first line
line := lines[0]
header := ""
body := []string{}
if isArchiveItemHeader(line, prefix, suffix, format) {
header = line
} else {
body = append(body, line)
}
for _, line := range lines[1:] {
// if the line is a header it indicates new contents, so "flush" (append) the current
// header/body and start tracking the new contents
if isArchiveItemHeader(line, prefix, suffix, format) {
contents = append(contents, contentItem{
header: header,
text: strings.Join(body, "\n"),
})
header = line
body = []string{}
continue
}
body = append(body, line)
}
// ensure remaining content is appended
if len(body) != 0 || header != "" {
contents = append(contents, contentItem{
header: header,
text: strings.Join(body, "\n"),
})
}
return contents
}
func stripPrefixSuffix(line string, prefix string, suffix string) string {
return strings.TrimPrefix(strings.TrimSuffix(line, suffix), prefix)
}
func getSectionNameRegex(prefix string, suffix string) (*regexp.Regexp, error) {
sectionPattern := fmt.Sprintf("%s.*%s", prefix, suffix)
sectionNameRegex, err := regexp.Compile(sectionPattern)
if err != nil {
return sectionNameRegex, fmt.Errorf("invalid section prefix [%s] or suffix [%s]", prefix, suffix)
}
return sectionNameRegex, nil
}
================================================
FILE: pkg/template/section_test.go
================================================
package template
import (
"fmt"
"strings"
"testing"
"time"
"github.com/dkaslovsky/textnote/pkg/template/templatetest"
"github.com/stretchr/testify/require"
)
func TestGetNameString(t *testing.T) {
type testCase struct {
name string
prefix string
suffix string
expected string
}
tests := map[string]testCase{
"empty name, empty prefix and suffix": {
name: "",
prefix: "",
suffix: "",
expected: "\n",
},
"empty name, non-empty prefix and suffix": {
name: "",
prefix: "p ",
suffix: " s",
expected: "p s\n",
},
"non-empty name, empty prefix and suffix": {
name: "name",
prefix: "",
suffix: "",
expected: "name\n",
},
"non-empty name, non-empty prefix and suffix": {
name: "name",
prefix: "p ",
suffix: " s",
expected: "p name s\n",
},
"non-empty name with spaces, non-empty prefix and suffix": {
name: " na me ",
prefix: "p ",
suffix: " s",
expected: "p na me s\n",
},
"non-empty name with newlines, non-empty prefix and suffix": {
name: " na \n me ",
prefix: "p ",
suffix: " s",
expected: "p na \n me s\n",
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
s := newSection(test.name)
str := s.getNameString(test.prefix, test.suffix)
require.Equal(t, test.expected, str)
})
}
}
func TestGetContentString(t *testing.T) {
type testCase struct {
contents []contentItem
expected string
}
tests := map[string]testCase{
"empty contents": {
contents: []contentItem{},
expected: "",
},
"single empty string contents with no header": {
contents: []contentItem{
{
header: "",
text: "",
},
},
expected: "\n",
},
"single empty string contents with header": {
contents: []contentItem{
{
header: "header",
text: "",
},
},
expected: "header\n",
},
"multiple empty string contents with no header": {
contents: []contentItem{
{
header: "",
text: "",
},
{
header: "",
text: "",
},
},
expected: "\n\n",
},
"multiple empty string contents with header": {
contents: []contentItem{
{
header: "header1",
text: "",
},
{
header: "header2",
text: "",
},
},
expected: "header1\nheader2\n",
},
"single nonempty contents with no header missing trailing newline": {
contents: []contentItem{
{
header: "",
text: "text\n goes\n here",
},
},
expected: "text\n goes\n here\n",
},
"single nonempty contents with no header": {
contents: []contentItem{
{
header: "",
text: "text\n goes\n here\n",
},
},
expected: "text\n goes\n here\n",
},
"single nonempty contents with header": {
contents: []contentItem{
{
header: "header",
text: "text\n goes\n here\n",
},
},
expected: "header\ntext\n goes\n here\n",
},
"multiple nonempty contents with no headers": {
contents: []contentItem{
{
header: "",
text: "text\n goes\n here\n",
},
{
header: "",
text: "text2\n goes2\n here2 \n",
},
},
expected: "text\n goes\n here\ntext2\n goes2\n here2 \n",
},
"multiple nonempty contents with headers": {
contents: []contentItem{
{
header: "header1 ",
text: "text\n goes\n here\n",
},
{
header: " header2",
text: "text2\n goes2\n here2 \n",
},
},
expected: "header1 \ntext\n goes\n here\n header2\ntext2\n goes2\n here2 \n",
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
s := newSection("name", test.contents...)
str := s.getContentString()
require.Equal(t, test.expected, str)
})
}
}
func TestParseSectionContents(t *testing.T) {
type testCase struct {
lines []string
expected []contentItem
}
tests := map[string]testCase{
"empty lines": {
lines: []string{},
expected: []contentItem{},
},
"single empty string line": {
lines: []string{""},
expected: []contentItem{
{},
},
},
"lines with no header": {
lines: strings.Split("hello\n world", "\n"),
expected: []contentItem{
{
header: "",
text: "hello\n world",
},
},
},
"lines with no header with newline at start and end": {
lines: strings.Split("\n\nhello\n world\n\n", "\n"),
expected: []contentItem{
{
header: "",
text: "\n\nhello\n world\n\n",
},
},
},
"lines with single header": {
lines: strings.Split(
fmt.Sprintf("%s\nhello\n world", templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts())),
"\n",
),
expected: []contentItem{
{
header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
text: "hello\n world",
},
},
},
"lines with single header with newline at start and end": {
lines: strings.Split(
fmt.Sprintf("\n%s\n\nhello\n world\n", templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts())),
"\n",
),
expected: []contentItem{
{},
{
header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
text: "\nhello\n world\n",
},
},
},
"lines with multiple headers": {
lines: strings.Split(
fmt.Sprintf("%s\nhello\n world\n%s\nhello2\n world2",
templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
),
"\n",
),
expected: []contentItem{
{
header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
text: "hello\n world",
},
{
header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
text: "hello2\n world2",
},
},
},
"lines with multiple headers with newline at start and end": {
lines: strings.Split(
fmt.Sprintf("\n%s\nhello\n\n world\n\n\n\n%s\nhello2\n world2\n\n\n\n\n",
templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
),
"\n",
),
expected: []contentItem{
{},
{
header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
text: "hello\n\n world\n\n\n",
},
{
header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
text: "hello2\n world2\n\n\n\n\n",
},
},
},
"header with no text": {
lines: strings.Split(
templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
"\n",
),
expected: []contentItem{
{
header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
text: "",
},
},
},
"multiple headers with no text": {
lines: strings.Split(
fmt.Sprintf("%s\n%s",
templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
),
"\n",
),
expected: []contentItem{
{
header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()),
text: "",
},
{
header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()),
text: "",
},
},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
opts := templatetest.GetOpts()
contents := parseSectionContents(test.lines, opts.Archive.SectionContentPrefix, opts.Archive.SectionContentSuffix, opts.File.TimeFormat)
require.Equal(t, test.expected, contents)
})
}
}
func TestSectionIsEmpty(t *testing.T) {
type testCase struct {
contents []contentItem
expected bool
}
tests := map[string]testCase{
"empty contents": {
contents: []contentItem{},
expected: true,
},
"single content with only newlines and empty header": {
contents: []contentItem{
{
header: "",
text: "\n\n\n",
},
},
expected: true,
},
"single content with only newlines and populated header": {
contents: []contentItem{
{
header: "header",
text: "\n\n\n",
},
},
expected: true,
},
"multiple contents with only newlines and empty headers": {
contents: []contentItem{
{
header: "",
text: "\n\n\n",
},
{
header: "",
text: "\n",
},
},
expected: true,
},
"multiple contents with only newlines and populated headers": {
contents: []contentItem{
{
header: "header1",
text: "\n\n\n",
},
{
header: "header2",
text: "\n",
},
},
expected: true,
},
"single content with text and no header": {
contents: []contentItem{
{
header: "",
text: "\n\nfoo\n",
},
},
expected: false,
},
"single content with text and populated header": {
contents: []contentItem{
{
header: "header",
text: "\n\nfoo\n",
},
},
expected: false,
},
"multiple contents with text and no headers": {
contents: []contentItem{
{
header: "",
text: "\n\nfoo\n",
},
{
header: "",
text: "bar",
},
},
expected: false,
},
"multiple contents with text and populated headers": {
contents: []contentItem{
{
header: "header1",
text: "\n\nfoo\n",
},
{
header: "header2",
text: "bar",
},
},
expected: false,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
s := newSection("name", test.contents...)
val := s.isEmpty()
require.Equal(t, test.expected, val)
})
}
}
func TestContentItemIsEmpty(t *testing.T) {
type testCase struct {
item contentItem
expected bool
}
tests := map[string]testCase{
"empty": {
item: contentItem{},
expected: true,
},
"only newlines and empty header": {
item: contentItem{
header: "",
text: "\n\n\n",
},
expected: true,
},
"only newlines and populated header": {
item: contentItem{
header: "header",
text: "\n\n\n",
},
expected: true,
},
"text and no header": {
item: contentItem{
header: "",
text: "\n\nfoo\n",
},
expected: false,
},
"text and populated header": {
item: contentItem{
header: "header",
text: "\n\nfoo\n",
},
expected: false,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
val := test.item.isEmpty()
require.Equal(t, test.expected, val)
})
}
}
================================================
FILE: pkg/template/template.go
================================================
package template
import (
"fmt"
"io"
"path/filepath"
"strings"
"time"
"github.com/dkaslovsky/textnote/pkg/config"
)
// Template contains the structure of a note
type Template struct {
opts config.Opts
date time.Time
sections []*section
sectionIdx map[string]int // map of section name to index in sections slice
}
// NewTemplate constructs a new Template
func NewTemplate(opts config.Opts, date time.Time) *Template {
t := &Template{
opts: opts,
date: date,
sections: []*section{},
sectionIdx: map[string]int{},
}
for idx, sectionName := range opts.Section.Names {
t.sections = append(t.sections, newSection(sectionName))
t.sectionIdx[sectionName] = idx
}
return t
}
// Write writes the template
func (t *Template) Write(w io.Writer) error {
_, err := w.Write([]byte(t.string()))
return err
}
// GetDate returns the template's date
func (t *Template) GetDate() time.Time {
return t.date
}
// GetFileCursorLine returns the line at which to place the cursor when opening the template
func (t *Template) GetFileCursorLine() int {
return t.opts.File.CursorLine
}
// GetFilePath generates a full path for a file based on the template date
func (t *Template) GetFilePath() string {
name := filepath.Join(t.opts.AppDir, t.date.Format(t.opts.File.TimeFormat))
if t.opts.File.Ext == "" {
return name
}
return fmt.Sprintf("%s.%s", name, t.opts.File.Ext)
}
// sectionGettable is the interface for getting a section
type sectionGettable interface {
getSection(string) (*section, error)
}
// CopySectionContents copies the contents of the specified section from a source template by
// appending to the contents of the receiver's section
func (t *Template) CopySectionContents(src sectionGettable, sectionName string) error {
tgtSec, err := t.getSection(sectionName)
if err != nil {
return fmt.Errorf("failed to find section in target: %w", err)
}
srcSec, err := src.getSection(sectionName)
if err != nil {
return fmt.Errorf("failed to find section in source: %w", err)
}
tgtSec.contents = append(tgtSec.contents, srcSec.contents...)
return nil
}
// DeleteSectionContents deletes the contents of a specified section
func (t *Template) DeleteSectionContents(sectionName string) error {
sec, err := t.getSection(sectionName)
if err != nil {
return fmt.Errorf("cannot delete section: %w", err)
}
sec.deleteContents()
return nil
}
// IsEmpty evaluates if a template is empty (ignores whitespace)
func (t *Template) IsEmpty() bool {
for _, sec := range t.sections {
if !sec.isEmpty() {
return false
}
}
return true
}
// Load populates a Template from the contents of a reader
func (t *Template) Load(r io.Reader) error {
raw, err := io.ReadAll(r)
if err != nil {
return fmt.Errorf("error loading template: %w", err)
}
sectionText := string(raw)
sectionNameRegex, err := getSectionNameRegex(t.opts.Section.Prefix, t.opts.Section.Suffix)
if err != nil {
return fmt.Errorf("cannot parse sections: %w", err)
}
sectionBoundaries := sectionNameRegex.FindAllStringSubmatchIndex(sectionText, -1)
numSections := len(sectionBoundaries)
// extract sections from sectionText
for i, idxs := range sectionBoundaries {
var curSecEnd int
// end of current section is marked by the beginning of the next section
// if current section is not the last section
if i != numSections-1 {
curSecEnd = sectionBoundaries[i+1][0]
} else {
curSecEnd = len(sectionText)
}
section, err := parseSection(sectionText[idxs[0]:curSecEnd], t.opts)
if err != nil {
return fmt.Errorf("failed to parse section while reading textnote: %w", err)
}
idx, found := t.sectionIdx[section.name]
if !found {
return fmt.Errorf("cannot load undefined section [%s]", section.name)
}
t.sections[idx] = section
}
return nil
}
func (t *Template) string() string {
str := t.makeHeader()
for _, section := range t.sections {
name := section.getNameString(t.opts.Section.Prefix, t.opts.Section.Suffix)
body := section.getContentString()
// default to trailing whitespace for empty body
if len(body) == 0 {
body = strings.Repeat("\n", t.opts.Section.TrailingNewlines)
}
str += fmt.Sprintf("%s%s", name, body)
}
return str
}
func (t *Template) makeHeader() string {
return fmt.Sprintf("%s%s%s\n%s",
t.opts.Header.Prefix,
t.date.Format(t.opts.Header.TimeFormat),
t.opts.Header.Suffix,
strings.Repeat("\n", t.opts.Header.TrailingNewlines),
)
}
func (t *Template) getSection(name string) (*section, error) {
idx, found := t.sectionIdx[name]
if !found {
return §ion{}, fmt.Errorf("section [%s] not found", name)
}
return t.sections[idx], nil
}
// ParseTemplateFileName extracts a time.Time from a file name and returns an additional
// bool indicating if name corresponds to a valid template file name
func ParseTemplateFileName(fileName string, opts config.FileOpts) (t time.Time, ok bool) {
// ensure extension matches template file name convention
ext := filepath.Ext(fileName)
if ext == "." {
return t, false
}
if strings.TrimPrefix(ext, ".") != opts.Ext {
return t, false
}
baseName := strings.TrimSuffix(fileName, ext)
t, err := time.Parse(opts.TimeFormat, baseName)
if err != nil {
return t, false
}
return t, true
}
================================================
FILE: pkg/template/template_test.go
================================================
package template
import (
"fmt"
"strings"
"testing"
"time"
"github.com/dkaslovsky/textnote/pkg/config"
"github.com/dkaslovsky/textnote/pkg/template/templatetest"
"github.com/stretchr/testify/require"
)
func TestNewTemplate(t *testing.T) {
type testCase struct {
sections []string
expectedSections []*section
expectedSectionIdx map[string]int
}
tests := map[string]testCase{
"no sections": {
sections: []string{},
expectedSections: []*section{},
expectedSectionIdx: map[string]int{},
},
"single section": {
sections: []string{
"section1",
},
expectedSections: []*section{
newSection("section1"),
},
expectedSectionIdx: map[string]int{
"section1": 0,
},
},
"multiple sections": {
sections: []string{
"section1",
"section3",
"section2",
},
expectedSections: []*section{
newSection("section1"),
newSection("section3"),
newSection("section2"),
},
expectedSectionIdx: map[string]int{
"section1": 0,
"section2": 2,
"section3": 1,
},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
opts := templatetest.GetOpts()
opts.Section.Names = test.sections
template := NewTemplate(opts, templatetest.Date)
require.Equal(t, templatetest.Date, template.date)
require.Equal(t, test.expectedSections, template.sections)
require.Equal(t, test.expectedSectionIdx, template.sectionIdx)
})
}
}
func TestGetFilePath(t *testing.T) {
t.Run("get file path with extension", func(t *testing.T) {
opts := templatetest.GetOpts()
opts.File.Ext = "txt"
template := NewTemplate(opts, templatetest.Date)
filePath := template.GetFilePath()
require.True(t, strings.HasPrefix(filePath, opts.AppDir))
require.True(t, strings.HasSuffix(filePath, ".txt"))
require.Equal(t, templatetest.Date.Format(opts.File.TimeFormat), stripPrefixSuffix(filePath,
fmt.Sprintf("%s/", opts.AppDir), ".txt"),
)
})
t.Run("get file path without extension", func(t *testing.T) {
opts := templatetest.GetOpts()
opts.File.Ext = ""
template := NewTemplate(opts, templatetest.Date)
filePath := template.GetFilePath()
require.True(t, strings.HasPrefix(filePath, opts.AppDir))
require.False(t, strings.HasSuffix(filePath, "."))
require.Equal(t, templatetest.Date.Format(opts.File.TimeFormat), stripPrefixSuffix(filePath,
fmt.Sprintf("%s/", opts.AppDir), ""),
)
})
}
func TestCopySectionContents(t *testing.T) {
type testCase struct {
sectionName string
existingContents []contentItem
incomingContents []contentItem
}
tests := map[string]testCase{
"copy empty contents into empty section": {
sectionName: "TestSection1",
existingContents: []contentItem{},
incomingContents: []contentItem{},
},
"copy empty contents into populated section": {
sectionName: "TestSection1",
existingContents: []contentItem{
{
header: "existingHeader",
text: "existingText1",
},
},
incomingContents: []contentItem{},
},
"copy contents with single element into empty section": {
sectionName: "TestSection1",
existingContents: []contentItem{},
incomingContents: []contentItem{
{
header: "header",
text: "text1",
},
},
},
"copy contents with single element into populated section": {
sectionName: "TestSection1",
existingContents: []contentItem{
{
header: "existingHeader",
text: "existingText1",
},
},
incomingContents: []contentItem{
{
header: "header",
text: "text1",
},
},
},
"copy contents with multiple element into empty section": {
sectionName: "TestSection1",
existingContents: []contentItem{},
incomingContents: []contentItem{
{
header: "header1",
text: "text1",
},
{
header: "header2",
text: "text2",
},
},
},
"copy contents with multiple elements into populated section": {
sectionName: "TestSection1",
existingContents: []contentItem{
{
header: "existingHeader",
text: "existingText1",
},
},
incomingContents: []contentItem{
{
header: "header1",
text: "text1",
},
{
header: "header2",
text: "text2",
},
},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
opts := templatetest.GetOpts()
src := NewTemplate(opts, templatetest.Date)
src.sections[src.sectionIdx[test.sectionName]].contents = test.incomingContents
template := NewTemplate(opts, templatetest.Date)
template.sections[template.sectionIdx[test.sectionName]].contents = test.existingContents
err := template.CopySectionContents(src, test.sectionName)
require.NoError(t, err)
for _, content := range test.incomingContents {
require.Contains(t, template.sections[template.sectionIdx[test.sectionName]].contents, content)
}
})
}
}
func TestCopySectionContentsFail(t *testing.T) {
t.Run("section does not exist in template", func(t *testing.T) {
toCopy := "toBeCopied"
opts := templatetest.GetOpts()
template := NewTemplate(opts, templatetest.Date)
src := NewTemplate(opts, templatetest.Date)
src.sections = append(src.sections, newSection(toCopy))
src.sectionIdx[toCopy] = len(src.sections) - 1
err := template.CopySectionContents(src, toCopy)
require.Error(t, err)
})
t.Run("section does not exist in source", func(t *testing.T) {
toCopy := "toBeCopied"
opts := templatetest.GetOpts()
template := NewTemplate(opts, templatetest.Date)
template.sections = append(template.sections, newSection(toCopy))
template.sectionIdx[toCopy] = len(template.sections) - 1
src := NewTemplate(opts, templatetest.Date)
err := template.CopySectionContents(src, toCopy)
require.Error(t, err)
})
}
func TestDeleteSectionContents(t *testing.T) {
t.Run("delete section with no contents", func(t *testing.T) {
toDelete := "sectionToBeDeleted"
template := NewTemplate(templatetest.GetOpts(), templatetest.Date)
template.sections = append(template.sections, newSection(toDelete))
template.sectionIdx[toDelete] = len(template.sections) - 1
err := template.DeleteSectionContents(toDelete)
require.NoError(t, err)
require.Empty(t, template.sections[len(template.sections)-1].contents)
})
t.Run("delete section with contents", func(t *testing.T) {
toDelete := "sectionToBeDeleted"
template := NewTemplate(templatetest.GetOpts(), templatetest.Date)
template.sections = append(template.sections, newSection(toDelete, contentItem{
header: "header",
text: "text goes here",
}))
template.sectionIdx[toDelete] = len(template.sections) - 1
err := template.DeleteSectionContents(toDelete)
require.NoError(t, err)
require.Empty(t, template.sections[len(template.sections)-1].contents)
})
t.Run("delete non-existent section", func(t *testing.T) {
toDelete := "sectionToBeDeleted"
opts := templatetest.GetOpts()
template := NewTemplate(opts, templatetest.Date)
err := template.DeleteSectionContents(toDelete)
require.Error(t, err)
})
}
func TestLoad(t *testing.T) {
type testCase struct {
text string
expectedSections []*section
}
tests := map[string]testCase{
"empty text": {
text: ``,
expectedSections: []*section{
newSection("TestSection1"),
newSection("TestSection2"),
newSection("TestSection3"),
},
},
"no sections in text": {
text: `-^-[Sun] 20 Dec 2020-v-
`,
expectedSections: []*section{
newSection("TestSection1"),
newSection("TestSection2"),
newSection("TestSection3"),
},
},
"single empty section in text": {
text: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_`,
expectedSections: []*section{
newSection("TestSection1"),
newSection("TestSection2"),
newSection("TestSection3"),
},
},
"single empty section with trainling newlines in text": {
text: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_
`,
expectedSections: []*section{
newSection("TestSection1"),
newSection("TestSection2"),
newSection("TestSection3"),
},
},
"single empty section with too many trainling newlines in text": {
text: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_
`,
expectedSections: []*section{
newSection("TestSection1"),
newSection("TestSection2"),
newSection("TestSection3"),
},
},
"single empty second section in text": {
text: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection2_q_`,
expectedSections: []*section{
newSection("TestSection1"),
newSection("TestSection2"),
newSection("TestSection3"),
},
},
"multiple empty sections in text": {
text: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_
_p_TestSection2_q_
_p_TestSection3_q_`,
expectedSections: []*section{
newSection("TestSection1"),
newSection("TestSection2"),
newSection("TestSection3"),
},
},
"multiple empty sections with trailing newlines in text": {
text: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_
_p_TestSection2_q_
_p_TestSection3_q_
`,
expectedSections: []*section{
newSection("TestSection1"),
newSection("TestSection2"),
newSection("TestSection3"),
},
},
"single section with contents in text": {
text: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_
text1
text2
_p_TestSection2_q_
_p_TestSection3_q_
`,
expectedSections: []*section{
newSection("TestSection1",
contentItem{
header: "",
text: "text1\n text2\n\n\n",
},
),
newSection("TestSection2"),
newSection("TestSection3"),
},
},
"multiple sections with contents in text": {
text: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_
text1
text2
_p_TestSection2_q_
text3
_p_TestSection3_q_
text4
`,
expectedSections: []*section{
newSection("TestSection1",
contentItem{
header: "",
text: "text1\n text2\n\n\n",
},
),
newSection("TestSection2",
contentItem{
header: "",
text: " text3\n",
},
),
newSection("TestSection3",
contentItem{
header: "",
text: "\ntext4\n\n",
}),
},
},
"section with single item header": {
text: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_
[2020-12-18]
text1a
text1b
_p_TestSection2_q_
_p_TestSection3_q_
`,
expectedSections: []*section{
newSection("TestSection1",
contentItem{
header: "[2020-12-18]",
text: "text1a\n text1b\n\n\n",
},
),
newSection("TestSection2"),
newSection("TestSection3"),
},
},
"section with multiple item headers": {
text: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_
[2020-12-16]
text1a
text1b
[2020-12-17]
text1c
[2020-12-18]
_p_TestSection2_q_
_p_TestSection3_q_
`,
expectedSections: []*section{
newSection("TestSection1",
contentItem{
header: "[2020-12-16]",
text: "text1a\n text1b\n\n",
},
contentItem{
header: "[2020-12-17]",
text: "text1c",
},
contentItem{
header: "[2020-12-18]",
text: "",
},
),
newSection("TestSection2"),
newSection("TestSection3"),
},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
template := NewTemplate(templatetest.GetOpts(), templatetest.Date)
err := template.Load(strings.NewReader(test.text))
require.NoError(t, err)
for _, expectedSection := range test.expectedSections {
sec, err := template.getSection(expectedSection.name)
require.NoError(t, err)
require.Equal(t, expectedSection, sec)
}
})
}
}
func TestString(t *testing.T) {
type testCase struct {
sections []*section
expected string
}
tests := map[string]testCase{
"empty template": {
sections: []*section{},
expected: `-^-[Sun] 20 Dec 2020-v-
`,
},
"single empty section": {
sections: []*section{
newSection("TestSection1"),
},
expected: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_
`,
},
"single section with text": {
sections: []*section{
newSection("TestSection1",
contentItem{
header: "",
text: "text",
},
),
},
expected: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_
text
`,
},
"single section with multiline text": {
sections: []*section{
newSection("TestSection1",
contentItem{
header: "",
text: "text1\ntext2\n\n text3text4\n- text5\n\n -text6\n\n",
},
),
},
expected: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_
text1
text2
text3text4
- text5
-text6
`,
},
"single section with text and header": {
sections: []*section{
newSection("TestSection1",
contentItem{
// in practice a Template will not have sections with headers
// and as such we expect no formatting to be applied
header: "header",
text: "text",
},
),
},
expected: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_
header
text
`,
},
"single section with multiple contents": {
sections: []*section{
newSection("TestSection1",
contentItem{
header: "",
text: "text1",
},
// in practice a Template will not have sections with multiple contents
contentItem{
header: "",
text: "text2",
},
),
},
expected: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_
text1
text2
`,
},
"multiple empty sections": {
sections: []*section{
newSection("TestSection1"),
newSection("TestSection2"),
newSection("TestSection3"),
},
expected: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_
_p_TestSection2_q_
_p_TestSection3_q_
`,
},
"multiple sections with only first populated": {
sections: []*section{
newSection("TestSection1",
contentItem{
header: "",
text: "text",
},
),
newSection("TestSection2"),
newSection("TestSection3"),
},
expected: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_
text
_p_TestSection2_q_
_p_TestSection3_q_
`,
},
"multiple sections with only middle populated": {
sections: []*section{
newSection("TestSection1"),
newSection("TestSection2",
contentItem{
header: "",
text: "text",
},
),
newSection("TestSection3"),
},
expected: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_
_p_TestSection2_q_
text
_p_TestSection3_q_
`,
},
"multiple sections with only last populated": {
sections: []*section{
newSection("TestSection1"),
newSection("TestSection2"),
newSection("TestSection3",
contentItem{
header: "",
text: "text",
},
),
},
expected: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_
_p_TestSection2_q_
_p_TestSection3_q_
text
`,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
opts := templatetest.GetOpts()
names := []string{}
for _, section := range test.sections {
names = append(names, section.name)
}
opts.Section.Names = names
template := NewTemplate(opts, templatetest.Date)
for i, section := range test.sections {
template.sections[i] = section
}
require.Equal(t, test.expected, template.string())
})
}
}
func TestParseTemplateFileName(t *testing.T) {
type testCase struct {
fileName string
opts config.FileOpts
expectedTime time.Time
expectedOk bool
}
tests := map[string]testCase{
"parsable file name with extension": {
fileName: "2020-12-29.txt",
opts: config.FileOpts{
Ext: "txt",
TimeFormat: "2006-01-02",
},
expectedTime: time.Date(2020, 12, 29, 0, 0, 0, 0, time.UTC),
expectedOk: true,
},
"parsable file name with no extension": {
fileName: "2020-12-29",
opts: config.FileOpts{
Ext: "",
TimeFormat: "2006-01-02",
},
expectedTime: time.Date(2020, 12, 29, 0, 0, 0, 0, time.UTC),
expectedOk: true,
},
"unparsable file name with extension": {
fileName: "2020Dec29.txt",
opts: config.FileOpts{
Ext: "txt",
TimeFormat: "2006-01-02",
},
expectedOk: false,
},
"unparsable file name with no extension": {
fileName: "2020Dec29",
opts: config.FileOpts{
Ext: "",
TimeFormat: "2006-01-02",
},
expectedOk: false,
},
"parsable file name with mismatched extension": {
fileName: "2020-12-29.foo",
opts: config.FileOpts{
Ext: "txt",
TimeFormat: "2006-01-02",
},
expectedOk: false,
},
"parsable file name with malformed extension and populated config ext": {
fileName: "2020-12-29.",
opts: config.FileOpts{
Ext: "txt",
TimeFormat: "2006-01-02",
},
expectedOk: false,
},
"parsable file name with malformed extension and unpopulated config ext": {
fileName: "2020-12-29.",
opts: config.FileOpts{
Ext: "",
TimeFormat: "2006-01-02",
},
expectedOk: false,
},
"parsable file name with archive prefix": {
fileName: "archive-2020-12-29.txt",
opts: config.FileOpts{
Ext: "txt",
TimeFormat: "2006-01-02",
},
expectedOk: false,
},
"archive file name convention": {
fileName: "archive-Dec2020.txt",
opts: config.FileOpts{
Ext: "txt",
TimeFormat: "2006-01-02",
},
expectedOk: false,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
parsedTime, ok := ParseTemplateFileName(test.fileName, test.opts)
require.Equal(t, test.expectedOk, ok)
if test.expectedOk {
require.Equal(t, test.expectedTime, parsedTime)
}
})
}
}
func TestIsEmpty(t *testing.T) {
type testCase struct {
templateFile string
expected bool
}
tests := map[string]testCase{
"no text": {
templateFile: ``,
expected: true,
},
"empty with one section": {
templateFile: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_
`,
expected: true,
},
"empty with multiple section": {
templateFile: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_
_p_TestSection2_q_
_p_TestSection3_q_
`,
expected: true,
},
"not empty with text": {
templateFile: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_
_p_TestSection2_q_
foobar
_p_TestSection3_q_
`,
expected: false,
},
"not empty with whitespace": {
templateFile: `-^-[Sun] 20 Dec 2020-v-
_p_TestSection1_q_
_p_TestSection2_q_
_p_TestSection3_q_
`,
expected: false,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
template := NewTemplate(templatetest.GetOpts(), templatetest.Date)
err := template.Load(strings.NewReader(test.templateFile))
require.NoError(t, err)
require.Equal(t, test.expected, template.IsEmpty())
})
}
}
================================================
FILE: pkg/template/templatetest/templatetest.go
================================================
// Package templatetest provides utilities for template testing
package templatetest
import (
"fmt"
"log"
"time"
"github.com/dkaslovsky/textnote/pkg/config"
)
// Date is a fixed date - changing this value will affect some tests
var Date = time.Date(2020, 12, 20, 1, 1, 1, 1, time.UTC)
// GetOpts returns a configuration struct for tests - changing these values will affect some tests
func GetOpts() config.Opts {
opts := config.Opts{
AppDir: "path/to/app/dir",
Header: config.HeaderOpts{
Prefix: "-^-",
Suffix: "-v-",
TrailingNewlines: 1,
TimeFormat: "[Mon] 02 Jan 2006",
},
Section: config.SectionOpts{
Prefix: "_p_",
Suffix: "_q_",
TrailingNewlines: 3,
Names: []string{
"TestSection1",
"TestSection2",
"TestSection3",
},
},
File: config.FileOpts{
Ext: "txt",
TimeFormat: "2006-01-02",
CursorLine: 1,
},
Archive: config.ArchiveOpts{
AfterDays: 7,
FilePrefix: "archive-",
HeaderPrefix: "ARCHIVEPREFIX ",
HeaderSuffix: " ARCHIVESUFFIX",
SectionContentPrefix: "[",
SectionContentSuffix: "]",
SectionContentTimeFormat: "2006-01-02",
MonthTimeFormat: "Jan2006",
},
Cli: config.CliOpts{
TimeFormat: "2006-01-02",
},
TemplateFileCountThresh: 90,
}
err := config.ValidateOpts(opts)
if err != nil {
log.Fatal(err)
}
return opts
}
// MakeItemHeader is a helper to construct a header property of a contentItem struct
func MakeItemHeader(date time.Time, opts config.Opts) string {
return fmt.Sprintf("%s%s%s",
opts.Archive.SectionContentPrefix,
date.Format(opts.Archive.SectionContentTimeFormat),
opts.Archive.SectionContentSuffix,
)
}
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
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[\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.