├── .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 /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - 16 | name: Checkout 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - 21 | name: Set up Go 22 | uses: actions/setup-go@v4 23 | with: 24 | go-version-file: 'go.mod' 25 | - 26 | name: Run GoReleaser 27 | uses: goreleaser/goreleaser-action@v5 28 | with: 29 | distribution: goreleaser 30 | version: latest 31 | args: release --clean 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Directory for binaries 18 | dist/ 19 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - env: 3 | - CGO_ENABLED=0 4 | ldflags: 5 | - -s -w -X main.version={{.Version}} 6 | goos: 7 | - darwin 8 | - linux 9 | - windows 10 | goarch: 11 | - amd64 12 | - arm64 13 | ignore: 14 | - goos: windows 15 | goarch: arm64 16 | changelog: 17 | skip: true 18 | archives: 19 | - format: binary 20 | name_template: "{{.Binary}}_{{.Os}}_{{.Arch}}" 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | env: 4 | - GO111MODULE=on TEXTNOTE_DIR=/tmp 5 | 6 | go: 7 | - 1.16.x 8 | 9 | branches: 10 | except: 11 | - /^(?i:dev)\/.*$/ 12 | 13 | before_install: 14 | - go get github.com/modocache/gover 15 | - go get github.com/mattn/goveralls 16 | 17 | script: 18 | - go test -v github.com/dkaslovsky/textnote/... -coverprofile=all.coverprofile 19 | - gover 20 | - goveralls -race -coverprofile gover.coverprofile -service travis-ci 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.3.0 / 2021-06-19 2 | 3 | * [ADDED] Second `--delete`/`-x` flag deletes source file left empty after moving section(s) 4 | 5 | ## 1.2.0 / 2021-04-26 6 | 7 | * [ADDED] Flag to open most recently dated ("latest") note 8 | * [ADDED] Configurable threshold for warning user of too many template files 9 | * [ADDED] Flags to display configuration file contents (`-f`) and active configuration (`-a`) 10 | * [ADDED] `update` subcommand for `config` command to overwrite configuration file with active configuration 11 | * [ADDED] `init` command to more cleanly initialize textnote application directories and files 12 | * [FIXED] Copy command defaults to latest note instead of potentially nonexistent note from previous day 13 | * [INTERNAL] Upgraded to Go 1.16 14 | * [INTERNAL] Deprecated use of `io/ioutil` 15 | 16 | ## 1.1.1 / 2021-02-28 17 | 18 | * [FIXED] Fall back on defaults for parameters missing from configuration file 19 | * [FIXED] Warning for unsupported editor configuration for cursorLine > 1 20 | 21 | ## 1.1.0 / 2021-02-16 22 | 23 | * [ADDED] Use $EDITOR environment variable for opening notes 24 | * [ADDED] Add support for vi/vim, nano, neovim, and emacs for using `file.cursorLine` config parameter 25 | 26 | ## 1.0.0 / 2021-02-09 27 | 28 | * Initial release 29 | -------------------------------------------------------------------------------- /CREDITS: -------------------------------------------------------------------------------- 1 | Go (the standard library) 2 | https://golang.org/ 3 | ---------------------------------------------------------------- 4 | Copyright (c) 2009 The Go Authors. All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are 8 | met: 9 | 10 | * Redistributions of source code must retain the above copyright 11 | notice, this list of conditions and the following disclaimer. 12 | * Redistributions in binary form must reproduce the above 13 | copyright notice, this list of conditions and the following disclaimer 14 | in the documentation and/or other materials provided with the 15 | distribution. 16 | * Neither the name of Google Inc. nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | ================================================================ 33 | 34 | dario.cat/mergo 35 | https://dario.cat/mergo 36 | ---------------------------------------------------------------- 37 | Copyright (c) 2013 Dario Castañé. All rights reserved. 38 | Copyright (c) 2012 The Go Authors. All rights reserved. 39 | 40 | Redistribution and use in source and binary forms, with or without 41 | modification, are permitted provided that the following conditions are 42 | met: 43 | 44 | * Redistributions of source code must retain the above copyright 45 | notice, this list of conditions and the following disclaimer. 46 | * Redistributions in binary form must reproduce the above 47 | copyright notice, this list of conditions and the following disclaimer 48 | in the documentation and/or other materials provided with the 49 | distribution. 50 | * Neither the name of Google Inc. nor the names of its 51 | contributors may be used to endorse or promote products derived from 52 | this software without specific prior written permission. 53 | 54 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 55 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 56 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 57 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 58 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 59 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 60 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 61 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 62 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 63 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 64 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 65 | 66 | ================================================================ 67 | 68 | github.com/BurntSushi/toml 69 | https://github.com/BurntSushi/toml 70 | ---------------------------------------------------------------- 71 | The MIT License (MIT) 72 | 73 | Copyright (c) 2013 TOML authors 74 | 75 | Permission is hereby granted, free of charge, to any person obtaining a copy 76 | of this software and associated documentation files (the "Software"), to deal 77 | in the Software without restriction, including without limitation the rights 78 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 79 | copies of the Software, and to permit persons to whom the Software is 80 | furnished to do so, subject to the following conditions: 81 | 82 | The above copyright notice and this permission notice shall be included in 83 | all copies or substantial portions of the Software. 84 | 85 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 86 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 87 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 88 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 89 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 90 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 91 | THE SOFTWARE. 92 | 93 | ================================================================ 94 | 95 | github.com/davecgh/go-spew 96 | https://github.com/davecgh/go-spew 97 | ---------------------------------------------------------------- 98 | ISC License 99 | 100 | Copyright (c) 2012-2016 Dave Collins 101 | 102 | Permission to use, copy, modify, and/or distribute this software for any 103 | purpose with or without fee is hereby granted, provided that the above 104 | copyright notice and this permission notice appear in all copies. 105 | 106 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 107 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 108 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 109 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 110 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 111 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 112 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 113 | 114 | ================================================================ 115 | 116 | github.com/ilyakaznacheev/cleanenv 117 | https://github.com/ilyakaznacheev/cleanenv 118 | ---------------------------------------------------------------- 119 | MIT License 120 | 121 | Copyright (c) 2019 Ilya Kaznacheev 122 | 123 | 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: 124 | 125 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 126 | 127 | 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. 128 | ================================================================ 129 | 130 | github.com/inconshreveable/mousetrap 131 | https://github.com/inconshreveable/mousetrap 132 | ---------------------------------------------------------------- 133 | Apache License 134 | Version 2.0, January 2004 135 | http://www.apache.org/licenses/ 136 | 137 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 138 | 139 | 1. Definitions. 140 | 141 | "License" shall mean the terms and conditions for use, reproduction, 142 | and distribution as defined by Sections 1 through 9 of this document. 143 | 144 | "Licensor" shall mean the copyright owner or entity authorized by 145 | the copyright owner that is granting the License. 146 | 147 | "Legal Entity" shall mean the union of the acting entity and all 148 | other entities that control, are controlled by, or are under common 149 | control with that entity. For the purposes of this definition, 150 | "control" means (i) the power, direct or indirect, to cause the 151 | direction or management of such entity, whether by contract or 152 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 153 | outstanding shares, or (iii) beneficial ownership of such entity. 154 | 155 | "You" (or "Your") shall mean an individual or Legal Entity 156 | exercising permissions granted by this License. 157 | 158 | "Source" form shall mean the preferred form for making modifications, 159 | including but not limited to software source code, documentation 160 | source, and configuration files. 161 | 162 | "Object" form shall mean any form resulting from mechanical 163 | transformation or translation of a Source form, including but 164 | not limited to compiled object code, generated documentation, 165 | and conversions to other media types. 166 | 167 | "Work" shall mean the work of authorship, whether in Source or 168 | Object form, made available under the License, as indicated by a 169 | copyright notice that is included in or attached to the work 170 | (an example is provided in the Appendix below). 171 | 172 | "Derivative Works" shall mean any work, whether in Source or Object 173 | form, that is based on (or derived from) the Work and for which the 174 | editorial revisions, annotations, elaborations, or other modifications 175 | represent, as a whole, an original work of authorship. For the purposes 176 | of this License, Derivative Works shall not include works that remain 177 | separable from, or merely link (or bind by name) to the interfaces of, 178 | the Work and Derivative Works thereof. 179 | 180 | "Contribution" shall mean any work of authorship, including 181 | the original version of the Work and any modifications or additions 182 | to that Work or Derivative Works thereof, that is intentionally 183 | submitted to Licensor for inclusion in the Work by the copyright owner 184 | or by an individual or Legal Entity authorized to submit on behalf of 185 | the copyright owner. For the purposes of this definition, "submitted" 186 | means any form of electronic, verbal, or written communication sent 187 | to the Licensor or its representatives, including but not limited to 188 | communication on electronic mailing lists, source code control systems, 189 | and issue tracking systems that are managed by, or on behalf of, the 190 | Licensor for the purpose of discussing and improving the Work, but 191 | excluding communication that is conspicuously marked or otherwise 192 | designated in writing by the copyright owner as "Not a Contribution." 193 | 194 | "Contributor" shall mean Licensor and any individual or Legal Entity 195 | on behalf of whom a Contribution has been received by Licensor and 196 | subsequently incorporated within the Work. 197 | 198 | 2. Grant of Copyright License. Subject to the terms and conditions of 199 | this License, each Contributor hereby grants to You a perpetual, 200 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 201 | copyright license to reproduce, prepare Derivative Works of, 202 | publicly display, publicly perform, sublicense, and distribute the 203 | Work and such Derivative Works in Source or Object form. 204 | 205 | 3. Grant of Patent License. Subject to the terms and conditions of 206 | this License, each Contributor hereby grants to You a perpetual, 207 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 208 | (except as stated in this section) patent license to make, have made, 209 | use, offer to sell, sell, import, and otherwise transfer the Work, 210 | where such license applies only to those patent claims licensable 211 | by such Contributor that are necessarily infringed by their 212 | Contribution(s) alone or by combination of their Contribution(s) 213 | with the Work to which such Contribution(s) was submitted. If You 214 | institute patent litigation against any entity (including a 215 | cross-claim or counterclaim in a lawsuit) alleging that the Work 216 | or a Contribution incorporated within the Work constitutes direct 217 | or contributory patent infringement, then any patent licenses 218 | granted to You under this License for that Work shall terminate 219 | as of the date such litigation is filed. 220 | 221 | 4. Redistribution. You may reproduce and distribute copies of the 222 | Work or Derivative Works thereof in any medium, with or without 223 | modifications, and in Source or Object form, provided that You 224 | meet the following conditions: 225 | 226 | (a) You must give any other recipients of the Work or 227 | Derivative Works a copy of this License; and 228 | 229 | (b) You must cause any modified files to carry prominent notices 230 | stating that You changed the files; and 231 | 232 | (c) You must retain, in the Source form of any Derivative Works 233 | that You distribute, all copyright, patent, trademark, and 234 | attribution notices from the Source form of the Work, 235 | excluding those notices that do not pertain to any part of 236 | the Derivative Works; and 237 | 238 | (d) If the Work includes a "NOTICE" text file as part of its 239 | distribution, then any Derivative Works that You distribute must 240 | include a readable copy of the attribution notices contained 241 | within such NOTICE file, excluding those notices that do not 242 | pertain to any part of the Derivative Works, in at least one 243 | of the following places: within a NOTICE text file distributed 244 | as part of the Derivative Works; within the Source form or 245 | documentation, if provided along with the Derivative Works; or, 246 | within a display generated by the Derivative Works, if and 247 | wherever such third-party notices normally appear. The contents 248 | of the NOTICE file are for informational purposes only and 249 | do not modify the License. You may add Your own attribution 250 | notices within Derivative Works that You distribute, alongside 251 | or as an addendum to the NOTICE text from the Work, provided 252 | that such additional attribution notices cannot be construed 253 | as modifying the License. 254 | 255 | You may add Your own copyright statement to Your modifications and 256 | may provide additional or different license terms and conditions 257 | for use, reproduction, or distribution of Your modifications, or 258 | for any such Derivative Works as a whole, provided Your use, 259 | reproduction, and distribution of the Work otherwise complies with 260 | the conditions stated in this License. 261 | 262 | 5. Submission of Contributions. Unless You explicitly state otherwise, 263 | any Contribution intentionally submitted for inclusion in the Work 264 | by You to the Licensor shall be under the terms and conditions of 265 | this License, without any additional terms or conditions. 266 | Notwithstanding the above, nothing herein shall supersede or modify 267 | the terms of any separate license agreement you may have executed 268 | with Licensor regarding such Contributions. 269 | 270 | 6. Trademarks. This License does not grant permission to use the trade 271 | names, trademarks, service marks, or product names of the Licensor, 272 | except as required for reasonable and customary use in describing the 273 | origin of the Work and reproducing the content of the NOTICE file. 274 | 275 | 7. Disclaimer of Warranty. Unless required by applicable law or 276 | agreed to in writing, Licensor provides the Work (and each 277 | Contributor provides its Contributions) on an "AS IS" BASIS, 278 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 279 | implied, including, without limitation, any warranties or conditions 280 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 281 | PARTICULAR PURPOSE. You are solely responsible for determining the 282 | appropriateness of using or redistributing the Work and assume any 283 | risks associated with Your exercise of permissions under this License. 284 | 285 | 8. Limitation of Liability. In no event and under no legal theory, 286 | whether in tort (including negligence), contract, or otherwise, 287 | unless required by applicable law (such as deliberate and grossly 288 | negligent acts) or agreed to in writing, shall any Contributor be 289 | liable to You for damages, including any direct, indirect, special, 290 | incidental, or consequential damages of any character arising as a 291 | result of this License or out of the use or inability to use the 292 | Work (including but not limited to damages for loss of goodwill, 293 | work stoppage, computer failure or malfunction, or any and all 294 | other commercial damages or losses), even if such Contributor 295 | has been advised of the possibility of such damages. 296 | 297 | 9. Accepting Warranty or Additional Liability. While redistributing 298 | the Work or Derivative Works thereof, You may choose to offer, 299 | and charge a fee for, acceptance of support, warranty, indemnity, 300 | or other liability obligations and/or rights consistent with this 301 | License. However, in accepting such obligations, You may act only 302 | on Your own behalf and on Your sole responsibility, not on behalf 303 | of any other Contributor, and only if You agree to indemnify, 304 | defend, and hold each Contributor harmless for any liability 305 | incurred by, or claims asserted against, such Contributor by reason 306 | of your accepting any such warranty or additional liability. 307 | 308 | END OF TERMS AND CONDITIONS 309 | 310 | APPENDIX: How to apply the Apache License to your work. 311 | 312 | To apply the Apache License to your work, attach the following 313 | boilerplate notice, with the fields enclosed by brackets "[]" 314 | replaced with your own identifying information. (Don't include 315 | the brackets!) The text should be enclosed in the appropriate 316 | comment syntax for the file format. We also recommend that a 317 | file or class name and description of purpose be included on the 318 | same "printed page" as the copyright notice for easier 319 | identification within third-party archives. 320 | 321 | Copyright 2022 Alan Shreve (@inconshreveable) 322 | 323 | Licensed under the Apache License, Version 2.0 (the "License"); 324 | you may not use this file except in compliance with the License. 325 | You may obtain a copy of the License at 326 | 327 | http://www.apache.org/licenses/LICENSE-2.0 328 | 329 | Unless required by applicable law or agreed to in writing, software 330 | distributed under the License is distributed on an "AS IS" BASIS, 331 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 332 | See the License for the specific language governing permissions and 333 | limitations under the License. 334 | 335 | ================================================================ 336 | 337 | github.com/joho/godotenv 338 | https://github.com/joho/godotenv 339 | ---------------------------------------------------------------- 340 | Copyright (c) 2013 John Barton 341 | 342 | MIT License 343 | 344 | Permission is hereby granted, free of charge, to any person obtaining 345 | a copy of this software and associated documentation files (the 346 | "Software"), to deal in the Software without restriction, including 347 | without limitation the rights to use, copy, modify, merge, publish, 348 | distribute, sublicense, and/or sell copies of the Software, and to 349 | permit persons to whom the Software is furnished to do so, subject to 350 | the following conditions: 351 | 352 | The above copyright notice and this permission notice shall be 353 | included in all copies or substantial portions of the Software. 354 | 355 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 356 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 357 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 358 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 359 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 360 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 361 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 362 | 363 | 364 | ================================================================ 365 | 366 | github.com/pkg/errors 367 | https://github.com/pkg/errors 368 | ---------------------------------------------------------------- 369 | Copyright (c) 2015, Dave Cheney 370 | All rights reserved. 371 | 372 | Redistribution and use in source and binary forms, with or without 373 | modification, are permitted provided that the following conditions are met: 374 | 375 | * Redistributions of source code must retain the above copyright notice, this 376 | list of conditions and the following disclaimer. 377 | 378 | * Redistributions in binary form must reproduce the above copyright notice, 379 | this list of conditions and the following disclaimer in the documentation 380 | and/or other materials provided with the distribution. 381 | 382 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 383 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 384 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 385 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 386 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 387 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 388 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 389 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 390 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 391 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 392 | 393 | ================================================================ 394 | 395 | github.com/pmezard/go-difflib 396 | https://github.com/pmezard/go-difflib 397 | ---------------------------------------------------------------- 398 | Copyright (c) 2013, Patrick Mezard 399 | All rights reserved. 400 | 401 | Redistribution and use in source and binary forms, with or without 402 | modification, are permitted provided that the following conditions are 403 | met: 404 | 405 | Redistributions of source code must retain the above copyright 406 | notice, this list of conditions and the following disclaimer. 407 | Redistributions in binary form must reproduce the above copyright 408 | notice, this list of conditions and the following disclaimer in the 409 | documentation and/or other materials provided with the distribution. 410 | The names of its contributors may not be used to endorse or promote 411 | products derived from this software without specific prior written 412 | permission. 413 | 414 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 415 | IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 416 | TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 417 | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 418 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 419 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 420 | TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 421 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 422 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 423 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 424 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 425 | 426 | ================================================================ 427 | 428 | github.com/spf13/cobra 429 | https://github.com/spf13/cobra 430 | ---------------------------------------------------------------- 431 | Apache License 432 | Version 2.0, January 2004 433 | http://www.apache.org/licenses/ 434 | 435 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 436 | 437 | 1. Definitions. 438 | 439 | "License" shall mean the terms and conditions for use, reproduction, 440 | and distribution as defined by Sections 1 through 9 of this document. 441 | 442 | "Licensor" shall mean the copyright owner or entity authorized by 443 | the copyright owner that is granting the License. 444 | 445 | "Legal Entity" shall mean the union of the acting entity and all 446 | other entities that control, are controlled by, or are under common 447 | control with that entity. For the purposes of this definition, 448 | "control" means (i) the power, direct or indirect, to cause the 449 | direction or management of such entity, whether by contract or 450 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 451 | outstanding shares, or (iii) beneficial ownership of such entity. 452 | 453 | "You" (or "Your") shall mean an individual or Legal Entity 454 | exercising permissions granted by this License. 455 | 456 | "Source" form shall mean the preferred form for making modifications, 457 | including but not limited to software source code, documentation 458 | source, and configuration files. 459 | 460 | "Object" form shall mean any form resulting from mechanical 461 | transformation or translation of a Source form, including but 462 | not limited to compiled object code, generated documentation, 463 | and conversions to other media types. 464 | 465 | "Work" shall mean the work of authorship, whether in Source or 466 | Object form, made available under the License, as indicated by a 467 | copyright notice that is included in or attached to the work 468 | (an example is provided in the Appendix below). 469 | 470 | "Derivative Works" shall mean any work, whether in Source or Object 471 | form, that is based on (or derived from) the Work and for which the 472 | editorial revisions, annotations, elaborations, or other modifications 473 | represent, as a whole, an original work of authorship. For the purposes 474 | of this License, Derivative Works shall not include works that remain 475 | separable from, or merely link (or bind by name) to the interfaces of, 476 | the Work and Derivative Works thereof. 477 | 478 | "Contribution" shall mean any work of authorship, including 479 | the original version of the Work and any modifications or additions 480 | to that Work or Derivative Works thereof, that is intentionally 481 | submitted to Licensor for inclusion in the Work by the copyright owner 482 | or by an individual or Legal Entity authorized to submit on behalf of 483 | the copyright owner. For the purposes of this definition, "submitted" 484 | means any form of electronic, verbal, or written communication sent 485 | to the Licensor or its representatives, including but not limited to 486 | communication on electronic mailing lists, source code control systems, 487 | and issue tracking systems that are managed by, or on behalf of, the 488 | Licensor for the purpose of discussing and improving the Work, but 489 | excluding communication that is conspicuously marked or otherwise 490 | designated in writing by the copyright owner as "Not a Contribution." 491 | 492 | "Contributor" shall mean Licensor and any individual or Legal Entity 493 | on behalf of whom a Contribution has been received by Licensor and 494 | subsequently incorporated within the Work. 495 | 496 | 2. Grant of Copyright License. Subject to the terms and conditions of 497 | this License, each Contributor hereby grants to You a perpetual, 498 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 499 | copyright license to reproduce, prepare Derivative Works of, 500 | publicly display, publicly perform, sublicense, and distribute the 501 | Work and such Derivative Works in Source or Object form. 502 | 503 | 3. Grant of Patent License. Subject to the terms and conditions of 504 | this License, each Contributor hereby grants to You a perpetual, 505 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 506 | (except as stated in this section) patent license to make, have made, 507 | use, offer to sell, sell, import, and otherwise transfer the Work, 508 | where such license applies only to those patent claims licensable 509 | by such Contributor that are necessarily infringed by their 510 | Contribution(s) alone or by combination of their Contribution(s) 511 | with the Work to which such Contribution(s) was submitted. If You 512 | institute patent litigation against any entity (including a 513 | cross-claim or counterclaim in a lawsuit) alleging that the Work 514 | or a Contribution incorporated within the Work constitutes direct 515 | or contributory patent infringement, then any patent licenses 516 | granted to You under this License for that Work shall terminate 517 | as of the date such litigation is filed. 518 | 519 | 4. Redistribution. You may reproduce and distribute copies of the 520 | Work or Derivative Works thereof in any medium, with or without 521 | modifications, and in Source or Object form, provided that You 522 | meet the following conditions: 523 | 524 | (a) You must give any other recipients of the Work or 525 | Derivative Works a copy of this License; and 526 | 527 | (b) You must cause any modified files to carry prominent notices 528 | stating that You changed the files; and 529 | 530 | (c) You must retain, in the Source form of any Derivative Works 531 | that You distribute, all copyright, patent, trademark, and 532 | attribution notices from the Source form of the Work, 533 | excluding those notices that do not pertain to any part of 534 | the Derivative Works; and 535 | 536 | (d) If the Work includes a "NOTICE" text file as part of its 537 | distribution, then any Derivative Works that You distribute must 538 | include a readable copy of the attribution notices contained 539 | within such NOTICE file, excluding those notices that do not 540 | pertain to any part of the Derivative Works, in at least one 541 | of the following places: within a NOTICE text file distributed 542 | as part of the Derivative Works; within the Source form or 543 | documentation, if provided along with the Derivative Works; or, 544 | within a display generated by the Derivative Works, if and 545 | wherever such third-party notices normally appear. The contents 546 | of the NOTICE file are for informational purposes only and 547 | do not modify the License. You may add Your own attribution 548 | notices within Derivative Works that You distribute, alongside 549 | or as an addendum to the NOTICE text from the Work, provided 550 | that such additional attribution notices cannot be construed 551 | as modifying the License. 552 | 553 | You may add Your own copyright statement to Your modifications and 554 | may provide additional or different license terms and conditions 555 | for use, reproduction, or distribution of Your modifications, or 556 | for any such Derivative Works as a whole, provided Your use, 557 | reproduction, and distribution of the Work otherwise complies with 558 | the conditions stated in this License. 559 | 560 | 5. Submission of Contributions. Unless You explicitly state otherwise, 561 | any Contribution intentionally submitted for inclusion in the Work 562 | by You to the Licensor shall be under the terms and conditions of 563 | this License, without any additional terms or conditions. 564 | Notwithstanding the above, nothing herein shall supersede or modify 565 | the terms of any separate license agreement you may have executed 566 | with Licensor regarding such Contributions. 567 | 568 | 6. Trademarks. This License does not grant permission to use the trade 569 | names, trademarks, service marks, or product names of the Licensor, 570 | except as required for reasonable and customary use in describing the 571 | origin of the Work and reproducing the content of the NOTICE file. 572 | 573 | 7. Disclaimer of Warranty. Unless required by applicable law or 574 | agreed to in writing, Licensor provides the Work (and each 575 | Contributor provides its Contributions) on an "AS IS" BASIS, 576 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 577 | implied, including, without limitation, any warranties or conditions 578 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 579 | PARTICULAR PURPOSE. You are solely responsible for determining the 580 | appropriateness of using or redistributing the Work and assume any 581 | risks associated with Your exercise of permissions under this License. 582 | 583 | 8. Limitation of Liability. In no event and under no legal theory, 584 | whether in tort (including negligence), contract, or otherwise, 585 | unless required by applicable law (such as deliberate and grossly 586 | negligent acts) or agreed to in writing, shall any Contributor be 587 | liable to You for damages, including any direct, indirect, special, 588 | incidental, or consequential damages of any character arising as a 589 | result of this License or out of the use or inability to use the 590 | Work (including but not limited to damages for loss of goodwill, 591 | work stoppage, computer failure or malfunction, or any and all 592 | other commercial damages or losses), even if such Contributor 593 | has been advised of the possibility of such damages. 594 | 595 | 9. Accepting Warranty or Additional Liability. While redistributing 596 | the Work or Derivative Works thereof, You may choose to offer, 597 | and charge a fee for, acceptance of support, warranty, indemnity, 598 | or other liability obligations and/or rights consistent with this 599 | License. However, in accepting such obligations, You may act only 600 | on Your own behalf and on Your sole responsibility, not on behalf 601 | of any other Contributor, and only if You agree to indemnify, 602 | defend, and hold each Contributor harmless for any liability 603 | incurred by, or claims asserted against, such Contributor by reason 604 | of your accepting any such warranty or additional liability. 605 | 606 | ================================================================ 607 | 608 | github.com/spf13/pflag 609 | https://github.com/spf13/pflag 610 | ---------------------------------------------------------------- 611 | Copyright (c) 2012 Alex Ogier. All rights reserved. 612 | Copyright (c) 2012 The Go Authors. All rights reserved. 613 | 614 | Redistribution and use in source and binary forms, with or without 615 | modification, are permitted provided that the following conditions are 616 | met: 617 | 618 | * Redistributions of source code must retain the above copyright 619 | notice, this list of conditions and the following disclaimer. 620 | * Redistributions in binary form must reproduce the above 621 | copyright notice, this list of conditions and the following disclaimer 622 | in the documentation and/or other materials provided with the 623 | distribution. 624 | * Neither the name of Google Inc. nor the names of its 625 | contributors may be used to endorse or promote products derived from 626 | this software without specific prior written permission. 627 | 628 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 629 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 630 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 631 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 632 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 633 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 634 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 635 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 636 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 637 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 638 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 639 | 640 | ================================================================ 641 | 642 | github.com/stretchr/testify 643 | https://github.com/stretchr/testify 644 | ---------------------------------------------------------------- 645 | MIT License 646 | 647 | Copyright (c) 2012-2020 Mat Ryer, Tyler Bunnell and contributors. 648 | 649 | Permission is hereby granted, free of charge, to any person obtaining a copy 650 | of this software and associated documentation files (the "Software"), to deal 651 | in the Software without restriction, including without limitation the rights 652 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 653 | copies of the Software, and to permit persons to whom the Software is 654 | furnished to do so, subject to the following conditions: 655 | 656 | The above copyright notice and this permission notice shall be included in all 657 | copies or substantial portions of the Software. 658 | 659 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 660 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 661 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 662 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 663 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 664 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 665 | SOFTWARE. 666 | 667 | ================================================================ 668 | 669 | gopkg.in/check.v1 670 | https://gopkg.in/check.v1 671 | ---------------------------------------------------------------- 672 | Gocheck - A rich testing framework for Go 673 | 674 | Copyright (c) 2010-2013 Gustavo Niemeyer 675 | 676 | All rights reserved. 677 | 678 | Redistribution and use in source and binary forms, with or without 679 | modification, are permitted provided that the following conditions are met: 680 | 681 | 1. Redistributions of source code must retain the above copyright notice, this 682 | list of conditions and the following disclaimer. 683 | 2. Redistributions in binary form must reproduce the above copyright notice, 684 | this list of conditions and the following disclaimer in the documentation 685 | and/or other materials provided with the distribution. 686 | 687 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 688 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 689 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 690 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 691 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 692 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 693 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 694 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 695 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 696 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 697 | 698 | ================================================================ 699 | 700 | gopkg.in/yaml.v3 701 | https://gopkg.in/yaml.v3 702 | ---------------------------------------------------------------- 703 | 704 | This project is covered by two different licenses: MIT and Apache. 705 | 706 | #### MIT License #### 707 | 708 | The following files were ported to Go from C files of libyaml, and thus 709 | are still covered by their original MIT license, with the additional 710 | copyright staring in 2011 when the project was ported over: 711 | 712 | apic.go emitterc.go parserc.go readerc.go scannerc.go 713 | writerc.go yamlh.go yamlprivateh.go 714 | 715 | Copyright (c) 2006-2010 Kirill Simonov 716 | Copyright (c) 2006-2011 Kirill Simonov 717 | 718 | Permission is hereby granted, free of charge, to any person obtaining a copy of 719 | this software and associated documentation files (the "Software"), to deal in 720 | the Software without restriction, including without limitation the rights to 721 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 722 | of the Software, and to permit persons to whom the Software is furnished to do 723 | so, subject to the following conditions: 724 | 725 | The above copyright notice and this permission notice shall be included in all 726 | copies or substantial portions of the Software. 727 | 728 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 729 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 730 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 731 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 732 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 733 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 734 | SOFTWARE. 735 | 736 | ### Apache License ### 737 | 738 | All the remaining project files are covered by the Apache license: 739 | 740 | Copyright (c) 2011-2019 Canonical Ltd 741 | 742 | Licensed under the Apache License, Version 2.0 (the "License"); 743 | you may not use this file except in compliance with the License. 744 | You may obtain a copy of the License at 745 | 746 | http://www.apache.org/licenses/LICENSE-2.0 747 | 748 | Unless required by applicable law or agreed to in writing, software 749 | distributed under the License is distributed on an "AS IS" BASIS, 750 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 751 | See the License for the specific language governing permissions and 752 | limitations under the License. 753 | 754 | ================================================================ 755 | 756 | olympos.io/encoding/edn 757 | https://olympos.io/encoding/edn 758 | ---------------------------------------------------------------- 759 | Copyright (c) 2015, The Go Authors, Jean Niklas L'orange 760 | All rights reserved. 761 | 762 | Redistribution and use in source and binary forms, with or without modification, 763 | are permitted provided that the following conditions are met: 764 | 765 | * Redistributions of source code must retain the above copyright notice, this 766 | list of conditions and the following disclaimer. 767 | * Redistributions in binary form must reproduce the above copyright notice, 768 | this list of conditions and the following disclaimer in the documentation and/or 769 | other materials provided with the distribution. 770 | * Neither the name of Google Inc., the copyright holder nor the names of its 771 | contributors may be used to endorse or promote products derived from this 772 | software without specific prior written permission. 773 | 774 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 775 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 776 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 777 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 778 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 779 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 780 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 781 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 782 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 783 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 784 | 785 | ================================================================ 786 | 787 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Daniel Kaslovsky 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROJ := "$(notdir $(shell pwd))" 2 | BRANCH := "$(shell git rev-parse --abbrev-ref HEAD)" 3 | STATUS := "$(shell git status -s)" 4 | 5 | BUILD_OUTDIR = "dist" 6 | BUILD_FILE_PATTERN := "${PROJ}_{{.OS}}_{{.Arch}}" 7 | 8 | BUILD_ARCH = "amd64 arm64" 9 | BUILD_OS = "linux darwin windows" 10 | BUILD_LDFLAGS := "-s -w -X main.version=$(BRANCH)" 11 | 12 | TAG_REGEX = "^v[0-9]\.[0-9]\.[0-9]$$" 13 | 14 | export GO111MODULE=on 15 | 16 | .PHONY: test 17 | test: 18 | go test ./... 19 | 20 | .PHONY: tidy 21 | tidy: 22 | @go mod tidy 23 | @sleep 1 24 | 25 | .PHONY: credits 26 | credits: tidy 27 | @gocredits -w 28 | @sleep 1 29 | 30 | .PHONY: prepare 31 | prepare: test tidy credits 32 | 33 | .PHONY: build 34 | build: test 35 | gox -ldflags=${BUILD_LDFLAGS} -os=${BUILD_OS} -arch=${BUILD_ARCH} -output=${BUILD_OUTDIR}/${BRANCH}/${BUILD_FILE_PATTERN} 36 | 37 | .PHONY: release 38 | release: checkbranch checkstatus build 39 | ghr "${BRANCH}" "${BUILD_OUTDIR}/${BRANCH}/" 40 | 41 | .PHONY: checkbranch 42 | checkbranch: 43 | ifeq (${BRANCH}, "$(shell echo ${BRANCH} | grep ${TAG_REGEX})") 44 | @echo "branch name ${BRANCH} successfully checked for release" 45 | else 46 | @echo "branch name ${BRANCH} does not follow semver naming convention, will not release" 47 | @exit 1 48 | endif 49 | 50 | .PHONY: checkstatus 51 | checkstatus: 52 | ifneq (${STATUS}, "") 53 | @echo "dirty branch: check git status" 54 | @exit 1 55 | endif 56 | @: 57 | 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # textnote 2 | Simple tool for creating and organizing daily notes on the command line 3 | 4 | [![Build Status](https://travis-ci.com/dkaslovsky/textnote.svg?branch=main)](https://travis-ci.com/github/dkaslovsky/textnote) 5 | [![Coverage Status](https://coveralls.io/repos/github/dkaslovsky/textnote/badge.svg?branch=main)](https://coveralls.io/github/dkaslovsky/textnote?branch=main) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/dkaslovsky/textnote)](https://goreportcard.com/report/github.com/dkaslovsky/textnote) 7 | [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/dkaslovsky/textnote/blob/main/LICENSE) 8 | 9 |
10 | 11 | ## Overview 12 | textnote is a command line tool for quickly creating and managing daily plain text notes. 13 | It is designed for ease of use to encourage the practice of daily, organized note taking. 14 | 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. 15 | 16 | Key features: 17 | - Configurable, sectioned note template 18 | - Easily bring content forward to the next day's note (for those to-dos that didn't quite get done today...) 19 | - Simple command to consolidate daily notes into monthly archive files 20 | - Create and open today's note with the default `textnote` command 21 | 22 | All note files are stored locally on the file system in a single directory. 23 | Notes can easily be synced to a remote server or cloud service if so desired by ensuring the application directory is remotely synced. 24 | 25 | textnote opens notes using the text editor specified by the environment variable `$EDITOR` and defaults to Vim if the environment variable is not set. 26 | See the [Editor-Specific Configuration](#editor-specific-configuration) subsection for more details. 27 | 28 |
29 | 30 | ## Table of Contents 31 | - [Overview](#overview) 32 | - [Quick Start](#quick-start) 33 | - [Installation](#installation) 34 | - [Releases](#releases) 35 | - [Installing from source](#installing-from-source) 36 | - [Usage](#usage) 37 | - [`open`](#open) 38 | - [`archive`](#archive) 39 | - [Additional Functionality](#additional-functionality) 40 | - [Configuration](#configuration) 41 | - [Defaults](#defaults) 42 | - [Environment Variable Overrides](#environment-variable-overrides) 43 | - [Editor-Specific Configuration](#editor-specific-configuration) 44 | - [License](#license) 45 | 46 |
47 | 48 | ## Quick Start 49 | 1. Install textnote (see [Installation](#installation)) 50 | 2. Set a single environment variable `TEXTNOTE_DIR` to specify the directory for textnote's files 51 | 52 | That's it, textnote is ready to go! 53 | 54 | The directory specified by `TEXTNOTE_DIR` and the default configuration file will be automatically created the first time textnote is run. 55 | 56 | Start writing notes for today with a single command 57 | ``` 58 | $ textnote 59 | ``` 60 | 61 | To first configure textnote before creating notes, run 62 | ``` 63 | $ textnote init 64 | ``` 65 | and then edit the configuration file found at the displayed path. 66 | 67 |
68 | 69 | ## Installation 70 | textnote can be installed by downloading a prebuilt binary or by the `go get` command. 71 | 72 |
73 | 74 | ### Releases 75 | The recommended installation method is downloading the latest released binary. 76 | Download the appropriate binary for your operating system from this repository's [releases](https://github.com/dkaslovsky/textnote/releases/latest) page or via `curl`: 77 | 78 | macOS 79 | ``` 80 | $ curl -o textnote -L https://github.com/dkaslovsky/textnote/releases/latest/download/textnote_darwin_amd64 81 | ``` 82 | 83 | Linux 84 | ``` 85 | $ curl -o textnote -L https://github.com/dkaslovsky/textnote/releases/latest/download/textnote_linux_amd64 86 | ``` 87 | 88 | Windows 89 | ``` 90 | > curl.exe -o textnote.exe -L https://github.com/dkaslovsky/textnote/releases/latest/download/textnote_windows_amd64.exe 91 | ``` 92 | 93 |
94 | 95 | ### Installing from source 96 | 97 | textnote can also be installed using Go's built-in tooling: 98 | ``` 99 | $ go get -u github.com/dkaslovsky/textnote 100 | ``` 101 | Build from source by cloning this repository and running `go build`. 102 | 103 | 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)). 104 | 105 |
106 | 107 | ## Usage 108 | 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. 109 | 110 |
111 | 112 | ### **`open`** 113 | The `open` command will open a dated note in an editor, creating it first if it does not exist. 114 | 115 | Opening or creating a note for the current day is the default action. 116 | Simply run the root command to open or create a note for the current day: 117 | ``` 118 | $ textnote 119 | ``` 120 | which, using the default configuration and assuming today is 2021-01-24, will create and open an empty note template: 121 | ``` 122 | [Sun] 24 Jan 2021 123 | 124 | ___TODO___ 125 | 126 | 127 | 128 | ___DONE___ 129 | 130 | 131 | 132 | ___NOTES___ 133 | 134 | 135 | 136 | ``` 137 | To open a note for a specific date other than the current day, specify the date with the `--date` flag: 138 | ``` 139 | $ textnote open --date 2020-12-22 140 | ``` 141 | where the date format is specified in the configuration. 142 | 143 | Alternatively, a note can be opened by passing the number of days prior to the current day using the `-d` flag. For example, 144 | ``` 145 | $ textnote open -d 1 146 | ``` 147 | opens yesterday's note. 148 | 149 | Sections from previous notes can be copied or moved into a current note. 150 | Each section to be copied is specified in a separate `-s` flag. 151 | 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. 152 | For example, 153 | ``` 154 | $ textnote open -s TODO -s NOTES 155 | ``` 156 | will create today's note with the "TODO" and "NOTES" sections copied from the most recently dated (often yesterday's) note, while 157 | ``` 158 | $ textnote open --copy 2021-01-17 -s TODO 159 | ``` 160 | creates today's note with the "TODO" section copied from the 2021-01-17 note. 161 | Use the `-c` flag to instead specify the source by the number of days back from the current day. 162 | For example, 163 | ``` 164 | $ textnote open -c 3 -s TODO 165 | ``` 166 | creates today's note with the "TODO" section copied from 3 days ago. 167 | 168 | To move instead of copy, add the `-x` flag to any copy command. 169 | For example, 170 | ``` 171 | $ textnote open --copy 2021-01-17 -s NOTES -x 172 | ``` 173 | moves the "NOTES" section contents from the 2021-01-17 note into the note for today. 174 | 175 | Pass two delete flags (`-xx`) to also delete the source note if moving section(s) leaves the source empty: 176 | ``` 177 | $ textnote open --copy 2021-01-17 -s NOTES -xx 178 | ``` 179 | 180 | The `--date` and `--copy` (or `-d` and `-c`) flags can be used in combination if such a workflow is desired. 181 | 182 | For convenience, the `-t` flag can be used to open tomorrow's note: 183 | ``` 184 | $ textnote open -t 185 | ``` 186 | For example, 187 | ``` 188 | $ textnote open -t -s TODO 189 | ``` 190 | creates a note for tomorrow with a copy of today's "TODO" section contents, assuming a note for today exits. 191 | 192 | Also for convenience, the latest (most recent) dated note can be opened using the `-l` flag: 193 | ``` 194 | $ textnote open -l 195 | ``` 196 | 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. 197 | It will ignore notes dated in the future. 198 | 199 | 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. 200 | 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. 201 | This message can be effectively disabled by configuring the `templateFileCountThresh` configuration parameter to be very large, but doing so is not recommended. 202 | 203 | The flag options are summarized by the command's help: 204 | ``` 205 | $ textnote open -h 206 | 207 | open or create a note template 208 | 209 | Usage: 210 | textnote open [flags] 211 | 212 | Flags: 213 | --copy string date of note for copying sections (defaults to date of most recent note, cannot be used with copy-back flag) 214 | -c, --copy-back uint number of days back from today for copying from a note (cannot be used with copy flag) 215 | --date string date for note to be opened (defaults to today) 216 | -d, --days-back uint number of days back from today for opening a note (cannot be used with date, tomorrow, or latest flags) 217 | -x, --delete count delete sections after copy (pass flag twice to also delete empty source note) 218 | -h, --help help for open 219 | -l, --latest specify the most recent dated note to be opened (cannot be used with date, days-back, or tomorrow flags) 220 | -s, --section strings section to copy (defaults to none) 221 | -t, --tomorrow specify tomorrow as the date for note to be opened (cannot be used with date, days-back, or latest flags) 222 | ``` 223 | 224 | 225 |
226 | 227 | ### **`archive`** 228 | 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. 229 | Only notes older than a number of days specified in the configuration are archived. 230 | 231 | Running the archive command 232 | ``` 233 | $ textnote archive 234 | ``` 235 | generates an archive file for every month for which a note exists. 236 | For example, an archive of the January 2021 notes, assuming the default configuration, will have the form 237 | ``` 238 | ARCHIVE Jan2021 239 | 240 | ___TODO___ 241 | [2021-01-03] 242 | ... 243 | [2021-01-04] 244 | ... 245 | 246 | 247 | 248 | ___DONE___ 249 | [2021-01-03] 250 | ... 251 | [2021-01-04] 252 | ... 253 | [2021-01-06] 254 | ... 255 | 256 | 257 | ___NOTES___ 258 | [2021-01-06] 259 | ... 260 | 261 | 262 | 263 | ``` 264 | with ellipses representing the daily notes' contents. 265 | 266 | By default, the `archive` command is non-destructive: it will create archive files and leave all notes in place. 267 | To delete the individual note files and retain only the generated archives, run the command with the `-x` flag: 268 | ``` 269 | $ textnote archive -x 270 | ``` 271 | 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. 272 | Running with the `--dry-run` flag prints the file names to be deleted without performing any actions: 273 | ``` 274 | $ textnote archive --dry-run 275 | ``` 276 | 277 | If the `archive` command is run without the delete flag, archive files are written and the original notes are left in place. 278 | 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: 279 | ``` 280 | $ textnote archive -x -n 281 | ``` 282 | 283 | The flag options are summarized by the command's help: 284 | ``` 285 | $ textnote archive -h 286 | 287 | consolidate notes into monthly archive files 288 | 289 | Usage: 290 | textnote archive [flags] 291 | 292 | Flags: 293 | -x, --delete delete individual files after archiving 294 | --dry-run print file names to be deleted instead of performing deletes (other flags are ignored) 295 | -h, --help help for archive 296 | -n, --no-write disable writing archive files (helpful for deleting previously archived files) 297 | ``` 298 | 299 |
300 | 301 | ### **Additional Functionality** 302 | textnote is designed for simplicity. 303 | 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). 304 | 305 | A few simple command line functions for searching, listing, and printing notes are available in a [gist](https://gist.github.com/dkaslovsky/010fd26c4d0975639a5c286fa631d6c9). 306 | 307 |
308 | 309 | ## Configuration 310 | While textnote is intended to be extremely lightweight, it is also designed to be highly configurable. 311 | In particular, the template (sections, headers, date formats, and whitespace) for generating notes can be customized as desired. 312 | One might wish to configure headers and section titles for markdown compatibility or change date formats to match regional convention. 313 | 314 | Configuration is read from the `$TEXTNOTE_DIR/.config.yml` file. 315 | Changes to configuration parameters can be made by updating this file. 316 | Individual configuration parameters also can be overridden with [environment variables](#environment-variable-overrides). 317 | 318 | Importantly, if textnote's configuration is changed, notes created using a previous configuration might be incompatible with textnote's functionality. 319 | 320 | The configuration file can be displayed by running the `config` command with the `-f` flag: 321 | ``` 322 | $ textnote config -f 323 | ``` 324 | The configuration file path is displayed by using the `-p` flag: 325 | ``` 326 | $ textnote config -p 327 | ``` 328 | [Defaults](#defaults) are used for configuration parameters omitted from the configuration file or configuration [environment variables](#environment-variable-overrides). 329 | The `config` command with the `-a` flag displays the full "active" configuration used when the application runs, including default and environment parameters: 330 | ``` 331 | $ textnote config -a 332 | ``` 333 | To update the configuration file to match the active configuration, run 334 | ``` 335 | $ textnote config update 336 | ``` 337 | This command overwrites the existing configuration file. 338 | It can be used instead of manual updates to the configuration file by passing environment variables. 339 | For example, 340 | ``` 341 | $ TEXTNOTE_ARCHIVE_FILE_PREFIX="my_archive-" textnote config update 342 | ``` 343 | The `update` command is also helpful for writing configuration parameters that have been added with new versions of textnote. 344 | 345 | The `config` command options are summarized by the command's help: 346 | ``` 347 | $ textnote config -h 348 | 349 | manages the application's configuration 350 | 351 | Usage: 352 | textnote config [flags] 353 | textnote config [command] 354 | 355 | Available Commands: 356 | update update the configuration file with active configuration 357 | 358 | Flags: 359 | -a, --active display configuration the application actively uses (includes environment variable configuration) 360 | -f, --file display contents of configuration file (default) 361 | -h, --help help for config 362 | -p, --path display path to configuration file 363 | 364 | Use "textnote config [command] --help" for more information about a command. 365 | ``` 366 | 367 |
368 | 369 | ### Defaults 370 | The default configuration file is automatically written the first time textnote is run: 371 | ``` 372 | header: 373 | prefix: "" # prefix to attach to header 374 | suffix: "" # suffix to attach to header 375 | trailingNewlines: 1 # number of newlines after header 376 | timeFormat: '[Mon] 02 Jan 2006' # Golang format for header dates 377 | section: 378 | prefix: ___ # prefix to attach to section name 379 | suffix: ___ # suffix to attach to section name 380 | trailingNewlines: 3 # number of newlines for empty section 381 | names: # section names 382 | - TODO 383 | - DONE 384 | - NOTES 385 | file: 386 | ext: txt # extension to use for note files 387 | timeFormat: "2006-01-02" # Golang format for note file names 388 | cursorLine: 4 # line to place cursor when opening a note 389 | archive: 390 | afterDays: 14 # number of days after which a note can be archived 391 | filePrefix: archive- # prefix to attach to archive file names 392 | headerPrefix: 'ARCHIVE ' # prefix to attach to header of archive notes 393 | headerSuffix: "" # suffix to attach to header of archive notes 394 | sectionContentPrefix: '[' # prefix to attach to section content date 395 | sectionContentSuffix: ']' # suffix to attach to section content date 396 | sectionContentTimeFormat: "2006-01-02" # Golang format for section content dates 397 | monthTimeFormat: Jan2006 # Golang format for month archive file and header dates 398 | cli: 399 | timeFormat: "2006-01-02" # Golang format for CLI date input 400 | templateFileCountThresh: 90 # threshold for displaying a warning for too many template files 401 | ``` 402 | 403 | ### Environment Variable Overrides 404 | Any configuration parameter can be overridden by setting a corresponding environment variable. 405 | Note that setting an environment variable does not change the value specified in the configuration file. 406 | The full list of environment variables is listed below and is always available by running `textnote --help`: 407 | ``` 408 | TEXTNOTE_TEMPLATE_FILE_COUNT_THRESH int 409 | threshold for warning too many template files 410 | TEXTNOTE_HEADER_PREFIX string 411 | prefix to attach to header 412 | TEXTNOTE_HEADER_SUFFIX string 413 | suffix to attach to header 414 | TEXTNOTE_HEADER_TRAILING_NEWLINES int 415 | number of newlines to attach to end of header 416 | TEXTNOTE_HEADER_TIME_FORMAT string 417 | formatting string to form headers from timestamps 418 | TEXTNOTE_SECTION_PREFIX string 419 | prefix to attach to section names 420 | TEXTNOTE_SECTION_SUFFIX string 421 | suffix to attach to section names 422 | TEXTNOTE_SECTION_TRAILING_NEWLINES int 423 | number of newlines to attach to end of each section 424 | TEXTNOTE_SECTION_NAMES slice 425 | section names 426 | TEXTNOTE_FILE_EXT string 427 | extension for all files written 428 | TEXTNOTE_FILE_TIME_FORMAT string 429 | formatting string to form file names from timestamps 430 | TEXTNOTE_FILE_CURSOR_LINE int 431 | line to place cursor when opening 432 | TEXTNOTE_ARCHIVE_AFTER_DAYS int 433 | number of days after which to archive a file 434 | TEXTNOTE_ARCHIVE_FILE_PREFIX string 435 | prefix attached to the file name of all archive files 436 | TEXTNOTE_ARCHIVE_HEADER_PREFIX string 437 | override header prefix for archive files 438 | TEXTNOTE_ARCHIVE_HEADER_SUFFIX string 439 | override header suffix for archive files 440 | TEXTNOTE_ARCHIVE_SECTION_CONTENT_PREFIX string 441 | prefix to attach to section content date 442 | TEXTNOTE_ARCHIVE_SECTION_CONTENT_SUFFIX string 443 | suffix to attach to section content date 444 | TEXTNOTE_ARCHIVE_SECTION_CONTENT_TIME_FORMAT string 445 | formatting string dated section content 446 | TEXTNOTE_ARCHIVE_MONTH_TIME_FORMAT string 447 | formatting string for month archive timestamps 448 | TEXTNOTE_CLI_TIME_FORMAT string 449 | formatting string for timestamp CLI flags 450 | ``` 451 | 452 |
453 | 454 | ### Editor-Specific Configuration 455 | Currently, textnote supports the `file.cusorLine` and `TEXTNOTE_FILE_CURSOR_LINE` configuration for the following editors: 456 | * Vi/Vim 457 | * Emacs 458 | * Neovim 459 | * Nano 460 | 461 | textnote will work with all other editors but will not respect this configuration parameter. 462 | 463 |
464 | 465 | ## License 466 | textnote is released under the [MIT License](https://github.com/dkaslovsky/textnote/blob/main/LICENSE). 467 | Dependency licenses are available in this repository's [CREDITS](./CREDITS) file. 468 | -------------------------------------------------------------------------------- /cmd/archive/archive.go: -------------------------------------------------------------------------------- 1 | package archive 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "time" 8 | 9 | "github.com/dkaslovsky/textnote/pkg/archive" 10 | "github.com/dkaslovsky/textnote/pkg/config" 11 | "github.com/dkaslovsky/textnote/pkg/file" 12 | "github.com/dkaslovsky/textnote/pkg/template" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | type commandOptions struct { 17 | delete bool 18 | noWrite bool 19 | dryRun bool 20 | } 21 | 22 | // CreateArchiveCmd creates the today subcommand 23 | func CreateArchiveCmd() *cobra.Command { 24 | cmdOpts := commandOptions{} 25 | cmd := &cobra.Command{ 26 | Use: "archive", 27 | Short: "consolidate notes into archive files", 28 | Long: "consolidate notes into monthly archive files", 29 | SilenceUsage: true, 30 | RunE: func(cmd *cobra.Command, args []string) error { 31 | opts, err := config.Load() 32 | if err != nil { 33 | return err 34 | } 35 | return run(opts, cmdOpts) 36 | }, 37 | } 38 | attachOpts(cmd, &cmdOpts) 39 | return cmd 40 | } 41 | 42 | func attachOpts(cmd *cobra.Command, cmdOpts *commandOptions) { 43 | flags := cmd.Flags() 44 | flags.BoolVarP(&cmdOpts.delete, "delete", "x", false, "delete individual files after archiving") 45 | flags.BoolVarP(&cmdOpts.noWrite, "no-write", "n", false, "disable writing archive files (helpful for deleting previously archived files)") 46 | flags.BoolVar(&cmdOpts.dryRun, "dry-run", false, "print file names to be deleted instead of performing deletes (other flags are ignored)") 47 | } 48 | 49 | func run(templateOpts config.Opts, cmdOpts commandOptions) error { 50 | archiver := archive.NewArchiver(templateOpts, file.NewReadWriter(), time.Now()) 51 | 52 | files, err := os.ReadDir(templateOpts.AppDir) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | // add template files to archiver 58 | for _, f := range files { 59 | if f.IsDir() { 60 | continue 61 | } 62 | 63 | // parse date from template file name, skipping non-template files 64 | templateDate, ok := template.ParseTemplateFileName(f.Name(), templateOpts.File) 65 | if !ok { 66 | continue 67 | } 68 | 69 | err := archiver.Add(templateDate) 70 | if err != nil { 71 | log.Printf("skipping unarchivable file [%s]: %s", f.Name(), err) 72 | continue 73 | } 74 | } 75 | 76 | // print file names for dry-run 77 | if cmdOpts.dryRun { 78 | files := archiver.GetArchivedFiles() 79 | fmt.Printf("running \"archive --delete\" will remove [%d] files\n", len(files)) 80 | for _, fileName := range files { 81 | fmt.Printf("- %s\n", fileName) 82 | } 83 | return nil 84 | } 85 | 86 | // write archive files 87 | if !cmdOpts.noWrite { 88 | err = archiver.Write() 89 | if err != nil { 90 | return err 91 | } 92 | } 93 | 94 | // return if not deleting archived files 95 | if !cmdOpts.delete { 96 | return nil 97 | } 98 | 99 | // delete individual archived files 100 | numDeleted := 0 101 | for _, fileName := range archiver.GetArchivedFiles() { 102 | err = os.Remove(fileName) 103 | if err != nil { 104 | log.Printf("unable to remove file [%s]: %s", fileName, err) 105 | continue 106 | } 107 | numDeleted++ 108 | } 109 | log.Printf("removed [%d] files after archiving", numDeleted) 110 | 111 | return nil 112 | } 113 | -------------------------------------------------------------------------------- /cmd/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | 9 | "github.com/dkaslovsky/textnote/pkg/config" 10 | "github.com/spf13/cobra" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | type commandOptions struct { 15 | path bool 16 | active bool 17 | file bool 18 | } 19 | 20 | // CreateConfigCmd creates the config subcommand 21 | func CreateConfigCmd() *cobra.Command { 22 | cmdOpts := commandOptions{} 23 | cmd := &cobra.Command{ 24 | Use: "config", 25 | Short: "manage configuration", 26 | Long: "manages the application's configuration", 27 | RunE: func(cmd *cobra.Command, args []string) error { 28 | configPath := config.GetConfigFilePath() 29 | 30 | if cmdOpts.path { 31 | log.Printf("configuration file path: [%s]", configPath) 32 | return nil 33 | } 34 | 35 | if cmdOpts.active { 36 | return displayActiveConfig() 37 | } 38 | 39 | // default 40 | return displayConfigFile(configPath) 41 | }, 42 | } 43 | attachOpts(cmd, &cmdOpts) 44 | cmd.AddCommand(CreateConfigUpdateCmd()) 45 | return cmd 46 | } 47 | 48 | func attachOpts(cmd *cobra.Command, cmdOpts *commandOptions) { 49 | flags := cmd.Flags() 50 | flags.BoolVarP(&cmdOpts.path, "path", "p", false, "display path to configuration file") 51 | flags.BoolVarP(&cmdOpts.active, "active", "a", false, "display configuration the application actively uses (includes environment variable configuration)") 52 | flags.BoolVarP(&cmdOpts.file, "file", "f", false, "display contents of configuration file (default)") 53 | } 54 | 55 | // CreateConfigUpdateCmd creates the config update subcommand 56 | func CreateConfigUpdateCmd() *cobra.Command { 57 | cmd := &cobra.Command{ 58 | Use: "update", 59 | Short: "update the configuration file with active configuration", 60 | Long: "update the configuration file to match the active configuration", 61 | RunE: func(cmd *cobra.Command, args []string) error { 62 | active, err := getActiveConfigYaml() 63 | if err != nil { 64 | return err 65 | } 66 | return os.WriteFile(config.GetConfigFilePath(), active, 0o644) 67 | }, 68 | } 69 | return cmd 70 | } 71 | 72 | func displayConfigFile(configPath string) error { 73 | _, err := os.Stat(configPath) 74 | if os.IsNotExist(err) { 75 | return fmt.Errorf("cannot find configuration file [%s]", configPath) 76 | } 77 | f, err := os.Open(configPath) 78 | if err != nil { 79 | return fmt.Errorf("unable to open configuration file [%s]: %w", configPath, err) 80 | } 81 | c, err := io.ReadAll(f) 82 | if err != nil { 83 | return fmt.Errorf("unable to read configuration file [%s]: %w", configPath, err) 84 | } 85 | log.Print(string(c)) 86 | return nil 87 | } 88 | 89 | func displayActiveConfig() error { 90 | yml, err := getActiveConfigYaml() 91 | if err != nil { 92 | return err 93 | } 94 | log.Print(string(yml)) 95 | return nil 96 | } 97 | 98 | func getActiveConfigYaml() ([]byte, error) { 99 | opts, err := config.Load() 100 | if err != nil { 101 | return []byte{}, err 102 | } 103 | return yaml.Marshal(opts) 104 | } 105 | -------------------------------------------------------------------------------- /cmd/initialize/initialize.go: -------------------------------------------------------------------------------- 1 | package initialize 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/dkaslovsky/textnote/pkg/config" 7 | ) 8 | 9 | // CreateInitCmd creates the init subcommand 10 | func CreateInitCmd() *cobra.Command { 11 | cmd := &cobra.Command{ 12 | Use: "init", 13 | Short: "initialize the application", 14 | Long: "initialize the application's required directories and files", 15 | RunE: func(cmd *cobra.Command, args []string) error { 16 | return config.InitApp() 17 | }, 18 | } 19 | return cmd 20 | } 21 | -------------------------------------------------------------------------------- /cmd/open/open.go: -------------------------------------------------------------------------------- 1 | package open 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math" 7 | "os" 8 | "strings" 9 | "time" 10 | 11 | "github.com/dkaslovsky/textnote/pkg/config" 12 | "github.com/dkaslovsky/textnote/pkg/editor" 13 | "github.com/dkaslovsky/textnote/pkg/file" 14 | "github.com/dkaslovsky/textnote/pkg/template" 15 | "github.com/pkg/errors" 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | const day = 24 * time.Hour 20 | 21 | type commandOptions struct { 22 | // mutually exclusive flags for date to open 23 | date string 24 | daysBack uint 25 | tomorrow bool 26 | latest bool 27 | 28 | // mutually exclusive flags for copy date 29 | copyDate string 30 | copyDaysBack uint 31 | 32 | deleteFlagVal int // count of number of times delete flag is passed 33 | deleteSections bool // delete sections on copy (deleteFlagVal > 0) 34 | deleteEmpty bool // delete file if empty after deleting sections (deleteFlagVal > 1) 35 | 36 | sections []string 37 | } 38 | 39 | // CreateOpenCmd creates the open subcommand 40 | func CreateOpenCmd() *cobra.Command { 41 | cmdOpts := commandOptions{} 42 | cmd := &cobra.Command{ 43 | Use: "open", 44 | Short: "open a note", 45 | Long: "open or create a note template", 46 | SilenceUsage: true, 47 | RunE: func(cmd *cobra.Command, args []string) error { 48 | opts, err := config.Load() 49 | if err != nil { 50 | return err 51 | } 52 | now := time.Now() 53 | numFilesSearchedForDate, err := setDateOpt(&cmdOpts, opts, getDirFiles, now) 54 | if err != nil { 55 | return err 56 | } 57 | numFilesSearchedForCopy, err := setCopyDateOpt(&cmdOpts, opts, getDirFiles, now) 58 | if err != nil { 59 | return err 60 | } 61 | warnTooManyTemplateFiles(max(numFilesSearchedForDate, numFilesSearchedForCopy), opts.TemplateFileCountThresh) 62 | setDeleteOpts(&cmdOpts) 63 | return run(opts, cmdOpts) 64 | }, 65 | } 66 | attachOpts(cmd, &cmdOpts) 67 | return cmd 68 | } 69 | 70 | func attachOpts(cmd *cobra.Command, cmdOpts *commandOptions) { 71 | flags := cmd.Flags() 72 | 73 | // mutually exclusive flags for date to open 74 | flags.StringVar(&cmdOpts.date, "date", "", "date for note to be opened (defaults to today)") 75 | 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)") 76 | 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)") 77 | 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)") 78 | 79 | // mutually exclusive flags for copy date 80 | 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)") 81 | 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)") 82 | 83 | flags.StringSliceVarP(&cmdOpts.sections, "section", "s", []string{}, "section to copy (defaults to none)") 84 | flags.CountVarP(&cmdOpts.deleteFlagVal, "delete", "x", "delete sections after copy (pass flag twice to also delete empty source note)") 85 | } 86 | 87 | func setDateOpt(cmdOpts *commandOptions, templateOpts config.Opts, getFiles func(string) ([]string, error), now time.Time) (int, error) { 88 | var ( 89 | date string 90 | numFiles int 91 | errMutuallyExclusive = errors.New("only one of [date, days-back, tomorrow, latest] flags may be used") 92 | ) 93 | 94 | if cmdOpts.date != "" { 95 | date = cmdOpts.date 96 | } 97 | 98 | if cmdOpts.daysBack != 0 { 99 | if date != "" { 100 | return numFiles, errMutuallyExclusive 101 | } 102 | date = now.Add(-day * time.Duration(cmdOpts.daysBack)).Format(templateOpts.Cli.TimeFormat) 103 | } 104 | 105 | if cmdOpts.tomorrow { 106 | if date != "" { 107 | return numFiles, errMutuallyExclusive 108 | } 109 | date = now.Add(day).Format(templateOpts.Cli.TimeFormat) 110 | } 111 | 112 | if cmdOpts.latest { 113 | if date != "" { 114 | return numFiles, errMutuallyExclusive 115 | } 116 | 117 | files, err := getFiles(templateOpts.AppDir) 118 | if err != nil { 119 | return numFiles, err 120 | } 121 | var latest string 122 | latest, numFiles = getLatestTemplateFile(files, now, templateOpts.File) 123 | if latest == "" { 124 | return numFiles, fmt.Errorf("failed to find latest template file in [%s]", templateOpts.AppDir) 125 | } 126 | if templateOpts.File.Ext != "" { 127 | latest = strings.TrimSuffix(latest, fmt.Sprintf(".%s", templateOpts.File.Ext)) 128 | } 129 | date = latest 130 | } 131 | 132 | // default to today 133 | if date == "" { 134 | date = now.Format(templateOpts.Cli.TimeFormat) 135 | } 136 | 137 | cmdOpts.date = date 138 | return numFiles, nil 139 | } 140 | 141 | func setCopyDateOpt(cmdOpts *commandOptions, templateOpts config.Opts, getFiles func(string) ([]string, error), now time.Time) (int, error) { 142 | numFiles := 0 143 | 144 | if cmdOpts.copyDate != "" && cmdOpts.copyDaysBack != 0 { 145 | return numFiles, errors.New("only one of [copy, copy-back] flags may be used") 146 | } 147 | 148 | if cmdOpts.copyDate != "" { 149 | if _, err := time.Parse(templateOpts.Cli.TimeFormat, cmdOpts.copyDate); err != nil { 150 | return numFiles, fmt.Errorf("cannot copy note from malformed date [%s]: %w", cmdOpts.copyDate, err) 151 | } 152 | return numFiles, nil 153 | } 154 | if cmdOpts.copyDaysBack != 0 { 155 | cmdOpts.copyDate = now.Add(-day * time.Duration(cmdOpts.copyDaysBack)).Format(templateOpts.Cli.TimeFormat) 156 | return numFiles, nil 157 | } 158 | 159 | // default to latest 160 | files, err := getFiles(templateOpts.AppDir) 161 | if err != nil { 162 | return numFiles, err 163 | } 164 | latest, numFiles := getLatestTemplateFile(files, now, templateOpts.File) 165 | if templateOpts.File.Ext != "" { 166 | latest = strings.TrimSuffix(latest, fmt.Sprintf(".%s", templateOpts.File.Ext)) 167 | } 168 | cmdOpts.copyDate = latest 169 | 170 | return numFiles, nil 171 | } 172 | 173 | func setDeleteOpts(cmdOpts *commandOptions) { 174 | cmdOpts.deleteSections = cmdOpts.deleteFlagVal > 0 175 | cmdOpts.deleteEmpty = cmdOpts.deleteFlagVal > 1 176 | } 177 | 178 | func run(templateOpts config.Opts, cmdOpts commandOptions) error { 179 | date, err := time.Parse(templateOpts.Cli.TimeFormat, cmdOpts.date) 180 | if err != nil { 181 | return fmt.Errorf("cannot create note for malformed date [%s]: %w", cmdOpts.date, err) 182 | } 183 | 184 | t := template.NewTemplate(templateOpts, date) 185 | rw := file.NewReadWriter() 186 | ed := editor.GetEditor(os.Getenv(editor.EnvEditor)) 187 | 188 | // open file if no sections to copy 189 | if len(cmdOpts.sections) == 0 { 190 | if !rw.Exists(t) { 191 | err := rw.Overwrite(t) 192 | if err != nil { 193 | return err 194 | } 195 | } 196 | return openInEditor(t, ed) 197 | } 198 | 199 | // load source for copy 200 | if cmdOpts.copyDate == "" { 201 | return fmt.Errorf("cannot find note to copy, [%s] might be empty", templateOpts.AppDir) 202 | } 203 | if cmdOpts.copyDate == cmdOpts.date { 204 | return fmt.Errorf("copying from note dated [%s] not allowed when writing to note for date [%s]", cmdOpts.copyDate, cmdOpts.date) 205 | } 206 | copyDate, err := time.Parse(templateOpts.Cli.TimeFormat, cmdOpts.copyDate) 207 | if err != nil { 208 | return fmt.Errorf("cannot copy note from malformed date [%s]: %w", cmdOpts.copyDate, err) 209 | } 210 | src := template.NewTemplate(templateOpts, copyDate) 211 | err = rw.Read(src) 212 | if err != nil { 213 | return fmt.Errorf("cannot read source file for copy: %w", err) 214 | } 215 | // load template contents if it exists 216 | if rw.Exists(t) { 217 | err := rw.Read(t) 218 | if err != nil { 219 | return fmt.Errorf("cannot load template file: %w", err) 220 | } 221 | } 222 | // copy from source to template 223 | err = copySections(src, t, cmdOpts.sections) 224 | if err != nil { 225 | return err 226 | } 227 | 228 | if cmdOpts.deleteSections { 229 | err = deleteSections(src, cmdOpts.sections) 230 | if err != nil { 231 | return fmt.Errorf("failed to remove section content from source file: %w", err) 232 | } 233 | 234 | if cmdOpts.deleteEmpty && src.IsEmpty() { 235 | err = os.Remove(src.GetFilePath()) 236 | if err != nil { 237 | return fmt.Errorf("failed to remove empty source file: %w", err) 238 | } 239 | } else { 240 | err = rw.Overwrite(src) 241 | if err != nil { 242 | return fmt.Errorf("failed to save changes to source file: %w", err) 243 | } 244 | 245 | } 246 | } 247 | 248 | err = rw.Overwrite(t) 249 | if err != nil { 250 | return fmt.Errorf("failed to write file: %w", err) 251 | } 252 | return openInEditor(t, ed) 253 | } 254 | 255 | func copySections(src *template.Template, tgt *template.Template, sectionNames []string) error { 256 | for _, sectionName := range sectionNames { 257 | err := tgt.CopySectionContents(src, sectionName) 258 | if err != nil { 259 | return fmt.Errorf("cannot copy section [%s] from source to target: %w", sectionName, err) 260 | } 261 | } 262 | return nil 263 | } 264 | 265 | func deleteSections(t *template.Template, sectionNames []string) error { 266 | for _, sectionName := range sectionNames { 267 | err := t.DeleteSectionContents(sectionName) 268 | if err != nil { 269 | return fmt.Errorf("cannot delete section [%s] from template: %w", sectionName, err) 270 | } 271 | } 272 | return nil 273 | } 274 | 275 | func openInEditor(t *template.Template, ed *editor.Editor) error { 276 | if t.GetFileCursorLine() > 1 && !ed.Supported { 277 | log.Printf("Editor [%s] only supported with its default arguments, additional configuration ignored", ed.Cmd) 278 | } 279 | if ed.Default { 280 | log.Printf("Environment variable [%s] not set, attempting to use default editor [%s]", editor.EnvEditor, ed.Cmd) 281 | } 282 | return ed.Open(t) 283 | } 284 | 285 | func getLatestTemplateFile(files []string, now time.Time, opts config.FileOpts) (string, int) { 286 | latest := "" 287 | delta := math.Inf(1) 288 | numTemplateFiles := 0 289 | 290 | for _, f := range files { 291 | fileTime, ok := template.ParseTemplateFileName(f, opts) 292 | if !ok { 293 | // skip archive files and other non-template files that cannot be parsed 294 | continue 295 | } 296 | numTemplateFiles++ 297 | curdelta := now.Sub(fileTime).Hours() 298 | if curdelta < 0 { 299 | continue 300 | } 301 | if curdelta < delta { 302 | delta = curdelta 303 | latest = f 304 | } 305 | } 306 | 307 | return latest, numTemplateFiles 308 | } 309 | 310 | func getDirFiles(dir string) ([]string, error) { 311 | fileNames := []string{} 312 | 313 | dirItems, err := os.ReadDir(dir) 314 | if err != nil { 315 | return fileNames, err 316 | } 317 | 318 | for _, item := range dirItems { 319 | if item.IsDir() { 320 | continue 321 | } 322 | fileNames = append(fileNames, item.Name()) 323 | } 324 | 325 | return fileNames, nil 326 | } 327 | 328 | func warnTooManyTemplateFiles(n int, thresh int) { 329 | if n > thresh { 330 | log.Printf("searching for latest template found more than %d files, consider running archive command for more efficient performance", thresh) 331 | } 332 | } 333 | 334 | func max(i, j int) int { 335 | if i > j { 336 | return i 337 | } 338 | return j 339 | } 340 | -------------------------------------------------------------------------------- /cmd/open/open_test.go: -------------------------------------------------------------------------------- 1 | package open 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/dkaslovsky/textnote/pkg/template/templatetest" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestGetLatestTemplateFile(t *testing.T) { 12 | opts := templatetest.GetOpts() 13 | 14 | type testCase struct { 15 | files []string 16 | now time.Time 17 | expectedLatest string 18 | expectedNumFound int 19 | } 20 | 21 | tests := map[string]testCase{ 22 | "empty directory": { 23 | files: []string{}, 24 | now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), 25 | expectedLatest: "", 26 | expectedNumFound: 0, 27 | }, 28 | "no timestamped template files": { 29 | files: []string{ 30 | "archive-Dec2019.txt", 31 | "archive-2019-11-01.txt", 32 | }, 33 | now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), 34 | expectedLatest: "", 35 | expectedNumFound: 0, 36 | }, 37 | "single template file in future": { 38 | files: []string{ 39 | "2020-04-13.txt", 40 | }, 41 | now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), 42 | expectedLatest: "", 43 | expectedNumFound: 1, 44 | }, 45 | "single template file": { 46 | files: []string{ 47 | "2020-03-11.txt", 48 | }, 49 | now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), 50 | expectedLatest: "2020-03-11.txt", 51 | expectedNumFound: 1, 52 | }, 53 | "multiple template files": { 54 | files: []string{ 55 | "2020-03-11.txt", 56 | "2020-03-12.txt", 57 | "2020-03-13.txt", 58 | }, 59 | now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), 60 | expectedLatest: "2020-03-13.txt", 61 | expectedNumFound: 3, 62 | }, 63 | "multiple template files with one in future": { 64 | files: []string{ 65 | "2020-04-11.txt", 66 | "2020-04-12.txt", 67 | "2020-04-13.txt", 68 | }, 69 | now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), 70 | expectedLatest: "2020-04-12.txt", 71 | expectedNumFound: 3, 72 | }, 73 | "mix of timestamped template files and other files": { 74 | files: []string{ 75 | ".config", 76 | "foobar", 77 | "2020-03-11.txt", 78 | "2020-03-12.txt", 79 | "2020-03-13.txt", 80 | "archive_April2020", 81 | }, 82 | now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), 83 | expectedLatest: "2020-03-13.txt", 84 | expectedNumFound: 3, 85 | }, 86 | } 87 | 88 | for name, test := range tests { 89 | t.Run(name, func(t *testing.T) { 90 | latest, numFound := getLatestTemplateFile(test.files, test.now, opts.File) 91 | require.Equal(t, test.expectedLatest, latest) 92 | require.Equal(t, test.expectedNumFound, numFound) 93 | }) 94 | } 95 | } 96 | 97 | func TestSetDateOpt(t *testing.T) { 98 | type testCase struct { 99 | cmdOpts *commandOptions 100 | files []string 101 | now time.Time 102 | expectedDate string 103 | expectedNumFiles int 104 | shouldErr bool 105 | } 106 | 107 | tests := map[string]testCase{ 108 | "multiple mutually exclusive flags: date and daysBack set": { 109 | cmdOpts: &commandOptions{ 110 | date: "2020-04-11", 111 | daysBack: 2, 112 | }, 113 | files: []string{ 114 | "2020-04-11.txt", 115 | "2020-04-10.txt", 116 | "2020-04-09.txt", 117 | }, 118 | now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), 119 | shouldErr: true, 120 | }, 121 | "multiple mutually exclusive flags: date and tomorrow set": { 122 | cmdOpts: &commandOptions{ 123 | date: "2020-04-11", 124 | tomorrow: true, 125 | }, 126 | files: []string{ 127 | "2020-04-11.txt", 128 | "2020-04-10.txt", 129 | "2020-04-09.txt", 130 | }, 131 | now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), 132 | shouldErr: true, 133 | }, 134 | "multiple mutually exclusive flags: date and latest set": { 135 | cmdOpts: &commandOptions{ 136 | date: "2020-04-11", 137 | latest: true, 138 | }, 139 | files: []string{ 140 | "2020-04-11.txt", 141 | "2020-04-10.txt", 142 | "2020-04-09.txt", 143 | }, 144 | now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), 145 | shouldErr: true, 146 | }, 147 | "multiple mutually exclusive flags: daysBack and tomorrow set": { 148 | cmdOpts: &commandOptions{ 149 | daysBack: 2, 150 | tomorrow: true, 151 | }, 152 | files: []string{ 153 | "2020-04-11.txt", 154 | "2020-04-10.txt", 155 | "2020-04-09.txt", 156 | }, 157 | now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), 158 | shouldErr: true, 159 | }, 160 | "multiple mutually exclusive flags: daysBack and latest set": { 161 | cmdOpts: &commandOptions{ 162 | daysBack: 2, 163 | latest: true, 164 | }, 165 | files: []string{ 166 | "2020-04-11.txt", 167 | "2020-04-10.txt", 168 | "2020-04-09.txt", 169 | }, 170 | now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), 171 | shouldErr: true, 172 | }, 173 | "multiple mutually exclusive flags: tomorrow and latest set": { 174 | cmdOpts: &commandOptions{ 175 | tomorrow: true, 176 | latest: true, 177 | }, 178 | files: []string{ 179 | "2020-04-11.txt", 180 | "2020-04-10.txt", 181 | "2020-04-09.txt", 182 | }, 183 | now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), 184 | shouldErr: true, 185 | }, 186 | "use date": { 187 | cmdOpts: &commandOptions{ 188 | date: "2020-04-11", 189 | }, 190 | files: []string{ 191 | "2020-04-11.txt", 192 | "2020-04-10.txt", 193 | "2020-04-09.txt", 194 | }, 195 | now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), 196 | expectedDate: "2020-04-11", 197 | expectedNumFiles: 0, 198 | shouldErr: false, 199 | }, 200 | "use daysBack": { 201 | cmdOpts: &commandOptions{ 202 | daysBack: 2, 203 | }, 204 | files: []string{ 205 | "2020-04-11.txt", 206 | "2020-04-10.txt", 207 | "2020-04-09.txt", 208 | }, 209 | now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), 210 | expectedDate: "2020-04-10", 211 | expectedNumFiles: 0, 212 | shouldErr: false, 213 | }, 214 | "use tomorrow": { 215 | cmdOpts: &commandOptions{ 216 | tomorrow: true, 217 | }, 218 | files: []string{ 219 | "2020-04-11.txt", 220 | "2020-04-10.txt", 221 | "2020-04-09.txt", 222 | }, 223 | now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), 224 | expectedDate: "2020-04-13", 225 | expectedNumFiles: 0, 226 | shouldErr: false, 227 | }, 228 | "use latest": { 229 | cmdOpts: &commandOptions{ 230 | latest: true, 231 | }, 232 | files: []string{ 233 | "2020-04-11.txt", 234 | "2020-04-10.txt", 235 | "2020-04-09.txt", 236 | }, 237 | now: time.Date(2020, 4, 15, 0, 0, 0, 0, time.UTC), 238 | expectedDate: "2020-04-11", 239 | expectedNumFiles: 3, 240 | shouldErr: false, 241 | }, 242 | "no latest found": { 243 | cmdOpts: &commandOptions{ 244 | latest: true, 245 | }, 246 | files: []string{}, 247 | now: time.Date(2020, 4, 15, 0, 0, 0, 0, time.UTC), 248 | shouldErr: true, 249 | }, 250 | "default to today": { 251 | cmdOpts: &commandOptions{}, 252 | files: []string{ 253 | "2020-04-11.txt", 254 | "2020-04-10.txt", 255 | "2020-04-09.txt", 256 | }, 257 | now: time.Date(2020, 4, 15, 0, 0, 0, 0, time.UTC), 258 | expectedDate: "2020-04-15", 259 | expectedNumFiles: 0, 260 | shouldErr: false, 261 | }, 262 | } 263 | 264 | for name, test := range tests { 265 | t.Run(name, func(t *testing.T) { 266 | // setup 267 | getFiles := func(dir string) ([]string, error) { 268 | return test.files, nil 269 | } 270 | templateOpts := templatetest.GetOpts() 271 | 272 | // test 273 | numFiles, err := setDateOpt(test.cmdOpts, templateOpts, getFiles, test.now) 274 | if test.shouldErr { 275 | require.Error(t, err) 276 | return 277 | } 278 | require.Equal(t, test.expectedNumFiles, numFiles) 279 | require.NoError(t, err) 280 | require.Equal(t, test.expectedDate, test.cmdOpts.date) 281 | }) 282 | } 283 | } 284 | 285 | func TestSetCopyDateOpt(t *testing.T) { 286 | type testCase struct { 287 | cmdOpts *commandOptions 288 | files []string 289 | now time.Time 290 | expectedDate string 291 | expectedNumFiles int 292 | shouldErr bool 293 | } 294 | 295 | tests := map[string]testCase{ 296 | "multiple mutually exclusive flags: copyDate and copyDaysBack set": { 297 | cmdOpts: &commandOptions{ 298 | copyDate: "2020-04-11", 299 | copyDaysBack: 2, 300 | }, 301 | files: []string{ 302 | "2020-04-11.txt", 303 | "2020-04-10.txt", 304 | "2020-04-09.txt", 305 | }, 306 | now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), 307 | shouldErr: true, 308 | }, 309 | "use copyDate": { 310 | cmdOpts: &commandOptions{ 311 | copyDate: "2020-04-11", 312 | }, 313 | files: []string{ 314 | "2020-04-11.txt", 315 | "2020-04-10.txt", 316 | "2020-04-09.txt", 317 | }, 318 | now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), 319 | expectedDate: "2020-04-11", 320 | expectedNumFiles: 0, 321 | shouldErr: false, 322 | }, 323 | "use copyDaysBack": { 324 | cmdOpts: &commandOptions{ 325 | copyDaysBack: 2, 326 | }, 327 | files: []string{ 328 | "2020-04-11.txt", 329 | "2020-04-10.txt", 330 | "2020-04-09.txt", 331 | }, 332 | now: time.Date(2020, 4, 12, 0, 0, 0, 0, time.UTC), 333 | expectedDate: "2020-04-10", 334 | expectedNumFiles: 0, 335 | shouldErr: false, 336 | }, 337 | "default to latest": { 338 | cmdOpts: &commandOptions{}, 339 | files: []string{ 340 | "2020-04-11.txt", 341 | "2020-04-10.txt", 342 | "2020-04-09.txt", 343 | }, 344 | now: time.Date(2020, 4, 15, 0, 0, 0, 0, time.UTC), 345 | expectedDate: "2020-04-11", 346 | expectedNumFiles: 3, 347 | shouldErr: false, 348 | }, 349 | "no latest found": { 350 | cmdOpts: &commandOptions{}, 351 | files: []string{}, 352 | now: time.Date(2020, 4, 15, 0, 0, 0, 0, time.UTC), 353 | expectedDate: "", 354 | expectedNumFiles: 0, 355 | shouldErr: false, 356 | }, 357 | } 358 | 359 | for name, test := range tests { 360 | t.Run(name, func(t *testing.T) { 361 | // setup 362 | getFiles := func(dir string) ([]string, error) { 363 | return test.files, nil 364 | } 365 | templateOpts := templatetest.GetOpts() 366 | 367 | // test 368 | numFiles, err := setCopyDateOpt(test.cmdOpts, templateOpts, getFiles, test.now) 369 | if test.shouldErr { 370 | require.Error(t, err) 371 | return 372 | } 373 | require.Equal(t, test.expectedNumFiles, numFiles) 374 | require.NoError(t, err) 375 | require.Equal(t, test.expectedDate, test.cmdOpts.copyDate) 376 | }) 377 | } 378 | } 379 | 380 | func TestSetDeleteOpts(t *testing.T) { 381 | type testCase struct { 382 | cmdOpts *commandOptions 383 | expectedDeleteSections bool 384 | expectedDeleteEmpty bool 385 | } 386 | 387 | tests := map[string]testCase{ 388 | "deleteFlagVal = 0": { 389 | cmdOpts: &commandOptions{ 390 | deleteFlagVal: 0, 391 | }, 392 | expectedDeleteSections: false, 393 | expectedDeleteEmpty: false, 394 | }, 395 | "deleteFlagVal < 0": { 396 | cmdOpts: &commandOptions{ 397 | deleteFlagVal: -1, 398 | }, 399 | expectedDeleteSections: false, 400 | expectedDeleteEmpty: false, 401 | }, 402 | "deleteFlagVal = 1": { 403 | cmdOpts: &commandOptions{ 404 | deleteFlagVal: 1, 405 | }, 406 | expectedDeleteSections: true, 407 | expectedDeleteEmpty: false, 408 | }, 409 | "deleteFlagVal = 2": { 410 | cmdOpts: &commandOptions{ 411 | deleteFlagVal: 2, 412 | }, 413 | expectedDeleteSections: true, 414 | expectedDeleteEmpty: true, 415 | }, 416 | "deleteFlagVal > 2": { 417 | cmdOpts: &commandOptions{ 418 | deleteFlagVal: 3, 419 | }, 420 | expectedDeleteSections: true, 421 | expectedDeleteEmpty: true, 422 | }, 423 | } 424 | 425 | for name, test := range tests { 426 | t.Run(name, func(t *testing.T) { 427 | setDeleteOpts(test.cmdOpts) 428 | require.Equal(t, test.expectedDeleteSections, test.cmdOpts.deleteSections) 429 | require.Equal(t, test.expectedDeleteEmpty, test.cmdOpts.deleteEmpty) 430 | }) 431 | } 432 | } 433 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/dkaslovsky/textnote/cmd/archive" 8 | "github.com/dkaslovsky/textnote/cmd/config" 9 | "github.com/dkaslovsky/textnote/cmd/initialize" 10 | "github.com/dkaslovsky/textnote/cmd/open" 11 | pkgconf "github.com/dkaslovsky/textnote/pkg/config" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | // Run executes the CLI 16 | func Run(name string, version string) error { 17 | cmd := &cobra.Command{ 18 | Use: name, 19 | Long: fmt.Sprintf("Name:\n %s - a simple tool for creating and organizing daily notes on the command line", name), 20 | SilenceUsage: true, 21 | SilenceErrors: true, 22 | RunE: func(cmd *cobra.Command, args []string) error { 23 | // run the open command with default options as the default application command 24 | return open.CreateOpenCmd().Execute() 25 | }, 26 | } 27 | 28 | cmd.AddCommand( 29 | open.CreateOpenCmd(), 30 | archive.CreateArchiveCmd(), 31 | config.CreateConfigCmd(), 32 | initialize.CreateInitCmd(), 33 | ) 34 | 35 | setVersion(cmd, version) 36 | setHelp(cmd, name) 37 | 38 | return cmd.Execute() 39 | } 40 | 41 | func setVersion(cmd *cobra.Command, version string) { 42 | if version != "" { 43 | cmd.Version = version 44 | return 45 | } 46 | 47 | cmd.Version = "unavailable" 48 | cmd.SetVersionTemplate( 49 | fmt.Sprintf("%s: built from source", strings.TrimSuffix(cmd.VersionTemplate(), "\n")), 50 | ) 51 | } 52 | 53 | func setHelp(cmd *cobra.Command, name string) { 54 | // set custom help message for the root command 55 | defaultHelpFunc := cmd.HelpFunc() 56 | cmd.SetHelpFunc(func(cmd *cobra.Command, s []string) { 57 | defaultHelpFunc(cmd, s) 58 | if cmd.Name() != name { 59 | return 60 | } 61 | if description := pkgconf.DescribeEnvVars(); description != "" { 62 | fmt.Printf("\nOverride configuration using environment variables:%s", description) 63 | } 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dkaslovsky/textnote 2 | 3 | go 1.21.4 4 | 5 | require ( 6 | dario.cat/mergo v1.0.0 7 | github.com/ilyakaznacheev/cleanenv v1.5.0 8 | github.com/pkg/errors v0.9.1 9 | github.com/spf13/cobra v1.8.0 10 | github.com/stretchr/testify v1.8.4 11 | gopkg.in/yaml.v3 v3.0.1 12 | ) 13 | 14 | require ( 15 | github.com/BurntSushi/toml v1.2.1 // indirect 16 | github.com/davecgh/go-spew v1.1.1 // indirect 17 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 18 | github.com/joho/godotenv v1.5.1 // indirect 19 | github.com/pmezard/go-difflib v1.0.0 // indirect 20 | github.com/spf13/pflag v1.0.5 // indirect 21 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= 2 | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= 4 | github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= 9 | github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= 10 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 11 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 12 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 13 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 14 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 15 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 16 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 17 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 18 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 19 | github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 20 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 21 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 22 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 23 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 24 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 25 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 26 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 27 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 28 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 29 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= 30 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= 31 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/dkaslovsky/textnote/cmd" 7 | "github.com/dkaslovsky/textnote/pkg/config" 8 | ) 9 | 10 | const name = "textnote" 11 | 12 | var version string // set by build ldflags 13 | 14 | func main() { 15 | log.SetFlags(0) 16 | 17 | err := config.InitApp() 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | 22 | err = cmd.Run(name, version) 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /pkg/archive/archive.go: -------------------------------------------------------------------------------- 1 | package archive 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | "github.com/dkaslovsky/textnote/pkg/config" 9 | "github.com/dkaslovsky/textnote/pkg/file" 10 | "github.com/dkaslovsky/textnote/pkg/template" 11 | ) 12 | 13 | // Archiver consolidates templates into archives 14 | type Archiver struct { 15 | opts config.Opts 16 | rw readWriter 17 | date time.Time // timestamp for calculating if a file is old enough to be archived 18 | 19 | // monthArchives maintains a map of formatted month timestamp to the corresponding archive 20 | monthArchives map[string]*template.MonthArchiveTemplate 21 | // archivedFiles maintains the file names that have been archived 22 | archivedFiles []string 23 | } 24 | 25 | // NewArchiver constructs a new Archiver 26 | func NewArchiver(opts config.Opts, rw readWriter, date time.Time) *Archiver { 27 | return &Archiver{ 28 | opts: opts, 29 | rw: rw, 30 | date: date, 31 | 32 | monthArchives: map[string]*template.MonthArchiveTemplate{}, 33 | archivedFiles: []string{}, 34 | } 35 | } 36 | 37 | // Add adds a template corresponding to a date to the archive 38 | func (a *Archiver) Add(date time.Time) error { 39 | // recent files are not archived 40 | if a.date.Sub(date).Hours() <= float64(a.opts.Archive.AfterDays*24) { 41 | return nil 42 | } 43 | 44 | t := template.NewTemplate(a.opts, date) 45 | err := a.rw.Read(t) 46 | if err != nil { 47 | return fmt.Errorf("cannot add unreadable file [%s] to archive: %w", t.GetFilePath(), err) 48 | } 49 | 50 | monthKey := date.Format(a.opts.Archive.MonthTimeFormat) 51 | if _, found := a.monthArchives[monthKey]; !found { 52 | a.monthArchives[monthKey] = template.NewMonthArchiveTemplate(a.opts, date) 53 | } 54 | 55 | archive := a.monthArchives[monthKey] 56 | for _, section := range a.opts.Section.Names { 57 | err := archive.ArchiveSectionContents(t, section) 58 | if err != nil { 59 | return fmt.Errorf("cannot add contents from [%s] to archive: %w", t.GetFilePath(), err) 60 | } 61 | } 62 | 63 | a.archivedFiles = append(a.archivedFiles, t.GetFilePath()) 64 | return nil 65 | } 66 | 67 | // Write writes all of the archive templates stored in the Archiver 68 | func (a *Archiver) Write() error { 69 | for _, t := range a.monthArchives { 70 | if a.rw.Exists(t) { 71 | existing := template.NewMonthArchiveTemplate(a.opts, t.GetDate()) 72 | err := a.rw.Read(existing) 73 | if err != nil { 74 | return fmt.Errorf("unable to open existing archive file [%s]: %w", existing.GetFilePath(), err) 75 | } 76 | err = t.Merge(existing) 77 | if err != nil { 78 | return fmt.Errorf("unable to from merge existing archive file [%s] %w", existing.GetFilePath(), err) 79 | } 80 | } 81 | 82 | err := a.rw.Overwrite(t) 83 | if err != nil { 84 | return fmt.Errorf("failed to write archive file [%s]: %w", t.GetFilePath(), err) 85 | } 86 | log.Printf("wrote archive file [%s]", t.GetFilePath()) 87 | } 88 | return nil 89 | } 90 | 91 | // GetArchivedFiles returns the files that have been archived 92 | func (a *Archiver) GetArchivedFiles() []string { 93 | return a.archivedFiles 94 | } 95 | 96 | // readWriter is the interface for executing file operations 97 | type readWriter interface { 98 | Read(file.ReadWriteable) error 99 | Overwrite(file.ReadWriteable) error 100 | Exists(file.ReadWriteable) bool 101 | } 102 | -------------------------------------------------------------------------------- /pkg/archive/archive_test.go: -------------------------------------------------------------------------------- 1 | package archive 2 | 3 | import ( 4 | "bytes" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/dkaslovsky/textnote/pkg/file" 11 | "github.com/dkaslovsky/textnote/pkg/template" 12 | "github.com/dkaslovsky/textnote/pkg/template/templatetest" 13 | 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | // 18 | // mocks 19 | // 20 | 21 | type testReadWriter struct { 22 | exists bool 23 | toRead string 24 | written string 25 | } 26 | 27 | func newTestReadWriter(exists bool, toRead string) *testReadWriter { 28 | return &testReadWriter{ 29 | exists: exists, 30 | toRead: toRead, 31 | written: "", 32 | } 33 | } 34 | 35 | func (trw *testReadWriter) Read(rwable file.ReadWriteable) error { 36 | r := strings.NewReader(trw.toRead) 37 | return rwable.Load(r) 38 | } 39 | 40 | func (trw *testReadWriter) Overwrite(rwable file.ReadWriteable) error { 41 | buf := new(bytes.Buffer) 42 | err := rwable.Write(buf) 43 | if err != nil { 44 | return err 45 | } 46 | trw.written = buf.String() 47 | return nil 48 | } 49 | 50 | func (trw *testReadWriter) Exists(rwable file.ReadWriteable) bool { 51 | return trw.exists 52 | } 53 | 54 | // 55 | // Tests 56 | // 57 | 58 | func TestAdd(t *testing.T) { 59 | type testCase struct { 60 | date time.Time 61 | templateText string 62 | existing map[string]string 63 | expectedArchives map[string]string 64 | expectedFiles []string 65 | } 66 | 67 | tests := map[string]testCase{ 68 | "add template that should not be archived": { 69 | date: time.Date(2020, 12, 20, 0, 0, 0, 0, time.UTC), 70 | expectedArchives: map[string]string{}, 71 | expectedFiles: []string{}, 72 | }, 73 | "add template from last day that should not be archived": { 74 | date: time.Date(2020, 12, 14, 0, 0, 0, 0, time.UTC), 75 | expectedArchives: map[string]string{}, 76 | expectedFiles: []string{}, 77 | }, 78 | "add template from first day that should be archived": { 79 | date: time.Date(2020, 12, 13, 0, 0, 0, 0, time.UTC), 80 | templateText: `-^-[Sun] 13 Dec 2020-v- 81 | 82 | _p_TestSection1_q_ 83 | text1 84 | text2 85 | 86 | 87 | 88 | _p_TestSection2_q_ 89 | 90 | 91 | 92 | _p_TestSection3_q_ 93 | 94 | 95 | 96 | `, 97 | expectedArchives: map[string]string{ 98 | "Dec2020": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 99 | 100 | _p_TestSection1_q_ 101 | [2020-12-13] 102 | text1 103 | text2 104 | 105 | 106 | 107 | _p_TestSection2_q_ 108 | 109 | 110 | 111 | _p_TestSection3_q_ 112 | 113 | 114 | 115 | `, 116 | }, 117 | expectedFiles: []string{ 118 | "2020-12-13.txt", 119 | }, 120 | }, 121 | "add template from current month": { 122 | date: time.Date(2020, 12, 1, 0, 0, 0, 0, time.UTC), 123 | templateText: `-^-[Tue] 01 Dec 2020-v- 124 | 125 | _p_TestSection1_q_ 126 | text1 127 | text2 128 | 129 | 130 | 131 | _p_TestSection2_q_ 132 | 133 | 134 | 135 | _p_TestSection3_q_ 136 | 137 | 138 | 139 | `, 140 | expectedArchives: map[string]string{ 141 | "Dec2020": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 142 | 143 | _p_TestSection1_q_ 144 | [2020-12-01] 145 | text1 146 | text2 147 | 148 | 149 | 150 | _p_TestSection2_q_ 151 | 152 | 153 | 154 | _p_TestSection3_q_ 155 | 156 | 157 | 158 | `, 159 | }, 160 | expectedFiles: []string{ 161 | "2020-12-01.txt", 162 | }, 163 | }, 164 | "add template from different month": { 165 | date: time.Date(2020, 11, 1, 0, 0, 0, 0, time.UTC), 166 | templateText: `-^-[Sun] 01 Nov 2020-v- 167 | 168 | _p_TestSection1_q_ 169 | text1 170 | text2 171 | 172 | 173 | 174 | _p_TestSection2_q_ 175 | 176 | 177 | 178 | _p_TestSection3_q_ 179 | 180 | 181 | 182 | `, 183 | expectedArchives: map[string]string{ 184 | "Nov2020": `ARCHIVEPREFIX Nov2020 ARCHIVESUFFIX 185 | 186 | _p_TestSection1_q_ 187 | [2020-11-01] 188 | text1 189 | text2 190 | 191 | 192 | 193 | _p_TestSection2_q_ 194 | 195 | 196 | 197 | _p_TestSection3_q_ 198 | 199 | 200 | 201 | `, 202 | }, 203 | expectedFiles: []string{ 204 | "2020-11-01.txt", 205 | }, 206 | }, 207 | "add template from different year": { 208 | date: time.Date(2019, 11, 2, 0, 0, 0, 0, time.UTC), 209 | templateText: `-^-[Sat] 02 Nov 2019-v- 210 | 211 | _p_TestSection1_q_ 212 | text1 213 | text2 214 | 215 | 216 | 217 | _p_TestSection2_q_ 218 | 219 | 220 | 221 | _p_TestSection3_q_ 222 | 223 | 224 | 225 | `, 226 | expectedArchives: map[string]string{ 227 | "Nov2019": `ARCHIVEPREFIX Nov2019 ARCHIVESUFFIX 228 | 229 | _p_TestSection1_q_ 230 | [2019-11-02] 231 | text1 232 | text2 233 | 234 | 235 | 236 | _p_TestSection2_q_ 237 | 238 | 239 | 240 | _p_TestSection3_q_ 241 | 242 | 243 | 244 | `, 245 | }, 246 | expectedFiles: []string{ 247 | "2019-11-02.txt", 248 | }, 249 | }, 250 | "add template with earlier date to existing archive": { 251 | date: time.Date(2020, 12, 1, 0, 0, 0, 0, time.UTC), 252 | templateText: `-^-[Tue] 01 Dec 2020-v- 253 | 254 | _p_TestSection1_q_ 255 | text1 256 | text2 257 | 258 | 259 | 260 | _p_TestSection2_q_ 261 | 262 | 263 | 264 | _p_TestSection3_q_ 265 | 266 | 267 | 268 | `, 269 | existing: map[string]string{ 270 | "Dec2020": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 271 | 272 | _p_TestSection1_q_ 273 | [2020-12-02] 274 | existingText1 275 | existingText2 276 | existingText3 277 | 278 | _p_TestSection2_q_ 279 | 280 | 281 | 282 | _p_TestSection3_q_ 283 | 284 | 285 | 286 | `, 287 | }, 288 | expectedArchives: map[string]string{ 289 | "Dec2020": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 290 | 291 | _p_TestSection1_q_ 292 | [2020-12-01] 293 | text1 294 | text2 295 | [2020-12-02] 296 | existingText1 297 | existingText2 298 | existingText3 299 | 300 | 301 | 302 | _p_TestSection2_q_ 303 | 304 | 305 | 306 | _p_TestSection3_q_ 307 | 308 | 309 | 310 | `, 311 | }, 312 | expectedFiles: []string{ 313 | "2020-12-01.txt", 314 | }, 315 | }, 316 | "add template with later date to existing archive": { 317 | date: time.Date(2020, 12, 2, 0, 0, 0, 0, time.UTC), 318 | templateText: `-^-[Wed] 02 Dec 2020-v- 319 | 320 | _p_TestSection1_q_ 321 | text1 322 | text2 323 | 324 | 325 | 326 | _p_TestSection2_q_ 327 | 328 | 329 | 330 | _p_TestSection3_q_ 331 | 332 | 333 | 334 | `, 335 | existing: map[string]string{ 336 | "Dec2020": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 337 | 338 | _p_TestSection1_q_ 339 | [2020-12-01] 340 | existingText1 341 | existingText2 342 | existingText3 343 | 344 | _p_TestSection2_q_ 345 | 346 | 347 | 348 | _p_TestSection3_q_ 349 | 350 | 351 | 352 | `, 353 | }, 354 | expectedArchives: map[string]string{ 355 | "Dec2020": `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 356 | 357 | _p_TestSection1_q_ 358 | [2020-12-01] 359 | existingText1 360 | existingText2 361 | existingText3 362 | [2020-12-02] 363 | text1 364 | text2 365 | 366 | 367 | 368 | _p_TestSection2_q_ 369 | 370 | 371 | 372 | _p_TestSection3_q_ 373 | 374 | 375 | 376 | `, 377 | }, 378 | expectedFiles: []string{ 379 | "2020-12-02.txt", 380 | }, 381 | }, 382 | } 383 | 384 | for name, test := range tests { 385 | t.Run(name, func(t *testing.T) { 386 | opts := templatetest.GetOpts() 387 | trw := newTestReadWriter(true, test.templateText) 388 | a := NewArchiver(opts, trw, templatetest.Date) 389 | for key, text := range test.existing { 390 | existingDate, err := time.Parse(opts.Archive.MonthTimeFormat, key) 391 | require.NoError(t, err) 392 | 393 | m := template.NewMonthArchiveTemplate(opts, existingDate) 394 | err = m.Load(strings.NewReader(text)) 395 | require.NoError(t, err) 396 | 397 | a.monthArchives[key] = m 398 | } 399 | 400 | err := a.Add(test.date) 401 | require.NoError(t, err) 402 | 403 | require.Equal(t, len(test.expectedArchives), len(a.monthArchives)) 404 | for key, expectedText := range test.expectedArchives { 405 | buf := new(bytes.Buffer) 406 | monthArchive, found := a.monthArchives[key] 407 | require.True(t, found) 408 | err := monthArchive.Write(buf) 409 | require.NoError(t, err) 410 | require.Equal(t, expectedText, buf.String()) 411 | } 412 | 413 | expectedFilesWithFullPath := []string{} 414 | for _, f := range test.expectedFiles { 415 | fullPath := filepath.Join(opts.AppDir, f) 416 | expectedFilesWithFullPath = append(expectedFilesWithFullPath, fullPath) 417 | } 418 | require.ElementsMatch(t, expectedFilesWithFullPath, a.GetArchivedFiles()) 419 | }) 420 | } 421 | } 422 | 423 | func TestWrite(t *testing.T) { 424 | type testCase struct { 425 | text string 426 | exists bool 427 | existingText string 428 | expected string 429 | } 430 | 431 | tests := map[string]testCase{ 432 | "write with empty archive in archiver to new archive": { 433 | exists: false, 434 | expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 435 | 436 | _p_TestSection1_q_ 437 | 438 | 439 | 440 | _p_TestSection2_q_ 441 | 442 | 443 | 444 | _p_TestSection3_q_ 445 | 446 | 447 | 448 | `, 449 | }, 450 | "write with empty archive in archiver to existing archive": { 451 | exists: true, 452 | existingText: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 453 | 454 | _p_TestSection1_q_ 455 | [2020-12-15] 456 | existingText1a 457 | 458 | 459 | 460 | _p_TestSection2_q_ 461 | 462 | 463 | 464 | _p_TestSection3_q_ 465 | [2020-12-15] 466 | existingText3a 467 | [2020-12-22] 468 | existingText3b 469 | 470 | 471 | 472 | `, 473 | expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 474 | 475 | _p_TestSection1_q_ 476 | [2020-12-15] 477 | existingText1a 478 | 479 | 480 | 481 | _p_TestSection2_q_ 482 | 483 | 484 | 485 | _p_TestSection3_q_ 486 | [2020-12-15] 487 | existingText3a 488 | [2020-12-22] 489 | existingText3b 490 | 491 | 492 | 493 | `, 494 | }, 495 | "write to new archive": { 496 | text: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 497 | 498 | _p_TestSection1_q_ 499 | [2020-12-17] 500 | text1a 501 | [2020-12-19] 502 | text1b 503 | 504 | _p_TestSection2_q_ 505 | 506 | _p_TestSection3_q_ 507 | [2020-12-18] 508 | text3a 509 | [2020-12-19] 510 | text3b 511 | 512 | `, 513 | exists: false, 514 | expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 515 | 516 | _p_TestSection1_q_ 517 | [2020-12-17] 518 | text1a 519 | [2020-12-19] 520 | text1b 521 | 522 | 523 | 524 | _p_TestSection2_q_ 525 | 526 | 527 | 528 | _p_TestSection3_q_ 529 | [2020-12-18] 530 | text3a 531 | [2020-12-19] 532 | text3b 533 | 534 | 535 | 536 | `, 537 | }, 538 | "write to existing archive": { 539 | text: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 540 | 541 | _p_TestSection1_q_ 542 | [2020-12-17] 543 | text1a 544 | [2020-12-19] 545 | text1b 546 | 547 | _p_TestSection2_q_ 548 | 549 | _p_TestSection3_q_ 550 | [2020-12-18] 551 | text3a 552 | [2020-12-19] 553 | text3b 554 | 555 | `, 556 | exists: true, 557 | existingText: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 558 | 559 | _p_TestSection1_q_ 560 | [2020-12-15] 561 | existingText1a 562 | 563 | 564 | 565 | _p_TestSection2_q_ 566 | 567 | 568 | 569 | _p_TestSection3_q_ 570 | [2020-12-15] 571 | existingText3a 572 | [2020-12-22] 573 | existingText3b 574 | 575 | 576 | 577 | `, 578 | expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 579 | 580 | _p_TestSection1_q_ 581 | [2020-12-15] 582 | existingText1a 583 | [2020-12-17] 584 | text1a 585 | [2020-12-19] 586 | text1b 587 | 588 | 589 | 590 | _p_TestSection2_q_ 591 | 592 | 593 | 594 | _p_TestSection3_q_ 595 | [2020-12-15] 596 | existingText3a 597 | [2020-12-18] 598 | text3a 599 | [2020-12-19] 600 | text3b 601 | [2020-12-22] 602 | existingText3b 603 | 604 | 605 | 606 | `, 607 | }, 608 | } 609 | 610 | for name, test := range tests { 611 | t.Run(name, func(t *testing.T) { 612 | opts := templatetest.GetOpts() 613 | date := templatetest.Date 614 | key := date.Format(opts.Archive.MonthTimeFormat) 615 | 616 | template := template.NewMonthArchiveTemplate(opts, date) 617 | err := template.Load(strings.NewReader(test.text)) 618 | require.NoError(t, err) 619 | 620 | trw := newTestReadWriter(test.exists, test.existingText) 621 | a := NewArchiver(opts, trw, date) 622 | a.monthArchives[key] = template 623 | 624 | err = a.Write() 625 | require.NoError(t, err) 626 | require.Equal(t, test.expected, trw.written) 627 | }) 628 | } 629 | } 630 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "dario.cat/mergo" 11 | "github.com/ilyakaznacheev/cleanenv" 12 | "github.com/pkg/errors" 13 | "gopkg.in/yaml.v3" 14 | ) 15 | 16 | const ( 17 | // envAppDir is the name of the environment variable specifying the application directory 18 | envAppDir = "TEXTNOTE_DIR" 19 | // fileName is the name of the configuration file 20 | fileName = ".config.yml" 21 | ) 22 | 23 | // appDir is the directory in which the application stores its files 24 | var appDir = os.Getenv(envAppDir) 25 | 26 | // Opts are options that configure the application 27 | type Opts struct { 28 | AppDir string `yaml:"-"` // AppDir is always read from the environment and is not written to file 29 | Header HeaderOpts `yaml:"header"` 30 | Section SectionOpts `yaml:"section"` 31 | File FileOpts `yaml:"file"` 32 | Archive ArchiveOpts `yaml:"archive"` 33 | Cli CliOpts `yaml:"cli"` 34 | TemplateFileCountThresh int `yaml:"templateFileCountThresh" env:"TEXTNOTE_TEMPLATE_FILE_COUNT_THRESH" env-description:"threshold for warning too many template files"` 35 | } 36 | 37 | // HeaderOpts are options for configuring the header of a note 38 | type HeaderOpts struct { 39 | Prefix string `yaml:"prefix" env:"TEXTNOTE_HEADER_PREFIX" env-description:"prefix to attach to header"` 40 | Suffix string `yaml:"suffix" env:"TEXTNOTE_HEADER_SUFFIX" env-description:"suffix to attach to header"` 41 | TrailingNewlines int `yaml:"trailingNewlines" env:"TEXTNOTE_HEADER_TRAILING_NEWLINES" env-description:"number of newlines to attach to end of header"` 42 | TimeFormat string `yaml:"timeFormat" env:"TEXTNOTE_HEADER_TIME_FORMAT" env-description:"formatting string to form headers from timestamps"` 43 | } 44 | 45 | // SectionOpts are options for configuring sections of a note 46 | type SectionOpts struct { 47 | Prefix string `yaml:"prefix" env:"TEXTNOTE_SECTION_PREFIX" env-description:"prefix to attach to section names"` 48 | Suffix string `yaml:"suffix" env:"TEXTNOTE_SECTION_SUFFIX" env-description:"suffix to attach to section names"` 49 | TrailingNewlines int `yaml:"trailingNewlines" env:"TEXTNOTE_SECTION_TRAILING_NEWLINES" env-description:"number of newlines to attach to end of each section"` 50 | Names []string `yaml:"names" env:"TEXTNOTE_SECTION_NAMES" env-description:"section names"` 51 | } 52 | 53 | // FileOpts are options for configuring file outputs 54 | type FileOpts struct { 55 | Ext string `yaml:"ext" env:"TEXTNOTE_FILE_EXT" env-description:"extension for all files written"` 56 | TimeFormat string `yaml:"timeFormat" env:"TEXTNOTE_FILE_TIME_FORMAT" env-description:"formatting string to form file names from timestamps"` 57 | CursorLine int `yaml:"cursorLine" env:"TEXTNOTE_FILE_CURSOR_LINE" env-description:"line to place cursor when opening"` 58 | } 59 | 60 | // ArchiveOpts are options for configuring note archives 61 | type ArchiveOpts struct { 62 | AfterDays int `yaml:"afterDays" env:"TEXTNOTE_ARCHIVE_AFTER_DAYS" env-description:"number of days after which to archive a file"` 63 | FilePrefix string `yaml:"filePrefix" env:"TEXTNOTE_ARCHIVE_FILE_PREFIX" env-description:"prefix attached to the file name of all archive files"` 64 | HeaderPrefix string `yaml:"headerPrefix" env:"TEXTNOTE_ARCHIVE_HEADER_PREFIX" env-description:"override header prefix for archive files"` 65 | HeaderSuffix string `yaml:"headerSuffix" env:"TEXTNOTE_ARCHIVE_HEADER_SUFFIX" env-description:"override header suffix for archive files"` 66 | SectionContentPrefix string `yaml:"sectionContentPrefix" env:"TEXTNOTE_ARCHIVE_SECTION_CONTENT_PREFIX" env-description:"prefix to attach to section content date"` 67 | SectionContentSuffix string `yaml:"sectionContentSuffix" env:"TEXTNOTE_ARCHIVE_SECTION_CONTENT_SUFFIX" env-description:"suffix to attach to section content date"` 68 | SectionContentTimeFormat string `yaml:"sectionContentTimeFormat" env:"TEXTNOTE_ARCHIVE_SECTION_CONTENT_TIME_FORMAT" env-description:"formatting string dated section content"` 69 | MonthTimeFormat string `yaml:"monthTimeFormat" env:"TEXTNOTE_ARCHIVE_MONTH_TIME_FORMAT" env-description:"formatting string for month archive timestamps"` 70 | } 71 | 72 | // CliOpts are options for configuring the CLI 73 | type CliOpts struct { 74 | TimeFormat string `yaml:"timeFormat" env:"TEXTNOTE_CLI_TIME_FORMAT" env-description:"formatting string for timestamp CLI flags"` 75 | } 76 | 77 | // OptsBackCompat are options maintained for backwards compatibility that will be honored in the absence (zero-value) of their 78 | // replacements as handled in loadBackCompat() 79 | type OptsBackCompat struct { 80 | // TemplateFileCountThresh holds the value of the field "templateFileCountTresh" (note the typo) in a yaml configuration file 81 | TemplateFileCountThresh int `yaml:"templateFileCountTresh"` 82 | } 83 | 84 | func getDefaultOpts() Opts { 85 | return Opts{ 86 | Header: HeaderOpts{ 87 | Prefix: "", 88 | Suffix: "", 89 | TrailingNewlines: 1, 90 | TimeFormat: "[Mon] 02 Jan 2006", 91 | }, 92 | Section: SectionOpts{ 93 | Prefix: "___", 94 | Suffix: "___", 95 | TrailingNewlines: 3, 96 | Names: []string{ 97 | "TODO", 98 | "DONE", 99 | "NOTES", 100 | }, 101 | }, 102 | File: FileOpts{ 103 | Ext: "txt", 104 | TimeFormat: "2006-01-02", 105 | CursorLine: 4, 106 | }, 107 | Archive: ArchiveOpts{ 108 | AfterDays: 14, 109 | FilePrefix: "archive-", 110 | HeaderPrefix: "ARCHIVE ", 111 | HeaderSuffix: "", 112 | SectionContentPrefix: "[", 113 | SectionContentSuffix: "]", 114 | SectionContentTimeFormat: "2006-01-02", 115 | MonthTimeFormat: "Jan2006", 116 | }, 117 | Cli: CliOpts{ 118 | TimeFormat: "2006-01-02", 119 | }, 120 | TemplateFileCountThresh: 90, 121 | } 122 | } 123 | 124 | // Load loads the configuration from file and/or evironment 125 | func Load() (Opts, error) { 126 | opts := Opts{} 127 | 128 | // parse config file allowing environment variable overrides 129 | err := loadFromEnv(GetConfigFilePath(), &opts) 130 | if err != nil { 131 | return opts, fmt.Errorf("unable to read config file: %w", err) 132 | } 133 | 134 | // overwrite defaults with opts from file/env 135 | defaults := getDefaultOpts() 136 | err = mergo.Merge(&opts, defaults) 137 | if err != nil { 138 | return opts, fmt.Errorf("unable to integrate configuration from file with defaults: %w", err) 139 | } 140 | 141 | // set AppDir as read from environment 142 | opts.AppDir = appDir 143 | 144 | err = ValidateOpts(opts) 145 | if err != nil { 146 | return opts, fmt.Errorf("configuration error in [%s]: %w", fileName, err) 147 | } 148 | 149 | return opts, nil 150 | } 151 | 152 | func loadFromEnv(path string, opts *Opts) error { 153 | err := cleanenv.ReadConfig(path, opts) 154 | if err != nil { 155 | return err 156 | } 157 | 158 | err = loadBackCompat(path, opts) 159 | if err != nil { 160 | return fmt.Errorf("unable to read config file for backwards compatibility fields: %w", err) 161 | } 162 | 163 | return nil 164 | } 165 | 166 | func loadBackCompat(path string, opts *Opts) error { 167 | // TemplateFileCountThresh backwards compatibility with previously typo'd field 168 | if opts.TemplateFileCountThresh != 0 { 169 | return nil 170 | } 171 | backcompat := OptsBackCompat{} 172 | err := cleanenv.ReadConfig(GetConfigFilePath(), &backcompat) 173 | if err != nil { 174 | return err 175 | } 176 | opts.TemplateFileCountThresh = backcompat.TemplateFileCountThresh 177 | return nil 178 | } 179 | 180 | // CreateIfNotExists writes defaults to the configuration file if it does not already exist 181 | func CreateIfNotExists() error { 182 | configPath := GetConfigFilePath() 183 | _, err := os.Stat(configPath) 184 | if !os.IsNotExist(err) { 185 | // config file exists, nothing to do 186 | return nil 187 | } 188 | 189 | defaults := getDefaultOpts() 190 | yml, err := yaml.Marshal(defaults) 191 | if err != nil { 192 | return fmt.Errorf("unable to generate config file: %w", err) 193 | } 194 | err = os.WriteFile(configPath, yml, 0o644) 195 | if err != nil { 196 | return fmt.Errorf("unable to create configuration file [%s]: %w", configPath, err) 197 | } 198 | log.Printf("created default configuration file: [%s]", configPath) 199 | return nil 200 | } 201 | 202 | // EnsureAppDir validates that the application directory exists or is created 203 | func EnsureAppDir() error { 204 | if appDir == "" { 205 | return fmt.Errorf("required environment variable [%s] is not set", envAppDir) 206 | } 207 | 208 | finfo, err := os.Stat(appDir) 209 | if os.IsNotExist(err) { 210 | err := os.MkdirAll(appDir, 0o755) 211 | if err != nil { 212 | return err 213 | } 214 | log.Printf("created directory [%s]", appDir) 215 | return nil 216 | } 217 | 218 | if !finfo.IsDir() { 219 | return fmt.Errorf("[%s=%s] must be a directory", envAppDir, appDir) 220 | } 221 | return nil 222 | } 223 | 224 | // ValidateOpts returns an error if the specified options are misconfigured 225 | func ValidateOpts(opts Opts) error { 226 | // validate appDir is not empty 227 | if opts.AppDir == "" { 228 | return fmt.Errorf("must include path to application directory in %s environment variable", envAppDir) 229 | } 230 | 231 | // validate at least one section 232 | if len(opts.Section.Names) == 0 { 233 | return errors.New("must include at least one section") 234 | } 235 | 236 | // validate section names are unique 237 | uniq := map[string]struct{}{} 238 | for _, name := range opts.Section.Names { 239 | uniq[name] = struct{}{} 240 | } 241 | if len(uniq) != len(opts.Section.Names) { 242 | return errors.New("section names must be unique") 243 | } 244 | 245 | // validate file archive prefix: this is needed for determining if a file is an archive 246 | if opts.Archive.FilePrefix == "" || strings.ReplaceAll(opts.Archive.FilePrefix, " ", "") == "" { 247 | return errors.New("file prefix for archives must not be empty") 248 | } 249 | 250 | // validate archive after days is at least 1 251 | if opts.Archive.AfterDays < 1 { 252 | return errors.New("archive after days must be greater than or equal to 1") 253 | } 254 | 255 | // validate file extension does not contain leading dot 256 | if strings.HasPrefix(opts.File.Ext, ".") { 257 | return errors.New("file extension must not include leading dot") 258 | } 259 | 260 | // validate the file cursor line is not negative 261 | if opts.File.CursorLine < 0 { 262 | return errors.New("cursor line must not be negative") 263 | } 264 | 265 | // validate threshold for warning on too many template files is larger than archive after days 266 | if opts.TemplateFileCountThresh <= opts.Archive.AfterDays { 267 | return errors.New("template file count threshold must be larger than archive after days") 268 | } 269 | 270 | return nil 271 | } 272 | 273 | // DescribeEnvVars returns a description string for environment variables used to configure the application 274 | func DescribeEnvVars() string { 275 | header := "" 276 | description, err := cleanenv.GetDescription(&Opts{}, &header) 277 | if err != nil { 278 | return "" 279 | } 280 | return description 281 | } 282 | 283 | // GetConfigFilePath constructs the full path to the configuration file 284 | func GetConfigFilePath() string { 285 | return filepath.Join(appDir, fileName) 286 | } 287 | 288 | // InitApp initializes the application by ensuring the necessary directories and files exist 289 | func InitApp() error { 290 | err := EnsureAppDir() 291 | if err != nil { 292 | return err 293 | } 294 | err = CreateIfNotExists() 295 | if err != nil { 296 | return err 297 | } 298 | return nil 299 | } 300 | -------------------------------------------------------------------------------- /pkg/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestValidateOpts(t *testing.T) { 10 | t.Run("no appDir", func(t *testing.T) { 11 | opts := getTestOpts() 12 | opts.AppDir = "" 13 | err := ValidateOpts(opts) 14 | require.Error(t, err) 15 | }) 16 | 17 | t.Run("no section names", func(t *testing.T) { 18 | opts := getTestOpts() 19 | opts.Section.Names = []string{} 20 | err := ValidateOpts(opts) 21 | require.Error(t, err) 22 | }) 23 | 24 | t.Run("section names are not unique", func(t *testing.T) { 25 | opts := getTestOpts() 26 | opts.Section.Names = []string{ 27 | "section1", 28 | "section2", 29 | "section1", 30 | } 31 | err := ValidateOpts(opts) 32 | require.Error(t, err) 33 | }) 34 | 35 | t.Run("section names are unique", func(t *testing.T) { 36 | opts := getTestOpts() 37 | opts.Section.Names = []string{ 38 | "section1", 39 | "section2", 40 | "section3", 41 | } 42 | err := ValidateOpts(opts) 43 | require.NoError(t, err) 44 | }) 45 | 46 | t.Run("archive file prefix is empty string", func(t *testing.T) { 47 | opts := getTestOpts() 48 | opts.Archive.FilePrefix = "" 49 | err := ValidateOpts(opts) 50 | require.Error(t, err) 51 | }) 52 | 53 | t.Run("archive file prefix is blank", func(t *testing.T) { 54 | opts := getTestOpts() 55 | opts.Archive.FilePrefix = " " 56 | err := ValidateOpts(opts) 57 | require.Error(t, err) 58 | }) 59 | 60 | t.Run("archive file prefix is not empty or blank", func(t *testing.T) { 61 | opts := getTestOpts() 62 | opts.Archive.FilePrefix = "xyzarchivexyz" 63 | err := ValidateOpts(opts) 64 | require.NoError(t, err) 65 | }) 66 | 67 | t.Run("archive after days is negative", func(t *testing.T) { 68 | opts := getTestOpts() 69 | opts.Archive.AfterDays = -1 70 | err := ValidateOpts(opts) 71 | require.Error(t, err) 72 | }) 73 | 74 | t.Run("archive after days is zero", func(t *testing.T) { 75 | opts := getTestOpts() 76 | opts.Archive.AfterDays = 0 77 | err := ValidateOpts(opts) 78 | require.Error(t, err) 79 | }) 80 | 81 | t.Run("archive after days is one", func(t *testing.T) { 82 | opts := getTestOpts() 83 | opts.Archive.AfterDays = 1 84 | err := ValidateOpts(opts) 85 | require.NoError(t, err) 86 | }) 87 | 88 | t.Run("empty file extension should not error", func(t *testing.T) { 89 | opts := getTestOpts() 90 | opts.File.Ext = "" 91 | err := ValidateOpts(opts) 92 | require.NoError(t, err) 93 | }) 94 | 95 | t.Run("file extension without dot should not error", func(t *testing.T) { 96 | opts := getTestOpts() 97 | opts.File.Ext = "txt" 98 | err := ValidateOpts(opts) 99 | require.NoError(t, err) 100 | }) 101 | 102 | t.Run("file extension with leading dot should not error", func(t *testing.T) { 103 | opts := getTestOpts() 104 | opts.File.Ext = ".txt" 105 | err := ValidateOpts(opts) 106 | require.Error(t, err) 107 | }) 108 | 109 | t.Run("file cursor line is negative", func(t *testing.T) { 110 | opts := getTestOpts() 111 | opts.File.CursorLine = -2 112 | err := ValidateOpts(opts) 113 | require.Error(t, err) 114 | }) 115 | 116 | t.Run("file cursor line is zero", func(t *testing.T) { 117 | opts := getTestOpts() 118 | opts.File.CursorLine = 0 119 | err := ValidateOpts(opts) 120 | require.NoError(t, err) 121 | }) 122 | 123 | t.Run("template file count threshold not greater than archive after days should error", func(t *testing.T) { 124 | opts := getTestOpts() 125 | opts.Archive.AfterDays = 100 126 | opts.TemplateFileCountThresh = 100 127 | err := ValidateOpts(opts) 128 | require.Error(t, err) 129 | }) 130 | 131 | t.Run("template file count threshold greater than archive after days should not error", func(t *testing.T) { 132 | opts := getTestOpts() 133 | opts.Archive.AfterDays = 100 134 | opts.TemplateFileCountThresh = 101 135 | err := ValidateOpts(opts) 136 | require.NoError(t, err) 137 | }) 138 | } 139 | 140 | func getTestOpts() Opts { 141 | opts := getDefaultOpts() 142 | opts.AppDir = "path/to/appDir" 143 | return opts 144 | } 145 | -------------------------------------------------------------------------------- /pkg/editor/editor.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | ) 8 | 9 | // EnvEditor is the name of the environment variable specifying the editor for opening notes 10 | const EnvEditor = "EDITOR" 11 | 12 | const ( 13 | editorNameEmacs = "emacs" 14 | editorNameNano = "nano" 15 | editorNameNeovim = "nvim" 16 | editorNameVi = "vi" 17 | editorNameVim = "vim" 18 | ) 19 | 20 | // openable is the interface that an editor opens 21 | type openable interface { 22 | GetFilePath() string 23 | GetFileCursorLine() int 24 | } 25 | 26 | // Editor encapsulates the commands and args necessary to open an editor in a shell 27 | type Editor struct { 28 | Cmd string 29 | GetArgs func(int) []string 30 | Supported bool 31 | Default bool 32 | } 33 | 34 | // Open opens an object satisfying the openable interface in the editor 35 | // NOTE: it is recommended to use Go >= v.1.15.7 due to call to exec.Command() 36 | // See: https://blog.golang.org/path-security 37 | func (e *Editor) Open(o openable) error { 38 | args := append(e.GetArgs(o.GetFileCursorLine()), o.GetFilePath()) 39 | cmd := exec.Command(e.Cmd, args...) 40 | cmd.Stdout = os.Stdout 41 | cmd.Stdin = os.Stdin 42 | cmd.Stderr = os.Stderr 43 | return cmd.Run() 44 | } 45 | 46 | // GetEditor gets an Editor based on a provided name 47 | func GetEditor(name string) *Editor { 48 | switch name { 49 | case editorNameVi, editorNameVim: 50 | return &Editor{ 51 | Cmd: name, 52 | GetArgs: func(line int) []string { 53 | return []string{ 54 | fmt.Sprintf("+%d", line), 55 | } 56 | }, 57 | Supported: true, 58 | Default: false, 59 | } 60 | case editorNameEmacs: 61 | return &Editor{ 62 | Cmd: editorNameEmacs, 63 | GetArgs: func(line int) []string { 64 | return []string{ 65 | fmt.Sprintf("+%d", line), 66 | } 67 | }, 68 | Supported: true, 69 | Default: false, 70 | } 71 | case editorNameNano: 72 | return &Editor{ 73 | Cmd: editorNameNano, 74 | GetArgs: func(line int) []string { 75 | return []string{ 76 | fmt.Sprintf("+%d", line), 77 | } 78 | }, 79 | Supported: true, 80 | Default: false, 81 | } 82 | case editorNameNeovim: 83 | return &Editor{ 84 | Cmd: editorNameNeovim, 85 | GetArgs: func(line int) []string { 86 | return []string{ 87 | fmt.Sprintf("+%d", line), 88 | } 89 | }, 90 | Supported: true, 91 | Default: false, 92 | } 93 | // use Vim as the default editor 94 | case "": 95 | return &Editor{ 96 | Cmd: editorNameVim, 97 | GetArgs: func(line int) []string { 98 | return []string{ 99 | fmt.Sprintf("+%d", line), 100 | } 101 | }, 102 | Supported: true, 103 | Default: true, 104 | } 105 | // unrecognized editor will be passed no arguments 106 | default: 107 | return &Editor{ 108 | Cmd: name, 109 | GetArgs: func(line int) []string { 110 | return []string{} 111 | }, 112 | Supported: false, 113 | Default: false, 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /pkg/file/file.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "io" 5 | "os" 6 | ) 7 | 8 | // ReadWriteable is the interface on which file operations are executed 9 | type ReadWriteable interface { 10 | Load(io.Reader) error 11 | Write(io.Writer) error 12 | GetFilePath() string 13 | } 14 | 15 | // ReadWriter executes file operations 16 | type ReadWriter struct{} 17 | 18 | // NewReadWriter constructs a new ReadWriter 19 | func NewReadWriter() *ReadWriter { 20 | return &ReadWriter{} 21 | } 22 | 23 | // Read reads from file 24 | func (rw *ReadWriter) Read(rwable ReadWriteable) error { 25 | r, err := os.Open(rwable.GetFilePath()) 26 | if err != nil { 27 | return err 28 | } 29 | defer r.Close() 30 | return rwable.Load(r) 31 | } 32 | 33 | // Overwrite writes a template to a file, overwriting existing file contents if any 34 | func (rw *ReadWriter) Overwrite(rwable ReadWriteable) error { 35 | f, err := os.OpenFile(rwable.GetFilePath(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) 36 | if err != nil { 37 | return err 38 | } 39 | defer f.Close() 40 | 41 | err = rwable.Write(f) 42 | if err != nil { 43 | return err 44 | } 45 | return nil 46 | } 47 | 48 | // Exists evaluates if a file exists 49 | func (rw *ReadWriter) Exists(rwable ReadWriteable) bool { 50 | fileName := rwable.GetFilePath() 51 | _, err := os.Stat(fileName) 52 | return !os.IsNotExist(err) 53 | } 54 | -------------------------------------------------------------------------------- /pkg/template/archive.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "path/filepath" 7 | "regexp" 8 | "strings" 9 | "time" 10 | 11 | "github.com/dkaslovsky/textnote/pkg/config" 12 | ) 13 | 14 | // MonthArchiveTemplate contains the structure of a month archive 15 | type MonthArchiveTemplate struct { 16 | *Template 17 | } 18 | 19 | // NewMonthArchiveTemplate constructs a new MonthArchiveTemplate 20 | func NewMonthArchiveTemplate(opts config.Opts, date time.Time) *MonthArchiveTemplate { 21 | monthDate := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location()) 22 | return &MonthArchiveTemplate{ 23 | NewTemplate(opts, monthDate), 24 | } 25 | } 26 | 27 | // Write writes the template 28 | // This function is needed to ensure the string() method of the MonthArchiveTemplate is called 29 | func (t *MonthArchiveTemplate) Write(w io.Writer) error { 30 | _, err := w.Write([]byte(t.string())) 31 | return err 32 | } 33 | 34 | // GetFilePath generates a full path for a file based on the template date 35 | func (t *MonthArchiveTemplate) GetFilePath() string { 36 | name := filepath.Join( 37 | t.opts.AppDir, 38 | t.opts.Archive.FilePrefix+t.date.Format(t.opts.Archive.MonthTimeFormat), 39 | ) 40 | if t.opts.File.Ext == "" { 41 | return name 42 | } 43 | return fmt.Sprintf("%s.%s", name, t.opts.File.Ext) 44 | } 45 | 46 | // ArchiveSectionContents concatenates the contents of the specified section from a source template and 47 | // appends to the contents of the receiver's section with a header derived from the source template's date 48 | func (t *MonthArchiveTemplate) ArchiveSectionContents(src *Template, sectionName string) error { 49 | tgtSec, err := t.getSection(sectionName) 50 | if err != nil { 51 | return fmt.Errorf("failed to find section in target: %w", err) 52 | } 53 | srcSec, err := src.getSection(sectionName) 54 | if err != nil { 55 | return fmt.Errorf("failed to find section in source: %w", err) 56 | } 57 | 58 | // flatten text from contents into a single string 59 | txt := "" 60 | for _, content := range srcSec.contents { 61 | txt += content.text 62 | } 63 | if len(txt) == 0 { 64 | return nil 65 | } 66 | 67 | tgtSec.contents = append(tgtSec.contents, contentItem{ 68 | header: t.makeContentHeader(src.GetDate()), 69 | text: txt, 70 | }) 71 | return nil 72 | } 73 | 74 | // Merge merges a source MonthArchiveTemplate into the receiver 75 | // This is a convenience function that iterates and copies all sections in the receiver 76 | func (t *MonthArchiveTemplate) Merge(src *MonthArchiveTemplate) error { 77 | for sectionName := range t.sectionIdx { 78 | err := t.CopySectionContents(src, sectionName) 79 | if err != nil { 80 | return err 81 | } 82 | } 83 | return nil 84 | } 85 | 86 | func (t *MonthArchiveTemplate) string() string { 87 | str := t.makeHeader() 88 | for _, section := range t.sections { 89 | name := section.getNameString(t.opts.Section.Prefix, t.opts.Section.Suffix) 90 | 91 | section.sortContents() 92 | body := section.getContentString() 93 | body = regexp.MustCompile(`\n{2,}`).ReplaceAllString(body, "\n") // remove blank lines 94 | 95 | str += fmt.Sprintf("%s%s%s", name, body, strings.Repeat("\n", t.opts.Section.TrailingNewlines)) 96 | } 97 | return str 98 | } 99 | 100 | func (t *MonthArchiveTemplate) makeHeader() string { 101 | return fmt.Sprintf("%s%s%s\n%s", 102 | t.opts.Archive.HeaderPrefix, 103 | t.date.Format(t.opts.Archive.MonthTimeFormat), 104 | t.opts.Archive.HeaderSuffix, 105 | strings.Repeat("\n", t.opts.Header.TrailingNewlines), 106 | ) 107 | } 108 | 109 | func (t *MonthArchiveTemplate) makeContentHeader(date time.Time) string { 110 | return fmt.Sprintf("%s%s%s", 111 | t.opts.Archive.SectionContentPrefix, 112 | date.Format(t.opts.Archive.SectionContentTimeFormat), 113 | t.opts.Archive.SectionContentSuffix, 114 | ) 115 | } 116 | 117 | // isArchiveItemHeader evaluates if a line matches the pattern of a dated header in a section of an archive 118 | func isArchiveItemHeader(line string, prefix string, suffix string, format string) bool { 119 | if !strings.HasPrefix(line, prefix) { 120 | return false 121 | } 122 | if !strings.HasSuffix(line, suffix) { 123 | return false 124 | } 125 | _, err := time.Parse(format, stripPrefixSuffix(line, prefix, suffix)) 126 | return err == nil 127 | } 128 | -------------------------------------------------------------------------------- /pkg/template/archive_test.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "github.com/dkaslovsky/textnote/pkg/template/templatetest" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestNewMonthArchiveTemplate(t *testing.T) { 14 | type testCase struct { 15 | date time.Time 16 | expected time.Time 17 | } 18 | 19 | tests := map[string]testCase{ 20 | "first of the month": { 21 | date: time.Date(2020, 12, 1, 2, 3, 4, 5, time.UTC), 22 | expected: time.Date(2020, 12, 1, 0, 0, 0, 0, time.UTC), 23 | }, 24 | "not first of the month": { 25 | date: time.Date(2020, 12, 15, 2, 3, 4, 5, time.UTC), 26 | expected: time.Date(2020, 12, 1, 0, 0, 0, 0, time.UTC), 27 | }, 28 | "non UTC location": { 29 | date: time.Date(2020, 12, 15, 2, 3, 4, 5, time.FixedZone("UTC-8", -8*60*60)), 30 | expected: time.Date(2020, 12, 1, 0, 0, 0, 0, time.FixedZone("UTC-8", -8*60*60)), 31 | }, 32 | } 33 | 34 | for name, test := range tests { 35 | t.Run(name, func(t *testing.T) { 36 | m := NewMonthArchiveTemplate(templatetest.GetOpts(), test.date) 37 | require.Equal(t, test.expected, m.date) 38 | }) 39 | } 40 | } 41 | 42 | func TestArchiveGetFilePath(t *testing.T) { 43 | t.Run("get file path with extension", func(t *testing.T) { 44 | opts := templatetest.GetOpts() 45 | opts.File.Ext = "txt" 46 | template := NewMonthArchiveTemplate(opts, templatetest.Date) 47 | filePath := template.GetFilePath() 48 | require.True(t, strings.HasPrefix(filePath, opts.AppDir)) 49 | require.True(t, strings.HasSuffix(filePath, ".txt")) 50 | require.Equal(t, 51 | opts.Archive.FilePrefix+templatetest.Date.Format(opts.Archive.MonthTimeFormat), 52 | stripPrefixSuffix(filePath, fmt.Sprintf("%s/", opts.AppDir), ".txt"), 53 | ) 54 | }) 55 | 56 | t.Run("get file path without extension", func(t *testing.T) { 57 | opts := templatetest.GetOpts() 58 | opts.File.Ext = "" 59 | template := NewMonthArchiveTemplate(opts, templatetest.Date) 60 | filePath := template.GetFilePath() 61 | require.True(t, strings.HasPrefix(filePath, opts.AppDir)) 62 | require.False(t, strings.HasSuffix(filePath, ".")) 63 | require.Equal(t, 64 | opts.Archive.FilePrefix+templatetest.Date.Format(opts.Archive.MonthTimeFormat), 65 | stripPrefixSuffix(filePath, fmt.Sprintf("%s/", opts.AppDir), ""), 66 | ) 67 | }) 68 | } 69 | 70 | func TestArchiveSectionContents(t *testing.T) { 71 | type testCase struct { 72 | sectionName string 73 | existingContents []contentItem 74 | sourceDate time.Time 75 | sourceContents []contentItem 76 | expectedContents []contentItem 77 | } 78 | 79 | tests := map[string]testCase{ 80 | "archive empty contents into empty section": { 81 | sectionName: "TestSection1", 82 | existingContents: []contentItem{}, 83 | sourceDate: templatetest.Date, 84 | sourceContents: []contentItem{}, 85 | expectedContents: []contentItem{}, 86 | }, 87 | "archive empty contents into populated section": { 88 | sectionName: "TestSection1", 89 | existingContents: []contentItem{ 90 | { 91 | header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), 92 | text: "existingText1", 93 | }, 94 | }, 95 | sourceDate: templatetest.Date.Add(24 * time.Hour), 96 | sourceContents: []contentItem{}, 97 | expectedContents: []contentItem{ 98 | { 99 | header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), 100 | text: "existingText1", 101 | }, 102 | }, 103 | }, 104 | "archive contents with single element into empty section": { 105 | sectionName: "TestSection1", 106 | existingContents: []contentItem{}, 107 | sourceDate: templatetest.Date.Add(24 * time.Hour), 108 | sourceContents: []contentItem{ 109 | { 110 | header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), 111 | text: "text1", 112 | }, 113 | }, 114 | expectedContents: []contentItem{ 115 | { 116 | header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), 117 | text: "text1", 118 | }, 119 | }, 120 | }, 121 | "archive contents with single element into populated section": { 122 | sectionName: "TestSection1", 123 | existingContents: []contentItem{ 124 | { 125 | header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), 126 | text: "existingText1", 127 | }, 128 | }, 129 | sourceDate: templatetest.Date.Add(24 * time.Hour), 130 | sourceContents: []contentItem{ 131 | { 132 | header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), 133 | text: "sourceText1", 134 | }, 135 | }, 136 | expectedContents: []contentItem{ 137 | { 138 | header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), 139 | text: "existingText1", 140 | }, 141 | { 142 | header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), 143 | text: "sourceText1", 144 | }, 145 | }, 146 | }, 147 | "archive contents with multiple element into empty section": { 148 | sectionName: "TestSection1", 149 | existingContents: []contentItem{}, 150 | sourceDate: templatetest.Date.Add(24 * time.Hour), 151 | sourceContents: []contentItem{ 152 | { 153 | header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), 154 | text: "text1\n", 155 | }, 156 | { 157 | header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), 158 | text: "text2\n\n", 159 | }, 160 | }, 161 | expectedContents: []contentItem{ 162 | { 163 | header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), 164 | text: "text1\ntext2\n\n", 165 | }, 166 | }, 167 | }, 168 | "archive contents with multiple elements into populated section": { 169 | sectionName: "TestSection1", 170 | existingContents: []contentItem{ 171 | { 172 | header: templatetest.MakeItemHeader(templatetest.Date.Add(-24*time.Hour), templatetest.GetOpts()), 173 | text: "existingText", 174 | }, 175 | }, 176 | sourceDate: templatetest.Date.Add(24 * time.Hour), 177 | sourceContents: []contentItem{ 178 | { 179 | header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), 180 | text: "text1\n", 181 | }, 182 | { 183 | header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), 184 | text: "text2\n\n", 185 | }, 186 | }, 187 | expectedContents: []contentItem{ 188 | { 189 | header: templatetest.MakeItemHeader(templatetest.Date.Add(-24*time.Hour), templatetest.GetOpts()), 190 | text: "existingText", 191 | }, 192 | { 193 | header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), 194 | text: "text1\ntext2\n\n", 195 | }, 196 | }, 197 | }, 198 | "archive contents from source with same date": { 199 | sectionName: "TestSection1", 200 | existingContents: []contentItem{ 201 | { 202 | header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), 203 | text: "existingText", 204 | }, 205 | }, 206 | sourceDate: templatetest.Date, 207 | sourceContents: []contentItem{ 208 | { 209 | header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), 210 | text: "text1\n", 211 | }, 212 | { 213 | header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), 214 | text: "text2\n\n", 215 | }, 216 | }, 217 | expectedContents: []contentItem{ 218 | { 219 | header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), 220 | text: "existingText", 221 | }, 222 | { 223 | header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), 224 | text: "text1\ntext2\n\n", 225 | }, 226 | }, 227 | }, 228 | "source header does not matter": { 229 | sectionName: "TestSection1", 230 | existingContents: []contentItem{}, 231 | sourceDate: templatetest.Date.Add(24 * time.Hour), 232 | sourceContents: []contentItem{ 233 | { 234 | header: "doesn't matter 1", 235 | text: "text1\n", 236 | }, 237 | { 238 | header: "doesn't matter 2", 239 | text: "text2\n\n", 240 | }, 241 | }, 242 | expectedContents: []contentItem{ 243 | { 244 | header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), 245 | text: "text1\ntext2\n\n", 246 | }, 247 | }, 248 | }, 249 | } 250 | 251 | for name, test := range tests { 252 | t.Run(name, func(t *testing.T) { 253 | opts := templatetest.GetOpts() 254 | src := NewTemplate(opts, test.sourceDate) 255 | src.sections[src.sectionIdx[test.sectionName]].contents = test.sourceContents 256 | template := NewMonthArchiveTemplate(opts, templatetest.Date) 257 | template.sections[template.sectionIdx[test.sectionName]].contents = test.existingContents 258 | 259 | err := template.ArchiveSectionContents(src, test.sectionName) 260 | require.NoError(t, err) 261 | require.Equal(t, template.sections[template.sectionIdx[test.sectionName]].contents, test.expectedContents) 262 | }) 263 | } 264 | } 265 | 266 | func TestArchiveSectionContentsFail(t *testing.T) { 267 | t.Run("section does not exist in template", func(t *testing.T) { 268 | toCopy := "toBeArchived" 269 | opts := templatetest.GetOpts() 270 | template := NewMonthArchiveTemplate(opts, templatetest.Date) 271 | src := NewTemplate(opts, templatetest.Date) 272 | src.sections = append(src.sections, newSection(toCopy)) 273 | src.sectionIdx[toCopy] = len(src.sections) - 1 274 | 275 | err := template.ArchiveSectionContents(src, toCopy) 276 | require.Error(t, err) 277 | }) 278 | 279 | t.Run("section does not exist in source", func(t *testing.T) { 280 | toCopy := "toBeArchived" 281 | opts := templatetest.GetOpts() 282 | template := NewMonthArchiveTemplate(opts, templatetest.Date) 283 | template.sections = append(template.sections, newSection(toCopy)) 284 | template.sectionIdx[toCopy] = len(template.sections) - 1 285 | src := NewTemplate(opts, templatetest.Date) 286 | 287 | err := template.ArchiveSectionContents(src, toCopy) 288 | require.Error(t, err) 289 | }) 290 | } 291 | 292 | func TestArchiveString(t *testing.T) { 293 | type testCase struct { 294 | sections []*section 295 | expected string 296 | } 297 | 298 | tests := map[string]testCase{ 299 | "empty template": { 300 | sections: []*section{}, 301 | expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 302 | 303 | `, 304 | }, 305 | "single empty section": { 306 | sections: []*section{ 307 | newSection("TestSection1"), 308 | }, 309 | expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 310 | 311 | _p_TestSection1_q_ 312 | 313 | 314 | 315 | `, 316 | }, 317 | "single section": { 318 | sections: []*section{ 319 | newSection("TestSection1", 320 | contentItem{ 321 | header: "[2020-12-19]", 322 | text: "text", 323 | }, 324 | ), 325 | }, 326 | expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 327 | 328 | _p_TestSection1_q_ 329 | [2020-12-19] 330 | text 331 | 332 | 333 | 334 | `, 335 | }, 336 | "single section with multiline text": { 337 | sections: []*section{ 338 | newSection("TestSection1", 339 | contentItem{ 340 | header: "[2020-12-19]", 341 | text: "text1\ntext2\n\n text3text4\n- text5\n\n -text6\n\n", 342 | }, 343 | ), 344 | }, 345 | expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 346 | 347 | _p_TestSection1_q_ 348 | [2020-12-19] 349 | text1 350 | text2 351 | text3text4 352 | - text5 353 | -text6 354 | 355 | 356 | 357 | `, 358 | }, 359 | "single section with multiple contents": { 360 | sections: []*section{ 361 | newSection("TestSection1", 362 | contentItem{ 363 | header: "[2020-12-18]", 364 | text: "text1\n", 365 | }, 366 | contentItem{ 367 | header: "[2020-12-19]", 368 | text: "text2\n", 369 | }, 370 | ), 371 | }, 372 | expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 373 | 374 | _p_TestSection1_q_ 375 | [2020-12-18] 376 | text1 377 | [2020-12-19] 378 | text2 379 | 380 | 381 | 382 | `, 383 | }, 384 | "multiple empty sections": { 385 | sections: []*section{ 386 | newSection("TestSection1"), 387 | newSection("TestSection2"), 388 | newSection("TestSection3"), 389 | }, 390 | expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 391 | 392 | _p_TestSection1_q_ 393 | 394 | 395 | 396 | _p_TestSection2_q_ 397 | 398 | 399 | 400 | _p_TestSection3_q_ 401 | 402 | 403 | 404 | `, 405 | }, 406 | "multiple sections with only first populated": { 407 | sections: []*section{ 408 | newSection("TestSection1", 409 | contentItem{ 410 | header: "[2020-12-18]", 411 | text: "text", 412 | }, 413 | ), 414 | newSection("TestSection2"), 415 | newSection("TestSection3"), 416 | }, 417 | expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 418 | 419 | _p_TestSection1_q_ 420 | [2020-12-18] 421 | text 422 | 423 | 424 | 425 | _p_TestSection2_q_ 426 | 427 | 428 | 429 | _p_TestSection3_q_ 430 | 431 | 432 | 433 | `, 434 | }, 435 | "multiple sections with only middle populated": { 436 | sections: []*section{ 437 | newSection("TestSection1"), 438 | newSection("TestSection2", 439 | contentItem{ 440 | header: "[2020-12-18]", 441 | text: "text", 442 | }, 443 | ), 444 | newSection("TestSection3"), 445 | }, 446 | expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 447 | 448 | _p_TestSection1_q_ 449 | 450 | 451 | 452 | _p_TestSection2_q_ 453 | [2020-12-18] 454 | text 455 | 456 | 457 | 458 | _p_TestSection3_q_ 459 | 460 | 461 | 462 | `, 463 | }, 464 | "multiple sections with only last populated": { 465 | sections: []*section{ 466 | newSection("TestSection1"), 467 | newSection("TestSection2"), 468 | newSection("TestSection3", 469 | contentItem{ 470 | header: "[2020-12-18]", 471 | text: "text", 472 | }, 473 | ), 474 | }, 475 | expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 476 | 477 | _p_TestSection1_q_ 478 | 479 | 480 | 481 | _p_TestSection2_q_ 482 | 483 | 484 | 485 | _p_TestSection3_q_ 486 | [2020-12-18] 487 | text 488 | 489 | 490 | 491 | `, 492 | }, 493 | "sections with out of order items should be sorted": { 494 | sections: []*section{ 495 | newSection("TestSection1"), 496 | newSection("TestSection2"), 497 | newSection("TestSection3", 498 | contentItem{ 499 | header: "[2020-12-18]", 500 | text: "text 2020-12-18", 501 | }, 502 | contentItem{ 503 | header: "[2020-12-16]", 504 | text: "text 2020-12-16", 505 | }, 506 | contentItem{ 507 | header: "[2020-12-17]", 508 | text: "text 2020-12-17", 509 | }, 510 | ), 511 | }, 512 | expected: `ARCHIVEPREFIX Dec2020 ARCHIVESUFFIX 513 | 514 | _p_TestSection1_q_ 515 | 516 | 517 | 518 | _p_TestSection2_q_ 519 | 520 | 521 | 522 | _p_TestSection3_q_ 523 | [2020-12-16] 524 | text 2020-12-16 525 | [2020-12-17] 526 | text 2020-12-17 527 | [2020-12-18] 528 | text 2020-12-18 529 | 530 | 531 | 532 | `, 533 | }, 534 | } 535 | 536 | for name, test := range tests { 537 | t.Run(name, func(t *testing.T) { 538 | opts := templatetest.GetOpts() 539 | names := []string{} 540 | for _, section := range test.sections { 541 | names = append(names, section.name) 542 | } 543 | opts.Section.Names = names 544 | 545 | template := NewMonthArchiveTemplate(opts, templatetest.Date) 546 | for i, section := range test.sections { 547 | template.sections[i] = section 548 | } 549 | 550 | require.Equal(t, test.expected, template.string()) 551 | }) 552 | } 553 | } 554 | 555 | func TestIsArchiveItemHeader(t *testing.T) { 556 | type testCase struct { 557 | header string 558 | prefix string 559 | suffix string 560 | format string 561 | expected bool 562 | } 563 | 564 | tests := map[string]testCase{ 565 | "valid header": { 566 | header: "[2020-07-28]", 567 | prefix: "[", 568 | suffix: "]", 569 | format: "2006-01-02", 570 | expected: true, 571 | }, 572 | "valid header with no prefix or suffix": { 573 | header: "2020-07-28", 574 | prefix: "", 575 | suffix: "", 576 | format: "2006-01-02", 577 | expected: true, 578 | }, 579 | "invalid header with wrong prefix": { 580 | header: "<2020-07-28]", 581 | prefix: "[", 582 | suffix: "]", 583 | format: "2006-01-02", 584 | expected: false, 585 | }, 586 | "invalid header with wrong suffix": { 587 | header: "[2020-07-28>", 588 | prefix: "[", 589 | suffix: "]", 590 | format: "2006-01-02", 591 | expected: false, 592 | }, 593 | "invalid header with wrong format": { 594 | header: "[2020-July-28]", 595 | prefix: "[", 596 | suffix: "]", 597 | format: "2006-01-02", 598 | expected: false, 599 | }, 600 | } 601 | 602 | for name, test := range tests { 603 | t.Run(name, func(t *testing.T) { 604 | val := isArchiveItemHeader(test.header, test.prefix, test.suffix, test.format) 605 | require.Equal(t, test.expected, val) 606 | }) 607 | } 608 | } 609 | -------------------------------------------------------------------------------- /pkg/template/section.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "sort" 7 | "strings" 8 | 9 | "github.com/dkaslovsky/textnote/pkg/config" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | // section is a named section of a Template 14 | type section struct { 15 | name string 16 | contents []contentItem 17 | } 18 | 19 | // newSection constructs a Section 20 | func newSection(name string, items ...contentItem) *section { 21 | return §ion{ 22 | name: name, 23 | contents: items, 24 | } 25 | } 26 | 27 | func (s *section) deleteContents() { 28 | s.contents = []contentItem{} 29 | } 30 | 31 | func (s *section) sortContents() { 32 | // stable sort to preserve order for empty header case 33 | sort.SliceStable(s.contents, func(i, j int) bool { 34 | return s.contents[i].header < s.contents[j].header 35 | }) 36 | } 37 | 38 | func (s *section) isEmpty() bool { 39 | for _, content := range s.contents { 40 | if !content.isEmpty() { 41 | return false 42 | } 43 | } 44 | return true 45 | } 46 | 47 | func (s *section) getNameString(prefix string, suffix string) string { 48 | return fmt.Sprintf("%s%s%s\n", prefix, s.name, suffix) 49 | } 50 | 51 | func (s *section) getContentString() string { 52 | str := "" 53 | for _, content := range s.contents { 54 | txt := content.string() 55 | if !strings.HasSuffix(txt, "\n") { 56 | txt += "\n" 57 | } 58 | str += txt 59 | } 60 | return str 61 | } 62 | 63 | type contentItem struct { 64 | header string 65 | text string 66 | } 67 | 68 | func (ci contentItem) string() string { 69 | if ci.header != "" { 70 | return fmt.Sprintf("%s\n%s", ci.header, ci.text) 71 | } 72 | return ci.text 73 | } 74 | 75 | func (ci contentItem) isEmpty() bool { 76 | // exclude trailing newlines for empty content check 77 | strippedTxt := strings.Replace(ci.text, "\n", "", -1) 78 | return len(strippedTxt) == 0 79 | } 80 | 81 | func parseSection(text string, opts config.Opts) (*section, error) { 82 | if len(text) == 0 { 83 | return nil, errors.New("cannot parse Section from empty input") 84 | } 85 | 86 | lines := strings.Split(text, "\n") 87 | name := stripPrefixSuffix(lines[0], opts.Section.Prefix, opts.Section.Suffix) 88 | contents := parseSectionContents( 89 | lines[1:], 90 | opts.Archive.SectionContentPrefix, 91 | opts.Archive.SectionContentSuffix, 92 | opts.File.TimeFormat, 93 | ) 94 | 95 | // return section populated with contents if any contentItem is non-empty 96 | for _, content := range contents { 97 | if !content.isEmpty() { 98 | return newSection(name, contents...), nil 99 | } 100 | } 101 | // all contents are empty so return unpopulated section 102 | return newSection(name), nil 103 | } 104 | 105 | func parseSectionContents(lines []string, prefix string, suffix string, format string) []contentItem { 106 | contents := []contentItem{} 107 | if len(lines) == 0 { 108 | return contents 109 | } 110 | 111 | // parse first line 112 | line := lines[0] 113 | header := "" 114 | body := []string{} 115 | if isArchiveItemHeader(line, prefix, suffix, format) { 116 | header = line 117 | } else { 118 | body = append(body, line) 119 | } 120 | 121 | for _, line := range lines[1:] { 122 | // if the line is a header it indicates new contents, so "flush" (append) the current 123 | // header/body and start tracking the new contents 124 | if isArchiveItemHeader(line, prefix, suffix, format) { 125 | contents = append(contents, contentItem{ 126 | header: header, 127 | text: strings.Join(body, "\n"), 128 | }) 129 | 130 | header = line 131 | body = []string{} 132 | continue 133 | } 134 | 135 | body = append(body, line) 136 | } 137 | 138 | // ensure remaining content is appended 139 | if len(body) != 0 || header != "" { 140 | contents = append(contents, contentItem{ 141 | header: header, 142 | text: strings.Join(body, "\n"), 143 | }) 144 | } 145 | return contents 146 | } 147 | 148 | func stripPrefixSuffix(line string, prefix string, suffix string) string { 149 | return strings.TrimPrefix(strings.TrimSuffix(line, suffix), prefix) 150 | } 151 | 152 | func getSectionNameRegex(prefix string, suffix string) (*regexp.Regexp, error) { 153 | sectionPattern := fmt.Sprintf("%s.*%s", prefix, suffix) 154 | sectionNameRegex, err := regexp.Compile(sectionPattern) 155 | if err != nil { 156 | return sectionNameRegex, fmt.Errorf("invalid section prefix [%s] or suffix [%s]", prefix, suffix) 157 | } 158 | return sectionNameRegex, nil 159 | } 160 | -------------------------------------------------------------------------------- /pkg/template/section_test.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "github.com/dkaslovsky/textnote/pkg/template/templatetest" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestGetNameString(t *testing.T) { 14 | type testCase struct { 15 | name string 16 | prefix string 17 | suffix string 18 | expected string 19 | } 20 | 21 | tests := map[string]testCase{ 22 | "empty name, empty prefix and suffix": { 23 | name: "", 24 | prefix: "", 25 | suffix: "", 26 | expected: "\n", 27 | }, 28 | "empty name, non-empty prefix and suffix": { 29 | name: "", 30 | prefix: "p ", 31 | suffix: " s", 32 | expected: "p s\n", 33 | }, 34 | "non-empty name, empty prefix and suffix": { 35 | name: "name", 36 | prefix: "", 37 | suffix: "", 38 | expected: "name\n", 39 | }, 40 | "non-empty name, non-empty prefix and suffix": { 41 | name: "name", 42 | prefix: "p ", 43 | suffix: " s", 44 | expected: "p name s\n", 45 | }, 46 | "non-empty name with spaces, non-empty prefix and suffix": { 47 | name: " na me ", 48 | prefix: "p ", 49 | suffix: " s", 50 | expected: "p na me s\n", 51 | }, 52 | "non-empty name with newlines, non-empty prefix and suffix": { 53 | name: " na \n me ", 54 | prefix: "p ", 55 | suffix: " s", 56 | expected: "p na \n me s\n", 57 | }, 58 | } 59 | 60 | for name, test := range tests { 61 | t.Run(name, func(t *testing.T) { 62 | s := newSection(test.name) 63 | str := s.getNameString(test.prefix, test.suffix) 64 | require.Equal(t, test.expected, str) 65 | }) 66 | } 67 | } 68 | 69 | func TestGetContentString(t *testing.T) { 70 | type testCase struct { 71 | contents []contentItem 72 | expected string 73 | } 74 | 75 | tests := map[string]testCase{ 76 | "empty contents": { 77 | contents: []contentItem{}, 78 | expected: "", 79 | }, 80 | "single empty string contents with no header": { 81 | contents: []contentItem{ 82 | { 83 | header: "", 84 | text: "", 85 | }, 86 | }, 87 | expected: "\n", 88 | }, 89 | "single empty string contents with header": { 90 | contents: []contentItem{ 91 | { 92 | header: "header", 93 | text: "", 94 | }, 95 | }, 96 | expected: "header\n", 97 | }, 98 | "multiple empty string contents with no header": { 99 | contents: []contentItem{ 100 | { 101 | header: "", 102 | text: "", 103 | }, 104 | { 105 | header: "", 106 | text: "", 107 | }, 108 | }, 109 | expected: "\n\n", 110 | }, 111 | "multiple empty string contents with header": { 112 | contents: []contentItem{ 113 | { 114 | header: "header1", 115 | text: "", 116 | }, 117 | { 118 | header: "header2", 119 | text: "", 120 | }, 121 | }, 122 | expected: "header1\nheader2\n", 123 | }, 124 | "single nonempty contents with no header missing trailing newline": { 125 | contents: []contentItem{ 126 | { 127 | header: "", 128 | text: "text\n goes\n here", 129 | }, 130 | }, 131 | expected: "text\n goes\n here\n", 132 | }, 133 | "single nonempty contents with no header": { 134 | contents: []contentItem{ 135 | { 136 | header: "", 137 | text: "text\n goes\n here\n", 138 | }, 139 | }, 140 | expected: "text\n goes\n here\n", 141 | }, 142 | "single nonempty contents with header": { 143 | contents: []contentItem{ 144 | { 145 | header: "header", 146 | text: "text\n goes\n here\n", 147 | }, 148 | }, 149 | expected: "header\ntext\n goes\n here\n", 150 | }, 151 | "multiple nonempty contents with no headers": { 152 | contents: []contentItem{ 153 | { 154 | header: "", 155 | text: "text\n goes\n here\n", 156 | }, 157 | { 158 | header: "", 159 | text: "text2\n goes2\n here2 \n", 160 | }, 161 | }, 162 | expected: "text\n goes\n here\ntext2\n goes2\n here2 \n", 163 | }, 164 | "multiple nonempty contents with headers": { 165 | contents: []contentItem{ 166 | { 167 | header: "header1 ", 168 | text: "text\n goes\n here\n", 169 | }, 170 | { 171 | header: " header2", 172 | text: "text2\n goes2\n here2 \n", 173 | }, 174 | }, 175 | expected: "header1 \ntext\n goes\n here\n header2\ntext2\n goes2\n here2 \n", 176 | }, 177 | } 178 | 179 | for name, test := range tests { 180 | t.Run(name, func(t *testing.T) { 181 | s := newSection("name", test.contents...) 182 | str := s.getContentString() 183 | require.Equal(t, test.expected, str) 184 | }) 185 | } 186 | } 187 | 188 | func TestParseSectionContents(t *testing.T) { 189 | type testCase struct { 190 | lines []string 191 | expected []contentItem 192 | } 193 | 194 | tests := map[string]testCase{ 195 | "empty lines": { 196 | lines: []string{}, 197 | expected: []contentItem{}, 198 | }, 199 | "single empty string line": { 200 | lines: []string{""}, 201 | expected: []contentItem{ 202 | {}, 203 | }, 204 | }, 205 | "lines with no header": { 206 | lines: strings.Split("hello\n world", "\n"), 207 | expected: []contentItem{ 208 | { 209 | header: "", 210 | text: "hello\n world", 211 | }, 212 | }, 213 | }, 214 | "lines with no header with newline at start and end": { 215 | lines: strings.Split("\n\nhello\n world\n\n", "\n"), 216 | expected: []contentItem{ 217 | { 218 | header: "", 219 | text: "\n\nhello\n world\n\n", 220 | }, 221 | }, 222 | }, 223 | "lines with single header": { 224 | lines: strings.Split( 225 | fmt.Sprintf("%s\nhello\n world", templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts())), 226 | "\n", 227 | ), 228 | expected: []contentItem{ 229 | { 230 | header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), 231 | text: "hello\n world", 232 | }, 233 | }, 234 | }, 235 | "lines with single header with newline at start and end": { 236 | lines: strings.Split( 237 | fmt.Sprintf("\n%s\n\nhello\n world\n", templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts())), 238 | "\n", 239 | ), 240 | expected: []contentItem{ 241 | {}, 242 | { 243 | header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), 244 | text: "\nhello\n world\n", 245 | }, 246 | }, 247 | }, 248 | "lines with multiple headers": { 249 | lines: strings.Split( 250 | fmt.Sprintf("%s\nhello\n world\n%s\nhello2\n world2", 251 | templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), 252 | templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), 253 | ), 254 | "\n", 255 | ), 256 | expected: []contentItem{ 257 | { 258 | header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), 259 | text: "hello\n world", 260 | }, 261 | { 262 | header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), 263 | text: "hello2\n world2", 264 | }, 265 | }, 266 | }, 267 | "lines with multiple headers with newline at start and end": { 268 | lines: strings.Split( 269 | fmt.Sprintf("\n%s\nhello\n\n world\n\n\n\n%s\nhello2\n world2\n\n\n\n\n", 270 | templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), 271 | templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), 272 | ), 273 | "\n", 274 | ), 275 | expected: []contentItem{ 276 | {}, 277 | { 278 | header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), 279 | text: "hello\n\n world\n\n\n", 280 | }, 281 | { 282 | header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), 283 | text: "hello2\n world2\n\n\n\n\n", 284 | }, 285 | }, 286 | }, 287 | "header with no text": { 288 | lines: strings.Split( 289 | templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), 290 | "\n", 291 | ), 292 | expected: []contentItem{ 293 | { 294 | header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), 295 | text: "", 296 | }, 297 | }, 298 | }, 299 | "multiple headers with no text": { 300 | lines: strings.Split( 301 | fmt.Sprintf("%s\n%s", 302 | templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), 303 | templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), 304 | ), 305 | "\n", 306 | ), 307 | expected: []contentItem{ 308 | { 309 | header: templatetest.MakeItemHeader(templatetest.Date, templatetest.GetOpts()), 310 | text: "", 311 | }, 312 | { 313 | header: templatetest.MakeItemHeader(templatetest.Date.Add(24*time.Hour), templatetest.GetOpts()), 314 | text: "", 315 | }, 316 | }, 317 | }, 318 | } 319 | 320 | for name, test := range tests { 321 | t.Run(name, func(t *testing.T) { 322 | opts := templatetest.GetOpts() 323 | contents := parseSectionContents(test.lines, opts.Archive.SectionContentPrefix, opts.Archive.SectionContentSuffix, opts.File.TimeFormat) 324 | require.Equal(t, test.expected, contents) 325 | }) 326 | } 327 | } 328 | 329 | func TestSectionIsEmpty(t *testing.T) { 330 | type testCase struct { 331 | contents []contentItem 332 | expected bool 333 | } 334 | 335 | tests := map[string]testCase{ 336 | "empty contents": { 337 | contents: []contentItem{}, 338 | expected: true, 339 | }, 340 | "single content with only newlines and empty header": { 341 | contents: []contentItem{ 342 | { 343 | header: "", 344 | text: "\n\n\n", 345 | }, 346 | }, 347 | expected: true, 348 | }, 349 | "single content with only newlines and populated header": { 350 | contents: []contentItem{ 351 | { 352 | header: "header", 353 | text: "\n\n\n", 354 | }, 355 | }, 356 | expected: true, 357 | }, 358 | "multiple contents with only newlines and empty headers": { 359 | contents: []contentItem{ 360 | { 361 | header: "", 362 | text: "\n\n\n", 363 | }, 364 | { 365 | header: "", 366 | text: "\n", 367 | }, 368 | }, 369 | expected: true, 370 | }, 371 | "multiple contents with only newlines and populated headers": { 372 | contents: []contentItem{ 373 | { 374 | header: "header1", 375 | text: "\n\n\n", 376 | }, 377 | { 378 | header: "header2", 379 | text: "\n", 380 | }, 381 | }, 382 | expected: true, 383 | }, 384 | "single content with text and no header": { 385 | contents: []contentItem{ 386 | { 387 | header: "", 388 | text: "\n\nfoo\n", 389 | }, 390 | }, 391 | expected: false, 392 | }, 393 | "single content with text and populated header": { 394 | contents: []contentItem{ 395 | { 396 | header: "header", 397 | text: "\n\nfoo\n", 398 | }, 399 | }, 400 | expected: false, 401 | }, 402 | "multiple contents with text and no headers": { 403 | contents: []contentItem{ 404 | { 405 | header: "", 406 | text: "\n\nfoo\n", 407 | }, 408 | { 409 | header: "", 410 | text: "bar", 411 | }, 412 | }, 413 | expected: false, 414 | }, 415 | "multiple contents with text and populated headers": { 416 | contents: []contentItem{ 417 | { 418 | header: "header1", 419 | text: "\n\nfoo\n", 420 | }, 421 | { 422 | header: "header2", 423 | text: "bar", 424 | }, 425 | }, 426 | expected: false, 427 | }, 428 | } 429 | 430 | for name, test := range tests { 431 | t.Run(name, func(t *testing.T) { 432 | s := newSection("name", test.contents...) 433 | val := s.isEmpty() 434 | require.Equal(t, test.expected, val) 435 | }) 436 | } 437 | } 438 | 439 | func TestContentItemIsEmpty(t *testing.T) { 440 | type testCase struct { 441 | item contentItem 442 | expected bool 443 | } 444 | 445 | tests := map[string]testCase{ 446 | "empty": { 447 | item: contentItem{}, 448 | expected: true, 449 | }, 450 | "only newlines and empty header": { 451 | item: contentItem{ 452 | header: "", 453 | text: "\n\n\n", 454 | }, 455 | expected: true, 456 | }, 457 | "only newlines and populated header": { 458 | item: contentItem{ 459 | header: "header", 460 | text: "\n\n\n", 461 | }, 462 | expected: true, 463 | }, 464 | "text and no header": { 465 | item: contentItem{ 466 | header: "", 467 | text: "\n\nfoo\n", 468 | }, 469 | expected: false, 470 | }, 471 | "text and populated header": { 472 | item: contentItem{ 473 | header: "header", 474 | text: "\n\nfoo\n", 475 | }, 476 | expected: false, 477 | }, 478 | } 479 | 480 | for name, test := range tests { 481 | t.Run(name, func(t *testing.T) { 482 | val := test.item.isEmpty() 483 | require.Equal(t, test.expected, val) 484 | }) 485 | } 486 | 487 | } 488 | -------------------------------------------------------------------------------- /pkg/template/template.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "path/filepath" 7 | "strings" 8 | "time" 9 | 10 | "github.com/dkaslovsky/textnote/pkg/config" 11 | ) 12 | 13 | // Template contains the structure of a note 14 | type Template struct { 15 | opts config.Opts 16 | date time.Time 17 | sections []*section 18 | sectionIdx map[string]int // map of section name to index in sections slice 19 | } 20 | 21 | // NewTemplate constructs a new Template 22 | func NewTemplate(opts config.Opts, date time.Time) *Template { 23 | t := &Template{ 24 | opts: opts, 25 | date: date, 26 | sections: []*section{}, 27 | sectionIdx: map[string]int{}, 28 | } 29 | for idx, sectionName := range opts.Section.Names { 30 | t.sections = append(t.sections, newSection(sectionName)) 31 | t.sectionIdx[sectionName] = idx 32 | } 33 | return t 34 | } 35 | 36 | // Write writes the template 37 | func (t *Template) Write(w io.Writer) error { 38 | _, err := w.Write([]byte(t.string())) 39 | return err 40 | } 41 | 42 | // GetDate returns the template's date 43 | func (t *Template) GetDate() time.Time { 44 | return t.date 45 | } 46 | 47 | // GetFileCursorLine returns the line at which to place the cursor when opening the template 48 | func (t *Template) GetFileCursorLine() int { 49 | return t.opts.File.CursorLine 50 | } 51 | 52 | // GetFilePath generates a full path for a file based on the template date 53 | func (t *Template) GetFilePath() string { 54 | name := filepath.Join(t.opts.AppDir, t.date.Format(t.opts.File.TimeFormat)) 55 | if t.opts.File.Ext == "" { 56 | return name 57 | } 58 | return fmt.Sprintf("%s.%s", name, t.opts.File.Ext) 59 | } 60 | 61 | // sectionGettable is the interface for getting a section 62 | type sectionGettable interface { 63 | getSection(string) (*section, error) 64 | } 65 | 66 | // CopySectionContents copies the contents of the specified section from a source template by 67 | // appending to the contents of the receiver's section 68 | func (t *Template) CopySectionContents(src sectionGettable, sectionName string) error { 69 | tgtSec, err := t.getSection(sectionName) 70 | if err != nil { 71 | return fmt.Errorf("failed to find section in target: %w", err) 72 | } 73 | srcSec, err := src.getSection(sectionName) 74 | if err != nil { 75 | return fmt.Errorf("failed to find section in source: %w", err) 76 | } 77 | tgtSec.contents = append(tgtSec.contents, srcSec.contents...) 78 | return nil 79 | } 80 | 81 | // DeleteSectionContents deletes the contents of a specified section 82 | func (t *Template) DeleteSectionContents(sectionName string) error { 83 | sec, err := t.getSection(sectionName) 84 | if err != nil { 85 | return fmt.Errorf("cannot delete section: %w", err) 86 | } 87 | sec.deleteContents() 88 | return nil 89 | } 90 | 91 | // IsEmpty evaluates if a template is empty (ignores whitespace) 92 | func (t *Template) IsEmpty() bool { 93 | for _, sec := range t.sections { 94 | if !sec.isEmpty() { 95 | return false 96 | } 97 | } 98 | return true 99 | } 100 | 101 | // Load populates a Template from the contents of a reader 102 | func (t *Template) Load(r io.Reader) error { 103 | raw, err := io.ReadAll(r) 104 | if err != nil { 105 | return fmt.Errorf("error loading template: %w", err) 106 | } 107 | sectionText := string(raw) 108 | 109 | sectionNameRegex, err := getSectionNameRegex(t.opts.Section.Prefix, t.opts.Section.Suffix) 110 | if err != nil { 111 | return fmt.Errorf("cannot parse sections: %w", err) 112 | } 113 | sectionBoundaries := sectionNameRegex.FindAllStringSubmatchIndex(sectionText, -1) 114 | numSections := len(sectionBoundaries) 115 | 116 | // extract sections from sectionText 117 | for i, idxs := range sectionBoundaries { 118 | var curSecEnd int 119 | // end of current section is marked by the beginning of the next section 120 | // if current section is not the last section 121 | if i != numSections-1 { 122 | curSecEnd = sectionBoundaries[i+1][0] 123 | } else { 124 | curSecEnd = len(sectionText) 125 | } 126 | 127 | section, err := parseSection(sectionText[idxs[0]:curSecEnd], t.opts) 128 | if err != nil { 129 | return fmt.Errorf("failed to parse section while reading textnote: %w", err) 130 | } 131 | 132 | idx, found := t.sectionIdx[section.name] 133 | if !found { 134 | return fmt.Errorf("cannot load undefined section [%s]", section.name) 135 | } 136 | t.sections[idx] = section 137 | } 138 | 139 | return nil 140 | } 141 | 142 | func (t *Template) string() string { 143 | str := t.makeHeader() 144 | for _, section := range t.sections { 145 | name := section.getNameString(t.opts.Section.Prefix, t.opts.Section.Suffix) 146 | body := section.getContentString() 147 | // default to trailing whitespace for empty body 148 | if len(body) == 0 { 149 | body = strings.Repeat("\n", t.opts.Section.TrailingNewlines) 150 | } 151 | str += fmt.Sprintf("%s%s", name, body) 152 | } 153 | return str 154 | } 155 | 156 | func (t *Template) makeHeader() string { 157 | return fmt.Sprintf("%s%s%s\n%s", 158 | t.opts.Header.Prefix, 159 | t.date.Format(t.opts.Header.TimeFormat), 160 | t.opts.Header.Suffix, 161 | strings.Repeat("\n", t.opts.Header.TrailingNewlines), 162 | ) 163 | } 164 | 165 | func (t *Template) getSection(name string) (*section, error) { 166 | idx, found := t.sectionIdx[name] 167 | if !found { 168 | return §ion{}, fmt.Errorf("section [%s] not found", name) 169 | } 170 | return t.sections[idx], nil 171 | } 172 | 173 | // ParseTemplateFileName extracts a time.Time from a file name and returns an additional 174 | // bool indicating if name corresponds to a valid template file name 175 | func ParseTemplateFileName(fileName string, opts config.FileOpts) (t time.Time, ok bool) { 176 | // ensure extension matches template file name convention 177 | ext := filepath.Ext(fileName) 178 | if ext == "." { 179 | return t, false 180 | } 181 | if strings.TrimPrefix(ext, ".") != opts.Ext { 182 | return t, false 183 | } 184 | 185 | baseName := strings.TrimSuffix(fileName, ext) 186 | t, err := time.Parse(opts.TimeFormat, baseName) 187 | if err != nil { 188 | return t, false 189 | } 190 | return t, true 191 | } 192 | -------------------------------------------------------------------------------- /pkg/template/template_test.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "github.com/dkaslovsky/textnote/pkg/config" 10 | "github.com/dkaslovsky/textnote/pkg/template/templatetest" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestNewTemplate(t *testing.T) { 15 | type testCase struct { 16 | sections []string 17 | expectedSections []*section 18 | expectedSectionIdx map[string]int 19 | } 20 | 21 | tests := map[string]testCase{ 22 | "no sections": { 23 | sections: []string{}, 24 | expectedSections: []*section{}, 25 | expectedSectionIdx: map[string]int{}, 26 | }, 27 | "single section": { 28 | sections: []string{ 29 | "section1", 30 | }, 31 | expectedSections: []*section{ 32 | newSection("section1"), 33 | }, 34 | expectedSectionIdx: map[string]int{ 35 | "section1": 0, 36 | }, 37 | }, 38 | "multiple sections": { 39 | sections: []string{ 40 | "section1", 41 | "section3", 42 | "section2", 43 | }, 44 | expectedSections: []*section{ 45 | newSection("section1"), 46 | newSection("section3"), 47 | newSection("section2"), 48 | }, 49 | expectedSectionIdx: map[string]int{ 50 | "section1": 0, 51 | "section2": 2, 52 | "section3": 1, 53 | }, 54 | }, 55 | } 56 | 57 | for name, test := range tests { 58 | t.Run(name, func(t *testing.T) { 59 | opts := templatetest.GetOpts() 60 | opts.Section.Names = test.sections 61 | template := NewTemplate(opts, templatetest.Date) 62 | 63 | require.Equal(t, templatetest.Date, template.date) 64 | require.Equal(t, test.expectedSections, template.sections) 65 | require.Equal(t, test.expectedSectionIdx, template.sectionIdx) 66 | }) 67 | } 68 | } 69 | 70 | func TestGetFilePath(t *testing.T) { 71 | t.Run("get file path with extension", func(t *testing.T) { 72 | opts := templatetest.GetOpts() 73 | opts.File.Ext = "txt" 74 | template := NewTemplate(opts, templatetest.Date) 75 | filePath := template.GetFilePath() 76 | require.True(t, strings.HasPrefix(filePath, opts.AppDir)) 77 | require.True(t, strings.HasSuffix(filePath, ".txt")) 78 | require.Equal(t, templatetest.Date.Format(opts.File.TimeFormat), stripPrefixSuffix(filePath, 79 | fmt.Sprintf("%s/", opts.AppDir), ".txt"), 80 | ) 81 | }) 82 | 83 | t.Run("get file path without extension", func(t *testing.T) { 84 | opts := templatetest.GetOpts() 85 | opts.File.Ext = "" 86 | template := NewTemplate(opts, templatetest.Date) 87 | filePath := template.GetFilePath() 88 | require.True(t, strings.HasPrefix(filePath, opts.AppDir)) 89 | require.False(t, strings.HasSuffix(filePath, ".")) 90 | require.Equal(t, templatetest.Date.Format(opts.File.TimeFormat), stripPrefixSuffix(filePath, 91 | fmt.Sprintf("%s/", opts.AppDir), ""), 92 | ) 93 | }) 94 | } 95 | 96 | func TestCopySectionContents(t *testing.T) { 97 | type testCase struct { 98 | sectionName string 99 | existingContents []contentItem 100 | incomingContents []contentItem 101 | } 102 | 103 | tests := map[string]testCase{ 104 | "copy empty contents into empty section": { 105 | sectionName: "TestSection1", 106 | existingContents: []contentItem{}, 107 | incomingContents: []contentItem{}, 108 | }, 109 | "copy empty contents into populated section": { 110 | sectionName: "TestSection1", 111 | existingContents: []contentItem{ 112 | { 113 | header: "existingHeader", 114 | text: "existingText1", 115 | }, 116 | }, 117 | incomingContents: []contentItem{}, 118 | }, 119 | "copy contents with single element into empty section": { 120 | sectionName: "TestSection1", 121 | existingContents: []contentItem{}, 122 | incomingContents: []contentItem{ 123 | { 124 | header: "header", 125 | text: "text1", 126 | }, 127 | }, 128 | }, 129 | "copy contents with single element into populated section": { 130 | sectionName: "TestSection1", 131 | existingContents: []contentItem{ 132 | { 133 | header: "existingHeader", 134 | text: "existingText1", 135 | }, 136 | }, 137 | incomingContents: []contentItem{ 138 | { 139 | header: "header", 140 | text: "text1", 141 | }, 142 | }, 143 | }, 144 | "copy contents with multiple element into empty section": { 145 | sectionName: "TestSection1", 146 | existingContents: []contentItem{}, 147 | incomingContents: []contentItem{ 148 | { 149 | header: "header1", 150 | text: "text1", 151 | }, 152 | { 153 | header: "header2", 154 | text: "text2", 155 | }, 156 | }, 157 | }, 158 | "copy contents with multiple elements into populated section": { 159 | sectionName: "TestSection1", 160 | existingContents: []contentItem{ 161 | { 162 | header: "existingHeader", 163 | text: "existingText1", 164 | }, 165 | }, 166 | incomingContents: []contentItem{ 167 | { 168 | header: "header1", 169 | text: "text1", 170 | }, 171 | { 172 | header: "header2", 173 | text: "text2", 174 | }, 175 | }, 176 | }, 177 | } 178 | 179 | for name, test := range tests { 180 | t.Run(name, func(t *testing.T) { 181 | opts := templatetest.GetOpts() 182 | src := NewTemplate(opts, templatetest.Date) 183 | src.sections[src.sectionIdx[test.sectionName]].contents = test.incomingContents 184 | template := NewTemplate(opts, templatetest.Date) 185 | template.sections[template.sectionIdx[test.sectionName]].contents = test.existingContents 186 | 187 | err := template.CopySectionContents(src, test.sectionName) 188 | require.NoError(t, err) 189 | for _, content := range test.incomingContents { 190 | require.Contains(t, template.sections[template.sectionIdx[test.sectionName]].contents, content) 191 | } 192 | }) 193 | } 194 | } 195 | 196 | func TestCopySectionContentsFail(t *testing.T) { 197 | t.Run("section does not exist in template", func(t *testing.T) { 198 | toCopy := "toBeCopied" 199 | opts := templatetest.GetOpts() 200 | template := NewTemplate(opts, templatetest.Date) 201 | src := NewTemplate(opts, templatetest.Date) 202 | src.sections = append(src.sections, newSection(toCopy)) 203 | src.sectionIdx[toCopy] = len(src.sections) - 1 204 | 205 | err := template.CopySectionContents(src, toCopy) 206 | require.Error(t, err) 207 | }) 208 | 209 | t.Run("section does not exist in source", func(t *testing.T) { 210 | toCopy := "toBeCopied" 211 | opts := templatetest.GetOpts() 212 | template := NewTemplate(opts, templatetest.Date) 213 | template.sections = append(template.sections, newSection(toCopy)) 214 | template.sectionIdx[toCopy] = len(template.sections) - 1 215 | src := NewTemplate(opts, templatetest.Date) 216 | 217 | err := template.CopySectionContents(src, toCopy) 218 | require.Error(t, err) 219 | }) 220 | } 221 | 222 | func TestDeleteSectionContents(t *testing.T) { 223 | t.Run("delete section with no contents", func(t *testing.T) { 224 | toDelete := "sectionToBeDeleted" 225 | template := NewTemplate(templatetest.GetOpts(), templatetest.Date) 226 | template.sections = append(template.sections, newSection(toDelete)) 227 | template.sectionIdx[toDelete] = len(template.sections) - 1 228 | 229 | err := template.DeleteSectionContents(toDelete) 230 | require.NoError(t, err) 231 | require.Empty(t, template.sections[len(template.sections)-1].contents) 232 | }) 233 | 234 | t.Run("delete section with contents", func(t *testing.T) { 235 | toDelete := "sectionToBeDeleted" 236 | template := NewTemplate(templatetest.GetOpts(), templatetest.Date) 237 | template.sections = append(template.sections, newSection(toDelete, contentItem{ 238 | header: "header", 239 | text: "text goes here", 240 | })) 241 | template.sectionIdx[toDelete] = len(template.sections) - 1 242 | 243 | err := template.DeleteSectionContents(toDelete) 244 | require.NoError(t, err) 245 | require.Empty(t, template.sections[len(template.sections)-1].contents) 246 | }) 247 | 248 | t.Run("delete non-existent section", func(t *testing.T) { 249 | toDelete := "sectionToBeDeleted" 250 | opts := templatetest.GetOpts() 251 | template := NewTemplate(opts, templatetest.Date) 252 | 253 | err := template.DeleteSectionContents(toDelete) 254 | require.Error(t, err) 255 | }) 256 | } 257 | 258 | func TestLoad(t *testing.T) { 259 | type testCase struct { 260 | text string 261 | expectedSections []*section 262 | } 263 | 264 | tests := map[string]testCase{ 265 | "empty text": { 266 | text: ``, 267 | expectedSections: []*section{ 268 | newSection("TestSection1"), 269 | newSection("TestSection2"), 270 | newSection("TestSection3"), 271 | }, 272 | }, 273 | "no sections in text": { 274 | text: `-^-[Sun] 20 Dec 2020-v- 275 | 276 | `, 277 | expectedSections: []*section{ 278 | newSection("TestSection1"), 279 | newSection("TestSection2"), 280 | newSection("TestSection3"), 281 | }, 282 | }, 283 | "single empty section in text": { 284 | text: `-^-[Sun] 20 Dec 2020-v- 285 | 286 | _p_TestSection1_q_`, 287 | expectedSections: []*section{ 288 | newSection("TestSection1"), 289 | newSection("TestSection2"), 290 | newSection("TestSection3"), 291 | }, 292 | }, 293 | "single empty section with trainling newlines in text": { 294 | text: `-^-[Sun] 20 Dec 2020-v- 295 | 296 | _p_TestSection1_q_ 297 | 298 | 299 | 300 | `, 301 | expectedSections: []*section{ 302 | newSection("TestSection1"), 303 | newSection("TestSection2"), 304 | newSection("TestSection3"), 305 | }, 306 | }, 307 | "single empty section with too many trainling newlines in text": { 308 | text: `-^-[Sun] 20 Dec 2020-v- 309 | 310 | _p_TestSection1_q_ 311 | 312 | 313 | 314 | 315 | 316 | `, 317 | expectedSections: []*section{ 318 | newSection("TestSection1"), 319 | newSection("TestSection2"), 320 | newSection("TestSection3"), 321 | }, 322 | }, 323 | "single empty second section in text": { 324 | text: `-^-[Sun] 20 Dec 2020-v- 325 | 326 | _p_TestSection2_q_`, 327 | expectedSections: []*section{ 328 | newSection("TestSection1"), 329 | newSection("TestSection2"), 330 | newSection("TestSection3"), 331 | }, 332 | }, 333 | "multiple empty sections in text": { 334 | text: `-^-[Sun] 20 Dec 2020-v- 335 | 336 | _p_TestSection1_q_ 337 | _p_TestSection2_q_ 338 | _p_TestSection3_q_`, 339 | expectedSections: []*section{ 340 | newSection("TestSection1"), 341 | newSection("TestSection2"), 342 | newSection("TestSection3"), 343 | }, 344 | }, 345 | "multiple empty sections with trailing newlines in text": { 346 | text: `-^-[Sun] 20 Dec 2020-v- 347 | 348 | _p_TestSection1_q_ 349 | 350 | 351 | 352 | _p_TestSection2_q_ 353 | 354 | 355 | 356 | _p_TestSection3_q_ 357 | 358 | 359 | 360 | `, 361 | expectedSections: []*section{ 362 | newSection("TestSection1"), 363 | newSection("TestSection2"), 364 | newSection("TestSection3"), 365 | }, 366 | }, 367 | "single section with contents in text": { 368 | text: `-^-[Sun] 20 Dec 2020-v- 369 | 370 | _p_TestSection1_q_ 371 | text1 372 | text2 373 | 374 | 375 | _p_TestSection2_q_ 376 | _p_TestSection3_q_ 377 | `, 378 | expectedSections: []*section{ 379 | newSection("TestSection1", 380 | contentItem{ 381 | header: "", 382 | text: "text1\n text2\n\n\n", 383 | }, 384 | ), 385 | newSection("TestSection2"), 386 | newSection("TestSection3"), 387 | }, 388 | }, 389 | "multiple sections with contents in text": { 390 | text: `-^-[Sun] 20 Dec 2020-v- 391 | 392 | _p_TestSection1_q_ 393 | text1 394 | text2 395 | 396 | 397 | _p_TestSection2_q_ 398 | text3 399 | _p_TestSection3_q_ 400 | 401 | text4 402 | 403 | `, 404 | expectedSections: []*section{ 405 | newSection("TestSection1", 406 | contentItem{ 407 | header: "", 408 | text: "text1\n text2\n\n\n", 409 | }, 410 | ), 411 | newSection("TestSection2", 412 | contentItem{ 413 | header: "", 414 | text: " text3\n", 415 | }, 416 | ), 417 | newSection("TestSection3", 418 | contentItem{ 419 | header: "", 420 | text: "\ntext4\n\n", 421 | }), 422 | }, 423 | }, 424 | "section with single item header": { 425 | text: `-^-[Sun] 20 Dec 2020-v- 426 | 427 | _p_TestSection1_q_ 428 | [2020-12-18] 429 | text1a 430 | text1b 431 | 432 | 433 | _p_TestSection2_q_ 434 | _p_TestSection3_q_ 435 | `, 436 | expectedSections: []*section{ 437 | newSection("TestSection1", 438 | contentItem{ 439 | header: "[2020-12-18]", 440 | text: "text1a\n text1b\n\n\n", 441 | }, 442 | ), 443 | newSection("TestSection2"), 444 | newSection("TestSection3"), 445 | }, 446 | }, 447 | "section with multiple item headers": { 448 | text: `-^-[Sun] 20 Dec 2020-v- 449 | 450 | _p_TestSection1_q_ 451 | [2020-12-16] 452 | text1a 453 | text1b 454 | 455 | 456 | [2020-12-17] 457 | text1c 458 | [2020-12-18] 459 | _p_TestSection2_q_ 460 | _p_TestSection3_q_ 461 | `, 462 | expectedSections: []*section{ 463 | newSection("TestSection1", 464 | contentItem{ 465 | header: "[2020-12-16]", 466 | text: "text1a\n text1b\n\n", 467 | }, 468 | contentItem{ 469 | header: "[2020-12-17]", 470 | text: "text1c", 471 | }, 472 | contentItem{ 473 | header: "[2020-12-18]", 474 | text: "", 475 | }, 476 | ), 477 | newSection("TestSection2"), 478 | newSection("TestSection3"), 479 | }, 480 | }, 481 | } 482 | 483 | for name, test := range tests { 484 | t.Run(name, func(t *testing.T) { 485 | template := NewTemplate(templatetest.GetOpts(), templatetest.Date) 486 | err := template.Load(strings.NewReader(test.text)) 487 | require.NoError(t, err) 488 | for _, expectedSection := range test.expectedSections { 489 | sec, err := template.getSection(expectedSection.name) 490 | require.NoError(t, err) 491 | require.Equal(t, expectedSection, sec) 492 | } 493 | }) 494 | } 495 | } 496 | 497 | func TestString(t *testing.T) { 498 | type testCase struct { 499 | sections []*section 500 | expected string 501 | } 502 | 503 | tests := map[string]testCase{ 504 | "empty template": { 505 | sections: []*section{}, 506 | expected: `-^-[Sun] 20 Dec 2020-v- 507 | 508 | `, 509 | }, 510 | "single empty section": { 511 | sections: []*section{ 512 | newSection("TestSection1"), 513 | }, 514 | expected: `-^-[Sun] 20 Dec 2020-v- 515 | 516 | _p_TestSection1_q_ 517 | 518 | 519 | 520 | `, 521 | }, 522 | "single section with text": { 523 | sections: []*section{ 524 | newSection("TestSection1", 525 | contentItem{ 526 | header: "", 527 | text: "text", 528 | }, 529 | ), 530 | }, 531 | expected: `-^-[Sun] 20 Dec 2020-v- 532 | 533 | _p_TestSection1_q_ 534 | text 535 | `, 536 | }, 537 | "single section with multiline text": { 538 | sections: []*section{ 539 | newSection("TestSection1", 540 | contentItem{ 541 | header: "", 542 | text: "text1\ntext2\n\n text3text4\n- text5\n\n -text6\n\n", 543 | }, 544 | ), 545 | }, 546 | expected: `-^-[Sun] 20 Dec 2020-v- 547 | 548 | _p_TestSection1_q_ 549 | text1 550 | text2 551 | 552 | text3text4 553 | - text5 554 | 555 | -text6 556 | 557 | `, 558 | }, 559 | "single section with text and header": { 560 | sections: []*section{ 561 | newSection("TestSection1", 562 | contentItem{ 563 | // in practice a Template will not have sections with headers 564 | // and as such we expect no formatting to be applied 565 | header: "header", 566 | text: "text", 567 | }, 568 | ), 569 | }, 570 | expected: `-^-[Sun] 20 Dec 2020-v- 571 | 572 | _p_TestSection1_q_ 573 | header 574 | text 575 | `, 576 | }, 577 | "single section with multiple contents": { 578 | sections: []*section{ 579 | newSection("TestSection1", 580 | contentItem{ 581 | header: "", 582 | text: "text1", 583 | }, 584 | // in practice a Template will not have sections with multiple contents 585 | contentItem{ 586 | header: "", 587 | text: "text2", 588 | }, 589 | ), 590 | }, 591 | expected: `-^-[Sun] 20 Dec 2020-v- 592 | 593 | _p_TestSection1_q_ 594 | text1 595 | text2 596 | `, 597 | }, 598 | "multiple empty sections": { 599 | sections: []*section{ 600 | newSection("TestSection1"), 601 | newSection("TestSection2"), 602 | newSection("TestSection3"), 603 | }, 604 | expected: `-^-[Sun] 20 Dec 2020-v- 605 | 606 | _p_TestSection1_q_ 607 | 608 | 609 | 610 | _p_TestSection2_q_ 611 | 612 | 613 | 614 | _p_TestSection3_q_ 615 | 616 | 617 | 618 | `, 619 | }, 620 | "multiple sections with only first populated": { 621 | sections: []*section{ 622 | newSection("TestSection1", 623 | contentItem{ 624 | header: "", 625 | text: "text", 626 | }, 627 | ), 628 | newSection("TestSection2"), 629 | newSection("TestSection3"), 630 | }, 631 | expected: `-^-[Sun] 20 Dec 2020-v- 632 | 633 | _p_TestSection1_q_ 634 | text 635 | _p_TestSection2_q_ 636 | 637 | 638 | 639 | _p_TestSection3_q_ 640 | 641 | 642 | 643 | `, 644 | }, 645 | "multiple sections with only middle populated": { 646 | sections: []*section{ 647 | newSection("TestSection1"), 648 | newSection("TestSection2", 649 | contentItem{ 650 | header: "", 651 | text: "text", 652 | }, 653 | ), 654 | newSection("TestSection3"), 655 | }, 656 | expected: `-^-[Sun] 20 Dec 2020-v- 657 | 658 | _p_TestSection1_q_ 659 | 660 | 661 | 662 | _p_TestSection2_q_ 663 | text 664 | _p_TestSection3_q_ 665 | 666 | 667 | 668 | `, 669 | }, 670 | "multiple sections with only last populated": { 671 | sections: []*section{ 672 | newSection("TestSection1"), 673 | newSection("TestSection2"), 674 | newSection("TestSection3", 675 | contentItem{ 676 | header: "", 677 | text: "text", 678 | }, 679 | ), 680 | }, 681 | expected: `-^-[Sun] 20 Dec 2020-v- 682 | 683 | _p_TestSection1_q_ 684 | 685 | 686 | 687 | _p_TestSection2_q_ 688 | 689 | 690 | 691 | _p_TestSection3_q_ 692 | text 693 | `, 694 | }, 695 | } 696 | 697 | for name, test := range tests { 698 | t.Run(name, func(t *testing.T) { 699 | opts := templatetest.GetOpts() 700 | names := []string{} 701 | for _, section := range test.sections { 702 | names = append(names, section.name) 703 | } 704 | opts.Section.Names = names 705 | 706 | template := NewTemplate(opts, templatetest.Date) 707 | for i, section := range test.sections { 708 | template.sections[i] = section 709 | } 710 | 711 | require.Equal(t, test.expected, template.string()) 712 | }) 713 | } 714 | } 715 | 716 | func TestParseTemplateFileName(t *testing.T) { 717 | type testCase struct { 718 | fileName string 719 | opts config.FileOpts 720 | expectedTime time.Time 721 | expectedOk bool 722 | } 723 | 724 | tests := map[string]testCase{ 725 | "parsable file name with extension": { 726 | fileName: "2020-12-29.txt", 727 | opts: config.FileOpts{ 728 | Ext: "txt", 729 | TimeFormat: "2006-01-02", 730 | }, 731 | expectedTime: time.Date(2020, 12, 29, 0, 0, 0, 0, time.UTC), 732 | expectedOk: true, 733 | }, 734 | "parsable file name with no extension": { 735 | fileName: "2020-12-29", 736 | opts: config.FileOpts{ 737 | Ext: "", 738 | TimeFormat: "2006-01-02", 739 | }, 740 | expectedTime: time.Date(2020, 12, 29, 0, 0, 0, 0, time.UTC), 741 | expectedOk: true, 742 | }, 743 | "unparsable file name with extension": { 744 | fileName: "2020Dec29.txt", 745 | opts: config.FileOpts{ 746 | Ext: "txt", 747 | TimeFormat: "2006-01-02", 748 | }, 749 | expectedOk: false, 750 | }, 751 | "unparsable file name with no extension": { 752 | fileName: "2020Dec29", 753 | opts: config.FileOpts{ 754 | Ext: "", 755 | TimeFormat: "2006-01-02", 756 | }, 757 | expectedOk: false, 758 | }, 759 | "parsable file name with mismatched extension": { 760 | fileName: "2020-12-29.foo", 761 | opts: config.FileOpts{ 762 | Ext: "txt", 763 | TimeFormat: "2006-01-02", 764 | }, 765 | expectedOk: false, 766 | }, 767 | "parsable file name with malformed extension and populated config ext": { 768 | fileName: "2020-12-29.", 769 | opts: config.FileOpts{ 770 | Ext: "txt", 771 | TimeFormat: "2006-01-02", 772 | }, 773 | expectedOk: false, 774 | }, 775 | "parsable file name with malformed extension and unpopulated config ext": { 776 | fileName: "2020-12-29.", 777 | opts: config.FileOpts{ 778 | Ext: "", 779 | TimeFormat: "2006-01-02", 780 | }, 781 | expectedOk: false, 782 | }, 783 | "parsable file name with archive prefix": { 784 | fileName: "archive-2020-12-29.txt", 785 | opts: config.FileOpts{ 786 | Ext: "txt", 787 | TimeFormat: "2006-01-02", 788 | }, 789 | expectedOk: false, 790 | }, 791 | "archive file name convention": { 792 | fileName: "archive-Dec2020.txt", 793 | opts: config.FileOpts{ 794 | Ext: "txt", 795 | TimeFormat: "2006-01-02", 796 | }, 797 | expectedOk: false, 798 | }, 799 | } 800 | 801 | for name, test := range tests { 802 | t.Run(name, func(t *testing.T) { 803 | parsedTime, ok := ParseTemplateFileName(test.fileName, test.opts) 804 | require.Equal(t, test.expectedOk, ok) 805 | if test.expectedOk { 806 | require.Equal(t, test.expectedTime, parsedTime) 807 | } 808 | }) 809 | } 810 | } 811 | 812 | func TestIsEmpty(t *testing.T) { 813 | type testCase struct { 814 | templateFile string 815 | expected bool 816 | } 817 | 818 | tests := map[string]testCase{ 819 | "no text": { 820 | templateFile: ``, 821 | expected: true, 822 | }, 823 | "empty with one section": { 824 | templateFile: `-^-[Sun] 20 Dec 2020-v- 825 | 826 | _p_TestSection1_q_ 827 | 828 | `, 829 | expected: true, 830 | }, 831 | "empty with multiple section": { 832 | templateFile: `-^-[Sun] 20 Dec 2020-v- 833 | 834 | _p_TestSection1_q_ 835 | 836 | _p_TestSection2_q_ 837 | 838 | _p_TestSection3_q_ 839 | 840 | 841 | 842 | 843 | `, 844 | expected: true, 845 | }, 846 | "not empty with text": { 847 | templateFile: `-^-[Sun] 20 Dec 2020-v- 848 | 849 | _p_TestSection1_q_ 850 | 851 | _p_TestSection2_q_ 852 | foobar 853 | 854 | 855 | _p_TestSection3_q_ 856 | 857 | `, 858 | expected: false, 859 | }, 860 | "not empty with whitespace": { 861 | templateFile: `-^-[Sun] 20 Dec 2020-v- 862 | 863 | _p_TestSection1_q_ 864 | 865 | _p_TestSection2_q_ 866 | 867 | _p_TestSection3_q_ 868 | `, 869 | expected: false, 870 | }, 871 | } 872 | 873 | for name, test := range tests { 874 | t.Run(name, func(t *testing.T) { 875 | template := NewTemplate(templatetest.GetOpts(), templatetest.Date) 876 | err := template.Load(strings.NewReader(test.templateFile)) 877 | require.NoError(t, err) 878 | require.Equal(t, test.expected, template.IsEmpty()) 879 | }) 880 | } 881 | } 882 | -------------------------------------------------------------------------------- /pkg/template/templatetest/templatetest.go: -------------------------------------------------------------------------------- 1 | // Package templatetest provides utilities for template testing 2 | package templatetest 3 | 4 | import ( 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | "github.com/dkaslovsky/textnote/pkg/config" 10 | ) 11 | 12 | // Date is a fixed date - changing this value will affect some tests 13 | var Date = time.Date(2020, 12, 20, 1, 1, 1, 1, time.UTC) 14 | 15 | // GetOpts returns a configuration struct for tests - changing these values will affect some tests 16 | func GetOpts() config.Opts { 17 | opts := config.Opts{ 18 | AppDir: "path/to/app/dir", 19 | Header: config.HeaderOpts{ 20 | Prefix: "-^-", 21 | Suffix: "-v-", 22 | TrailingNewlines: 1, 23 | TimeFormat: "[Mon] 02 Jan 2006", 24 | }, 25 | Section: config.SectionOpts{ 26 | Prefix: "_p_", 27 | Suffix: "_q_", 28 | TrailingNewlines: 3, 29 | Names: []string{ 30 | "TestSection1", 31 | "TestSection2", 32 | "TestSection3", 33 | }, 34 | }, 35 | File: config.FileOpts{ 36 | Ext: "txt", 37 | TimeFormat: "2006-01-02", 38 | CursorLine: 1, 39 | }, 40 | Archive: config.ArchiveOpts{ 41 | AfterDays: 7, 42 | FilePrefix: "archive-", 43 | HeaderPrefix: "ARCHIVEPREFIX ", 44 | HeaderSuffix: " ARCHIVESUFFIX", 45 | SectionContentPrefix: "[", 46 | SectionContentSuffix: "]", 47 | SectionContentTimeFormat: "2006-01-02", 48 | MonthTimeFormat: "Jan2006", 49 | }, 50 | Cli: config.CliOpts{ 51 | TimeFormat: "2006-01-02", 52 | }, 53 | TemplateFileCountThresh: 90, 54 | } 55 | 56 | err := config.ValidateOpts(opts) 57 | if err != nil { 58 | log.Fatal(err) 59 | } 60 | return opts 61 | } 62 | 63 | // MakeItemHeader is a helper to construct a header property of a contentItem struct 64 | func MakeItemHeader(date time.Time, opts config.Opts) string { 65 | return fmt.Sprintf("%s%s%s", 66 | opts.Archive.SectionContentPrefix, 67 | date.Format(opts.Archive.SectionContentTimeFormat), 68 | opts.Archive.SectionContentSuffix, 69 | ) 70 | } 71 | --------------------------------------------------------------------------------