Compare commits

...

253 commits

Author SHA1 Message Date
dependabot[bot]
f34a96abbe
Bump immutable from 5.1.3 to 5.1.5 in /pkg/server/assets (#718)
Bumps [immutable](https://github.com/immutable-js/immutable-js) from 5.1.3 to 5.1.5.
- [Release notes](https://github.com/immutable-js/immutable-js/releases)
- [Changelog](https://github.com/immutable-js/immutable-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/immutable-js/immutable-js/compare/v5.1.3...v5.1.5)

---
updated-dependencies:
- dependency-name: immutable
  dependency-version: 5.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-04 22:11:45 -08:00
dependabot[bot]
9fa312e3fc
Bump golang.org/x/crypto from 0.42.0 to 0.45.0 (#716)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.42.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.42.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-12 21:48:59 -08:00
Sung
8f37d34df6
Remove ls and cat commands (#715) 2025-11-07 23:53:41 -08:00
Sung
5c416e3a32
Add user list command (#714) 2025-11-01 19:59:51 -07:00
Sung
d5e11c23f6
Update self-hosting doc (#713) 2025-11-01 19:20:18 -07:00
Sung
f1d7123596
Fix log level (#712) 2025-11-01 14:06:33 -07:00
Sung
ce5c9b242a
Rename webUrl to baseUrl (#711) 2025-11-01 00:57:37 -07:00
Sung
e72322f847
Simplify email backend and remove --appEnv (#710)
* Improve logging

* Remove AppEnv

* Simplify email backend
2025-11-01 00:54:27 -07:00
Sung
e0c4cb1545
Use Apache 2.0 license (#708) 2025-10-31 23:41:21 -07:00
Sung
6314749263
Improve empty server sync when multiple clients exist (#706)
* Fix dbPath

* Require full sync when after another client uploads to an empty server

* Avoid orphan notes with empty sync
2025-10-26 17:54:24 -07:00
Sung
ae290a226f
Auto vacuum and manage connections (#705)
* Test concurrent sync

* Auto vacuum and manage connection
2025-10-26 16:59:53 -07:00
Sung
a46afb821f
Fix an edge case of repeated syncs due to orphaned note (#704)
* Split sync test

* Reproduce a bug

* Fix a bug

* Fix in a more correct way

* Add debug logs
2025-10-26 11:43:17 -07:00
Sung
f6a4c6344c
Remove public from CLI (#703)
* Remove public from CLI

* Write migration and test

* Use in-memory db for a test server

* Simplify CLI test db teardown

* Restructure packages to reduce duplication
2025-10-25 20:51:57 -07:00
Sung
e3380a4dfa
Remove unused templates (#702) 2025-10-19 21:42:53 -07:00
Sung
0a5728faf3
Merge user and account (#701) 2025-10-19 21:05:47 -07:00
Sung
b03ca999a5
Remove the unused encrypted and public fields (#700)
* Remove encrypted fields from notes and books

* Remove public from notes

* Use consistent flags
2025-10-19 18:32:20 -07:00
Sung
83ac43b737
Specify DBPath for docker (#699) 2025-10-19 15:38:52 -07:00
Sung
505fc67966
Fix server release for freebsd (#698) 2025-10-19 14:57:29 -07:00
Sung
7d44c541a4
Add Docker images for linux arm64, armv7, 386 (#697)
* Add multi-platform Docker support for ARM64, ARMv7, and 386

* Support freebsd amd64 for server

* Build docker images locally
2025-10-19 14:30:55 -07:00
Sung
850f9cc6c9
Manage users with server CLI (#696) 2025-10-19 11:01:48 -07:00
Sung
41f25514f0
Link to the doc (#695) 2025-10-19 01:01:26 -07:00
Sung
03889a3d7e
Converge if using same book names while syncing (#694)
* Add healthcheck for Docker

* Prevent nil pointer if endpoint is wrong

* Converge if using same book names while syncing
2025-10-18 16:03:12 -07:00
Sung
c8238aa327
Handle errors (#693) 2025-10-12 16:17:01 -07:00
Sung
346bd9afb1
Add dbPath flag and update apiEndpoint flag (#692)
* Allow to specify CLI db path as a flag

* Make API endpoint flag per command and change case
2025-10-12 15:08:11 -07:00
Sung
74119b1d0b
Automate release process (#691) 2025-10-12 13:02:59 -07:00
Sung
24491bc68a
Allow to upload all data to an empty server (#690)
* Handle server switch

* Avoid losing data in case of race

* Simplify
2025-10-12 12:03:20 -07:00
Sung
e0f68fc8d8
Rate limit client (#689) 2025-10-11 16:14:20 -07:00
Sung
fd7b2a78b2
Remove email verification (#688)
* Remove email verified flag

* Fix sass deprecation warnings
2025-10-11 13:35:39 -07:00
Sung
ca5af5e34a
Run server on port 3001 (#687)
* Simplify docker compose file

* Run on port 3001
2025-10-11 12:41:51 -07:00
Sung
162ceb4ad1
Simplify installation (#686) 2025-10-10 21:20:33 -07:00
Sung
637d4c6861
Simplify contribution (#685) 2025-10-05 22:26:48 -07:00
Sung
a62c7f9e93
Build cli v0.15.2 (#684) 2025-10-05 21:26:12 -07:00
Sung
5df3e7af70
Document change (#683) 2025-10-05 17:59:19 -07:00
Sung
b141b677bb
Cross-platform build for server (#682) 2025-10-05 17:56:13 -07:00
Sung
61162e2add
Use SQLite on the server (#681)
* Use SQLite on server

* Remove pro

* Simplify

* Use flag

* Automate release
2025-10-05 17:02:30 -07:00
Sung
5928f9619f
Upgrade go and dependencies (#679) 2025-10-04 00:25:12 -07:00
Sung
a26037f83a
Add sync test (#678) 2025-09-28 22:25:34 -07:00
Sung
b37820da1f
Remove Pro (#674) 2025-09-28 21:09:22 -07:00
Sung
8ffd44f362
Add 2025 (#673) 2025-08-08 22:31:20 -07:00
dependabot[bot]
895dcaa59f
Bump braces from 3.0.2 to 3.0.3 in /pkg/server/assets (#670)
Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3.
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-30 17:43:11 +10:00
dependabot[bot]
1ff4c07528
Bump golang.org/x/net from 0.20.0 to 0.23.0 (#669)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.20.0 to 0.23.0.
- [Commits](https://github.com/golang/net/compare/v0.20.0...v0.23.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-11 13:50:36 +10:00
Sung
bdc980e5ce
Release CLI 0.15.1 (#661) 2024-02-03 11:54:46 +11:00
Sung
f1c0d0bc04
Upgrade color library (#660) 2024-02-03 10:29:28 +11:00
Sung
f0ba8a1fa6
Upgrade to Go 1.21 (#658) 2024-01-28 14:44:21 +11:00
Sung
4dded18395
Update the instruction to use Docker compose v2 (#657) 2024-01-28 12:50:26 +11:00
Sung
818ebcce69
Add 2024 (#656) 2024-01-28 11:50:22 +11:00
Sung
94ad949453
Release CLI v0.15.0 (#647) 2023-05-27 18:08:19 +10:00
Sung
5eae1299f7
Allow to disable automatic upgrade check in CLI (#646) 2023-05-27 17:49:34 +10:00
Sung
2de1798fdd
Remove Darwin release (#644) 2023-03-18 16:57:40 +11:00
Sung
558bf8dbe1
Run CI (#643)
This reverts commit f18677e498.
2023-03-18 16:55:16 +11:00
Sung
664520e027
Allow to download darwin/arm64 using the installation script (#642) 2023-03-18 16:52:34 +11:00
Sung
f18677e498
Remove autoclose of inactive issue (#641) 2023-03-12 13:48:14 +11:00
Sung
b47c792d5f
Fix homebrew release (#640) 2023-03-10 21:32:10 +11:00
Sung
489d4f407a
Document CLI 0.14.0 release (#639) 2023-03-10 21:26:23 +11:00
Sung
17b4c7d6cf
Disable completion command (#637) 2023-03-10 21:05:18 +11:00
Sung
fd5f92c247
Close inactive issues (#636) 2023-03-10 20:29:56 +11:00
Sung
0ef9e20e8d
Release server v.2.1.1 (#635) 2023-03-04 13:28:49 +11:00
Sung
c3214f12f0
Fix missing assets in a server release (#634)
* Fix missing assets in a server release

* Fix missing library error
2023-03-04 13:26:36 +11:00
Sung
4258ea1715
Release server-v2.1.0 (#633)
* Release server-v2.1.0

* Fix smoke test
2023-03-04 11:57:40 +11:00
Sung
d52a5bdf48
Use OnPremises (#631) 2023-03-04 10:52:35 +11:00
Sung
dd6f8c7f65
Upgrade dependencies (#630)
* Upgrade dependencies

* Fix broken test due to upstream behavior
2023-03-03 22:10:25 +11:00
Sung
b07858a3df
Increase the upgrade check interval to 8 weeks (#629) 2023-03-03 21:37:12 +11:00
Sung
420f30fb92
Separate homebrew command for testing (#625) 2023-02-11 19:06:03 +11:00
Sung
4978e57fbe
Upgarde to use gh (#624) 2023-02-11 17:35:41 +11:00
Sung
86bfc44f47
Fix version tag (#622) 2023-02-10 23:21:24 +11:00
Sung
577a6e8a57
Release CLI 0.13.0 (#621) 2023-02-10 22:01:46 +11:00
Sung
9a83ac3c51
Distribute CLI for macOS with Apple silicon (#620) 2023-02-10 21:57:53 +11:00
Zach Nickel
ff0ae9ac29
Add note from stdin content (#616)
* add note from stdin content

* add tests and update help text
2023-02-10 21:34:23 +11:00
Sung
4a780574eb
Upgrade dependencies (#619) 2023-02-05 13:10:45 +11:00
Michael Robson
4007647c34
Update dnoterc location in SELF_HOSTING.md (#608)
Change to match move from ~/.dnote/dnoterc to current ~/.config/dnote/dnoterc
2023-02-05 12:44:06 +11:00
Sung
01b70bdcaa
Fix build status in README (#618) 2023-02-05 12:35:51 +11:00
Sung
5e6b96f0d6
Run CI (#617) 2023-02-05 12:30:03 +11:00
Sung
ee45144cd7
Add 2023 (#615) 2023-02-05 11:02:25 +11:00
Sung Won Cho
62fe48e451
Remove unused files (#601) 2022-05-10 20:35:43 +10:00
Sung Won Cho
8e5c051437
Add 2022 (#600) 2022-05-10 20:19:04 +10:00
Sung Won Cho
14284057ea
Release server 2.0.0 (#598) 2022-05-09 21:37:02 +10:00
Sung Won Cho
5bba57fd29
Remove dependency on packr (#597)
* Embed files

* Build CLI

* Remove packr

* Embed view directory

* Embed static files

* Make view engine

* Populate build info
2022-05-09 20:34:23 +10:00
Sung Won Cho
f649b5225b
Remove unused file (#596) 2022-04-25 11:51:00 +10:00
Sung Won Cho
26e8da8f0a
Ignore test files (#595) 2022-04-24 17:28:14 +10:00
Sung Won Cho
f7ac7f5ab6
Remove unused JavaScript projects (#594)
* Update sender

* Remove unused projects

* Fix style
2022-04-24 14:48:13 +10:00
Sung Won Cho
a4b7a07bff
Preserve backwards compatibility of health endpoint (#593) 2022-04-24 11:32:04 +10:00
Sung Won Cho
01a378c5b1
Simplify by removing web interface (#590)
* Implement MVC

* Implement settings

* Improve layout

* Lock sass dependency
2022-04-24 10:54:39 +10:00
Sung Won Cho
172f608b66
Remove Forum (#582) 2022-02-17 20:06:15 +11:00
Sung Won Cho
9a054e9ff6
Fix build annotation (#578) 2022-02-12 09:11:44 +11:00
Sung Won Cho
504ba44852
Add 2021 (#574) 2021-12-08 22:26:15 +11:00
Sung Won Cho
6eb68d1817
Document upgrade guide (#530) 2021-01-03 14:06:38 +11:00
Sung Won Cho
fe32dc4f2d
Release CLI 0.12.0 (#529) 2021-01-03 12:37:10 +11:00
Sung Won Cho
a4640977b5
Allow to skip frontmatter when viewing note (#528) 2021-01-03 12:32:24 +11:00
Sung Won Cho
e9f3b080d5
Use XDG base directory (#527)
* Create platform specific directory definitions

* Fix CLI integration test

* Rename dirs to paths and get config path

* Namespace

* Fix initialization of dirs

* Simplify and change description

* Simplify

* Fix build flag

* Bump sqlite version

* Bump xgo
2021-01-03 12:11:22 +11:00
Sung Won Cho
1b7473149c
Update install_postgres.sh (#525) 2021-01-03 11:37:34 +11:00
Monomax Bot
a9fe1e987e
Upgrade dependencies. (#501) 2020-08-25 18:36:16 +10:00
Sung Won Cho
258fd0995f
Fix lint (#500) 2020-08-23 12:25:24 +10:00
Sung Won Cho
cba1df56cc
Update dep (#481)
* Upgrade dep and fix lint

* Add lint fix

* Bump browser dep
2020-06-11 19:17:35 +10:00
Alessandro Buggin
32c13b3b53
Update docker-compose.yml to pull latest version (#477) 2020-06-03 18:05:20 +10:00
Sung Won Cho
0d44112904
Release server v1.0.4 (#473) 2020-05-23 10:56:00 +10:00
Sung Won Cho
2ca6818b9a
Invalidate existing sessions upon password change (#471) 2020-05-23 10:41:23 +10:00
Sung Won Cho
a060176cbb
Remove obsolete columns (#470) 2020-05-22 17:19:10 +10:00
Sung Won Cho
6acc2936e3
Reduce bundle size (#469)
* Rename handlers to api

* Fix imports

* Fix test

* Abstract

* Fix warning

* wip

* Split session

* Pass db

* Fix test

* Fix test

* Remove payment

* Fix state

* Fix flow

* Check password when changing email

* Add test methods

* Fix timestamp

* Document

* Remove clutter

* Redirect to login

* Fix

* Fix
2020-05-22 16:30:05 +10:00
Sung Won Cho
91d5c710ed
Release server 1.0.3 (#462) 2020-05-03 09:39:04 +10:00
Sung Won Cho
cee2db2510
Fix timeline (#460)
* Fix timeline

* Document change
2020-05-01 11:30:32 +10:00
Monomax Bot
1a0361922b
Upgrade dependencies. (#459) 2020-05-01 08:42:07 +10:00
Sung Won Cho
6515aacf42
Update install.sh (#457) 2020-04-26 11:13:11 +10:00
Sung Won Cho
fa9f43167d
Release CLI 0.11.1 (#456) 2020-04-25 19:13:26 +10:00
Sung Won Cho
5864e34bdd
Display helpful error if accidentally pointing to catchall handler (#455)
* Display helpful error if accidentally pointing to catchall handler

* Document change

* Fix logout

* Fix test
2020-04-25 18:48:44 +10:00
Sung Won Cho
ebe360d7ff
Dynamic hostname (#454)
* Display dynamic hostname for login

* Document change
2020-04-25 16:56:33 +10:00
Sung Won Cho
4b700f5e66
Fix upgrade url (#453) 2020-04-25 16:56:24 +10:00
Sung Won Cho
1c96a2bbaa
Sort notes by last activity (#452)
* Upgrade dep

* Sort by last activity

* Upgrade node

* Document change
2020-04-25 16:08:51 +10:00
Sung Won Cho
ae8531fe11
Speed up ci (#451) 2020-04-25 15:24:33 +10:00
Monomax Bot
96d3c50780
Upgrade dependencies (#450)
* Upgrade dependencies.

* Fix test

* Improve name

* Update doc

* Upgrade deps

* Fix lint

* Fix lint

Co-authored-by: Sung Won Cho <sung@monomax.sh>
2020-04-25 11:38:39 +10:00
Sung Won Cho
cabe02aa71
Arm64 build (#449)
* Build for arm64

* Build CLI for arm64

* Fix test
2020-04-24 18:44:43 +10:00
Craig Kaiser
5f77d92076
Add install instructions for self hosted using Apache2 (#448) 2020-04-24 15:45:11 +10:00
Sung Won Cho
6e690ea4da
Fix rules (#438)
* Fix network

* Fix migrations for table that no longer exist

* Document changed behavior
2020-03-29 09:43:42 +11:00
Sung Won Cho
0d9a1810bb
Add toggle for inactive email preference (#435) 2020-03-22 11:38:24 +11:00
Sung Won Cho
1e3efa9bb2
Remove deprecated classic migrations (#433)
* Remove repetition and digest

* Document change

* Remove classic

* Document change

* Document change

* Upgrade dep

* Fix changelog

* Upgrade dep

* fix

* fix
2020-03-22 11:00:50 +11:00
Sung Won Cho
a9f052b19c
Remove repetition (#432)
* Remove repetition and digest

* Document change

* Fix test

* Fix test
2020-03-22 10:36:13 +11:00
Sung Won Cho
b2da22acae
Add doc (#418) 2020-02-13 10:03:08 +10:00
Sung Won Cho
c9ec331672 Encapsulate config load (#414)
* Use config package

* Add config test

* Fix test
2020-02-10 09:56:47 +10:00
Sung Won Cho
9d79ea1b0e
fix typo (#413) 2020-02-07 13:01:16 +10:00
Sung Won Cho
9c94b273fb
Allow to upgrade (#412) 2020-02-07 12:29:02 +10:00
Sung Won Cho
fb4ccf1ada
Allow to use yearly (#411)
* Toggle

* Display breakdown

* Process yearly plan

* Display summary

* Fix style
2020-02-07 11:49:07 +10:00
Sung Won Cho
f35cb307b3
Add deprecation notice (#410) 2020-02-06 13:08:44 +10:00
Sung Won Cho
22373a78e1
Fix release (#409) 2020-02-06 12:48:28 +10:00
Sung Won Cho
054a3c47ac
Remove deprecated items (#408)
* Update README

* Remove deprecated item

* Remove deprecated item

* Upgrade web dependencies

* Upgrade dev depdenencies

* Document changes

* upgrade
2020-02-06 10:24:30 +10:00
Sung Won Cho
6c8cba57b5
Fix client (#407) 2020-02-05 15:28:45 +10:00
Sung Won Cho
cd80f9b72e
Release CLI 0.11.0 (#404)
* Release CLI 0.11.0

* Update outdated ldflags

* Fix release

* Fix date
2020-02-05 11:58:51 +10:00
Sung Won Cho
3c5819f1d1
Avoid refocus at the end of the textarea input (#405) 2020-02-05 08:12:09 +10:00
Sung Won Cho
58a2b581aa
Allow to specify username and password as flags (#403)
* Allow to specify username and password as flags

* Format
2020-02-04 12:30:26 +10:00
Sung Won Cho
df4763947c
Fix installation script (#398) 2020-02-03 14:59:32 +10:00
Sung Won Cho
b92bff942b
Deprecate spaced repetition (#397)
* Change button style

* Require pro

* Update README

* Change wording

* Require sudo
2020-02-03 14:29:42 +10:00
Sung Won Cho
e6b5aea147
Add client field (#396)
* Record client

* Fix test
2020-01-31 08:49:12 +10:00
Sung Won Cho
3c650187c5
Update README (#390)
* Update README

* Update wording
2020-01-11 17:19:59 +10:00
Sung Won Cho
6e22c918ec
Wrap words in note content (#389)
* Fix word-wrap

* Document change

* Fix date
2020-01-09 08:19:21 +10:00
Sung Won Cho
48d7af483c
Release 0.4.0 (#386)
* Document changes

* Update README

* Make Docker installation official
2020-01-08 15:46:58 +10:00
Sung Won Cho
d6496b27d1
Fix email type (#385) 2020-01-08 09:27:55 +11:00
Sung Won Cho
ea51514c85
Fix digest note review (#383)
* Fix issue where unreviewed note is displayed as reviewed

* Show unreviewed first

* Fix menu alignment on mobile

* Show completion message

* Display help on empty digest list

* Fix email type
2020-01-07 16:03:45 +11:00
Sung Won Cho
952e0e62f3
Update copyright notice for the new year (#382)
* Update script

* Update the year in the license
2020-01-07 11:59:40 +11:00
Sung Won Cho
91414da0ac
Digests on web (#380)
* Implement operations

* Implement digest endpoints

* Implement digests reducer and basic page

* Make note component reusable

* Implement digest page

* Add license

* Fix style and accessbility

* Fix loading

* Fix query

* Test

* Remove debug
2020-01-07 11:42:48 +11:00
Sung Won Cho
1427e910b7
0.3.4 (#378) 2019-12-24 13:16:23 +07:00
Sung Won Cho
3152ca770b
Alert when we stop learning (#375)
* Send password reset alert

* Send inactive reminder

* Send subscription confirmation email
2019-12-24 13:07:27 +07:00
Sung Won Cho
4caf82a998
Add syntax highlighting style (#377) 2019-12-24 12:48:05 +07:00
Sung Won Cho
912af37c9b
Revert "Upgrade dependencies. (#374)" (#376)
This reverts commit beceecb74d.
2019-12-24 10:20:07 +07:00
Monomax Bot
beceecb74d Upgrade dependencies. (#374) 2019-12-24 08:51:40 +07:00
Sung Won Cho
fce862621d
Fix release (#370) 2019-12-17 18:03:14 +07:00
Sung Won Cho
a18d9c817f
Fix release (#369) 2019-12-17 17:46:01 +07:00
Sung Won Cho
422f59ef79
0.3.3 (#368)
* Document release 0.3.3

* Bump
2019-12-17 17:17:09 +07:00
Monomax Bot
cd1950213a Upgrade dependencies (#367)
* Upgrade dependencies.

* Bump major
2019-12-17 17:06:35 +07:00
Sung Won Cho
7f981c9f57
Allow to disable registration (#365)
* Allow to disable registration

* Document changes

* Add env vars to templates

* Fix test

* fix
2019-12-17 15:15:06 +07:00
Sung Won Cho
3e41b29a74
Decouple web from App implementation (#364)
* Decouple app from web

* Simplify

* Fix test

* Encapsulate SSL logic to dbconn

* Fix test

* Fix email type
2019-12-17 12:26:42 +07:00
Sung Won Cho
7856d09a92
Fix OnPremise signup (#361)
* Allow to provide test server url directly

* Set on premise

* Fix release title
2019-12-16 15:27:41 +07:00
Sung Won Cho
891be61031
Improve email and signup for self-hosting users (#355)
* Add app abstraction

* Abstract out email sending

* Get sender

* Test

* Move operations to app

* Test

* Test

* Add license

* Fix lint
2019-12-14 12:10:48 +07:00
Sung Won Cho
295dcefff1
Upgrade (#360) 2019-12-14 11:41:33 +07:00
Sung Won Cho
9744a21789
Fix email type (#353) 2019-11-29 18:29:12 +08:00
Sung Won Cho
292dc7d515
Allow to receive welcome email with login instruction (#352)
* Implement email backend

* Add job ctx

* Remove job ctx and use EmailBackend everywhere

* Fix watcher to terminate cmd when inturrupted

* Test runner validation

* Send welcome email upon register

* Use plaintext for verification email

* Use plaintext for password reset email

* Fix from
2019-11-29 17:59:04 +08:00
Sung Won Cho
4adb7764ed
Add free plan (#351)
* Allow free usage

* Update features

* Update plan details

* Render footer

* Type

* Unlimited books

* Restrict repetition for non pro
2019-11-28 15:59:49 +08:00
Sung Won Cho
0e83ba1a5c
Allow to reuse watcher (#350)
* Make watcher reusable

* Allow to add multiple watched targets

* Move watcher

* Allow to ignore
2019-11-26 15:13:33 +08:00
Sung Won Cho
3883a076ef
Lint TypeScript (#349)
* extract dep

* Lint

* Lint

* Fix

* Fix build
2019-11-25 12:24:23 +08:00
Sung Won Cho
628d29c8d1
Revert "Lint JavaScript/TypeScript code (#346)" (#348)
This reverts commit e2e777db54.
2019-11-23 17:27:43 +08:00
Sung Won Cho
e2e777db54
Lint JavaScript/TypeScript code (#346)
* Implement lint in all js projects

* Use yarn workspace

* Remove package-lock.json

* Fix

* Extract common dev dep

* Bump
2019-11-23 15:40:33 +08:00
Monomax Bot
624892d9a8 Upgrade dependencies. (#342) 2019-11-22 17:54:29 +08:00
Sung Won Cho
45cc672671
Fix name conflict (#343) 2019-11-22 17:36:38 +08:00
Sung Won Cho
c0719053ef
Fix release script (#326) 2019-11-21 15:23:42 +08:00
Sung Won Cho
0c9670f442
Release 0.3.2 (#325) 2019-11-20 18:13:10 +08:00
Sung Won Cho
0bcd06600e
Fix direct access to note pages (#324)
* Initialize database

* Document change

* Test regression
2019-11-20 14:38:39 +08:00
Sung Won Cho
b5630adc88
Fix ratelimit (#321)
* Allow to synchronize large number of records

* Fix link

* Unexport

* Fix docker
2019-11-19 12:52:47 +08:00
Sung Won Cho
39d18638a9
Build Docker image (#316)
* Make preview image

* Use local build

* Fix build

* Release

* Build dnote docker

* Simplify

* Document
2019-11-18 16:59:53 +08:00
Sung Won Cho
6d474b1f18
Display helpful error message to the client (#314) 2019-11-18 11:16:46 +08:00
Sung Won Cho
22aa25498d
Fix test (#317) 2019-11-17 14:58:15 +08:00
Sung Won Cho
bd97209af8
Refactor to avoid global database variable (#313)
* Avoid global database

* Fix Twitter summary card

* Fix CLI test
2019-11-16 09:45:56 +08:00
Sung Won Cho
ec6773dc45
Fix verification link (#312) 2019-11-13 14:27:04 +08:00
Sung Won Cho
f519793273
Write a smoke test for self-hosting (#310)
* Fix release script

* Write a smoke testing for self-hosting

* Allow to supply tarball path

* Clear db
2019-11-12 18:22:20 +08:00
Sung Won Cho
b699f1a643
Properly embed static files into binary (#309)
* Properly embed static files into binary

* Document change
2019-11-12 13:20:43 +08:00
Sung Won Cho
22a4006efe
Display email verification link in repetition section (#308)
* Fix release title

* Display email verification warning

* Auto reset message

* Fix mobile menu not covering the screen

* Fix style
2019-11-12 13:09:43 +08:00
Sung Won Cho
ed5dd9c61a
Release 0.3.0 (#306) 2019-11-12 11:31:53 +08:00
Sung Won Cho
9bfc8f217a
Allow to recover from having missed a repetition processing (#305) 2019-11-12 11:16:06 +08:00
Sung Won Cho
6d8250a88b
Fix webpack error by passing absolute path (#304) 2019-11-12 09:03:59 +08:00
Sung Won Cho
2124e28a9f
Use go module (#303)
* Migrate to gomodule

* Fix install

* Update makefile
2019-11-11 15:28:47 +08:00
Sung Won Cho
cf62033fe7
Generate code coverage for js test (#302)
* Generate basic test coverage

* Use jest in web

* Use jest in jslib
2019-11-09 16:10:00 +08:00
Sung Won Cho
5d9662373b
Simplify dev environment (#301) 2019-11-08 17:30:01 -08:00
Sung Won Cho
41ada2298c
Implement note sharing (#300)
* Implement publicity control for notes

* Implement server side rendering for note sharing

* Implement UI

* Modularize

* Remove autofocus

* Fix test

* Document
2019-11-07 22:47:25 -08:00
Sung Won Cho
cbfafb0a40
Fix typo (#298)
* Fix typo

* Document SmtpPort
2019-11-03 20:35:57 -08:00
Sung Won Cho
4a3fd2d1a7
Use Vagrant for easy development setup (#297)
* Use vagrant

* Stop using polling

* Fix test

* Document

* Fix
2019-11-03 20:04:17 -08:00
Sung Won Cho
e3d0ddfc92
Document changes (#295) 2019-11-03 19:24:55 -08:00
Matthias Bilger
914b8dc398 Allow unsecure database connection in production (#276) 2019-11-03 19:05:39 -08:00
Sung Won Cho
478b412169
Display version in settings (#293)
* Display version in settings

* Fix placeholder

* Fix button size

* Fix time format

* Add support link

* Add license
2019-10-30 00:58:08 -07:00
Sung Won Cho
8e7d24d409
Allow to configure SMTP port (#292)
* Allow to customize SMTP port

* Document change

* Fix test

* Add env vars
2019-10-29 21:34:52 -07:00
Sung Won Cho
5d6ad342f3
Customize app URLs in the emails (#290)
* Allow to customize app url in emails

* Validate env var

* Test

* Add license

* Add guide
2019-10-29 20:21:08 -07:00
Sung Won Cho
47d4a8f6fc
Allow to configure endpoints for browser extension (#285)
* Allow to change endpoints in settings

* Allow to reset default settings

* Fix auth

* Release browser extension 2.0.0
2019-10-28 22:50:10 -07:00
Sung Won Cho
bdb75fd16c
Upgrade (#284)
* Add licenses

* Upgrade deps

* Update deprecated

* Upgrade deps
2019-10-28 18:36:24 -07:00
Sung Won Cho
26633b1bf0
Document changes for 0.2.0 (#282) 2019-10-28 02:09:32 -07:00
Sung Won Cho
5902585216
Allow to specify repetition rule (#280)
* Implement data model and basic UI for repetition rules

* Implement tooltip

* Allow to update and write test

* Stop processing the first repetition until having waited at least the frequency amount

* Set up email dev server reload

* Test pro only

* Allow to toggle repetition using token

* Remove unused

* Add last active

* Simplify nextActive calculation

* Create weekly digest repetition rules for existing users

* Fix style

* Fix link

* Create default repetition rule upon signup

* Get notes with thresholds

* Fix test

* Fix test
2019-10-28 00:34:59 -07:00
Sung Won Cho
9f6d4dbbaa
Avoid constantly reinitializing focus (#265) 2019-10-10 10:25:40 +08:00
Sung Won Cho
b2d813b2fa
Fix failing requests (#263)
* Fix a failing request

* Update changelog

* Bump

* Update changelog
2019-10-02 18:54:05 +08:00
Sung Won Cho
46384b237a
Allow to select books with printable characters while trigger is focused (#262) 2019-10-02 17:16:07 +08:00
Sung Won Cho
97ff614ab9
Treat newline as neweline (#261)
* Treat newline as neweline

* Update changelog
2019-10-02 16:34:55 +08:00
Sung Won Cho
edfaa3f953
Support multiple editor session and improve persistence. (#260)
* Move the cursor to the end upon focusing text area

* Allow multiple editor session and improve autosave

* Fix data fetching

* Fix mobile flash style
2019-10-02 16:00:33 +08:00
Sung Won Cho
d84e4c6e57
Document changes (#258)
* Document changes

* Add feature request
2019-09-30 16:36:28 +08:00
Sung Won Cho
6e8a70dbd0
Fix asset path (#257) 2019-09-30 15:55:07 +08:00
Sung Won Cho
35c41a219e
Release browser extension 0.1.0 (#256)
* Bump browser extension version

* Fix browser extension alert
2019-09-30 15:36:11 +08:00
Sung Won Cho
1250825578
Include license notices (#255) 2019-09-30 11:55:11 +08:00
Sung Won Cho
2758923c34
Implement personal knowedge base version (#254)
* Allow to add and edit notes

* Implement search

* Implement settings

* Implement checkout page

* Implement paywall

* Fix inconsistent margin

* Render mobile menu

* Allow to logout

* emails

* Implement user migration

* Always build standalone

* Embed digest in email

* Move browser extension

* Fix test

* Use system font

* Add favicon and app icons

* Make tabbar smaller

* Initialize focus on editor

* Fix various UI audit issues

* Simplify asset serving

* Register sw

* Upgrade deps
2019-09-30 11:02:09 +08:00
Sung Won Cho
6cfd26f5c5
Specify api path for CLI configuration (#253) 2019-09-27 18:00:00 +08:00
Sung Won Cho
d71abdb150
Add debug log to HTTP request (#252) 2019-09-26 09:40:19 +08:00
Sung Won Cho
df4866b4d4
Allow to build for a specific platform (#250) 2019-09-11 23:45:06 +10:00
Sung Won Cho
bda05f71f6
Add instructions on configuring CLI (#251) 2019-09-07 12:46:12 +10:00
Sung Won Cho
a3ee4fbc21
Improve shell portability (#249) 2019-08-13 12:54:06 +10:00
Sung Won Cho
7439df0e29
Bump dependencies (#248) 2019-08-13 10:05:54 +10:00
Sung Won Cho
7e81c2cde6
Implement payment method update (#245)
* Implement payment method update

* Add license
2019-08-03 19:22:48 +10:00
Sung Won Cho
2c80340e19
Fix homebrew release (#242) 2019-08-01 11:42:43 +10:00
Sung Won Cho
4079dd4db9
Add vscode (#241) 2019-08-01 10:52:28 +10:00
Sung Won Cho
12a933d8ec
Simplify remove book command (#240)
* Allow to skip confirmation when removing

* Allow to pass book name directly as an argument when removing
2019-08-01 10:45:30 +10:00
Sung Won Cho
e84e8ed2f3
Allow to rename books (#239)
* Allow to rename books

* Avoid mutating the flag variable

* Validate book name upon edit

* Write integration test

* Add missing licenses

* Mark book as dirty upon renaming so it can be synced

* Remove TODO that is no longer needed
2019-08-01 09:21:24 +10:00
Sung Won Cho
8f54e3b3e6
Fix conflict report format (#237)
* Improve assert output

* Get editor input as is

* Avoid appending a linebreak to a normal diff

* Test multi-line conflict diff
2019-07-30 18:57:23 +10:00
Sung Won Cho
aee2c0c8ae
Upgrade to Postgres 11 (#235)
* Automate generation of migration files

* Upgrade to postgres 11
2019-07-29 17:24:43 +10:00
Sung Won Cho
b45952968b
Implement structured logging (#232)
* Implement structured logging

* Remove debug

* Log additional information for ncoming requests
2019-07-29 10:05:48 +10:00
Sung Won Cho
0317e7f1de
Allow to move notes to another book (#234)
* Print more information upon edit

* Allow to move notes between books

* Allow to update content and book at the same time

* Fix test
2019-07-27 19:51:30 +10:00
Sung Won Cho
71ca81b3ef
Exclude non-CLI releases from CLI update check (#231)
* Fix CLI release script

* Walk to find CLI releases

* Fix missing homebrew shasum

* Test CLI release walk

* Test pagingated result
2019-07-25 18:45:17 +10:00
Sung Won Cho
901918bce4
Fix release process and update self hosting instruction (#229)
* Get release script to work

* Update outdated instruction
2019-07-25 12:14:33 +10:00
Sung Won Cho
2c82e36f7a
Require database connection configuraton (#228)
* Require explicit configuration for database connection

* Test validation

* Remove stutter

* Only use packr for self hosting

* Restart server upon change
2019-07-24 17:32:12 +10:00
Sung Won Cho
a5bcf771d7
Allow empty param in the connection string (#227) 2019-07-23 17:52:00 +10:00
Sung Won Cho
ee14a3c9be
Fix test by enabling database SSL only in production (#226)
* Fix test

* Always specify sslmode
2019-07-23 17:06:03 +10:00
Sung Won Cho
73d66eea07
Simplify the env vars (#225) 2019-07-23 15:55:23 +10:00
Sung Won Cho
6fbe08e2a9
Simplify template path (#224)
* Simplify template path

* Fix
2019-07-22 15:42:56 +10:00
Sung Won Cho
357a587653
Implement server binary (#223) 2019-07-22 13:41:09 +10:00
Sung Won Cho
ddabdd9732
Allow multiple editor sessions (#221)
* Allow to get editor input from multiple sessions at a given time

* Add license
2019-07-13 13:02:09 +10:00
Sung Won Cho
92c3d8376e
Fix test (#217)
* Remove debug

* Fix missing directory
2019-07-02 18:27:29 +10:00
Tyler Estes
4fe5250e91 remove type=button from subscription cta button; fixes issue dnote/dnote#215 (#216) 2019-06-30 11:54:19 +10:00
Sung Won Cho
35dc7abfae
Server migration (#214)
* Allow to run server side postgres migration

* Automate migration creation

* Check if sql-migrate exists
2019-06-29 12:31:42 +10:00
Sung Won Cho
6feda8c392
Allow to customize endpoint in CLI (#213)
* Read api endpoint from config

* Migrate config file
2019-06-28 15:38:59 +10:00
Sung Won Cho
04b41bb53f
Fix installation link (#212) 2019-06-28 12:23:53 +10:00
Sung Won Cho
94fe2896a1
Change word (#211) 2019-06-28 11:53:09 +10:00
Sung Won Cho
0732d64bb9
Hide version (#210) 2019-06-27 15:15:46 +10:00
Sung Won Cho
c7a9ceee3c
Fix dependency installation (#209)
* Fix local js dependency installation

* Fix wording

* Fix test
2019-06-27 11:59:42 +10:00
Sung Won Cho
75d1cb42e8
Deps (#208)
* Consolidate deps

* Fix CI
2019-06-26 18:43:39 +10:00
Sung Won Cho
23a511dbe0
Improve package structure (#207)
* Improve package structure

* Set up travis
2019-06-25 19:20:19 +10:00
Sung Won Cho
ca03c8576a
Remove mention of unsupported feature (#205) 2019-06-12 20:20:07 +10:00
Sung Won Cho
1ff5ae6a37
Report note merge conflict (#204)
* Report note merge conflict

* Add licenses

* Write tests and remove unused
2019-06-10 16:15:08 +10:00
Sung Won Cho
539de99a94
Add PR template (#203) 2019-06-10 10:52:15 +10:00
Sung Won Cho
eed001eb93
Check response error (#200) 2019-06-01 15:40:57 +10:00
Sung Won Cho
dd176f8367
Add demo (#198) 2019-05-30 21:13:47 +10:00
Sung Won Cho
c5870d69ee
Update issue templates (#195)
* Update issue templates

* change logo
2019-05-29 21:57:55 +10:00
Sung Won Cho
9743d67dc4
Bump depedencies (#196) 2019-05-27 21:48:14 +10:00
Sung Won Cho
92e2924fcf
Autocompletion (#193)
* Add autocompletion script for bash and zsh

* Disallow book names with spaces

* Drop parenthesis

* Migrate book names with spaces

* Autocomplete for add

* test

* Make test run in go 1.11
2019-05-27 19:40:35 +10:00
Sung Won Cho
6ba0395a10
Fix edit command argument validation (#192) 2019-05-25 10:39:02 +10:00
Sung Won Cho
8a30723b74
Fix build (#188)
* fix

* fix
2019-05-23 22:58:58 +10:00
Sung Won Cho
3dff87e5a2
Implement checkout flow (#187)
* Implement new payment flow

* Update README

* Fix style
2019-05-23 22:20:15 +10:00
Sung Won Cho
813040ea21
Validate book name (#185) 2019-05-18 17:03:06 +10:00
Sung Won Cho
f526124243
Simplify view and edit command (#184)
* Simplify the view command

* Simplify the edit command

* Migrate number-only book names

* Run migration

* Simplify remove

* Print note info when adding, editing, or removing

* Disallow users from editing or removing already removed notes
2019-05-18 16:52:12 +10:00
Sung Won Cho
cc27714fb6
Fix incorrect prop (#183) 2019-05-18 15:01:23 +10:00
Sung Won Cho
9c31d7998d
Implement digests epage (#182)
* Render digests page

* Implement digests endpoint

* Fetch digests on the frontend

* Render digest items

* Bump deps
2019-05-15 14:34:19 +10:00
Sung Won Cho
84afef7985
Fix digest page scroll (#178) 2019-05-11 12:55:12 +10:00
Sung Won Cho
4cafd54247
Add a link to forum (#177)
* Fix link

* Add a link to forum
2019-05-10 19:03:31 +10:00
Sung Won Cho
a0554cd1da
Fix link (#176) 2019-05-10 18:03:28 +10:00
Sung Won Cho
a56d711a44
Fix install script (#175) 2019-05-10 17:57:51 +10:00
778 changed files with 38580 additions and 55721 deletions

21
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,21 @@
---
name: Bug report
about: Create a bug report
title: ''
labels: ''
assignees: ''
---
## Environment
* project (cli, server, browser-extension):
* version:
* operating system:
## Description
## Steps to reproduce

View file

@ -0,0 +1,14 @@
---
name: Feature request
about: Suggest an idea for a feature
---
* **Please describe the feature you would like to have.**
* **What alternatives have you considered?**
* **Any additional context**

View file

@ -0,0 +1 @@
## Short description

34
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,34 @@
name: CI
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: '>=1.25.0'
- name: Install dependencies
run: |
make install
- name: Test cli
run: |
make test-cli
- name: Test app
run: |
make test-api
- name: Test e2e
run: |
make test-e2e

77
.github/workflows/release-cli.yml vendored Normal file
View file

@ -0,0 +1,77 @@
name: Release CLI
on:
push:
tags:
- 'cli-v*'
jobs:
release:
runs-on: ubuntu-22.04
permissions:
contents: write
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version: '>=1.25.0'
- name: Extract version from tag
id: version
run: |
TAG=${GITHUB_REF#refs/tags/cli-v}
echo "version=$TAG" >> $GITHUB_OUTPUT
echo "Releasing version: $TAG"
- name: Install dependencies
run: make install
- name: Run CLI tests
run: make test-cli
- name: Run E2E tests
run: make test-e2e
- name: Build CLI
run: make version=${{ steps.version.outputs.version }} build-cli
- name: Generate changelog
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="cli-v${VERSION}"
# Find previous CLI tag
PREV_TAG=$(git tag --sort=-version:refname | grep "^cli-" | grep -v "^${TAG}$" | head -n 1)
if [ -z "$PREV_TAG" ]; then
echo "Error: No previous CLI tag found"
echo "This appears to be the first release."
exit 1
fi
./scripts/generate-changelog.sh cli "$TAG" "$PREV_TAG" > /tmp/changelog.txt
cat /tmp/changelog.txt
- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="cli-v${VERSION}"
# Determine if prerelease (version not matching major.minor.patch)
FLAGS=""
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
FLAGS="--prerelease"
fi
gh release create "$TAG" \
build/cli/*.tar.gz \
build/cli/*_checksums.txt \
$FLAGS \
--title="$TAG" \
--notes-file=/tmp/changelog.txt \
--draft

109
.github/workflows/release-server.yml vendored Normal file
View file

@ -0,0 +1,109 @@
name: Release Server
on:
push:
tags:
- 'server-v*'
jobs:
release:
runs-on: ubuntu-22.04
permissions:
contents: write
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version: '>=1.25.0'
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Extract version from tag
id: version
run: |
TAG=${GITHUB_REF#refs/tags/server-v}
echo "version=$TAG" >> $GITHUB_OUTPUT
echo "Releasing version: $TAG"
- name: Install dependencies
run: make install
- name: Run tests
run: make test
- name: Build server
run: make version=${{ steps.version.outputs.version }} build-server
- name: Generate changelog
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="server-v${VERSION}"
# Find previous server tag
PREV_TAG=$(git tag --sort=-version:refname | grep "^server-" | grep -v "^${TAG}$" | head -n 1)
if [ -z "$PREV_TAG" ]; then
echo "Error: No previous server tag found"
echo "This appears to be the first release."
exit 1
fi
./scripts/generate-changelog.sh server "$TAG" "$PREV_TAG" > /tmp/changelog.txt
cat /tmp/changelog.txt
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Prepare Docker build context
run: |
VERSION="${{ steps.version.outputs.version }}"
cp build/server/dnote_server_${VERSION}_linux_amd64.tar.gz host/docker/
cp build/server/dnote_server_${VERSION}_linux_arm64.tar.gz host/docker/
cp build/server/dnote_server_${VERSION}_linux_arm.tar.gz host/docker/
cp build/server/dnote_server_${VERSION}_linux_386.tar.gz host/docker/
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: ./host/docker
push: true
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/386
tags: |
dnote/dnote:${{ steps.version.outputs.version }}
dnote/dnote:latest
build-args: |
version=${{ steps.version.outputs.version }}
- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="server-v${VERSION}"
# Determine if prerelease (version not matching major.minor.patch)
FLAGS=""
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
FLAGS="--prerelease"
fi
gh release create "$TAG" \
build/server/*.tar.gz \
build/server/*_checksums.txt \
$FLAGS \
--title="$TAG" \
--notes-file=/tmp/changelog.txt \
--draft

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
/vendor
/build
.vagrant
*.log
node_modules
/test
tmp
*.db
/server

68
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,68 @@
# Contributing to Dnote
Dnote is an open source project.
* [Setting up](#setting-up)
* [Server](#server)
* [Command Line Interface](#command-line-interface)
## Setting up
The CLI and server are single single binary files with SQLite embedded - no databases to install, no containers to run, no VMs required.
**Prerequisites**
* Go 1.25+ ([Download](https://go.dev/dl/))
* Node.js 18+ ([Download](https://nodejs.org/) - only needed for building frontend assets)
**Quick Start**
1. Clone the repository
2. Install dependencies:
```bash
make install
```
3. Start developing! Run tests:
```bash
make test
```
Or start the dev server:
```bash
make dev-server
```
That's it. You're ready to contribute.
## Server
```bash
# Start dev server (runs on localhost:3001)
make dev-server
# Run tests
make test-api
# Run tests in watch mode
WATCH=true make test-api
```
## Command Line Interface
```bash
# Run tests
make test-cli
# Build dev version (places in your PATH)
make debug=true build-cli
# Build production version for all platforms
make version=v0.1.0 build-cli
# Build for a specific platform
# Note: You cannot cross-compile using this method because Dnote uses CGO
# and requires the OS specific headers.
GOOS=[insert OS] GOARCH=[insert arch] make version=v0.1.0 build-cli
# Debug mode
DNOTE_DEBUG=1 dnote sync
```

209
LICENSE
View file

@ -1,8 +1,203 @@
Source code in this repository is variously licensed under the GNU Affero General Public
License v3.0 (GNU AGPLv3), and GNU General Public License v3.0 (GNU GPLv3). A copy of each
license can be found in the licenses directory. The Source code for the cli is licensed under
GNU GPLv3. The source code for the server and the web is licensed under GNU AGPLv3. In all
other cases, source code in a given file is licensed under the GNU AGPLv3.
Unless otherwise noted at the beginning of the file, the copyright belongs to
Monomax Software Pty Ltd.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

104
Makefile Normal file
View file

@ -0,0 +1,104 @@
NPM := $(shell command -v npm 2> /dev/null)
GH := $(shell command -v gh 2> /dev/null)
currentDir = $(shell pwd)
serverOutputDir = ${currentDir}/build/server
cliOutputDir = ${currentDir}/build/cli
cliHomebrewDir = ${currentDir}/../homebrew-dnote
## installation
install: install-go install-js
.PHONY: install
install-go:
@echo "==> installing go dependencies"
@go mod download
.PHONY: install-go
install-js:
ifndef NPM
$(error npm is not installed)
endif
@echo "==> installing js dependencies"
ifeq ($(CI), true)
@(cd ${currentDir}/pkg/server/assets && npm ci --cache $(NPM_CACHE_DIR) --prefer-offline --unsafe-perm=true)
else
@(cd ${currentDir}/pkg/server/assets && npm install)
endif
.PHONY: install-js
## test
test: test-cli test-api test-e2e
.PHONY: test
test-cli: generate-cli-schema
@echo "==> running CLI test"
@(${currentDir}/scripts/cli/test.sh)
.PHONY: test-cli
test-api:
@echo "==> running API test"
@(${currentDir}/scripts/server/test-local.sh)
.PHONY: test-api
test-e2e:
@echo "==> running E2E test"
@(${currentDir}/scripts/e2e/test.sh)
.PHONY: test-e2e
# development
dev-server:
@echo "==> running dev environment"
@VERSION=master ${currentDir}/scripts/server/dev.sh
.PHONY: dev-server
build-server:
ifndef version
$(error version is required. Usage: make version=0.1.0 build-server)
endif
@echo "==> building server assets"
@(cd "${currentDir}/pkg/server/assets/" && ./styles/build.sh)
@(cd "${currentDir}/pkg/server/assets/" && ./js/build.sh)
@echo "==> building server"
@${currentDir}/scripts/server/build.sh $(version)
.PHONY: build-server
build-server-docker: build-server
ifndef version
$(error version is required. Usage: make version=0.1.0 [platform=linux/amd64] build-server-docker)
endif
@echo "==> building Docker image"
@(cd ${currentDir}/host/docker && ./build.sh $(version) $(platform))
.PHONY: build-server-docker
generate-cli-schema:
@echo "==> generating CLI database schema"
@mkdir -p pkg/cli/database
@touch pkg/cli/database/schema.sql
@go run -tags fts5 ./pkg/cli/database/schema
.PHONY: generate-cli-schema
build-cli: generate-cli-schema
ifeq ($(debug), true)
@echo "==> building cli in dev mode"
@${currentDir}/scripts/cli/dev.sh
else
ifndef version
$(error version is required. Usage: make version=0.1.0 build-cli)
endif
@echo "==> building cli"
@${currentDir}/scripts/cli/build.sh $(version)
endif
.PHONY: build-cli
clean:
@git clean -f
@rm -rf build
.PHONY: clean

View file

@ -1,42 +1,64 @@
# Dnote
![Dnote](assets/logo.png)
=========================
A simple, encrypted personal knowledge base.
![Build Status](https://github.com/dnote/dnote/actions/workflows/ci.yml/badge.svg)
## What It Does
Dnote is a simple command line notebook. Single binary, no dependencies. Since 2017.
Instantly capture your microlessons and get automatic reminders for spaced repetition. Because:
Your notes are stored in **one SQLite file** - portable, searchable, and completely under your control. Optional sync between devices via a self-hosted server with REST API access.
* we forget exponentially unless we write down what we learn and come back.
* ideas cannot be grokked unless we can put them down in clear words.
```sh
# Add a note (or omit -c to launch your editor)
dnote add linux -c "Check disk usage with df -h"
## How to Use
# View notes in a book
dnote view linux
Use the following to keep Dnote handy.
# Full-text search
dnote find "disk usage"
- [CLI](https://github.com/dnote/dnote/blob/fix-loading/cli/README.md)
- [Web](https://dnote.io)
- [Browser extension](https://github.com/dnote/browser-extension)
- [Atom](https://github.com/dnote/dnote-atom)
# Sync notes
dnote sync
```
## Installation
### User Stories
```bash
# Linux, macOS, FreeBSD, Windows
curl -s https://www.getdnote.com/install | sh
- [How I Built a Personal Knowledge Base for Myself](https://dnote.io/blog/how-i-built-personal-knowledge-base-for-myself/)
- [I Wrote Down Everything I Learned While Programming for a Month](https://dnote.io/blog/writing-everything-i-learn-coding-for-a-month/)
# macOS with Homebrew
brew install dnote
```
## Security
Or [download binary](https://github.com/dnote/dnote/releases).
Dnote is end-to-end encrypted and respects your privacy. It does not track you.
## Server (Optional)
When syncing, your data is encrypted with AES-256. Dnote server has zero knowledge about note contents.
Server is a binary with SQLite embedded. No database setup is required.
## Self-host
If using docker, create a compose.yml:
Instructions are coming soon.
```yaml
services:
dnote:
image: dnote/dnote:latest
container_name: dnote
ports:
- 3001:3001
volumes:
- ./dnote_data:/data
restart: unless-stopped
```
## Links
Then run:
- [Dnote](https://dnote.io)
- [Dnote Pro](https://dnote.io/pricing)
```bash
docker-compose up -d
```
[![Build Status](https://semaphoreci.com/api/v1/dnote/dnote-2/branches/master/badge.svg)](https://semaphoreci.com/dnote/dnote-2)
Or see the [guide](https://www.getdnote.com/docs/server/manual) for binary installation.
## Documentation
See the [Dnote doc](https://www.getdnote.com/docs).

43
SELF_HOSTING.md Normal file
View file

@ -0,0 +1,43 @@
# Self-Hosting Dnote Server
Please see the [doc](https://www.getdnote.com/docs/server) for more.
## Docker Installation
1. Install [Docker](https://docs.docker.com/install/).
2. Install Docker [Compose plugin](https://docs.docker.com/compose/install/linux/).
3. Create a `compose.yml` file with the following content:
```yaml
services:
dnote:
image: dnote/dnote:latest
container_name: dnote
ports:
- 3001:3001
volumes:
- ./dnote_data:/data
restart: unless-stopped
```
4. Run the following to download the image and start the container
```
docker compose up -d
```
Visit http://localhost:3001 in your browser to see Dnote running.
## Manual Installation
Download from [releases](https://github.com/dnote/dnote/releases), extract, and run:
```bash
tar -xzf dnote-server-$version-$os.tar.gz
mv ./dnote-server /usr/local/bin
dnote-server start --baseUrl=https://your.server
```
You're up and running. Database: `~/.local/share/dnote/server.db` (customize with `--dbPath`). Run `dnote-server start --help` for options.
Set `apiEndpoint: https://your.server/api` in `~/.config/dnote/dnoterc` to connect your CLI to the server.

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -1,50 +0,0 @@
# Contributing
This is a guide for contributors.
## Set up
First, download the project
```sh
go get github.com/dnote/dnote/cli
```
Go to the project root and download dependencies using [dep](https://github.com/golang/dep).
```sh
dep ensure
```
## Test
Run
```sh
./scripts/test.sh
```
## Debug
Run Dnote with `DNOTE_DEBUG=1` to print debugging statements.
## Release
* Build for all target platforms, tag, push tags
* Release on GitHub and [Dnote Homebrew tap](https://github.com/dnote/homebrew-dnote).
```sh
VERSION=0.4.8 make release
```
* Build, without releasing, for all target platforms
```sh
VERSION=0.4.8 make
```
**Note**
- If a release is not stable,
- disable the homebrew release by commenting out `homebrew` block in `.goreleaser.yml`
- mark release as pre-release on GitHub release

148
cli/Gopkg.lock generated
View file

@ -1,148 +0,0 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
digest = "1:cd0089a5b5d872ac1b772087c7ee0ff2e71de50aa3a51826be64a63963a85287"
name = "github.com/dnote/actions"
packages = ["."]
pruneopts = ""
revision = "60e81aff027d39f4494c5ee5c1db9c3efc015ccf"
version = "v0.2.0"
[[projects]]
digest = "1:e988ed0ca0d81f4d28772760c02ee95084961311291bdfefc1b04617c178b722"
name = "github.com/dnote/color"
packages = ["."]
pruneopts = ""
revision = "5b77d2a35fb0ede96d138fc9a99f5c9b6aef11b4"
version = "v1.7.0"
[[projects]]
branch = "master"
digest = "1:fe99ddb68e996f2f9f7995e9765bc283ceef12dbe30de17922900c1cfa9dfc09"
name = "github.com/google/go-github"
packages = ["github"]
pruneopts = ""
revision = "b7b480f79db7ae436e87bef80ff47596139af8f2"
[[projects]]
digest = "1:cea4aa2038169ee558bf507d5ea02c94ca85bcca28a4c7bb99fd59b31e43a686"
name = "github.com/google/go-querystring"
packages = ["query"]
pruneopts = ""
revision = "44c6ddd0a2342c386950e880b658017258da92fc"
version = "v1.0.0"
[[projects]]
digest = "1:870d441fe217b8e689d7949fef6e43efbc787e50f200cb1e70dbca9204a1d6be"
name = "github.com/inconshreveable/mousetrap"
packages = ["."]
pruneopts = ""
revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75"
version = "v1.0"
[[projects]]
digest = "1:9ea83adf8e96d6304f394d40436f2eb44c1dc3250d223b74088cc253a6cd0a1c"
name = "github.com/mattn/go-colorable"
packages = ["."]
pruneopts = ""
revision = "167de6bfdfba052fa6b2d3664c8f5272e23c9072"
version = "v0.0.9"
[[projects]]
digest = "1:3140e04675a6a91d2a20ea9d10bdadf6072085502e6def6768361260aee4b967"
name = "github.com/mattn/go-isatty"
packages = ["."]
pruneopts = ""
revision = "6ca4dbf54d38eea1a992b3c722a76a5d1c4cb25c"
version = "v0.0.4"
[[projects]]
digest = "1:8bbdb2b3dce59271877770d6fe7dcbb8362438fa7d2e1e1f688e4bf2aac72706"
name = "github.com/mattn/go-sqlite3"
packages = ["."]
pruneopts = ""
revision = "c7c4067b79cc51e6dfdcef5c702e74b1e0fa7c75"
version = "v1.10.0"
[[projects]]
digest = "1:1d7e1867c49a6dd9856598ef7c3123604ea3daabf5b83f303ff457bcbc410b1d"
name = "github.com/pkg/errors"
packages = ["."]
pruneopts = ""
revision = "ba968bfe8b2f7e042a574c888954fccecfa385b4"
version = "v0.8.1"
[[projects]]
digest = "1:7f569d906bdd20d906b606415b7d794f798f91a62fcfb6a4daa6d50690fb7a3f"
name = "github.com/satori/go.uuid"
packages = ["."]
pruneopts = ""
revision = "f58768cc1a7a7e77a3bd49e98cdd21419399b6a3"
version = "v1.2.0"
[[projects]]
digest = "1:a1403cc8a94b8d7956ee5e9694badef0e7b051af289caad1cf668331e3ffa4f6"
name = "github.com/spf13/cobra"
packages = ["."]
pruneopts = ""
revision = "ef82de70bb3f60c65fb8eebacbb2d122ef517385"
version = "v0.0.3"
[[projects]]
digest = "1:cbaf13cdbfef0e4734ed8a7504f57fe893d471d62a35b982bf6fb3f036449a66"
name = "github.com/spf13/pflag"
packages = ["."]
pruneopts = ""
revision = "298182f68c66c05229eb03ac171abe6e309ee79a"
version = "v1.0.3"
[[projects]]
branch = "master"
digest = "1:d0f4eb7abce3fbd3f0dcbbc03ffe18464846afd34c815928d2ae11c1e5aded04"
name = "golang.org/x/crypto"
packages = [
"hkdf",
"pbkdf2",
"ssh/terminal",
]
pruneopts = ""
revision = "ffb98f73852f696ea2bb21a617a5c4b3e067a439"
[[projects]]
branch = "master"
digest = "1:7e3b61f51ebcb58b3894928ed7c63aae68820dec1dd57166e5d6e65ef2868f40"
name = "golang.org/x/sys"
packages = [
"unix",
"windows",
]
pruneopts = ""
revision = "b90733256f2e882e81d52f9126de08df5615afd9"
[[projects]]
branch = "v2"
digest = "1:cedccf16b71e86db87a24f8d4c70b0a855872eb967cb906a66b95de56aefbd0d"
name = "gopkg.in/yaml.v2"
packages = ["."]
pruneopts = ""
revision = "51d6538a90f86fe93ac480b35f37b2be17fef232"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
input-imports = [
"github.com/dnote/actions",
"github.com/dnote/color",
"github.com/google/go-github/github",
"github.com/mattn/go-sqlite3",
"github.com/pkg/errors",
"github.com/satori/go.uuid",
"github.com/spf13/cobra",
"golang.org/x/crypto/hkdf",
"golang.org/x/crypto/pbkdf2",
"golang.org/x/crypto/ssh/terminal",
"gopkg.in/yaml.v2",
]
solver-name = "gps-cdcl"
solver-version = 1

View file

@ -1,53 +0,0 @@
# Gopkg.toml example
#
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
# for detailed Gopkg.toml documentation.
#
# required = ["github.com/user/thing/cmd/thing"]
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
#
# [[constraint]]
# name = "github.com/user/project"
# version = "1.0.0"
#
# [[constraint]]
# name = "github.com/user/project2"
# branch = "dev"
# source = "github.com/myfork/project2"
#
# [[override]]
# name = "github.com/x/y"
# version = "2.4.0"
[[constraint]]
branch = "master"
name = "github.com/google/go-github"
[[constraint]]
name = "github.com/pkg/errors"
version = "0.8.0"
[[constraint]]
name = "github.com/spf13/cobra"
version = "0.0.3"
[[constraint]]
branch = "v2"
name = "gopkg.in/yaml.v2"
[[constraint]]
name = "github.com/satori/go.uuid"
version = "1.1.0"
[[constraint]]
name = "github.com/dnote/actions"
version = "0.2.0"
[[constraint]]
name = "github.com/mattn/go-sqlite3"
version = "1.10.0"
[[constraint]]
name = "github.com/dnote/color"
version = "1.7.0"

View file

@ -1,12 +0,0 @@
all:
./scripts/build.sh $(VERSION)
.PHONY: all
release:
./scripts/build.sh $(VERSION)
./scripts/release.sh $(VERSION)
.PHONY: release
clean:
@git clean -f
.PHONY: clean

View file

@ -1,33 +0,0 @@
# Dnote CLI
A simple command line interface for Dnote.
![Dnote](assets/dnote.gif)
It is Designed to minimize context switching for taking notes.
## Install
On macOS, you can install using Homebrew:
```sh
brew tap dnote/dnote
brew install dnote
# to upgrade to the latest version
brew upgrade dnote
```
On Linux or macOS, you can use the installation script:
curl -s https://raw.githubusercontent.com/dnote/dnote/master/cli/install.sh | sh
In some cases, you might need an elevated permission:
curl -s https://raw.githubusercontent.com/dnote/dnote/master/cli/install.sh | sudo sh
Otherwise, you can download the binary for your platform manually from the [releases page](https://github.com/dnote/dnote/releases).
## Commands
Please refer to [commands](/COMMANDS.md).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View file

@ -1,149 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package add
import (
"database/sql"
"time"
"github.com/dnote/dnote/cli/core"
"github.com/dnote/dnote/cli/infra"
"github.com/dnote/dnote/cli/log"
"github.com/dnote/dnote/cli/utils"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var reservedBookNames = []string{"trash", "conflicts"}
var content string
var example = `
* Open an editor to write content
dnote add git
* Skip the editor by providing content directly
dnote add git -c "time is a part of the commit hash"`
func preRun(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
return errors.New("Incorrect number of argument")
}
return nil
}
// NewCmd returns a new add command
func NewCmd(ctx infra.DnoteCtx) *cobra.Command {
cmd := &cobra.Command{
Use: "add <book>",
Short: "Add a new note",
Aliases: []string{"a", "n", "new"},
Example: example,
PreRunE: preRun,
RunE: newRun(ctx),
}
f := cmd.Flags()
f.StringVarP(&content, "content", "c", "", "The new content for the note")
return cmd
}
func isReservedName(name string) bool {
for _, n := range reservedBookNames {
if name == n {
return true
}
}
return false
}
func newRun(ctx infra.DnoteCtx) core.RunEFunc {
return func(cmd *cobra.Command, args []string) error {
bookName := args[0]
if isReservedName(bookName) {
return errors.Errorf("book name '%s' is reserved", bookName)
}
if content == "" {
fpath := core.GetDnoteTmpContentPath(ctx)
err := core.GetEditorInput(ctx, fpath, &content)
if err != nil {
return errors.Wrap(err, "Failed to get editor input")
}
}
if content == "" {
return errors.New("Empty content")
}
ts := time.Now().UnixNano()
err := writeNote(ctx, bookName, content, ts)
if err != nil {
return errors.Wrap(err, "Failed to write note")
}
log.Successf("added to %s\n", bookName)
log.PrintContent(content)
if err := core.CheckUpdate(ctx); err != nil {
log.Error(errors.Wrap(err, "automatically checking updates").Error())
}
return nil
}
}
func writeNote(ctx infra.DnoteCtx, bookLabel string, content string, ts int64) error {
tx, err := ctx.DB.Begin()
if err != nil {
return errors.Wrap(err, "beginning a transaction")
}
var bookUUID string
err = tx.QueryRow("SELECT uuid FROM books WHERE label = ?", bookLabel).Scan(&bookUUID)
if err == sql.ErrNoRows {
bookUUID = utils.GenerateUUID()
b := core.NewBook(bookUUID, bookLabel, 0, false, true)
err = b.Insert(tx)
if err != nil {
tx.Rollback()
return errors.Wrap(err, "creating the book")
}
} else if err != nil {
return errors.Wrap(err, "finding the book")
}
noteUUID := utils.GenerateUUID()
n := core.NewNote(noteUUID, bookUUID, content, ts, 0, 0, false, false, true)
err = n.Insert(tx)
if err != nil {
tx.Rollback()
return errors.Wrap(err, "creating the note")
}
tx.Commit()
return nil
}

View file

@ -1,113 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package cat
import (
"database/sql"
"fmt"
"time"
"github.com/dnote/dnote/cli/core"
"github.com/dnote/dnote/cli/infra"
"github.com/dnote/dnote/cli/log"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var example = `
* See the notes with index 2 from a book 'javascript'
dnote cat javascript 2
`
var deprecationWarning = `and "view" will replace it in v0.5.0.
Run "dnote view --help" for more information.
`
func preRun(cmd *cobra.Command, args []string) error {
if len(args) != 2 {
return errors.New("Incorrect number of arguments")
}
return nil
}
// NewCmd returns a new cat command
func NewCmd(ctx infra.DnoteCtx) *cobra.Command {
cmd := &cobra.Command{
Use: "cat <book name> <note index>",
Aliases: []string{"c"},
Short: "See a note",
Example: example,
RunE: NewRun(ctx),
PreRunE: preRun,
Deprecated: deprecationWarning,
}
return cmd
}
type noteInfo struct {
BookLabel string
UUID string
Content string
AddedOn int64
EditedOn int64
}
// NewRun returns a new run function
func NewRun(ctx infra.DnoteCtx) core.RunEFunc {
return func(cmd *cobra.Command, args []string) error {
db := ctx.DB
bookLabel := args[0]
noteRowID := args[1]
var bookUUID string
err := db.QueryRow("SELECT uuid FROM books WHERE label = ?", bookLabel).Scan(&bookUUID)
if err == sql.ErrNoRows {
return errors.Errorf("book '%s' not found", bookLabel)
} else if err != nil {
return errors.Wrap(err, "querying the book")
}
var info noteInfo
err = db.QueryRow(`SELECT books.label, notes.uuid, notes.body, notes.added_on, notes.edited_on
FROM notes
INNER JOIN books ON books.uuid = notes.book_uuid
WHERE notes.rowid = ? AND books.uuid = ?`, noteRowID, bookUUID).
Scan(&info.BookLabel, &info.UUID, &info.Content, &info.AddedOn, &info.EditedOn)
if err == sql.ErrNoRows {
return errors.Errorf("note %s not found in the book '%s'", noteRowID, bookLabel)
} else if err != nil {
return errors.Wrap(err, "querying the note")
}
log.Infof("book name: %s\n", info.BookLabel)
log.Infof("note uuid: %s\n", info.UUID)
log.Infof("created at: %s\n", time.Unix(0, info.AddedOn).Format("Jan 2, 2006 3:04pm (MST)"))
if info.EditedOn != 0 {
log.Infof("updated at: %s\n", time.Unix(0, info.EditedOn).Format("Jan 2, 2006 3:04pm (MST)"))
}
fmt.Printf("\n------------------------content------------------------\n")
fmt.Printf("%s", info.Content)
fmt.Printf("\n-------------------------------------------------------\n")
return nil
}
}

View file

@ -1,130 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package edit
import (
"database/sql"
"fmt"
"io/ioutil"
"time"
"github.com/dnote/dnote/cli/core"
"github.com/dnote/dnote/cli/infra"
"github.com/dnote/dnote/cli/log"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var newContent string
var example = `
* Edit the note by index in a book
dnote edit js 3
* Skip the prompt by providing new content directly
dnote edit js 3 -c "new content"`
// NewCmd returns a new edit command
func NewCmd(ctx infra.DnoteCtx) *cobra.Command {
cmd := &cobra.Command{
Use: "edit",
Short: "Edit a note or a book",
Aliases: []string{"e"},
Example: example,
PreRunE: preRun,
RunE: newRun(ctx),
}
f := cmd.Flags()
f.StringVarP(&newContent, "content", "c", "", "The new content for the note")
return cmd
}
func preRun(cmd *cobra.Command, args []string) error {
if len(args) != 2 {
return errors.New("Incorrect number of argument")
}
return nil
}
func newRun(ctx infra.DnoteCtx) core.RunEFunc {
return func(cmd *cobra.Command, args []string) error {
db := ctx.DB
bookLabel := args[0]
noteRowID := args[1]
bookUUID, err := core.GetBookUUID(ctx, bookLabel)
if err != nil {
return errors.Wrap(err, "finding book uuid")
}
var noteUUID, oldContent string
err = db.QueryRow("SELECT uuid, body FROM notes WHERE rowid = ? AND book_uuid = ?", noteRowID, bookUUID).Scan(&noteUUID, &oldContent)
if err == sql.ErrNoRows {
return errors.Errorf("note %s not found in the book '%s'", noteRowID, bookLabel)
} else if err != nil {
return errors.Wrap(err, "querying the book")
}
if newContent == "" {
fpath := core.GetDnoteTmpContentPath(ctx)
e := ioutil.WriteFile(fpath, []byte(oldContent), 0644)
if e != nil {
return errors.Wrap(e, "preparing tmp content file")
}
e = core.GetEditorInput(ctx, fpath, &newContent)
if e != nil {
return errors.Wrap(err, "getting editor input")
}
}
if oldContent == newContent {
return errors.New("Nothing changed")
}
ts := time.Now().UnixNano()
newContent = core.SanitizeContent(newContent)
tx, err := db.Begin()
if err != nil {
return errors.Wrap(err, "beginning a transaction")
}
_, err = tx.Exec(`UPDATE notes
SET body = ?, edited_on = ?, dirty = ?
WHERE rowid = ? AND book_uuid = ?`, newContent, ts, true, noteRowID, bookUUID)
if err != nil {
tx.Rollback()
return errors.Wrap(err, "updating the note")
}
tx.Commit()
log.Success("edited the note\n")
fmt.Printf("\n------------------------content------------------------\n")
fmt.Printf("%s", newContent)
fmt.Printf("\n-------------------------------------------------------\n")
return nil
}
}

View file

@ -1,126 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package login
import (
"encoding/base64"
"strconv"
"github.com/dnote/dnote/cli/client"
"github.com/dnote/dnote/cli/core"
"github.com/dnote/dnote/cli/crypt"
"github.com/dnote/dnote/cli/infra"
"github.com/dnote/dnote/cli/log"
"github.com/dnote/dnote/cli/utils"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var example = `
dnote login`
// NewCmd returns a new login command
func NewCmd(ctx infra.DnoteCtx) *cobra.Command {
cmd := &cobra.Command{
Use: "login",
Short: "Login to dnote server",
Example: example,
RunE: newRun(ctx),
}
return cmd
}
// Do dervies credentials on the client side and requests a session token from the server
func Do(ctx infra.DnoteCtx, email, password string) error {
presigninResp, err := client.GetPresignin(ctx, email)
if err != nil {
return errors.Wrap(err, "getting presiginin")
}
masterKey, authKey, err := crypt.MakeKeys([]byte(password), []byte(email), presigninResp.Iteration)
if err != nil {
return errors.Wrap(err, "making keys")
}
authKeyB64 := base64.StdEncoding.EncodeToString(authKey)
signinResp, err := client.Signin(ctx, email, authKeyB64)
if err != nil {
return errors.Wrap(err, "requesting session")
}
cipherKeyDec, err := crypt.AesGcmDecrypt(masterKey, signinResp.CipherKeyEnc)
if err != nil {
return errors.Wrap(err, "decrypting cipher key")
}
cipherKeyDecB64 := base64.StdEncoding.EncodeToString(cipherKeyDec)
db := ctx.DB
tx, err := db.Begin()
if err != nil {
return errors.Wrap(err, "beginning a transaction")
}
if err := core.UpsertSystem(tx, infra.SystemCipherKey, cipherKeyDecB64); err != nil {
return errors.Wrap(err, "saving enc key")
}
if err := core.UpsertSystem(tx, infra.SystemSessionKey, signinResp.Key); err != nil {
return errors.Wrap(err, "saving session key")
}
if err := core.UpsertSystem(tx, infra.SystemSessionKeyExpiry, strconv.FormatInt(signinResp.ExpiresAt, 10)); err != nil {
return errors.Wrap(err, "saving session key")
}
tx.Commit()
return nil
}
func newRun(ctx infra.DnoteCtx) core.RunEFunc {
return func(cmd *cobra.Command, args []string) error {
var email, password string
if err := utils.PromptInput("email", &email); err != nil {
return errors.Wrap(err, "getting email input")
}
if email == "" {
return errors.New("Email is empty")
}
if err := utils.PromptPassword("password", &password); err != nil {
return errors.Wrap(err, "getting password input")
}
if password == "" {
return errors.New("Password is empty")
}
err := Do(ctx, email, password)
if errors.Cause(err) == client.ErrInvalidLogin {
log.Error("wrong login\n")
return nil
} else if err != nil {
return errors.Wrap(err, "logging in")
}
log.Success("logged in\n")
return nil
}
}

View file

@ -1,100 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package logout
import (
"database/sql"
"github.com/dnote/dnote/cli/client"
"github.com/dnote/dnote/cli/core"
"github.com/dnote/dnote/cli/infra"
"github.com/dnote/dnote/cli/log"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
// ErrNotLoggedIn is an error for logging out when not logged in
var ErrNotLoggedIn = errors.New("not logged in")
var example = `
dnote logout`
// NewCmd returns a new logout command
func NewCmd(ctx infra.DnoteCtx) *cobra.Command {
cmd := &cobra.Command{
Use: "logout",
Short: "Logout from the server",
Example: example,
RunE: newRun(ctx),
}
return cmd
}
// Do performs logout
func Do(ctx infra.DnoteCtx) error {
db := ctx.DB
tx, err := db.Begin()
if err != nil {
return errors.Wrap(err, "beginning a transaction")
}
var key string
err = core.GetSystem(tx, infra.SystemSessionKey, &key)
if errors.Cause(err) == sql.ErrNoRows {
return ErrNotLoggedIn
} else if err != nil {
return errors.Wrap(err, "getting session key")
}
err = client.Signout(ctx, key)
if err != nil {
return errors.Wrap(err, "requesting logout")
}
if err := core.DeleteSystem(tx, infra.SystemCipherKey); err != nil {
return errors.Wrap(err, "deleting enc key")
}
if err := core.DeleteSystem(tx, infra.SystemSessionKey); err != nil {
return errors.Wrap(err, "deleting session key")
}
if err := core.DeleteSystem(tx, infra.SystemSessionKeyExpiry); err != nil {
return errors.Wrap(err, "deleting session key expiry")
}
tx.Commit()
return nil
}
func newRun(ctx infra.DnoteCtx) core.RunEFunc {
return func(cmd *cobra.Command, args []string) error {
err := Do(ctx)
if err == ErrNotLoggedIn {
log.Error("not logged in\n")
return nil
} else if err != nil {
return errors.Wrap(err, "logging out")
}
log.Success("logged out\n")
return nil
}
}

View file

@ -1,202 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package ls
import (
"database/sql"
"fmt"
"strings"
"github.com/dnote/dnote/cli/core"
"github.com/dnote/dnote/cli/infra"
"github.com/dnote/dnote/cli/log"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var example = `
* List all books
dnote ls
* List notes in a book
dnote ls javascript
`
var deprecationWarning = `and "view" will replace it in v1.0.0.
Run "dnote view --help" for more information.
`
func preRun(cmd *cobra.Command, args []string) error {
if len(args) > 1 {
return errors.New("Incorrect number of argument")
}
return nil
}
// NewCmd returns a new ls command
func NewCmd(ctx infra.DnoteCtx) *cobra.Command {
cmd := &cobra.Command{
Use: "ls <book name?>",
Aliases: []string{"l", "notes"},
Short: "List all notes",
Example: example,
RunE: NewRun(ctx),
PreRunE: preRun,
Deprecated: deprecationWarning,
}
return cmd
}
// NewRun returns a new run function for ls
func NewRun(ctx infra.DnoteCtx) core.RunEFunc {
return func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
if err := printBooks(ctx); err != nil {
return errors.Wrap(err, "viewing books")
}
return nil
}
bookName := args[0]
if err := printNotes(ctx, bookName); err != nil {
return errors.Wrapf(err, "viewing book '%s'", bookName)
}
return nil
}
}
// bookInfo is an information about the book to be printed on screen
type bookInfo struct {
BookLabel string
NoteCount int
}
// noteInfo is an information about the note to be printed on screen
type noteInfo struct {
RowID int
Body string
}
// getNewlineIdx returns the index of newline character in a string
func getNewlineIdx(str string) int {
var ret int
ret = strings.Index(str, "\n")
if ret == -1 {
ret = strings.Index(str, "\r\n")
}
return ret
}
// formatBody returns an excerpt of the given raw note content and a boolean
// indicating if the returned string has been excertped
func formatBody(noteBody string) (string, bool) {
newlineIdx := getNewlineIdx(noteBody)
if newlineIdx > -1 {
ret := strings.Trim(noteBody[0:newlineIdx], " ")
return ret, true
}
return strings.Trim(noteBody, " "), false
}
func printBooks(ctx infra.DnoteCtx) error {
db := ctx.DB
rows, err := db.Query(`SELECT books.label, count(notes.uuid) note_count
FROM books
LEFT JOIN notes ON notes.book_uuid = books.uuid AND notes.deleted = false
WHERE books.deleted = false
GROUP BY books.uuid
ORDER BY books.label ASC;`)
if err != nil {
return errors.Wrap(err, "querying books")
}
defer rows.Close()
infos := []bookInfo{}
for rows.Next() {
var info bookInfo
err = rows.Scan(&info.BookLabel, &info.NoteCount)
if err != nil {
return errors.Wrap(err, "scanning a row")
}
infos = append(infos, info)
}
for _, info := range infos {
log.Printf("%s %s\n", info.BookLabel, log.ColorYellow.Sprintf("(%d)", info.NoteCount))
}
return nil
}
func printNotes(ctx infra.DnoteCtx, bookName string) error {
db := ctx.DB
var bookUUID string
err := db.QueryRow("SELECT uuid FROM books WHERE label = ?", bookName).Scan(&bookUUID)
if err == sql.ErrNoRows {
return errors.New("book not found")
} else if err != nil {
return errors.Wrap(err, "querying the book")
}
rows, err := db.Query(`SELECT rowid, body FROM notes WHERE book_uuid = ? AND deleted = ? ORDER BY added_on ASC;`, bookUUID, false)
if err != nil {
return errors.Wrap(err, "querying notes")
}
defer rows.Close()
infos := []noteInfo{}
for rows.Next() {
var info noteInfo
err = rows.Scan(&info.RowID, &info.Body)
if err != nil {
return errors.Wrap(err, "scanning a row")
}
infos = append(infos, info)
}
log.Infof("on book %s\n", bookName)
for _, info := range infos {
body, isExcerpt := formatBody(info.Body)
rowid := log.ColorYellow.Sprintf("(%d)", info.RowID)
if isExcerpt {
body = fmt.Sprintf("%s %s", body, log.ColorYellow.Sprintf("[---More---]"))
}
log.Plainf("%s %s\n", rowid, body)
}
return nil
}

View file

@ -1,163 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package remove
import (
"database/sql"
"fmt"
"github.com/dnote/dnote/cli/core"
"github.com/dnote/dnote/cli/infra"
"github.com/dnote/dnote/cli/log"
"github.com/dnote/dnote/cli/utils"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var targetBookName string
var example = `
* Delete a note by its index from a book
dnote delete js 2
* Delete a book
dnote delete -b js`
// NewCmd returns a new remove command
func NewCmd(ctx infra.DnoteCtx) *cobra.Command {
cmd := &cobra.Command{
Use: "remove",
Short: "Remove a note or a book",
Aliases: []string{"rm", "d", "delete"},
Example: example,
RunE: newRun(ctx),
}
f := cmd.Flags()
f.StringVarP(&targetBookName, "book", "b", "", "The book name to delete")
return cmd
}
func newRun(ctx infra.DnoteCtx) core.RunEFunc {
return func(cmd *cobra.Command, args []string) error {
if targetBookName != "" {
if err := removeBook(ctx, targetBookName); err != nil {
return errors.Wrap(err, "removing the book")
}
return nil
}
if len(args) < 2 {
return errors.New("Missing argument")
}
targetBook := args[0]
noteRowID := args[1]
if err := removeNote(ctx, noteRowID, targetBook); err != nil {
return errors.Wrap(err, "removing the note")
}
return nil
}
}
func removeNote(ctx infra.DnoteCtx, noteRowID, bookLabel string) error {
db := ctx.DB
bookUUID, err := core.GetBookUUID(ctx, bookLabel)
if err != nil {
return errors.Wrap(err, "finding book uuid")
}
var noteUUID, noteContent string
err = db.QueryRow("SELECT uuid, body FROM notes WHERE rowid = ? AND book_uuid = ?", noteRowID, bookUUID).Scan(&noteUUID, &noteContent)
if err == sql.ErrNoRows {
return errors.Errorf("note %s not found in the book '%s'", noteRowID, bookLabel)
} else if err != nil {
return errors.Wrap(err, "querying the book")
}
// todo: multiline
log.Printf("body: \"%s\"\n", noteContent)
ok, err := utils.AskConfirmation("remove this note?", false)
if err != nil {
return errors.Wrap(err, "getting confirmation")
}
if !ok {
log.Warnf("aborted by user\n")
return nil
}
tx, err := db.Begin()
if err != nil {
return errors.Wrap(err, "beginning a transaction")
}
if _, err = tx.Exec("UPDATE notes SET deleted = ?, dirty = ?, body = ? WHERE uuid = ? AND book_uuid = ?", true, true, "", noteUUID, bookUUID); err != nil {
return errors.Wrap(err, "removing the note")
}
tx.Commit()
log.Successf("removed from %s\n", bookLabel)
return nil
}
func removeBook(ctx infra.DnoteCtx, bookLabel string) error {
db := ctx.DB
bookUUID, err := core.GetBookUUID(ctx, bookLabel)
if err != nil {
return errors.Wrap(err, "finding book uuid")
}
ok, err := utils.AskConfirmation(fmt.Sprintf("delete book '%s' and all its notes?", bookLabel), false)
if err != nil {
return errors.Wrap(err, "getting confirmation")
}
if !ok {
log.Warnf("aborted by user\n")
return nil
}
tx, err := db.Begin()
if err != nil {
return errors.Wrap(err, "beginning a transaction")
}
if _, err = tx.Exec("UPDATE notes SET deleted = ?, dirty = ?, body = ? WHERE book_uuid = ?", true, true, "", bookUUID); err != nil {
return errors.Wrap(err, "removing notes in the book")
}
// override the label with a random string
uniqLabel := utils.GenerateUUID()
if _, err = tx.Exec("UPDATE books SET deleted = ?, dirty = ?, label = ? WHERE uuid = ?", true, true, uniqLabel, bookUUID); err != nil {
return errors.Wrap(err, "removing the book")
}
tx.Commit()
log.Success("removed book\n")
return nil
}

View file

@ -1,67 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package root
import (
"github.com/dnote/dnote/cli/core"
"github.com/dnote/dnote/cli/infra"
"github.com/dnote/dnote/cli/migrate"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var root = &cobra.Command{
Use: "dnote",
Short: "Dnote - Instantly capture what you learn while coding",
SilenceErrors: true,
SilenceUsage: true,
}
// Register adds a new command
func Register(cmd *cobra.Command) {
root.AddCommand(cmd)
}
// Execute runs the main command
func Execute() error {
return root.Execute()
}
// Prepare initializes necessary files
func Prepare(ctx infra.DnoteCtx) error {
if err := core.InitFiles(ctx); err != nil {
return errors.Wrap(err, "initializing files")
}
if err := infra.InitDB(ctx); err != nil {
return errors.Wrap(err, "initializing database")
}
if err := core.InitSystem(ctx); err != nil {
return errors.Wrap(err, "initializing system data")
}
if err := migrate.Legacy(ctx); err != nil {
return errors.Wrap(err, "running legacy migration")
}
if err := migrate.Run(ctx, migrate.LocalSequence, migrate.LocalMode); err != nil {
return errors.Wrap(err, "running migration")
}
return nil
}

File diff suppressed because it is too large Load diff

View file

@ -1,40 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package version
import (
"fmt"
"github.com/dnote/dnote/cli/infra"
"github.com/spf13/cobra"
)
// NewCmd returns a new version command
func NewCmd(ctx infra.DnoteCtx) *cobra.Command {
cmd := &cobra.Command{
Use: "version",
Short: "Print the version number of Dnote",
Long: "Print the version number of Dnote",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("dnote %s\n", ctx.Version)
},
}
return cmd
}

View file

@ -1,78 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package view
import (
"github.com/dnote/dnote/cli/core"
"github.com/dnote/dnote/cli/infra"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/dnote/dnote/cli/cmd/cat"
"github.com/dnote/dnote/cli/cmd/ls"
)
var example = `
* View all books
dnote view
* List notes in a book
dnote view javascript
* View a particular note in a book
dnote view javascript 0
`
func preRun(cmd *cobra.Command, args []string) error {
if len(args) > 2 {
return errors.New("Incorrect number of argument")
}
return nil
}
// NewCmd returns a new view command
func NewCmd(ctx infra.DnoteCtx) *cobra.Command {
cmd := &cobra.Command{
Use: "view <book name?> <note index?>",
Aliases: []string{"v"},
Short: "List books, notes or view a content",
Example: example,
RunE: newRun(ctx),
PreRunE: preRun,
}
return cmd
}
func newRun(ctx infra.DnoteCtx) core.RunEFunc {
return func(cmd *cobra.Command, args []string) error {
var run core.RunEFunc
if len(args) <= 1 {
run = ls.NewRun(ctx)
} else if len(args) == 2 {
run = cat.NewRun(ctx)
} else {
return errors.New("Incorrect number of arguments")
}
return run(cmd, args)
}
}

View file

@ -1,358 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package core
import (
"database/sql"
"fmt"
"io/ioutil"
"os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/dnote/dnote/cli/infra"
"github.com/dnote/dnote/cli/log"
"github.com/dnote/dnote/cli/utils"
"github.com/pkg/errors"
"github.com/satori/go.uuid"
"github.com/spf13/cobra"
"gopkg.in/yaml.v2"
)
var (
// ConfigFilename is the name of the config file
ConfigFilename = "dnoterc"
// TmpContentFilename is the name of the temporary file that holds editor input
TmpContentFilename = "DNOTE_TMPCONTENT.md"
)
// RunEFunc is a function type of dnote commands
type RunEFunc func(*cobra.Command, []string) error
// GetConfigPath returns the path to the dnote config file
func GetConfigPath(ctx infra.DnoteCtx) string {
return fmt.Sprintf("%s/%s", ctx.DnoteDir, ConfigFilename)
}
// GetDnoteTmpContentPath returns the path to the temporary file containing
// content being added or edited
func GetDnoteTmpContentPath(ctx infra.DnoteCtx) string {
return fmt.Sprintf("%s/%s", ctx.DnoteDir, TmpContentFilename)
}
// GetBookUUID returns a uuid of a book given a label
func GetBookUUID(ctx infra.DnoteCtx, label string) (string, error) {
db := ctx.DB
var ret string
err := db.QueryRow("SELECT uuid FROM books WHERE label = ?", label).Scan(&ret)
if err == sql.ErrNoRows {
return ret, errors.Errorf("book '%s' not found", label)
} else if err != nil {
return ret, errors.Wrap(err, "querying the book")
}
return ret, nil
}
// getEditorCommand returns the system's editor command with appropriate flags,
// if necessary, to make the command wait until editor is close to exit.
func getEditorCommand() string {
editor := os.Getenv("EDITOR")
var ret string
switch editor {
case "atom":
ret = "atom -w"
case "subl":
ret = "subl -n -w"
case "mate":
ret = "mate -w"
case "vim":
ret = "vim"
case "nano":
ret = "nano"
case "emacs":
ret = "emacs"
case "nvim":
ret = "nvim"
default:
ret = "vi"
}
return ret
}
// InitFiles creates, if necessary, the dnote directory and files inside
func InitFiles(ctx infra.DnoteCtx) error {
if err := initDnoteDir(ctx); err != nil {
return errors.Wrap(err, "creating the dnote dir")
}
if err := initConfigFile(ctx); err != nil {
return errors.Wrap(err, "generating the config file")
}
return nil
}
// initConfigFile populates a new config file if it does not exist yet
func initConfigFile(ctx infra.DnoteCtx) error {
path := GetConfigPath(ctx)
if utils.FileExists(path) {
return nil
}
editor := getEditorCommand()
config := infra.Config{
Editor: editor,
}
b, err := yaml.Marshal(config)
if err != nil {
return errors.Wrap(err, "marshalling config into YAML")
}
err = ioutil.WriteFile(path, b, 0644)
if err != nil {
return errors.Wrap(err, "writing the config file")
}
return nil
}
// initDnoteDir initializes dnote directory if it does not exist yet
func initDnoteDir(ctx infra.DnoteCtx) error {
path := ctx.DnoteDir
if utils.FileExists(path) {
return nil
}
if err := os.MkdirAll(path, 0755); err != nil {
return errors.Wrap(err, "Failed to create dnote directory")
}
return nil
}
// WriteConfig writes the config to the config file
func WriteConfig(ctx infra.DnoteCtx, config infra.Config) error {
d, err := yaml.Marshal(config)
if err != nil {
return errors.Wrap(err, "marhsalling config")
}
configPath := GetConfigPath(ctx)
err = ioutil.WriteFile(configPath, d, 0644)
if err != nil {
errors.Wrap(err, "writing the config file")
}
return nil
}
// LogAction logs action and updates the last_action
func LogAction(tx *sql.Tx, schema int, actionType, data string, timestamp int64) error {
uuid := uuid.NewV4().String()
_, err := tx.Exec(`INSERT INTO actions (uuid, schema, type, data, timestamp)
VALUES (?, ?, ?, ?, ?)`, uuid, schema, actionType, data, timestamp)
if err != nil {
return errors.Wrap(err, "inserting an action")
}
_, err = tx.Exec("UPDATE system SET value = ? WHERE key = ?", timestamp, "last_action")
if err != nil {
return errors.Wrap(err, "updating last_action")
}
return nil
}
// ReadConfig reads the config file
func ReadConfig(ctx infra.DnoteCtx) (infra.Config, error) {
var ret infra.Config
configPath := GetConfigPath(ctx)
b, err := ioutil.ReadFile(configPath)
if err != nil {
return ret, errors.Wrap(err, "reading config file")
}
err = yaml.Unmarshal(b, &ret)
if err != nil {
return ret, errors.Wrap(err, "unmarshalling config")
}
return ret, nil
}
// SanitizeContent sanitizes note content
func SanitizeContent(s string) string {
var ret string
ret = strings.Trim(s, " ")
// Remove newline at the end of the file because POSIX defines a line as
// characters followed by a newline
ret = strings.TrimSuffix(ret, "\n")
ret = strings.TrimSuffix(ret, "\r\n")
return ret
}
func newEditorCmd(ctx infra.DnoteCtx, fpath string) (*exec.Cmd, error) {
config, err := ReadConfig(ctx)
if err != nil {
return nil, errors.Wrap(err, "reading config")
}
args := strings.Fields(config.Editor)
args = append(args, fpath)
return exec.Command(args[0], args[1:]...), nil
}
// GetEditorInput gets the user input by launching a text editor and waiting for
// it to exit
func GetEditorInput(ctx infra.DnoteCtx, fpath string, content *string) error {
if !utils.FileExists(fpath) {
f, err := os.Create(fpath)
if err != nil {
return errors.Wrap(err, "creating a temporary content file")
}
err = f.Close()
if err != nil {
return errors.Wrap(err, "closing the temporary content file")
}
}
cmd, err := newEditorCmd(ctx, fpath)
if err != nil {
return errors.Wrap(err, "creating an editor command")
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Start()
if err != nil {
return errors.Wrapf(err, "launching an editor")
}
err = cmd.Wait()
if err != nil {
return errors.Wrap(err, "waiting for the editor")
}
b, err := ioutil.ReadFile(fpath)
if err != nil {
return errors.Wrap(err, "reading the temporary content file")
}
err = os.Remove(fpath)
if err != nil {
return errors.Wrap(err, "removing the temporary content file")
}
raw := string(b)
c := SanitizeContent(raw)
*content = c
return nil
}
func initSystemKV(db *infra.DB, key string, val string) error {
var count int
if err := db.QueryRow("SELECT count(*) FROM system WHERE key = ?", key).Scan(&count); err != nil {
return errors.Wrapf(err, "counting %s", key)
}
if count > 0 {
return nil
}
if _, err := db.Exec("INSERT INTO system (key, value) VALUES (?, ?)", key, val); err != nil {
db.Rollback()
return errors.Wrapf(err, "inserting %s %s", key, val)
}
return nil
}
// InitSystem inserts system data if missing
func InitSystem(ctx infra.DnoteCtx) error {
db := ctx.DB
tx, err := db.Begin()
if err != nil {
return errors.Wrap(err, "beginning a transaction")
}
nowStr := strconv.FormatInt(time.Now().Unix(), 10)
if err := initSystemKV(tx, infra.SystemLastUpgrade, nowStr); err != nil {
return errors.Wrapf(err, "initializing system config for %s", infra.SystemLastUpgrade)
}
if err := initSystemKV(tx, infra.SystemLastMaxUSN, "0"); err != nil {
return errors.Wrapf(err, "initializing system config for %s", infra.SystemLastMaxUSN)
}
if err := initSystemKV(tx, infra.SystemLastSyncAt, "0"); err != nil {
return errors.Wrapf(err, "initializing system config for %s", infra.SystemLastSyncAt)
}
tx.Commit()
return nil
}
// GetValidSession returns a session key from the local storage if one exists and is not expired
// If one does not exist or is expired, it prints out an instruction and returns false
func GetValidSession(ctx infra.DnoteCtx) (string, bool, error) {
db := ctx.DB
var sessionKey string
var sessionKeyExpires int64
if err := GetSystem(db, infra.SystemSessionKey, &sessionKey); err != nil {
return "", false, errors.Wrap(err, "getting session key")
}
if err := GetSystem(db, infra.SystemSessionKeyExpiry, &sessionKeyExpires); err != nil {
return "", false, errors.Wrap(err, "getting session key expiry")
}
if sessionKey == "" {
log.Error("login required. please run `dnote login`\n")
return "", false, nil
}
if sessionKeyExpires < time.Now().Unix() {
log.Error("sesison expired. please run `dnote login`\n")
return "", false, nil
}
return sessionKey, true, nil
}

View file

@ -1,95 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package core
import (
"testing"
"github.com/dnote/dnote/cli/testutils"
"github.com/pkg/errors"
)
func TestInitSystemKV(t *testing.T) {
// Setup
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
db := ctx.DB
var originalCount int
testutils.MustScan(t, "counting system configs", db.QueryRow("SELECT count(*) FROM system"), &originalCount)
// Execute
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, "beginning a transaction"))
}
if err := initSystemKV(tx, "testKey", "testVal"); err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, "executing"))
}
tx.Commit()
// Test
var count int
testutils.MustScan(t, "counting system configs", db.QueryRow("SELECT count(*) FROM system"), &count)
testutils.AssertEqual(t, count, originalCount+1, "system count mismatch")
var val string
testutils.MustScan(t, "getting system value",
db.QueryRow("SELECT value FROM system WHERE key = ?", "testKey"), &val)
testutils.AssertEqual(t, val, "testVal", "system value mismatch")
}
func TestInitSystemKV_existing(t *testing.T) {
// Setup
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
db := ctx.DB
testutils.MustExec(t, "inserting a system config", db, "INSERT INTO system (key, value) VALUES (?, ?)", "testKey", "testVal")
var originalCount int
testutils.MustScan(t, "counting system configs", db.QueryRow("SELECT count(*) FROM system"), &originalCount)
// Execute
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, "beginning a transaction"))
}
if err := initSystemKV(tx, "testKey", "newTestVal"); err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, "executing"))
}
tx.Commit()
// Test
var count int
testutils.MustScan(t, "counting system configs", db.QueryRow("SELECT count(*) FROM system"), &count)
testutils.AssertEqual(t, count, originalCount, "system count mismatch")
var val string
testutils.MustScan(t, "getting system value",
db.QueryRow("SELECT value FROM system WHERE key = ?", "testKey"), &val)
testutils.AssertEqual(t, val, "testVal", "system value should not have been updated")
}

View file

@ -1,901 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package core
import (
"fmt"
"testing"
"github.com/dnote/dnote/cli/testutils"
"github.com/pkg/errors"
)
func TestNewNote(t *testing.T) {
testCases := []struct {
uuid string
bookUUID string
body string
addedOn int64
editedOn int64
usn int
public bool
deleted bool
dirty bool
}{
{
uuid: "n1-uuid",
bookUUID: "b1-uuid",
body: "n1-body",
addedOn: 1542058875,
editedOn: 0,
usn: 0,
public: false,
deleted: false,
dirty: false,
},
{
uuid: "n2-uuid",
bookUUID: "b2-uuid",
body: "n2-body",
addedOn: 1542058875,
editedOn: 1542058876,
usn: 1008,
public: true,
deleted: true,
dirty: true,
},
}
for idx, tc := range testCases {
got := NewNote(tc.uuid, tc.bookUUID, tc.body, tc.addedOn, tc.editedOn, tc.usn, tc.public, tc.deleted, tc.dirty)
testutils.AssertEqual(t, got.UUID, tc.uuid, fmt.Sprintf("UUID mismatch for test case %d", idx))
testutils.AssertEqual(t, got.BookUUID, tc.bookUUID, fmt.Sprintf("BookUUID mismatch for test case %d", idx))
testutils.AssertEqual(t, got.Body, tc.body, fmt.Sprintf("Body mismatch for test case %d", idx))
testutils.AssertEqual(t, got.AddedOn, tc.addedOn, fmt.Sprintf("AddedOn mismatch for test case %d", idx))
testutils.AssertEqual(t, got.EditedOn, tc.editedOn, fmt.Sprintf("EditedOn mismatch for test case %d", idx))
testutils.AssertEqual(t, got.USN, tc.usn, fmt.Sprintf("USN mismatch for test case %d", idx))
testutils.AssertEqual(t, got.Public, tc.public, fmt.Sprintf("Public mismatch for test case %d", idx))
testutils.AssertEqual(t, got.Deleted, tc.deleted, fmt.Sprintf("Deleted mismatch for test case %d", idx))
testutils.AssertEqual(t, got.Dirty, tc.dirty, fmt.Sprintf("Dirty mismatch for test case %d", idx))
}
}
func TestNoteInsert(t *testing.T) {
testCases := []struct {
uuid string
bookUUID string
body string
addedOn int64
editedOn int64
usn int
public bool
deleted bool
dirty bool
}{
{
uuid: "n1-uuid",
bookUUID: "b1-uuid",
body: "n1-body",
addedOn: 1542058875,
editedOn: 0,
usn: 0,
public: false,
deleted: false,
dirty: false,
},
{
uuid: "n2-uuid",
bookUUID: "b2-uuid",
body: "n2-body",
addedOn: 1542058875,
editedOn: 1542058876,
usn: 1008,
public: true,
deleted: true,
dirty: true,
},
}
for idx, tc := range testCases {
func() {
// Setup
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
n := Note{
UUID: tc.uuid,
BookUUID: tc.bookUUID,
Body: tc.body,
AddedOn: tc.addedOn,
EditedOn: tc.editedOn,
USN: tc.usn,
Public: tc.public,
Deleted: tc.deleted,
Dirty: tc.dirty,
}
// execute
db := ctx.DB
tx, err := db.Begin()
if err != nil {
t.Fatalf(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error())
}
if err := n.Insert(tx); err != nil {
tx.Rollback()
t.Fatalf(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error())
}
tx.Commit()
// test
var uuid, bookUUID, body string
var addedOn, editedOn int64
var usn int
var public, deleted, dirty bool
testutils.MustScan(t, "getting n1",
db.QueryRow("SELECT uuid, book_uuid, body, added_on, edited_on, usn, public, deleted, dirty FROM notes WHERE uuid = ?", tc.uuid),
&uuid, &bookUUID, &body, &addedOn, &editedOn, &usn, &public, &deleted, &dirty)
testutils.AssertEqual(t, uuid, tc.uuid, fmt.Sprintf("uuid mismatch for test case %d", idx))
testutils.AssertEqual(t, bookUUID, tc.bookUUID, fmt.Sprintf("bookUUID mismatch for test case %d", idx))
testutils.AssertEqual(t, body, tc.body, fmt.Sprintf("body mismatch for test case %d", idx))
testutils.AssertEqual(t, addedOn, tc.addedOn, fmt.Sprintf("addedOn mismatch for test case %d", idx))
testutils.AssertEqual(t, editedOn, tc.editedOn, fmt.Sprintf("editedOn mismatch for test case %d", idx))
testutils.AssertEqual(t, usn, tc.usn, fmt.Sprintf("usn mismatch for test case %d", idx))
testutils.AssertEqual(t, public, tc.public, fmt.Sprintf("public mismatch for test case %d", idx))
testutils.AssertEqual(t, deleted, tc.deleted, fmt.Sprintf("deleted mismatch for test case %d", idx))
testutils.AssertEqual(t, dirty, tc.dirty, fmt.Sprintf("dirty mismatch for test case %d", idx))
}()
}
}
func TestNoteUpdate(t *testing.T) {
testCases := []struct {
uuid string
bookUUID string
body string
addedOn int64
editedOn int64
usn int
public bool
deleted bool
dirty bool
newBookUUID string
newBody string
newEditedOn int64
newUSN int
newPublic bool
newDeleted bool
newDirty bool
}{
{
uuid: "n1-uuid",
bookUUID: "b1-uuid",
body: "n1-body",
addedOn: 1542058875,
editedOn: 0,
usn: 0,
public: false,
deleted: false,
dirty: false,
newBookUUID: "b1-uuid",
newBody: "n1-body edited",
newEditedOn: 1542058879,
newUSN: 0,
newPublic: false,
newDeleted: false,
newDirty: false,
},
{
uuid: "n1-uuid",
bookUUID: "b1-uuid",
body: "n1-body",
addedOn: 1542058875,
editedOn: 0,
usn: 0,
public: false,
deleted: false,
dirty: true,
newBookUUID: "b2-uuid",
newBody: "n1-body",
newEditedOn: 1542058879,
newUSN: 0,
newPublic: true,
newDeleted: false,
newDirty: false,
},
{
uuid: "n1-uuid",
bookUUID: "b1-uuid",
body: "n1-body",
addedOn: 1542058875,
editedOn: 0,
usn: 10,
public: false,
deleted: false,
dirty: false,
newBookUUID: "",
newBody: "",
newEditedOn: 1542058879,
newUSN: 151,
newPublic: false,
newDeleted: true,
newDirty: false,
},
{
uuid: "n1-uuid",
bookUUID: "b1-uuid",
body: "n1-body",
addedOn: 1542058875,
editedOn: 0,
usn: 0,
public: false,
deleted: false,
dirty: false,
newBookUUID: "",
newBody: "",
newEditedOn: 1542058879,
newUSN: 15,
newPublic: false,
newDeleted: true,
newDirty: false,
},
}
for idx, tc := range testCases {
func() {
// Setup
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
n1 := Note{
UUID: tc.uuid,
BookUUID: tc.bookUUID,
Body: tc.body,
AddedOn: tc.addedOn,
EditedOn: tc.editedOn,
USN: tc.usn,
Public: tc.public,
Deleted: tc.deleted,
Dirty: tc.dirty,
}
n2 := Note{
UUID: "n2-uuid",
BookUUID: "b10-uuid",
Body: "n2 body",
AddedOn: 1542058875,
EditedOn: 0,
USN: 39,
Public: false,
Deleted: false,
Dirty: false,
}
db := ctx.DB
testutils.MustExec(t, fmt.Sprintf("inserting n1 for test case %d", idx), db, "INSERT INTO notes (uuid, book_uuid, usn, added_on, edited_on, body, public, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", n1.UUID, n1.BookUUID, n1.USN, n1.AddedOn, n1.EditedOn, n1.Body, n1.Public, n1.Deleted, n1.Dirty)
testutils.MustExec(t, fmt.Sprintf("inserting n2 for test case %d", idx), db, "INSERT INTO notes (uuid, book_uuid, usn, added_on, edited_on, body, public, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", n2.UUID, n2.BookUUID, n2.USN, n2.AddedOn, n2.EditedOn, n2.Body, n2.Public, n2.Deleted, n2.Dirty)
// execute
tx, err := db.Begin()
if err != nil {
t.Fatalf(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error())
}
n1.BookUUID = tc.newBookUUID
n1.Body = tc.newBody
n1.EditedOn = tc.newEditedOn
n1.USN = tc.newUSN
n1.Public = tc.newPublic
n1.Deleted = tc.newDeleted
n1.Dirty = tc.newDirty
if err := n1.Update(tx); err != nil {
tx.Rollback()
t.Fatalf(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error())
}
tx.Commit()
// test
var n1Record, n2Record Note
testutils.MustScan(t, "getting n1",
db.QueryRow("SELECT uuid, book_uuid, body, added_on, edited_on, usn, public, deleted, dirty FROM notes WHERE uuid = ?", tc.uuid),
&n1Record.UUID, &n1Record.BookUUID, &n1Record.Body, &n1Record.AddedOn, &n1Record.EditedOn, &n1Record.USN, &n1Record.Public, &n1Record.Deleted, &n1Record.Dirty)
testutils.MustScan(t, "getting n2",
db.QueryRow("SELECT uuid, book_uuid, body, added_on, edited_on, usn, public, deleted, dirty FROM notes WHERE uuid = ?", n2.UUID),
&n2Record.UUID, &n2Record.BookUUID, &n2Record.Body, &n2Record.AddedOn, &n2Record.EditedOn, &n2Record.USN, &n2Record.Public, &n2Record.Deleted, &n2Record.Dirty)
testutils.AssertEqual(t, n1Record.UUID, n1.UUID, fmt.Sprintf("n1 uuid mismatch for test case %d", idx))
testutils.AssertEqual(t, n1Record.BookUUID, tc.newBookUUID, fmt.Sprintf("n1 bookUUID mismatch for test case %d", idx))
testutils.AssertEqual(t, n1Record.Body, tc.newBody, fmt.Sprintf("n1 body mismatch for test case %d", idx))
testutils.AssertEqual(t, n1Record.AddedOn, n1.AddedOn, fmt.Sprintf("n1 addedOn mismatch for test case %d", idx))
testutils.AssertEqual(t, n1Record.EditedOn, tc.newEditedOn, fmt.Sprintf("n1 editedOn mismatch for test case %d", idx))
testutils.AssertEqual(t, n1Record.USN, tc.newUSN, fmt.Sprintf("n1 usn mismatch for test case %d", idx))
testutils.AssertEqual(t, n1Record.Public, tc.newPublic, fmt.Sprintf("n1 public mismatch for test case %d", idx))
testutils.AssertEqual(t, n1Record.Deleted, tc.newDeleted, fmt.Sprintf("n1 deleted mismatch for test case %d", idx))
testutils.AssertEqual(t, n1Record.Dirty, tc.newDirty, fmt.Sprintf("n1 dirty mismatch for test case %d", idx))
testutils.AssertEqual(t, n2Record.UUID, n2.UUID, fmt.Sprintf("n2 uuid mismatch for test case %d", idx))
testutils.AssertEqual(t, n2Record.BookUUID, n2.BookUUID, fmt.Sprintf("n2 bookUUID mismatch for test case %d", idx))
testutils.AssertEqual(t, n2Record.Body, n2.Body, fmt.Sprintf("n2 body mismatch for test case %d", idx))
testutils.AssertEqual(t, n2Record.AddedOn, n2.AddedOn, fmt.Sprintf("n2 addedOn mismatch for test case %d", idx))
testutils.AssertEqual(t, n2Record.EditedOn, n2.EditedOn, fmt.Sprintf("n2 editedOn mismatch for test case %d", idx))
testutils.AssertEqual(t, n2Record.USN, n2.USN, fmt.Sprintf("n2 usn mismatch for test case %d", idx))
testutils.AssertEqual(t, n2Record.Public, n2.Public, fmt.Sprintf("n2 public mismatch for test case %d", idx))
testutils.AssertEqual(t, n2Record.Deleted, n2.Deleted, fmt.Sprintf("n2 deleted mismatch for test case %d", idx))
testutils.AssertEqual(t, n2Record.Dirty, n2.Dirty, fmt.Sprintf("n2 dirty mismatch for test case %d", idx))
}()
}
}
func TestNoteUpdateUUID(t *testing.T) {
testCases := []struct {
newUUID string
}{
{
newUUID: "n1-new-uuid",
},
{
newUUID: "n2-new-uuid",
},
}
for idx, tc := range testCases {
t.Run(fmt.Sprintf("testCase%d", idx), func(t *testing.T) {
// Setup
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
n1 := Note{
UUID: "n1-uuid",
BookUUID: "b1-uuid",
AddedOn: 1542058874,
Body: "n1-body",
USN: 1,
Deleted: true,
Dirty: false,
}
n2 := Note{
UUID: "n2-uuid",
BookUUID: "b1-uuid",
AddedOn: 1542058874,
Body: "n2-body",
USN: 1,
Deleted: true,
Dirty: false,
}
db := ctx.DB
testutils.MustExec(t, "inserting n1", db, "INSERT INTO notes (uuid, book_uuid, body, added_on, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", n1.UUID, n1.BookUUID, n1.Body, n1.AddedOn, n1.USN, n1.Deleted, n1.Dirty)
testutils.MustExec(t, "inserting n2", db, "INSERT INTO notes (uuid, book_uuid, body, added_on, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", n2.UUID, n2.BookUUID, n2.Body, n2.AddedOn, n2.USN, n2.Deleted, n2.Dirty)
// execute
tx, err := db.Begin()
if err != nil {
t.Fatalf(errors.Wrap(err, "beginning a transaction").Error())
}
if err := n1.UpdateUUID(tx, tc.newUUID); err != nil {
tx.Rollback()
t.Fatalf(errors.Wrap(err, "executing").Error())
}
tx.Commit()
// test
var n1Record, n2Record Note
testutils.MustScan(t, "getting n1",
db.QueryRow("SELECT uuid, body, usn, deleted, dirty FROM notes WHERE body = ?", "n1-body"),
&n1Record.UUID, &n1Record.Body, &n1Record.USN, &n1Record.Deleted, &n1Record.Dirty)
testutils.MustScan(t, "getting n2",
db.QueryRow("SELECT uuid, body, usn, deleted, dirty FROM notes WHERE body = ?", "n2-body"),
&n2Record.UUID, &n2Record.Body, &n2Record.USN, &n2Record.Deleted, &n2Record.Dirty)
testutils.AssertEqual(t, n1.UUID, tc.newUUID, "n1 original reference uuid mismatch")
testutils.AssertEqual(t, n1Record.UUID, tc.newUUID, "n1 uuid mismatch")
testutils.AssertEqual(t, n2Record.UUID, n2.UUID, "n2 uuid mismatch")
})
}
}
func TestNoteExpunge(t *testing.T) {
// Setup
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
n1 := Note{
UUID: "n1-uuid",
BookUUID: "b9-uuid",
Body: "n1 body",
AddedOn: 1542058874,
EditedOn: 0,
USN: 22,
Public: false,
Deleted: false,
Dirty: false,
}
n2 := Note{
UUID: "n2-uuid",
BookUUID: "b10-uuid",
Body: "n2 body",
AddedOn: 1542058875,
EditedOn: 0,
USN: 39,
Public: false,
Deleted: false,
Dirty: false,
}
db := ctx.DB
testutils.MustExec(t, "inserting n1", db, "INSERT INTO notes (uuid, book_uuid, usn, added_on, edited_on, body, public, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", n1.UUID, n1.BookUUID, n1.USN, n1.AddedOn, n1.EditedOn, n1.Body, n1.Public, n1.Deleted, n1.Dirty)
testutils.MustExec(t, "inserting n2", db, "INSERT INTO notes (uuid, book_uuid, usn, added_on, edited_on, body, public, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", n2.UUID, n2.BookUUID, n2.USN, n2.AddedOn, n2.EditedOn, n2.Body, n2.Public, n2.Deleted, n2.Dirty)
// execute
tx, err := db.Begin()
if err != nil {
t.Fatalf(errors.Wrap(err, "beginning a transaction").Error())
}
if err := n1.Expunge(tx); err != nil {
tx.Rollback()
t.Fatalf(errors.Wrap(err, "executing").Error())
}
tx.Commit()
// test
var noteCount int
testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), &noteCount)
testutils.AssertEqualf(t, noteCount, 1, "note count mismatch")
var n2Record Note
testutils.MustScan(t, "getting n2",
db.QueryRow("SELECT uuid, book_uuid, body, added_on, edited_on, usn, public, deleted, dirty FROM notes WHERE uuid = ?", n2.UUID),
&n2Record.UUID, &n2Record.BookUUID, &n2Record.Body, &n2Record.AddedOn, &n2Record.EditedOn, &n2Record.USN, &n2Record.Public, &n2Record.Deleted, &n2Record.Dirty)
testutils.AssertEqual(t, n2Record.UUID, n2.UUID, "n2 uuid mismatch")
testutils.AssertEqual(t, n2Record.BookUUID, n2.BookUUID, "n2 bookUUID mismatch")
testutils.AssertEqual(t, n2Record.Body, n2.Body, "n2 body mismatch")
testutils.AssertEqual(t, n2Record.AddedOn, n2.AddedOn, "n2 addedOn mismatch")
testutils.AssertEqual(t, n2Record.EditedOn, n2.EditedOn, "n2 editedOn mismatch")
testutils.AssertEqual(t, n2Record.USN, n2.USN, "n2 usn mismatch")
testutils.AssertEqual(t, n2Record.Public, n2.Public, "n2 public mismatch")
testutils.AssertEqual(t, n2Record.Deleted, n2.Deleted, "n2 deleted mismatch")
testutils.AssertEqual(t, n2Record.Dirty, n2.Dirty, "n2 dirty mismatch")
}
func TestNewBook(t *testing.T) {
testCases := []struct {
uuid string
label string
usn int
deleted bool
dirty bool
}{
{
uuid: "b1-uuid",
label: "b1-label",
usn: 0,
deleted: false,
dirty: false,
},
{
uuid: "b2-uuid",
label: "b2-label",
usn: 1008,
deleted: false,
dirty: true,
},
}
for idx, tc := range testCases {
got := NewBook(tc.uuid, tc.label, tc.usn, tc.deleted, tc.dirty)
testutils.AssertEqual(t, got.UUID, tc.uuid, fmt.Sprintf("UUID mismatch for test case %d", idx))
testutils.AssertEqual(t, got.Label, tc.label, fmt.Sprintf("Label mismatch for test case %d", idx))
testutils.AssertEqual(t, got.USN, tc.usn, fmt.Sprintf("USN mismatch for test case %d", idx))
testutils.AssertEqual(t, got.Deleted, tc.deleted, fmt.Sprintf("Deleted mismatch for test case %d", idx))
testutils.AssertEqual(t, got.Dirty, tc.dirty, fmt.Sprintf("Dirty mismatch for test case %d", idx))
}
}
func TestBookInsert(t *testing.T) {
testCases := []struct {
uuid string
label string
usn int
deleted bool
dirty bool
}{
{
uuid: "b1-uuid",
label: "b1-label",
usn: 10808,
deleted: false,
dirty: false,
},
{
uuid: "b1-uuid",
label: "b1-label",
usn: 10808,
deleted: false,
dirty: true,
},
}
for idx, tc := range testCases {
func() {
// Setup
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
b := Book{
UUID: tc.uuid,
Label: tc.label,
USN: tc.usn,
Dirty: tc.dirty,
Deleted: tc.deleted,
}
// execute
db := ctx.DB
tx, err := db.Begin()
if err != nil {
t.Fatalf(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error())
}
if err := b.Insert(tx); err != nil {
tx.Rollback()
t.Fatalf(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error())
}
tx.Commit()
// test
var uuid, label string
var usn int
var deleted, dirty bool
testutils.MustScan(t, "getting b1",
db.QueryRow("SELECT uuid, label, usn, deleted, dirty FROM books WHERE uuid = ?", tc.uuid),
&uuid, &label, &usn, &deleted, &dirty)
testutils.AssertEqual(t, uuid, tc.uuid, fmt.Sprintf("uuid mismatch for test case %d", idx))
testutils.AssertEqual(t, label, tc.label, fmt.Sprintf("label mismatch for test case %d", idx))
testutils.AssertEqual(t, usn, tc.usn, fmt.Sprintf("usn mismatch for test case %d", idx))
testutils.AssertEqual(t, deleted, tc.deleted, fmt.Sprintf("deleted mismatch for test case %d", idx))
testutils.AssertEqual(t, dirty, tc.dirty, fmt.Sprintf("dirty mismatch for test case %d", idx))
}()
}
}
func TestBookUpdate(t *testing.T) {
testCases := []struct {
uuid string
label string
usn int
deleted bool
dirty bool
newLabel string
newUSN int
newDeleted bool
newDirty bool
}{
{
uuid: "b1-uuid",
label: "b1-label",
usn: 0,
deleted: false,
dirty: false,
newLabel: "b1-label-edited",
newUSN: 0,
newDeleted: false,
newDirty: true,
},
{
uuid: "b1-uuid",
label: "b1-label",
usn: 0,
deleted: false,
dirty: false,
newLabel: "",
newUSN: 10,
newDeleted: true,
newDirty: false,
},
}
for idx, tc := range testCases {
func() {
// Setup
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
b1 := Book{
UUID: "b1-uuid",
Label: "b1-label",
USN: 1,
Deleted: true,
Dirty: false,
}
b2 := Book{
UUID: "b2-uuid",
Label: "b2-label",
USN: 1,
Deleted: true,
Dirty: false,
}
db := ctx.DB
testutils.MustExec(t, fmt.Sprintf("inserting b1 for test case %d", idx), db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", b1.UUID, b1.Label, b1.USN, b1.Deleted, b1.Dirty)
testutils.MustExec(t, fmt.Sprintf("inserting b2 for test case %d", idx), db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", b2.UUID, b2.Label, b2.USN, b2.Deleted, b2.Dirty)
// execute
tx, err := db.Begin()
if err != nil {
t.Fatalf(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error())
}
b1.Label = tc.newLabel
b1.USN = tc.newUSN
b1.Deleted = tc.newDeleted
b1.Dirty = tc.newDirty
if err := b1.Update(tx); err != nil {
tx.Rollback()
t.Fatalf(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error())
}
tx.Commit()
// test
var b1Record, b2Record Book
testutils.MustScan(t, "getting b1",
db.QueryRow("SELECT uuid, label, usn, deleted, dirty FROM books WHERE uuid = ?", tc.uuid),
&b1Record.UUID, &b1Record.Label, &b1Record.USN, &b1Record.Deleted, &b1Record.Dirty)
testutils.MustScan(t, "getting b2",
db.QueryRow("SELECT uuid, label, usn, deleted, dirty FROM books WHERE uuid = ?", b2.UUID),
&b2Record.UUID, &b2Record.Label, &b2Record.USN, &b2Record.Deleted, &b2Record.Dirty)
testutils.AssertEqual(t, b1Record.UUID, b1.UUID, fmt.Sprintf("b1 uuid mismatch for test case %d", idx))
testutils.AssertEqual(t, b1Record.Label, tc.newLabel, fmt.Sprintf("b1 label mismatch for test case %d", idx))
testutils.AssertEqual(t, b1Record.USN, tc.newUSN, fmt.Sprintf("b1 usn mismatch for test case %d", idx))
testutils.AssertEqual(t, b1Record.Deleted, tc.newDeleted, fmt.Sprintf("b1 deleted mismatch for test case %d", idx))
testutils.AssertEqual(t, b1Record.Dirty, tc.newDirty, fmt.Sprintf("b1 dirty mismatch for test case %d", idx))
testutils.AssertEqual(t, b2Record.UUID, b2.UUID, fmt.Sprintf("b2 uuid mismatch for test case %d", idx))
testutils.AssertEqual(t, b2Record.Label, b2.Label, fmt.Sprintf("b2 label mismatch for test case %d", idx))
testutils.AssertEqual(t, b2Record.USN, b2.USN, fmt.Sprintf("b2 usn mismatch for test case %d", idx))
testutils.AssertEqual(t, b2Record.Deleted, b2.Deleted, fmt.Sprintf("b2 deleted mismatch for test case %d", idx))
testutils.AssertEqual(t, b2Record.Dirty, b2.Dirty, fmt.Sprintf("b2 dirty mismatch for test case %d", idx))
}()
}
}
func TestBookUpdateUUID(t *testing.T) {
testCases := []struct {
newUUID string
}{
{
newUUID: "b1-new-uuid",
},
{
newUUID: "b2-new-uuid",
},
}
for idx, tc := range testCases {
t.Run(fmt.Sprintf("testCase%d", idx), func(t *testing.T) {
// Setup
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
b1 := Book{
UUID: "b1-uuid",
Label: "b1-label",
USN: 1,
Deleted: true,
Dirty: false,
}
b2 := Book{
UUID: "b2-uuid",
Label: "b2-label",
USN: 1,
Deleted: true,
Dirty: false,
}
db := ctx.DB
testutils.MustExec(t, "inserting b1", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", b1.UUID, b1.Label, b1.USN, b1.Deleted, b1.Dirty)
testutils.MustExec(t, "inserting b2", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", b2.UUID, b2.Label, b2.USN, b2.Deleted, b2.Dirty)
// execute
tx, err := db.Begin()
if err != nil {
t.Fatalf(errors.Wrap(err, "beginning a transaction").Error())
}
if err := b1.UpdateUUID(tx, tc.newUUID); err != nil {
tx.Rollback()
t.Fatalf(errors.Wrap(err, "executing").Error())
}
tx.Commit()
// test
var b1Record, b2Record Book
testutils.MustScan(t, "getting b1",
db.QueryRow("SELECT uuid, label, usn, deleted, dirty FROM books WHERE label = ?", "b1-label"),
&b1Record.UUID, &b1Record.Label, &b1Record.USN, &b1Record.Deleted, &b1Record.Dirty)
testutils.MustScan(t, "getting b2",
db.QueryRow("SELECT uuid, label, usn, deleted, dirty FROM books WHERE label = ?", "b2-label"),
&b2Record.UUID, &b2Record.Label, &b2Record.USN, &b2Record.Deleted, &b2Record.Dirty)
testutils.AssertEqual(t, b1.UUID, tc.newUUID, "b1 original reference uuid mismatch")
testutils.AssertEqual(t, b1Record.UUID, tc.newUUID, "b1 uuid mismatch")
testutils.AssertEqual(t, b2Record.UUID, b2.UUID, "b2 uuid mismatch")
})
}
}
func TestBookExpunge(t *testing.T) {
// Setup
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
b1 := Book{
UUID: "b1-uuid",
Label: "b1-label",
USN: 1,
Deleted: true,
Dirty: false,
}
b2 := Book{
UUID: "b2-uuid",
Label: "b2-label",
USN: 1,
Deleted: true,
Dirty: false,
}
db := ctx.DB
testutils.MustExec(t, "inserting b1", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", b1.UUID, b1.Label, b1.USN, b1.Deleted, b1.Dirty)
testutils.MustExec(t, "inserting b2", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", b2.UUID, b2.Label, b2.USN, b2.Deleted, b2.Dirty)
// execute
tx, err := db.Begin()
if err != nil {
t.Fatalf(errors.Wrap(err, "beginning a transaction").Error())
}
if err := b1.Expunge(tx); err != nil {
tx.Rollback()
t.Fatalf(errors.Wrap(err, "executing").Error())
}
tx.Commit()
// test
var bookCount int
testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount)
testutils.AssertEqualf(t, bookCount, 1, "book count mismatch")
var b2Record Book
testutils.MustScan(t, "getting b2",
db.QueryRow("SELECT uuid, label, usn, deleted, dirty FROM books WHERE uuid = ?", "b2-uuid"),
&b2Record.UUID, &b2Record.Label, &b2Record.USN, &b2Record.Deleted, &b2Record.Dirty)
testutils.AssertEqual(t, b2Record.UUID, b2.UUID, "b2 uuid mismatch")
testutils.AssertEqual(t, b2Record.Label, b2.Label, "b2 label mismatch")
testutils.AssertEqual(t, b2Record.USN, b2.USN, "b2 usn mismatch")
testutils.AssertEqual(t, b2Record.Deleted, b2.Deleted, "b2 deleted mismatch")
testutils.AssertEqual(t, b2Record.Dirty, b2.Dirty, "b2 dirty mismatch")
}
// TestNoteFTS tests that note full text search indices stay in sync with the notes after insert, update and delete
func TestNoteFTS(t *testing.T) {
// set up
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
// execute - insert
n := Note{
UUID: "n1-uuid",
BookUUID: "b1-uuid",
Body: "foo bar",
AddedOn: 1542058875,
EditedOn: 0,
USN: 0,
Public: false,
Deleted: false,
Dirty: false,
}
db := ctx.DB
tx, err := db.Begin()
if err != nil {
t.Fatalf(errors.Wrap(err, "beginning a transaction").Error())
}
if err := n.Insert(tx); err != nil {
tx.Rollback()
t.Fatalf(errors.Wrap(err, "inserting").Error())
}
tx.Commit()
// test
var noteCount, noteFtsCount, noteSearchCount int
testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), &noteCount)
testutils.MustScan(t, "counting note_fts", db.QueryRow("SELECT count(*) FROM note_fts"), &noteFtsCount)
testutils.MustScan(t, "counting search results", db.QueryRow("SELECT count(*) FROM note_fts WHERE note_fts MATCH ?", "foo"), &noteSearchCount)
testutils.AssertEqual(t, noteCount, 1, "noteCount mismatch")
testutils.AssertEqual(t, noteFtsCount, 1, "noteFtsCount mismatch")
testutils.AssertEqual(t, noteSearchCount, 1, "noteSearchCount mismatch")
// execute - update
tx, err = db.Begin()
if err != nil {
t.Fatalf(errors.Wrap(err, "beginning a transaction").Error())
}
n.Body = "baz quz"
if err := n.Update(tx); err != nil {
tx.Rollback()
t.Fatalf(errors.Wrap(err, "updating").Error())
}
tx.Commit()
// test
testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), &noteCount)
testutils.MustScan(t, "counting note_fts", db.QueryRow("SELECT count(*) FROM note_fts"), &noteFtsCount)
testutils.AssertEqual(t, noteCount, 1, "noteCount mismatch")
testutils.AssertEqual(t, noteFtsCount, 1, "noteFtsCount mismatch")
testutils.MustScan(t, "counting search results", db.QueryRow("SELECT count(*) FROM note_fts WHERE note_fts MATCH ?", "foo"), &noteSearchCount)
testutils.AssertEqual(t, noteSearchCount, 0, "noteSearchCount for foo mismatch")
testutils.MustScan(t, "counting search results", db.QueryRow("SELECT count(*) FROM note_fts WHERE note_fts MATCH ?", "baz"), &noteSearchCount)
testutils.AssertEqual(t, noteSearchCount, 1, "noteSearchCount for baz mismatch")
// execute - delete
tx, err = db.Begin()
if err != nil {
t.Fatalf(errors.Wrap(err, "beginning a transaction").Error())
}
if err := n.Expunge(tx); err != nil {
tx.Rollback()
t.Fatalf(errors.Wrap(err, "expunging").Error())
}
tx.Commit()
// test
testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), &noteCount)
testutils.MustScan(t, "counting note_fts", db.QueryRow("SELECT count(*) FROM note_fts"), &noteFtsCount)
testutils.AssertEqual(t, noteCount, 0, "noteCount mismatch")
testutils.AssertEqual(t, noteFtsCount, 0, "noteFtsCount mismatch")
}

View file

@ -1,103 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package core
import (
"encoding/base64"
"github.com/dnote/dnote/cli/infra"
"github.com/pkg/errors"
)
// InsertSystem inserets a system configuration
func InsertSystem(db *infra.DB, key, val string) error {
if _, err := db.Exec("INSERT INTO system (key, value) VALUES (? , ?);", key, val); err != nil {
return errors.Wrap(err, "saving system config")
}
return nil
}
// UpsertSystem inserts or updates a system configuration
func UpsertSystem(db *infra.DB, key, val string) error {
var count int
if err := db.QueryRow("SELECT count(*) FROM system WHERE key = ?", key).Scan(&count); err != nil {
return errors.Wrap(err, "counting system record")
}
if count == 0 {
if _, err := db.Exec("INSERT INTO system (key, value) VALUES (? , ?);", key, val); err != nil {
return errors.Wrap(err, "saving system config")
}
} else {
if _, err := db.Exec("UPDATE system SET value = ? WHERE key = ?", val, key); err != nil {
return errors.Wrap(err, "updating system config")
}
}
return nil
}
// UpdateSystem updates a system configuration
func UpdateSystem(db *infra.DB, key, val interface{}) error {
if _, err := db.Exec("UPDATE system SET value = ? WHERE key = ?", val, key); err != nil {
return errors.Wrap(err, "updating system config")
}
return nil
}
// GetSystem scans the given system configuration record onto the destination
func GetSystem(db *infra.DB, key string, dest interface{}) error {
if err := db.QueryRow("SELECT value FROM system WHERE key = ?", key).Scan(dest); err != nil {
return errors.Wrap(err, "finding system configuration record")
}
return nil
}
// DeleteSystem delets the given system record
func DeleteSystem(db *infra.DB, key string) error {
if _, err := db.Exec("DELETE FROM system WHERE key = ?", key); err != nil {
return errors.Wrap(err, "deleting system config")
}
return nil
}
// GetCipherKey retrieves the cipher key and decode the base64 into bytes.
func GetCipherKey(ctx infra.DnoteCtx) ([]byte, error) {
db, err := ctx.DB.Begin()
if err != nil {
return nil, errors.Wrap(err, "beginning transaction")
}
var cipherKeyB64 string
err = GetSystem(db, infra.SystemCipherKey, &cipherKeyB64)
if err != nil {
return []byte{}, errors.Wrap(err, "getting enc key")
}
cipherKey, err := base64.StdEncoding.DecodeString(cipherKeyB64)
if err != nil {
return nil, errors.Wrap(err, "decoding cipherKey from base64")
}
return cipherKey, nil
}

View file

@ -1,239 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package core
import (
"fmt"
"testing"
"github.com/dnote/dnote/cli/testutils"
"github.com/pkg/errors"
)
func TestInsertSystem(t *testing.T) {
testCases := []struct {
key string
val string
}{
{
key: "foo",
val: "1558089284",
},
{
key: "baz",
val: "quz",
},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("insert %s %s", tc.key, tc.val), func(t *testing.T) {
// Setup
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
// execute
db := ctx.DB
tx, err := db.Begin()
if err != nil {
t.Fatalf(errors.Wrap(err, "beginning a transaction").Error())
}
if err := InsertSystem(tx, tc.key, tc.val); err != nil {
tx.Rollback()
t.Fatalf(errors.Wrap(err, "executing for test case").Error())
}
tx.Commit()
// test
var key, val string
testutils.MustScan(t, "getting the saved record",
db.QueryRow("SELECT key, value FROM system WHERE key = ?", tc.key), &key, &val)
testutils.AssertEqual(t, key, tc.key, "key mismatch for test case")
testutils.AssertEqual(t, val, tc.val, "val mismatch for test case")
})
}
}
func TestUpsertSystem(t *testing.T) {
testCases := []struct {
key string
val string
countDelta int
}{
{
key: "foo",
val: "1558089284",
countDelta: 1,
},
{
key: "baz",
val: "quz2",
countDelta: 0,
},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("insert %s %s", tc.key, tc.val), func(t *testing.T) {
// Setup
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
db := ctx.DB
testutils.MustExec(t, "inserting a system configuration", db, "INSERT INTO system (key, value) VALUES (?, ?)", "baz", "quz")
var initialSystemCount int
testutils.MustScan(t, "counting records", db.QueryRow("SELECT count(*) FROM system"), &initialSystemCount)
// execute
tx, err := db.Begin()
if err != nil {
t.Fatalf(errors.Wrap(err, "beginning a transaction").Error())
}
if err := UpsertSystem(tx, tc.key, tc.val); err != nil {
tx.Rollback()
t.Fatalf(errors.Wrap(err, "executing for test case").Error())
}
tx.Commit()
// test
var key, val string
testutils.MustScan(t, "getting the saved record",
db.QueryRow("SELECT key, value FROM system WHERE key = ?", tc.key), &key, &val)
var systemCount int
testutils.MustScan(t, "counting records",
db.QueryRow("SELECT count(*) FROM system"), &systemCount)
testutils.AssertEqual(t, key, tc.key, "key mismatch")
testutils.AssertEqual(t, val, tc.val, "val mismatch")
testutils.AssertEqual(t, systemCount, initialSystemCount+tc.countDelta, "count mismatch")
})
}
}
func TestGetSystem(t *testing.T) {
t.Run(fmt.Sprintf("get string value"), func(t *testing.T) {
// Setup
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
// execute
db := ctx.DB
testutils.MustExec(t, "inserting a system configuration", db, "INSERT INTO system (key, value) VALUES (?, ?)", "foo", "bar")
tx, err := db.Begin()
if err != nil {
t.Fatalf(errors.Wrap(err, "beginning a transaction").Error())
}
var dest string
if err := GetSystem(tx, "foo", &dest); err != nil {
tx.Rollback()
t.Fatalf(errors.Wrap(err, "executing for test case").Error())
}
tx.Commit()
// test
testutils.AssertEqual(t, dest, "bar", "dest mismatch")
})
t.Run(fmt.Sprintf("get int64 value"), func(t *testing.T) {
// Setup
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
// execute
db := ctx.DB
testutils.MustExec(t, "inserting a system configuration", db, "INSERT INTO system (key, value) VALUES (?, ?)", "foo", 1234)
tx, err := db.Begin()
if err != nil {
t.Fatalf(errors.Wrap(err, "beginning a transaction").Error())
}
var dest int64
if err := GetSystem(tx, "foo", &dest); err != nil {
tx.Rollback()
t.Fatalf(errors.Wrap(err, "executing for test case").Error())
}
tx.Commit()
// test
testutils.AssertEqual(t, dest, int64(1234), "dest mismatch")
})
}
func TestUpdateSystem(t *testing.T) {
testCases := []struct {
key string
val string
countDelta int
}{
{
key: "foo",
val: "1558089284",
},
{
key: "foo",
val: "bar",
},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("update %s %s", tc.key, tc.val), func(t *testing.T) {
// Setup
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
db := ctx.DB
testutils.MustExec(t, "inserting a system configuration", db, "INSERT INTO system (key, value) VALUES (?, ?)", "foo", "fuz")
testutils.MustExec(t, "inserting a system configuration", db, "INSERT INTO system (key, value) VALUES (?, ?)", "baz", "quz")
var initialSystemCount int
testutils.MustScan(t, "counting records", db.QueryRow("SELECT count(*) FROM system"), &initialSystemCount)
// execute
tx, err := db.Begin()
if err != nil {
t.Fatalf(errors.Wrap(err, "beginning a transaction").Error())
}
if err := UpdateSystem(tx, tc.key, tc.val); err != nil {
tx.Rollback()
t.Fatalf(errors.Wrap(err, "executing for test case").Error())
}
tx.Commit()
// test
var key, val string
testutils.MustScan(t, "getting the saved record",
db.QueryRow("SELECT key, value FROM system WHERE key = ?", tc.key), &key, &val)
var systemCount int
testutils.MustScan(t, "counting records",
db.QueryRow("SELECT count(*) FROM system"), &systemCount)
testutils.AssertEqual(t, key, tc.key, "key mismatch")
testutils.AssertEqual(t, val, tc.val, "val mismatch")
testutils.AssertEqual(t, systemCount, initialSystemCount, "count mismatch")
})
}
}

View file

@ -1,117 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package core
import (
"context"
"fmt"
"time"
"github.com/dnote/dnote/cli/infra"
"github.com/dnote/dnote/cli/log"
"github.com/dnote/dnote/cli/utils"
"github.com/google/go-github/github"
"github.com/pkg/errors"
)
// upgradeInterval is 3 weeks
var upgradeInterval int64 = 86400 * 7 * 3
// shouldCheckUpdate checks if update should be checked
func shouldCheckUpdate(ctx infra.DnoteCtx) (bool, error) {
db := ctx.DB
var lastUpgrade int64
err := db.QueryRow("SELECT value FROM system WHERE key = ?", infra.SystemLastUpgrade).Scan(&lastUpgrade)
if err != nil {
return false, errors.Wrap(err, "getting last_udpate")
}
now := time.Now().Unix()
return now-lastUpgrade > upgradeInterval, nil
}
func touchLastUpgrade(ctx infra.DnoteCtx) error {
db := ctx.DB
now := time.Now().Unix()
_, err := db.Exec("UPDATE system SET value = ? WHERE key = ?", now, infra.SystemLastUpgrade)
if err != nil {
return errors.Wrap(err, "updating last_upgrade")
}
return nil
}
func checkVersion(ctx infra.DnoteCtx) error {
log.Infof("current version is %s\n", ctx.Version)
// Fetch the latest version
gh := github.NewClient(nil)
releases, _, err := gh.Repositories.ListReleases(context.Background(), "dnote", "cli", nil)
if err != nil {
return errors.Wrap(err, "fetching releases")
}
latest := releases[0]
latestVersion := (*latest.TagName)[1:]
log.Infof("latest version is %s\n", latestVersion)
if latestVersion == ctx.Version {
log.Success("you are up-to-date\n\n")
} else {
log.Infof("to upgrade, see https://github.com/dnote/dnote/cli/blob/master/README.md\n")
}
return nil
}
// CheckUpdate triggers update if needed
func CheckUpdate(ctx infra.DnoteCtx) error {
shouldCheck, err := shouldCheckUpdate(ctx)
if err != nil {
return errors.Wrap(err, "checking if dnote should check update")
}
if !shouldCheck {
return nil
}
err = touchLastUpgrade(ctx)
if err != nil {
return errors.Wrap(err, "updating the last upgrade timestamp")
}
fmt.Printf("\n")
willCheck, err := utils.AskConfirmation("check for upgrade?", true)
if err != nil {
return errors.Wrap(err, "getting user confirmation")
}
if !willCheck {
return nil
}
err = checkVersion(ctx)
if err != nil {
return errors.Wrap(err, "checking version")
}
return nil
}

View file

@ -1,125 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
// Package crypt provides cryptographic funcitonalities
package crypt
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"io"
"github.com/dnote/dnote/cli/log"
"github.com/pkg/errors"
"golang.org/x/crypto/hkdf"
"golang.org/x/crypto/pbkdf2"
)
var aesGcmNonceSize = 12
func runHkdf(secret, salt, info []byte) ([]byte, error) {
r := hkdf.New(sha256.New, secret, salt, info)
ret := make([]byte, 32)
_, err := io.ReadFull(r, ret)
if err != nil {
return []byte{}, errors.Wrap(err, "reading key bytes")
}
return ret, nil
}
// MakeKeys derives, from the given credential, a key set comprising of an encryption key
// and an authentication key
func MakeKeys(password, email []byte, iteration int) ([]byte, []byte, error) {
masterKey := pbkdf2.Key([]byte(password), []byte(email), iteration, 32, sha256.New)
log.Debug("email: %s, password: %s", email, password)
authKey, err := runHkdf(masterKey, email, []byte("auth"))
if err != nil {
return nil, nil, errors.Wrap(err, "deriving auth key")
}
return masterKey, authKey, nil
}
// AesGcmEncrypt encrypts the plaintext using AES in a GCM mode. It returns
// a ciphertext prepended by a 12 byte pseudo-random nonce, encoded in base64.
func AesGcmEncrypt(key, plaintext []byte) (string, error) {
if key == nil {
return "", errors.New("no key provided")
}
block, err := aes.NewCipher(key)
if err != nil {
return "", errors.Wrap(err, "initializing aes")
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return "", errors.Wrap(err, "initializing gcm")
}
nonce := make([]byte, aesGcmNonceSize)
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", errors.Wrap(err, "generating nonce")
}
ciphertext := aesgcm.Seal(nonce, nonce, []byte(plaintext), nil)
cipherKeyB64 := base64.StdEncoding.EncodeToString(ciphertext)
return cipherKeyB64, nil
}
// AesGcmDecrypt decrypts the encrypted data using AES in a GCM mode. The data should be
// a base64 encoded string in the format of 12 byte nonce followed by a ciphertext.
func AesGcmDecrypt(key []byte, dataB64 string) ([]byte, error) {
if key == nil {
return nil, errors.New("no key provided")
}
data, err := base64.StdEncoding.DecodeString(dataB64)
if err != nil {
return nil, errors.Wrap(err, "decoding base64 data")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, errors.Wrap(err, "initializing aes")
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return nil, errors.Wrap(err, "initializing gcm")
}
if len(data) < aesGcmNonceSize {
return nil, errors.Wrap(err, "malformed data")
}
nonce, ciphertext := data[:aesGcmNonceSize], data[aesGcmNonceSize:]
plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, errors.Wrap(err, "decrypting")
}
return plaintext, nil
}

View file

@ -1,118 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package crypt
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"fmt"
"testing"
"github.com/dnote/dnote/cli/testutils"
"github.com/pkg/errors"
)
func TestAesGcmEncrypt(t *testing.T) {
testCases := []struct {
key []byte
plaintext []byte
}{
{
key: []byte("AES256Key-32Characters1234567890"),
plaintext: []byte("foo bar baz quz"),
},
{
key: []byte("AES256Key-32Charactersabcdefghij"),
plaintext: []byte("1234 foo 5678 bar 7890 baz"),
},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("key %s plaintext %s", tc.key, tc.plaintext), func(t *testing.T) {
// encrypt
dataB64, err := AesGcmEncrypt(tc.key, tc.plaintext)
if err != nil {
t.Fatal(errors.Wrap(err, "performing encryption"))
}
// test that data can be decrypted
data, err := base64.StdEncoding.DecodeString(dataB64)
if err != nil {
t.Fatal(errors.Wrap(err, "decoding data from base64"))
}
nonce, ciphertext := data[:12], data[12:]
fmt.Println(string(data))
block, err := aes.NewCipher([]byte(tc.key))
if err != nil {
t.Fatal(errors.Wrap(err, "initializing aes"))
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
t.Fatal(errors.Wrap(err, "initializing gcm"))
}
plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
t.Fatal(errors.Wrap(err, "decode"))
}
testutils.AssertDeepEqual(t, plaintext, tc.plaintext, "plaintext mismatch")
})
}
}
func TestAesGcmDecrypt(t *testing.T) {
testCases := []struct {
key []byte
ciphertextB64 string
expectedPlaintext string
}{
{
key: []byte("AES256Key-32Characters1234567890"),
ciphertextB64: "M2ov9hWMQ52v1S/zigwX3bJt4cVCV02uiRm/grKqN/rZxNkJrD7vK4Ii0g==",
expectedPlaintext: "foo bar baz quz",
},
{
key: []byte("AES256Key-32Characters1234567890"),
ciphertextB64: "M4csFKUIUbD1FBEzLgHjscoKgN0lhMGJ0n2nKWiCkE/qSKlRP7kS",
expectedPlaintext: "foo\n1\nbar\n2",
},
{
key: []byte("AES256Key-32Characters1234567890"),
ciphertextB64: "pe/fnw73MR1clmVIlRSJ5gDwBdnPly/DF7DsR5dJVz4dHZlv0b10WzvJEGOCHZEr+Q==",
expectedPlaintext: "föo\nbār\nbåz & qūz",
},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("key %s ciphertext %s", tc.key, tc.ciphertextB64), func(t *testing.T) {
plaintext, err := AesGcmDecrypt(tc.key, tc.ciphertextB64)
if err != nil {
t.Fatal(errors.Wrap(err, "performing decryption"))
}
testutils.AssertDeepEqual(t, plaintext, []byte(tc.expectedPlaintext), "plaintext mismatch")
})
}
}

View file

@ -1,225 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
// Package infra defines dnote structure
package infra
import (
"database/sql"
"encoding/base64"
"fmt"
"os"
"os/user"
// use sqlite
_ "github.com/mattn/go-sqlite3"
"github.com/pkg/errors"
)
var (
// DnoteDirName is the name of the directory containing dnote files
DnoteDirName = ".dnote"
// SystemSchema is the key for schema in the system table
SystemSchema = "schema"
// SystemRemoteSchema is the key for remote schema in the system table
SystemRemoteSchema = "remote_schema"
// SystemLastSyncAt is the timestamp of the server at the last sync
SystemLastSyncAt = "last_sync_time"
// SystemLastMaxUSN is the user's max_usn from the server at the alst sync
SystemLastMaxUSN = "last_max_usn"
// SystemLastUpgrade is the timestamp at which the system more recently checked for an upgrade
SystemLastUpgrade = "last_upgrade"
// SystemCipherKey is the encryption key
SystemCipherKey = "enc_key"
// SystemSessionKey is the session key
SystemSessionKey = "session_token"
// SystemSessionKeyExpiry is the timestamp at which the session key will expire
SystemSessionKeyExpiry = "session_token_expiry"
)
// DnoteCtx is a context holding the information of the current runtime
type DnoteCtx struct {
HomeDir string
DnoteDir string
APIEndpoint string
Version string
DB *DB
SessionKey string
SessionKeyExpiry int64
CipherKey []byte
}
// Config holds dnote configuration
type Config struct {
Editor string
}
// NewCtx returns a new dnote context
func NewCtx(apiEndpoint, versionTag string) (DnoteCtx, error) {
homeDir, err := getHomeDir()
if err != nil {
return DnoteCtx{}, errors.Wrap(err, "Failed to get home dir")
}
dnoteDir := getDnoteDir(homeDir)
dnoteDBPath := fmt.Sprintf("%s/dnote.db", dnoteDir)
db, err := OpenDB(dnoteDBPath)
if err != nil {
return DnoteCtx{}, errors.Wrap(err, "conntecting to db")
}
ret := DnoteCtx{
HomeDir: homeDir,
DnoteDir: dnoteDir,
APIEndpoint: apiEndpoint,
Version: versionTag,
DB: db,
}
return ret, nil
}
// SetupCtx populates context and returns a new context
func SetupCtx(ctx DnoteCtx) (DnoteCtx, error) {
db := ctx.DB
var sessionKey, cipherKeyB64 string
var sessionKeyExpiry int64
err := db.QueryRow("SELECT value FROM system WHERE key = ?", SystemSessionKey).Scan(&sessionKey)
if err != nil && err != sql.ErrNoRows {
return ctx, errors.Wrap(err, "finding sesison key")
}
err = db.QueryRow("SELECT value FROM system WHERE key = ?", SystemCipherKey).Scan(&cipherKeyB64)
if err != nil && err != sql.ErrNoRows {
return ctx, errors.Wrap(err, "finding sesison key")
}
err = db.QueryRow("SELECT value FROM system WHERE key = ?", SystemSessionKeyExpiry).Scan(&sessionKeyExpiry)
if err != nil && err != sql.ErrNoRows {
return ctx, errors.Wrap(err, "finding sesison key expiry")
}
cipherKey, err := base64.StdEncoding.DecodeString(cipherKeyB64)
if err != nil {
return ctx, errors.Wrap(err, "decoding cipherKey from base64")
}
ret := DnoteCtx{
HomeDir: ctx.HomeDir,
DnoteDir: ctx.DnoteDir,
APIEndpoint: ctx.APIEndpoint,
Version: ctx.Version,
DB: ctx.DB,
SessionKey: sessionKey,
SessionKeyExpiry: sessionKeyExpiry,
CipherKey: cipherKey,
}
return ret, nil
}
func getDnoteDir(homeDir string) string {
var ret string
dnoteDirEnv := os.Getenv("DNOTE_DIR")
if dnoteDirEnv == "" {
ret = fmt.Sprintf("%s/%s", homeDir, DnoteDirName)
} else {
ret = dnoteDirEnv
}
return ret
}
func getHomeDir() (string, error) {
homeDirEnv := os.Getenv("DNOTE_HOME_DIR")
if homeDirEnv != "" {
return homeDirEnv, nil
}
usr, err := user.Current()
if err != nil {
return "", errors.Wrap(err, "Failed to get current user")
}
return usr.HomeDir, nil
}
// InitDB initializes the database.
// Ideally this process must be a part of migration sequence. But it is performed
// seaprately because it is a prerequisite for legacy migration.
func InitDB(ctx DnoteCtx) error {
db := ctx.DB
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS notes
(
id integer PRIMARY KEY AUTOINCREMENT,
uuid text NOT NULL,
book_uuid text NOT NULL,
content text NOT NULL,
added_on integer NOT NULL,
edited_on integer DEFAULT 0,
public bool DEFAULT false
)`)
if err != nil {
return errors.Wrap(err, "creating notes table")
}
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS books
(
uuid text PRIMARY KEY,
label text NOT NULL
)`)
if err != nil {
return errors.Wrap(err, "creating books table")
}
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS system
(
key string NOT NULL,
value text NOT NULL
)`)
if err != nil {
return errors.Wrap(err, "creating system table")
}
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS actions
(
uuid text PRIMARY KEY,
schema integer NOT NULL,
type text NOT NULL,
data text NOT NULL,
timestamp integer NOT NULL
)`)
if err != nil {
return errors.Wrap(err, "creating actions table")
}
_, err = db.Exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_books_label ON books(label);
CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_uuid ON notes(uuid);
CREATE UNIQUE INDEX IF NOT EXISTS idx_books_uuid ON books(uuid);
CREATE INDEX IF NOT EXISTS idx_notes_book_uuid ON notes(book_uuid);`)
if err != nil {
return errors.Wrap(err, "creating indices")
}
return nil
}

View file

@ -1,30 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package log
import (
"fmt"
)
// PrintContent prints the note content with an appropriate format.
func PrintContent(content string) {
fmt.Printf("\n-----------------------content-----------------------\n")
fmt.Printf("%s", content)
fmt.Printf("\n-----------------------------------------------------\n")
}

View file

@ -1,80 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package main
import (
"os"
"github.com/dnote/dnote/cli/cmd/root"
"github.com/dnote/dnote/cli/infra"
"github.com/dnote/dnote/cli/log"
_ "github.com/mattn/go-sqlite3"
"github.com/pkg/errors"
// commands
"github.com/dnote/dnote/cli/cmd/add"
"github.com/dnote/dnote/cli/cmd/cat"
"github.com/dnote/dnote/cli/cmd/edit"
"github.com/dnote/dnote/cli/cmd/find"
"github.com/dnote/dnote/cli/cmd/login"
"github.com/dnote/dnote/cli/cmd/logout"
"github.com/dnote/dnote/cli/cmd/ls"
"github.com/dnote/dnote/cli/cmd/remove"
"github.com/dnote/dnote/cli/cmd/sync"
"github.com/dnote/dnote/cli/cmd/version"
"github.com/dnote/dnote/cli/cmd/view"
)
// apiEndpoint and versionTag are populated during link time
var apiEndpoint string
var versionTag = "master"
func main() {
ctx, err := infra.NewCtx(apiEndpoint, versionTag)
if err != nil {
panic(errors.Wrap(err, "initializing context"))
}
defer ctx.DB.Close()
if err := root.Prepare(ctx); err != nil {
panic(errors.Wrap(err, "preparing dnote run"))
}
ctx, err = infra.SetupCtx(ctx)
if err != nil {
panic(errors.Wrap(err, "setting up context"))
}
root.Register(remove.NewCmd(ctx))
root.Register(edit.NewCmd(ctx))
root.Register(login.NewCmd(ctx))
root.Register(logout.NewCmd(ctx))
root.Register(add.NewCmd(ctx))
root.Register(ls.NewCmd(ctx))
root.Register(sync.NewCmd(ctx))
root.Register(version.NewCmd(ctx))
root.Register(cat.NewCmd(ctx))
root.Register(view.NewCmd(ctx))
root.Register(find.NewCmd(ctx))
if err := root.Execute(); err != nil {
log.Errorf("%s\n", err.Error())
os.Exit(1)
}
}

View file

@ -1,339 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package main
import (
"fmt"
"log"
"os"
"os/exec"
"testing"
"github.com/dnote/dnote/cli/core"
"github.com/dnote/dnote/cli/infra"
"github.com/dnote/dnote/cli/testutils"
"github.com/dnote/dnote/cli/utils"
"github.com/pkg/errors"
)
var binaryName = "test-dnote"
func TestMain(m *testing.M) {
if err := exec.Command("go", "build", "--tags", "fts5", "-o", binaryName).Run(); err != nil {
log.Print(errors.Wrap(err, "building a binary").Error())
os.Exit(1)
}
os.Exit(m.Run())
}
func TestInit(t *testing.T) {
// Set up
ctx := testutils.InitEnv(t, "./tmp", "./testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
// Execute
testutils.RunDnoteCmd(t, ctx, binaryName)
// Test
if !utils.FileExists(fmt.Sprintf("%s", ctx.DnoteDir)) {
t.Errorf("dnote directory was not initialized")
}
if !utils.FileExists(fmt.Sprintf("%s/%s", ctx.DnoteDir, core.ConfigFilename)) {
t.Errorf("config file was not initialized")
}
db := ctx.DB
var notesTableCount, booksTableCount, systemTableCount int
testutils.MustScan(t, "counting notes",
db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type = ? AND name = ?", "table", "notes"), &notesTableCount)
testutils.MustScan(t, "counting books",
db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type = ? AND name = ?", "table", "books"), &booksTableCount)
testutils.MustScan(t, "counting system",
db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type = ? AND name = ?", "table", "system"), &systemTableCount)
testutils.AssertEqual(t, notesTableCount, 1, "notes table count mismatch")
testutils.AssertEqual(t, booksTableCount, 1, "books table count mismatch")
testutils.AssertEqual(t, systemTableCount, 1, "system table count mismatch")
// test that all default system configurations are generated
var lastUpgrade, lastMaxUSN, lastSyncAt string
testutils.MustScan(t, "scanning last upgrade",
db.QueryRow("SELECT value FROM system WHERE key = ?", infra.SystemLastUpgrade), &lastUpgrade)
testutils.MustScan(t, "scanning last max usn",
db.QueryRow("SELECT value FROM system WHERE key = ?", infra.SystemLastMaxUSN), &lastMaxUSN)
testutils.MustScan(t, "scanning last sync at",
db.QueryRow("SELECT value FROM system WHERE key = ?", infra.SystemLastSyncAt), &lastSyncAt)
testutils.AssertNotEqual(t, lastUpgrade, "", "last upgrade should not be empty")
testutils.AssertNotEqual(t, lastMaxUSN, "", "last max usn should not be empty")
testutils.AssertNotEqual(t, lastSyncAt, "", "last sync at should not be empty")
}
func TestAddNote_NewBook_BodyFlag(t *testing.T) {
// Set up
ctx := testutils.InitEnv(t, "./tmp", "./testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
// Execute
testutils.RunDnoteCmd(t, ctx, binaryName, "add", "js", "-c", "foo")
// Test
db := ctx.DB
var noteCount, bookCount int
testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount)
testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), &noteCount)
testutils.AssertEqualf(t, bookCount, 1, "book count mismatch")
testutils.AssertEqualf(t, noteCount, 1, "note count mismatch")
var book core.Book
testutils.MustScan(t, "getting book", db.QueryRow("SELECT uuid, dirty FROM books where label = ?", "js"), &book.UUID, &book.Dirty)
var note core.Note
testutils.MustScan(t, "getting note",
db.QueryRow("SELECT uuid, body, added_on, dirty FROM notes where book_uuid = ?", book.UUID), &note.UUID, &note.Body, &note.AddedOn, &note.Dirty)
testutils.AssertEqual(t, book.Dirty, true, "Book dirty mismatch")
testutils.AssertNotEqual(t, note.UUID, "", "Note should have UUID")
testutils.AssertEqual(t, note.Body, "foo", "Note body mismatch")
testutils.AssertEqual(t, note.Dirty, true, "Note dirty mismatch")
testutils.AssertNotEqual(t, note.AddedOn, int64(0), "Note added_on mismatch")
}
func TestAddNote_ExistingBook_BodyFlag(t *testing.T) {
// Set up
ctx := testutils.InitEnv(t, "./tmp", "./testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
testutils.Setup3(t, ctx)
// Execute
testutils.RunDnoteCmd(t, ctx, binaryName, "add", "js", "-c", "foo")
// Test
db := ctx.DB
var noteCount, bookCount int
testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount)
testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), &noteCount)
testutils.AssertEqualf(t, bookCount, 1, "book count mismatch")
testutils.AssertEqualf(t, noteCount, 2, "note count mismatch")
var n1, n2 core.Note
testutils.MustScan(t, "getting n1",
db.QueryRow("SELECT uuid, body, added_on, dirty FROM notes WHERE book_uuid = ? AND uuid = ?", "js-book-uuid", "43827b9a-c2b0-4c06-a290-97991c896653"), &n1.UUID, &n1.Body, &n1.AddedOn, &n1.Dirty)
testutils.MustScan(t, "getting n2",
db.QueryRow("SELECT uuid, body, added_on, dirty FROM notes WHERE book_uuid = ? AND body = ?", "js-book-uuid", "foo"), &n2.UUID, &n2.Body, &n2.AddedOn, &n2.Dirty)
var book core.Book
testutils.MustScan(t, "getting book", db.QueryRow("SELECT dirty FROM books where label = ?", "js"), &book.Dirty)
testutils.AssertEqual(t, book.Dirty, false, "Book dirty mismatch")
testutils.AssertNotEqual(t, n1.UUID, "", "n1 should have UUID")
testutils.AssertEqual(t, n1.Body, "Booleans have toString()", "n1 body mismatch")
testutils.AssertEqual(t, n1.AddedOn, int64(1515199943), "n1 added_on mismatch")
testutils.AssertEqual(t, n1.Dirty, false, "n1 dirty mismatch")
testutils.AssertNotEqual(t, n2.UUID, "", "n2 should have UUID")
testutils.AssertEqual(t, n2.Body, "foo", "n2 body mismatch")
testutils.AssertEqual(t, n2.Dirty, true, "n2 dirty mismatch")
}
func TestEditNote_BodyFlag(t *testing.T) {
// Set up
ctx := testutils.InitEnv(t, "./tmp", "./testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
testutils.Setup4(t, ctx)
// Execute
testutils.RunDnoteCmd(t, ctx, binaryName, "edit", "js", "2", "-c", "foo bar")
// Test
db := ctx.DB
var noteCount, bookCount int
testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount)
testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), &noteCount)
testutils.AssertEqualf(t, bookCount, 1, "book count mismatch")
testutils.AssertEqualf(t, noteCount, 2, "note count mismatch")
var n1, n2 core.Note
testutils.MustScan(t, "getting n1",
db.QueryRow("SELECT uuid, body, added_on, dirty FROM notes where book_uuid = ? AND uuid = ?", "js-book-uuid", "43827b9a-c2b0-4c06-a290-97991c896653"), &n1.UUID, &n1.Body, &n1.AddedOn, &n1.Dirty)
testutils.MustScan(t, "getting n2",
db.QueryRow("SELECT uuid, body, added_on, dirty FROM notes where book_uuid = ? AND uuid = ?", "js-book-uuid", "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f"), &n2.UUID, &n2.Body, &n2.AddedOn, &n2.Dirty)
testutils.AssertEqual(t, n1.UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "n1 should have UUID")
testutils.AssertEqual(t, n1.Body, "Booleans have toString()", "n1 body mismatch")
testutils.AssertEqual(t, n1.Dirty, false, "n1 dirty mismatch")
testutils.AssertEqual(t, n2.UUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "Note should have UUID")
testutils.AssertEqual(t, n2.Body, "foo bar", "Note body mismatch")
testutils.AssertEqual(t, n2.Dirty, true, "n2 dirty mismatch")
testutils.AssertNotEqual(t, n2.EditedOn, 0, "Note edited_on mismatch")
}
func TestRemoveNote(t *testing.T) {
// Set up
ctx := testutils.InitEnv(t, "./tmp", "./testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
testutils.Setup2(t, ctx)
// Execute
testutils.WaitDnoteCmd(t, ctx, testutils.UserConfirm, binaryName, "remove", "js", "1")
// Test
db := ctx.DB
var noteCount, bookCount, jsNoteCount, linuxNoteCount int
testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount)
testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), &noteCount)
testutils.MustScan(t, "counting js notes", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "js-book-uuid"), &jsNoteCount)
testutils.MustScan(t, "counting linux notes", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "linux-book-uuid"), &linuxNoteCount)
testutils.AssertEqualf(t, bookCount, 2, "book count mismatch")
testutils.AssertEqualf(t, noteCount, 3, "note count mismatch")
testutils.AssertEqual(t, jsNoteCount, 2, "js book should have 2 notes")
testutils.AssertEqual(t, linuxNoteCount, 1, "linux book book should have 1 note")
var b1, b2 core.Book
var n1, n2, n3 core.Note
testutils.MustScan(t, "getting b1",
db.QueryRow("SELECT label, deleted, usn FROM books WHERE uuid = ?", "js-book-uuid"),
&b1.Label, &b1.Deleted, &b1.USN)
testutils.MustScan(t, "getting b2",
db.QueryRow("SELECT label, deleted, usn FROM books WHERE uuid = ?", "linux-book-uuid"),
&b2.Label, &b2.Deleted, &b2.USN)
testutils.MustScan(t, "getting n1",
db.QueryRow("SELECT uuid, body, added_on, deleted, dirty, usn FROM notes WHERE book_uuid = ? AND uuid = ?", "js-book-uuid", "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f"),
&n1.UUID, &n1.Body, &n1.AddedOn, &n1.Deleted, &n1.Dirty, &n1.USN)
testutils.MustScan(t, "getting n2",
db.QueryRow("SELECT uuid, body, added_on, deleted, dirty, usn FROM notes WHERE book_uuid = ? AND uuid = ?", "js-book-uuid", "43827b9a-c2b0-4c06-a290-97991c896653"),
&n2.UUID, &n2.Body, &n2.AddedOn, &n2.Deleted, &n2.Dirty, &n2.USN)
testutils.MustScan(t, "getting n3",
db.QueryRow("SELECT uuid, body, added_on, deleted, dirty, usn FROM notes WHERE book_uuid = ? AND uuid = ?", "linux-book-uuid", "3e065d55-6d47-42f2-a6bf-f5844130b2d2"),
&n3.UUID, &n3.Body, &n3.AddedOn, &n3.Deleted, &n3.Dirty, &n3.USN)
testutils.AssertEqual(t, b1.Label, "js", "b1 label mismatch")
testutils.AssertEqual(t, b1.Deleted, false, "b1 deleted mismatch")
testutils.AssertEqual(t, b1.Dirty, false, "b1 Dirty mismatch")
testutils.AssertEqual(t, b1.USN, 111, "b1 usn mismatch")
testutils.AssertEqual(t, b2.Label, "linux", "b2 label mismatch")
testutils.AssertEqual(t, b2.Deleted, false, "b2 deleted mismatch")
testutils.AssertEqual(t, b2.Dirty, false, "b2 Dirty mismatch")
testutils.AssertEqual(t, b2.USN, 122, "b2 usn mismatch")
testutils.AssertEqual(t, n1.UUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "n1 should have UUID")
testutils.AssertEqual(t, n1.Body, "", "n1 body mismatch")
testutils.AssertEqual(t, n1.Deleted, true, "n1 deleted mismatch")
testutils.AssertEqual(t, n1.Dirty, true, "n1 Dirty mismatch")
testutils.AssertEqual(t, n1.USN, 11, "n1 usn mismatch")
testutils.AssertEqual(t, n2.UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "n2 should have UUID")
testutils.AssertEqual(t, n2.Body, "n2 body", "n2 body mismatch")
testutils.AssertEqual(t, n2.Deleted, false, "n2 deleted mismatch")
testutils.AssertEqual(t, n2.Dirty, false, "n2 Dirty mismatch")
testutils.AssertEqual(t, n2.USN, 12, "n2 usn mismatch")
testutils.AssertEqual(t, n3.UUID, "3e065d55-6d47-42f2-a6bf-f5844130b2d2", "n3 should have UUID")
testutils.AssertEqual(t, n3.Body, "n3 body", "n3 body mismatch")
testutils.AssertEqual(t, n3.Deleted, false, "n3 deleted mismatch")
testutils.AssertEqual(t, n3.Dirty, false, "n3 Dirty mismatch")
testutils.AssertEqual(t, n3.USN, 13, "n3 usn mismatch")
}
func TestRemoveBook(t *testing.T) {
// Set up
ctx := testutils.InitEnv(t, "./tmp", "./testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
testutils.Setup2(t, ctx)
// Execute
testutils.WaitDnoteCmd(t, ctx, testutils.UserConfirm, binaryName, "remove", "-b", "js")
// Test
db := ctx.DB
var noteCount, bookCount, jsNoteCount, linuxNoteCount int
testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount)
testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), &noteCount)
testutils.MustScan(t, "counting js notes", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "js-book-uuid"), &jsNoteCount)
testutils.MustScan(t, "counting linux notes", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "linux-book-uuid"), &linuxNoteCount)
testutils.AssertEqualf(t, bookCount, 2, "book count mismatch")
testutils.AssertEqualf(t, noteCount, 3, "note count mismatch")
testutils.AssertEqual(t, jsNoteCount, 2, "js book should have 2 notes")
testutils.AssertEqual(t, linuxNoteCount, 1, "linux book book should have 1 note")
var b1, b2 core.Book
var n1, n2, n3 core.Note
testutils.MustScan(t, "getting b1",
db.QueryRow("SELECT label, dirty, deleted, usn FROM books WHERE uuid = ?", "js-book-uuid"),
&b1.Label, &b1.Dirty, &b1.Deleted, &b1.USN)
testutils.MustScan(t, "getting b2",
db.QueryRow("SELECT label, dirty, deleted, usn FROM books WHERE uuid = ?", "linux-book-uuid"),
&b2.Label, &b2.Dirty, &b2.Deleted, &b2.USN)
testutils.MustScan(t, "getting n1",
db.QueryRow("SELECT uuid, body, added_on, dirty, deleted, usn FROM notes WHERE book_uuid = ? AND uuid = ?", "js-book-uuid", "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f"),
&n1.UUID, &n1.Body, &n1.AddedOn, &n1.Deleted, &n1.Dirty, &n1.USN)
testutils.MustScan(t, "getting n2",
db.QueryRow("SELECT uuid, body, added_on, dirty, deleted, usn FROM notes WHERE book_uuid = ? AND uuid = ?", "js-book-uuid", "43827b9a-c2b0-4c06-a290-97991c896653"),
&n2.UUID, &n2.Body, &n2.AddedOn, &n2.Deleted, &n2.Dirty, &n2.USN)
testutils.MustScan(t, "getting n3",
db.QueryRow("SELECT uuid, body, added_on, dirty, deleted, usn FROM notes WHERE book_uuid = ? AND uuid = ?", "linux-book-uuid", "3e065d55-6d47-42f2-a6bf-f5844130b2d2"),
&n3.UUID, &n3.Body, &n3.AddedOn, &n3.Deleted, &n3.Dirty, &n3.USN)
testutils.AssertNotEqual(t, b1.Label, "js", "b1 label mismatch")
testutils.AssertEqual(t, b1.Dirty, true, "b1 Dirty mismatch")
testutils.AssertEqual(t, b1.Deleted, true, "b1 deleted mismatch")
testutils.AssertEqual(t, b1.USN, 111, "b1 usn mismatch")
testutils.AssertEqual(t, b2.Label, "linux", "b2 label mismatch")
testutils.AssertEqual(t, b2.Dirty, false, "b2 Dirty mismatch")
testutils.AssertEqual(t, b2.Deleted, false, "b2 deleted mismatch")
testutils.AssertEqual(t, b2.USN, 122, "b2 usn mismatch")
testutils.AssertEqual(t, n1.UUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "n1 should have UUID")
testutils.AssertEqual(t, n1.Body, "", "n1 body mismatch")
testutils.AssertEqual(t, n1.Dirty, true, "n1 Dirty mismatch")
testutils.AssertEqual(t, n1.Deleted, true, "n1 deleted mismatch")
testutils.AssertEqual(t, n1.USN, 11, "n1 usn mismatch")
testutils.AssertEqual(t, n2.UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "n2 should have UUID")
testutils.AssertEqual(t, n2.Body, "", "n2 body mismatch")
testutils.AssertEqual(t, n2.Dirty, true, "n2 Dirty mismatch")
testutils.AssertEqual(t, n2.Deleted, true, "n2 deleted mismatch")
testutils.AssertEqual(t, n2.USN, 12, "n2 usn mismatch")
testutils.AssertEqual(t, n3.UUID, "3e065d55-6d47-42f2-a6bf-f5844130b2d2", "n3 should have UUID")
testutils.AssertEqual(t, n3.Body, "n3 body", "n3 body mismatch")
testutils.AssertEqual(t, n3.Dirty, false, "n3 Dirty mismatch")
testutils.AssertEqual(t, n3.Deleted, false, "n3 deleted mismatch")
testutils.AssertEqual(t, n3.USN, 13, "n3 usn mismatch")
}

View file

@ -1,608 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package migrate
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"reflect"
"testing"
"github.com/dnote/dnote/cli/testutils"
"github.com/dnote/dnote/cli/utils"
"github.com/pkg/errors"
"gopkg.in/yaml.v2"
)
func TestMigrateToV1(t *testing.T) {
t.Run("yaml exists", func(t *testing.T) {
// set up
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
yamlPath, err := filepath.Abs(filepath.Join(ctx.HomeDir, ".dnote-yaml-archived"))
if err != nil {
panic(errors.Wrap(err, "Failed to get absolute YAML path").Error())
}
ioutil.WriteFile(yamlPath, []byte{}, 0644)
// execute
if err := migrateToV1(ctx); err != nil {
t.Fatal(errors.Wrapf(err, "Failed to migrate").Error())
}
// test
if utils.FileExists(yamlPath) {
t.Fatal("YAML archive file has not been deleted")
}
})
t.Run("yaml does not exist", func(t *testing.T) {
// set up
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
yamlPath, err := filepath.Abs(filepath.Join(ctx.HomeDir, ".dnote-yaml-archived"))
if err != nil {
panic(errors.Wrap(err, "Failed to get absolute YAML path").Error())
}
// execute
if err := migrateToV1(ctx); err != nil {
t.Fatal(errors.Wrapf(err, "Failed to migrate").Error())
}
// test
if utils.FileExists(yamlPath) {
t.Fatal("YAML archive file must not exist")
}
})
}
func TestMigrateToV2(t *testing.T) {
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
testutils.CopyFixture(ctx, "./fixtures/legacy-2-pre-dnote.json", "dnote")
// execute
if err := migrateToV2(ctx); err != nil {
t.Fatal(errors.Wrap(err, "Failed to migrate").Error())
}
// test
b := testutils.ReadFile(ctx, "dnote")
var postDnote migrateToV2PostDnote
if err := json.Unmarshal(b, &postDnote); err != nil {
t.Fatal(errors.Wrap(err, "Failed to unmarshal the result into Dnote").Error())
}
for _, book := range postDnote {
testutils.AssertNotEqual(t, book.Name, "", "Book name was not populated")
for _, note := range book.Notes {
if len(note.UUID) == 8 {
t.Errorf("Note UUID was not migrated. It has length of %d", len(note.UUID))
}
testutils.AssertNotEqual(t, note.AddedOn, int64(0), "AddedOn was not carried over")
testutils.AssertEqual(t, note.EditedOn, int64(0), "EditedOn was not created properly")
}
}
}
func TestMigrateToV3(t *testing.T) {
// set up
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
testutils.CopyFixture(ctx, "./fixtures/legacy-3-pre-dnote.json", "dnote")
// execute
if err := migrateToV3(ctx); err != nil {
t.Fatal(errors.Wrap(err, "Failed to migrate").Error())
}
// test
b := testutils.ReadFile(ctx, "dnote")
var postDnote migrateToV3Dnote
if err := json.Unmarshal(b, &postDnote); err != nil {
t.Fatal(errors.Wrap(err, "Failed to unmarshal the result into Dnote").Error())
}
b = testutils.ReadFile(ctx, "actions")
var actions []migrateToV3Action
if err := json.Unmarshal(b, &actions); err != nil {
t.Fatal(errors.Wrap(err, "Failed to unmarshal the actions").Error())
}
testutils.AssertEqual(t, len(actions), 6, "actions length mismatch")
for _, book := range postDnote {
for _, note := range book.Notes {
testutils.AssertNotEqual(t, note.AddedOn, int64(0), "AddedOn was not carried over")
}
}
}
func TestMigrateToV4(t *testing.T) {
// set up
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
defer os.Setenv("EDITOR", "")
testutils.CopyFixture(ctx, "./fixtures/legacy-4-pre-dnoterc.yaml", "dnoterc")
// execute
os.Setenv("EDITOR", "vim")
if err := migrateToV4(ctx); err != nil {
t.Fatal(errors.Wrap(err, "Failed to migrate").Error())
}
// test
b := testutils.ReadFile(ctx, "dnoterc")
var config migrateToV4PostConfig
if err := yaml.Unmarshal(b, &config); err != nil {
t.Fatal(errors.Wrap(err, "Failed to unmarshal the result into Dnote").Error())
}
testutils.AssertEqual(t, config.APIKey, "Oev6e1082ORasdf9rjkfjkasdfjhgei", "api key mismatch")
testutils.AssertEqual(t, config.Editor, "vim", "editor mismatch")
}
func TestMigrateToV5(t *testing.T) {
// set up
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
testutils.CopyFixture(ctx, "./fixtures/legacy-5-pre-actions.json", "actions")
// execute
if err := migrateToV5(ctx); err != nil {
t.Fatal(errors.Wrap(err, "migrating").Error())
}
// test
var oldActions []migrateToV5PreAction
testutils.ReadJSON("./fixtures/legacy-5-pre-actions.json", &oldActions)
b := testutils.ReadFile(ctx, "actions")
var migratedActions []migrateToV5PostAction
if err := json.Unmarshal(b, &migratedActions); err != nil {
t.Fatal(errors.Wrap(err, "unmarshalling migrated actions").Error())
}
if len(oldActions) != len(migratedActions) {
t.Fatalf("There were %d actions but after migration there were %d", len(oldActions), len(migratedActions))
}
for idx := range migratedActions {
migrated := migratedActions[idx]
old := oldActions[idx]
testutils.AssertNotEqual(t, migrated.UUID, "", fmt.Sprintf("uuid mismatch for migrated item with index %d", idx))
testutils.AssertEqual(t, migrated.Schema, 1, fmt.Sprintf("schema mismatch for migrated item with index %d", idx))
testutils.AssertEqual(t, migrated.Timestamp, old.Timestamp, fmt.Sprintf("timestamp mismatch for migrated item with index %d", idx))
testutils.AssertEqual(t, migrated.Type, old.Type, fmt.Sprintf("timestamp mismatch for migrated item with index %d", idx))
switch migrated.Type {
case migrateToV5ActionAddNote:
var oldData, migratedData migrateToV5AddNoteData
if err := json.Unmarshal(old.Data, &oldData); err != nil {
t.Fatal(errors.Wrap(err, "unmarshalling old data").Error())
}
if err := json.Unmarshal(migrated.Data, &migratedData); err != nil {
t.Fatal(errors.Wrap(err, "unmarshalling new data").Error())
}
testutils.AssertEqual(t, oldData.BookName, migratedData.BookName, fmt.Sprintf("data book_name mismatch for item idx %d", idx))
testutils.AssertEqual(t, oldData.Content, migratedData.Content, fmt.Sprintf("data content mismatch for item idx %d", idx))
testutils.AssertEqual(t, oldData.NoteUUID, migratedData.NoteUUID, fmt.Sprintf("data note_uuid mismatch for item idx %d", idx))
case migrateToV5ActionRemoveNote:
var oldData, migratedData migrateToV5RemoveNoteData
if err := json.Unmarshal(old.Data, &oldData); err != nil {
t.Fatal(errors.Wrap(err, "unmarshalling old data").Error())
}
if err := json.Unmarshal(migrated.Data, &migratedData); err != nil {
t.Fatal(errors.Wrap(err, "unmarshalling new data").Error())
}
testutils.AssertEqual(t, oldData.BookName, migratedData.BookName, fmt.Sprintf("data book_name mismatch for item idx %d", idx))
testutils.AssertEqual(t, oldData.NoteUUID, migratedData.NoteUUID, fmt.Sprintf("data note_uuid mismatch for item idx %d", idx))
case migrateToV5ActionAddBook:
var oldData, migratedData migrateToV5AddBookData
if err := json.Unmarshal(old.Data, &oldData); err != nil {
t.Fatal(errors.Wrap(err, "unmarshalling old data").Error())
}
if err := json.Unmarshal(migrated.Data, &migratedData); err != nil {
t.Fatal(errors.Wrap(err, "unmarshalling new data").Error())
}
testutils.AssertEqual(t, oldData.BookName, migratedData.BookName, fmt.Sprintf("data book_name mismatch for item idx %d", idx))
case migrateToV5ActionRemoveBook:
var oldData, migratedData migrateToV5RemoveBookData
if err := json.Unmarshal(old.Data, &oldData); err != nil {
t.Fatal(errors.Wrap(err, "unmarshalling old data").Error())
}
if err := json.Unmarshal(migrated.Data, &migratedData); err != nil {
t.Fatal(errors.Wrap(err, "unmarshalling new data").Error())
}
testutils.AssertEqual(t, oldData.BookName, migratedData.BookName, fmt.Sprintf("data book_name mismatch for item idx %d", idx))
case migrateToV5ActionEditNote:
var oldData migrateToV5PreEditNoteData
var migratedData migrateToV5PostEditNoteData
if err := json.Unmarshal(old.Data, &oldData); err != nil {
t.Fatal(errors.Wrap(err, "unmarshalling old data").Error())
}
if err := json.Unmarshal(migrated.Data, &migratedData); err != nil {
t.Fatal(errors.Wrap(err, "unmarshalling new data").Error())
}
testutils.AssertEqual(t, oldData.NoteUUID, migratedData.NoteUUID, fmt.Sprintf("data note_uuid mismatch for item idx %d", idx))
testutils.AssertEqual(t, oldData.Content, migratedData.Content, fmt.Sprintf("data content mismatch for item idx %d", idx))
testutils.AssertEqual(t, oldData.BookName, migratedData.FromBook, "book_name should have been renamed to from_book")
testutils.AssertEqual(t, migratedData.ToBook, "", "to_book should be empty")
}
}
}
func TestMigrateToV6(t *testing.T) {
// set up
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
testutils.CopyFixture(ctx, "./fixtures/legacy-6-pre-dnote.json", "dnote")
// execute
if err := migrateToV6(ctx); err != nil {
t.Fatal(errors.Wrap(err, "Failed to migrate").Error())
}
// test
b := testutils.ReadFile(ctx, "dnote")
var got migrateToV6PostDnote
if err := json.Unmarshal(b, &got); err != nil {
t.Fatal(errors.Wrap(err, "Failed to unmarshal the result into Dnote").Error())
}
b = testutils.ReadFileAbs("./fixtures/legacy-6-post-dnote.json")
var expected migrateToV6PostDnote
if err := json.Unmarshal(b, &expected); err != nil {
t.Fatal(errors.Wrap(err, "Failed to unmarshal the result into Dnote").Error())
}
if ok := reflect.DeepEqual(expected, got); !ok {
t.Errorf("Payload does not match.\nActual: %+v\nExpected: %+v", got, expected)
}
}
func TestMigrateToV7(t *testing.T) {
// set up
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", true)
defer testutils.TeardownEnv(ctx)
testutils.CopyFixture(ctx, "./fixtures/legacy-7-pre-actions.json", "actions")
// execute
if err := migrateToV7(ctx); err != nil {
t.Fatal(errors.Wrap(err, "migrating").Error())
}
// test
b := testutils.ReadFile(ctx, "actions")
var got []migrateToV7Action
if err := json.Unmarshal(b, &got); err != nil {
t.Fatal(errors.Wrap(err, "unmarshalling the result").Error())
}
b2 := testutils.ReadFileAbs("./fixtures/legacy-7-post-actions.json")
var expected []migrateToV7Action
if err := json.Unmarshal(b, &expected); err != nil {
t.Fatal(errors.Wrap(err, "unmarshalling the result into Dnote").Error())
}
ok, err := testutils.IsEqualJSON(b, b2)
if err != nil {
t.Fatal(errors.Wrap(err, "comparing JSON").Error())
}
if !ok {
t.Errorf("Result does not match.\nActual: %+v\nExpected: %+v", got, expected)
}
}
func TestMigrateToV8(t *testing.T) {
ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-1-pre-schema.sql", false)
defer testutils.TeardownEnv(ctx)
// set up
testutils.CopyFixture(ctx, "./fixtures/legacy-8-actions.json", "actions")
testutils.CopyFixture(ctx, "./fixtures/legacy-8-dnote.json", "dnote")
testutils.CopyFixture(ctx, "./fixtures/legacy-8-dnoterc.yaml", "dnoterc")
testutils.CopyFixture(ctx, "./fixtures/legacy-8-schema.yaml", "schema")
testutils.CopyFixture(ctx, "./fixtures/legacy-8-timestamps.yaml", "timestamps")
// execute
if err := migrateToV8(ctx); err != nil {
t.Fatal(errors.Wrap(err, "migrating").Error())
}
// test
// 1. test if files are migrated
dnoteFilePath := fmt.Sprintf("%s/dnote", ctx.DnoteDir)
dnotercPath := fmt.Sprintf("%s/dnoterc", ctx.DnoteDir)
schemaFilePath := fmt.Sprintf("%s/schema", ctx.DnoteDir)
timestampFilePath := fmt.Sprintf("%s/timestamps", ctx.DnoteDir)
if ok := utils.FileExists(dnoteFilePath); ok {
t.Errorf("%s still exists", dnoteFilePath)
}
if ok := utils.FileExists(schemaFilePath); ok {
t.Errorf("%s still exists", dnoteFilePath)
}
if ok := utils.FileExists(timestampFilePath); ok {
t.Errorf("%s still exists", dnoteFilePath)
}
if ok := utils.FileExists(dnotercPath); !ok {
t.Errorf("%s should exist", dnotercPath)
}
// 2. test if notes and books are migrated
db := ctx.DB
var bookCount, noteCount int
err := db.QueryRow("SELECT count(*) FROM books").Scan(&bookCount)
if err != nil {
panic(errors.Wrap(err, "counting books"))
}
err = db.QueryRow("SELECT count(*) FROM notes").Scan(&noteCount)
if err != nil {
panic(errors.Wrap(err, "counting notes"))
}
testutils.AssertEqual(t, bookCount, 2, "book count mismatch")
testutils.AssertEqual(t, noteCount, 3, "note count mismatch")
type bookInfo struct {
label string
uuid string
}
type noteInfo struct {
id int
uuid string
bookUUID string
content string
addedOn int64
editedOn int64
public bool
}
var b1, b2 bookInfo
var n1, n2, n3 noteInfo
err = db.QueryRow("SELECT label, uuid FROM books WHERE label = ?", "js").Scan(&b1.label, &b1.uuid)
if err != nil {
panic(errors.Wrap(err, "finding book 1"))
}
err = db.QueryRow("SELECT label, uuid FROM books WHERE label = ?", "css").Scan(&b2.label, &b2.uuid)
if err != nil {
panic(errors.Wrap(err, "finding book 2"))
}
err = db.QueryRow("SELECT id, uuid, book_uuid, content, added_on, edited_on, public FROM notes WHERE uuid = ?", "d69edb54-5b31-4cdd-a4a5-34f0a0bfa153").Scan(&n1.id, &n1.uuid, &n1.bookUUID, &n1.content, &n1.addedOn, &n1.editedOn, &n1.public)
if err != nil {
panic(errors.Wrap(err, "finding note 1"))
}
err = db.QueryRow("SELECT id, uuid, book_uuid, content, added_on, edited_on, public FROM notes WHERE uuid = ?", "35cbcab1-6a2a-4cc8-97e0-e73bbbd54626").Scan(&n2.id, &n2.uuid, &n2.bookUUID, &n2.content, &n2.addedOn, &n2.editedOn, &n2.public)
if err != nil {
panic(errors.Wrap(err, "finding note 2"))
}
err = db.QueryRow("SELECT id, uuid, book_uuid, content, added_on, edited_on, public FROM notes WHERE uuid = ?", "7c1fcfb2-de8b-4350-88f0-fb3cbaf6630a").Scan(&n3.id, &n3.uuid, &n3.bookUUID, &n3.content, &n3.addedOn, &n3.editedOn, &n3.public)
if err != nil {
panic(errors.Wrap(err, "finding note 3"))
}
testutils.AssertNotEqual(t, b1.uuid, "", "book 1 uuid should have been generated")
testutils.AssertEqual(t, b1.label, "js", "book 1 label mismatch")
testutils.AssertNotEqual(t, b2.uuid, "", "book 2 uuid should have been generated")
testutils.AssertEqual(t, b2.label, "css", "book 2 label mismatch")
testutils.AssertEqual(t, n1.uuid, "d69edb54-5b31-4cdd-a4a5-34f0a0bfa153", "note 1 uuid mismatch")
testutils.AssertNotEqual(t, n1.id, 0, "note 1 id should have been generated")
testutils.AssertEqual(t, n1.bookUUID, b2.uuid, "note 1 book_uuid mismatch")
testutils.AssertEqual(t, n1.content, "css test 1", "note 1 content mismatch")
testutils.AssertEqual(t, n1.addedOn, int64(1536977237), "note 1 added_on mismatch")
testutils.AssertEqual(t, n1.editedOn, int64(1536977253), "note 1 edited_on mismatch")
testutils.AssertEqual(t, n1.public, false, "note 1 public mismatch")
testutils.AssertEqual(t, n2.uuid, "35cbcab1-6a2a-4cc8-97e0-e73bbbd54626", "note 2 uuid mismatch")
testutils.AssertNotEqual(t, n2.id, 0, "note 2 id should have been generated")
testutils.AssertEqual(t, n2.bookUUID, b1.uuid, "note 2 book_uuid mismatch")
testutils.AssertEqual(t, n2.content, "js test 1", "note 2 content mismatch")
testutils.AssertEqual(t, n2.addedOn, int64(1536977229), "note 2 added_on mismatch")
testutils.AssertEqual(t, n2.editedOn, int64(0), "note 2 edited_on mismatch")
testutils.AssertEqual(t, n2.public, false, "note 2 public mismatch")
testutils.AssertEqual(t, n3.uuid, "7c1fcfb2-de8b-4350-88f0-fb3cbaf6630a", "note 3 uuid mismatch")
testutils.AssertNotEqual(t, n3.id, 0, "note 3 id should have been generated")
testutils.AssertEqual(t, n3.bookUUID, b1.uuid, "note 3 book_uuid mismatch")
testutils.AssertEqual(t, n3.content, "js test 2", "note 3 content mismatch")
testutils.AssertEqual(t, n3.addedOn, int64(1536977230), "note 3 added_on mismatch")
testutils.AssertEqual(t, n3.editedOn, int64(0), "note 3 edited_on mismatch")
testutils.AssertEqual(t, n3.public, false, "note 3 public mismatch")
// 3. test if actions are migrated
var actionCount int
err = db.QueryRow("SELECT count(*) FROM actions").Scan(&actionCount)
if err != nil {
panic(errors.Wrap(err, "counting actions"))
}
testutils.AssertEqual(t, actionCount, 11, "action count mismatch")
type actionInfo struct {
uuid string
schema int
actionType string
data string
timestamp int
}
var a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11 actionInfo
err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "6145c1b7-f286-4d9f-b0f6-00d274baefc6").Scan(&a1.uuid, &a1.schema, &a1.actionType, &a1.data, &a1.timestamp)
if err != nil {
panic(errors.Wrap(err, "finding a1"))
}
err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "c048a56b-179c-4f31-9995-81e9b32b7dd6").Scan(&a2.uuid, &a2.schema, &a2.actionType, &a2.data, &a2.timestamp)
if err != nil {
panic(errors.Wrap(err, "finding a2"))
}
err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "f557ef48-c304-47dc-adfb-46b7306e701f").Scan(&a3.uuid, &a3.schema, &a3.actionType, &a3.data, &a3.timestamp)
if err != nil {
panic(errors.Wrap(err, "finding a3"))
}
err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "8d79db34-343d-4331-ae5b-24743f17ca7f").Scan(&a4.uuid, &a4.schema, &a4.actionType, &a4.data, &a4.timestamp)
if err != nil {
panic(errors.Wrap(err, "finding a4"))
}
err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "b9c1ed4a-e6b3-41f2-983b-593ec7b8b7a1").Scan(&a5.uuid, &a5.schema, &a5.actionType, &a5.data, &a5.timestamp)
if err != nil {
panic(errors.Wrap(err, "finding a5"))
}
err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "06ed7ef0-f171-4bd7-ae8e-97b5d06a4c49").Scan(&a6.uuid, &a6.schema, &a6.actionType, &a6.data, &a6.timestamp)
if err != nil {
panic(errors.Wrap(err, "finding a6"))
}
err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "7f173cef-1688-4177-a373-145fcd822b2f").Scan(&a7.uuid, &a7.schema, &a7.actionType, &a7.data, &a7.timestamp)
if err != nil {
panic(errors.Wrap(err, "finding a7"))
}
err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "64352e08-aa7a-45f4-b760-b3f38b5e11fa").Scan(&a8.uuid, &a8.schema, &a8.actionType, &a8.data, &a8.timestamp)
if err != nil {
panic(errors.Wrap(err, "finding a8"))
}
err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "82e20a12-bda8-45f7-ac42-b453b6daa5ec").Scan(&a9.uuid, &a9.schema, &a9.actionType, &a9.data, &a9.timestamp)
if err != nil {
panic(errors.Wrap(err, "finding a9"))
}
err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "a29055f4-ace4-44fd-8800-3396edbccaef").Scan(&a10.uuid, &a10.schema, &a10.actionType, &a10.data, &a10.timestamp)
if err != nil {
panic(errors.Wrap(err, "finding a10"))
}
err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "871a5562-1bd0-43c1-b550-5bbb727ac7c4").Scan(&a11.uuid, &a11.schema, &a11.actionType, &a11.data, &a11.timestamp)
if err != nil {
panic(errors.Wrap(err, "finding a11"))
}
testutils.AssertEqual(t, a1.uuid, "6145c1b7-f286-4d9f-b0f6-00d274baefc6", "action 1 uuid mismatch")
testutils.AssertEqual(t, a1.schema, 1, "action 1 schema mismatch")
testutils.AssertEqual(t, a1.actionType, "add_book", "action 1 type mismatch")
testutils.AssertEqual(t, a1.data, `{"book_name":"js"}`, "action 1 data mismatch")
testutils.AssertEqual(t, a1.timestamp, 1536977229, "action 1 timestamp mismatch")
testutils.AssertEqual(t, a2.uuid, "c048a56b-179c-4f31-9995-81e9b32b7dd6", "action 2 uuid mismatch")
testutils.AssertEqual(t, a2.schema, 2, "action 2 schema mismatch")
testutils.AssertEqual(t, a2.actionType, "add_note", "action 2 type mismatch")
testutils.AssertEqual(t, a2.data, `{"note_uuid":"35cbcab1-6a2a-4cc8-97e0-e73bbbd54626","book_name":"js","content":"js test 1","public":false}`, "action 2 data mismatch")
testutils.AssertEqual(t, a2.timestamp, 1536977229, "action 2 timestamp mismatch")
testutils.AssertEqual(t, a3.uuid, "f557ef48-c304-47dc-adfb-46b7306e701f", "action 3 uuid mismatch")
testutils.AssertEqual(t, a3.schema, 2, "action 3 schema mismatch")
testutils.AssertEqual(t, a3.actionType, "add_note", "action 3 type mismatch")
testutils.AssertEqual(t, a3.data, `{"note_uuid":"7c1fcfb2-de8b-4350-88f0-fb3cbaf6630a","book_name":"js","content":"js test 2","public":false}`, "action 3 data mismatch")
testutils.AssertEqual(t, a3.timestamp, 1536977230, "action 3 timestamp mismatch")
testutils.AssertEqual(t, a4.uuid, "8d79db34-343d-4331-ae5b-24743f17ca7f", "action 4 uuid mismatch")
testutils.AssertEqual(t, a4.schema, 2, "action 4 schema mismatch")
testutils.AssertEqual(t, a4.actionType, "add_note", "action 4 type mismatch")
testutils.AssertEqual(t, a4.data, `{"note_uuid":"b23a88ba-b291-4294-9795-86b394db5dcf","book_name":"js","content":"js test 3","public":false}`, "action 4 data mismatch")
testutils.AssertEqual(t, a4.timestamp, 1536977234, "action 4 timestamp mismatch")
testutils.AssertEqual(t, a5.uuid, "b9c1ed4a-e6b3-41f2-983b-593ec7b8b7a1", "action 5 uuid mismatch")
testutils.AssertEqual(t, a5.schema, 1, "action 5 schema mismatch")
testutils.AssertEqual(t, a5.actionType, "add_book", "action 5 type mismatch")
testutils.AssertEqual(t, a5.data, `{"book_name":"css"}`, "action 5 data mismatch")
testutils.AssertEqual(t, a5.timestamp, 1536977237, "action 5 timestamp mismatch")
testutils.AssertEqual(t, a6.uuid, "06ed7ef0-f171-4bd7-ae8e-97b5d06a4c49", "action 6 uuid mismatch")
testutils.AssertEqual(t, a6.schema, 2, "action 6 schema mismatch")
testutils.AssertEqual(t, a6.actionType, "add_note", "action 6 type mismatch")
testutils.AssertEqual(t, a6.data, `{"note_uuid":"d69edb54-5b31-4cdd-a4a5-34f0a0bfa153","book_name":"css","content":"js test 3","public":false}`, "action 6 data mismatch")
testutils.AssertEqual(t, a6.timestamp, 1536977237, "action 6 timestamp mismatch")
testutils.AssertEqual(t, a7.uuid, "7f173cef-1688-4177-a373-145fcd822b2f", "action 7 uuid mismatch")
testutils.AssertEqual(t, a7.schema, 2, "action 7 schema mismatch")
testutils.AssertEqual(t, a7.actionType, "edit_note", "action 7 type mismatch")
testutils.AssertEqual(t, a7.data, `{"note_uuid":"d69edb54-5b31-4cdd-a4a5-34f0a0bfa153","from_book":"css","to_book":null,"content":"css test 1","public":null}`, "action 7 data mismatch")
testutils.AssertEqual(t, a7.timestamp, 1536977253, "action 7 timestamp mismatch")
testutils.AssertEqual(t, a8.uuid, "64352e08-aa7a-45f4-b760-b3f38b5e11fa", "action 8 uuid mismatch")
testutils.AssertEqual(t, a8.schema, 1, "action 8 schema mismatch")
testutils.AssertEqual(t, a8.actionType, "add_book", "action 8 type mismatch")
testutils.AssertEqual(t, a8.data, `{"book_name":"sql"}`, "action 8 data mismatch")
testutils.AssertEqual(t, a8.timestamp, 1536977261, "action 8 timestamp mismatch")
testutils.AssertEqual(t, a9.uuid, "82e20a12-bda8-45f7-ac42-b453b6daa5ec", "action 9 uuid mismatch")
testutils.AssertEqual(t, a9.schema, 2, "action 9 schema mismatch")
testutils.AssertEqual(t, a9.actionType, "add_note", "action 9 type mismatch")
testutils.AssertEqual(t, a9.data, `{"note_uuid":"2f47d390-685b-4b84-89ac-704c6fb8d3fb","book_name":"sql","content":"blah","public":false}`, "action 9 data mismatch")
testutils.AssertEqual(t, a9.timestamp, 1536977261, "action 9 timestamp mismatch")
testutils.AssertEqual(t, a10.uuid, "a29055f4-ace4-44fd-8800-3396edbccaef", "action 10 uuid mismatch")
testutils.AssertEqual(t, a10.schema, 1, "action 10 schema mismatch")
testutils.AssertEqual(t, a10.actionType, "remove_book", "action 10 type mismatch")
testutils.AssertEqual(t, a10.data, `{"book_name":"sql"}`, "action 10 data mismatch")
testutils.AssertEqual(t, a10.timestamp, 1536977268, "action 10 timestamp mismatch")
testutils.AssertEqual(t, a11.uuid, "871a5562-1bd0-43c1-b550-5bbb727ac7c4", "action 11 uuid mismatch")
testutils.AssertEqual(t, a11.schema, 1, "action 11 schema mismatch")
testutils.AssertEqual(t, a11.actionType, "remove_note", "action 11 type mismatch")
testutils.AssertEqual(t, a11.data, `{"note_uuid":"b23a88ba-b291-4294-9795-86b394db5dcf","book_name":"js"}`, "action 11 data mismatch")
testutils.AssertEqual(t, a11.timestamp, 1536977274, "action 11 timestamp mismatch")
// 3. test if system is migrated
var systemCount int
err = db.QueryRow("SELECT count(*) FROM system").Scan(&systemCount)
if err != nil {
panic(errors.Wrap(err, "counting system"))
}
testutils.AssertEqual(t, systemCount, 3, "action count mismatch")
var lastUpgrade, lastAction, bookmark int
err = db.QueryRow("SELECT value FROM system WHERE key = ?", "last_upgrade").Scan(&lastUpgrade)
if err != nil {
panic(errors.Wrap(err, "finding last_upgrade"))
}
err = db.QueryRow("SELECT value FROM system WHERE key = ?", "last_action").Scan(&lastAction)
if err != nil {
panic(errors.Wrap(err, "finding last_action"))
}
err = db.QueryRow("SELECT value FROM system WHERE key = ?", "bookmark").Scan(&bookmark)
if err != nil {
panic(errors.Wrap(err, "finding bookmark"))
}
testutils.AssertEqual(t, lastUpgrade, 1536977220, "last_upgrade mismatch")
testutils.AssertEqual(t, lastAction, 1536977274, "last_action mismatch")
testutils.AssertEqual(t, bookmark, 9, "bookmark mismatch")
}

View file

@ -1,965 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package migrate
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dnote/actions"
"github.com/dnote/dnote/cli/infra"
"github.com/dnote/dnote/cli/testutils"
"github.com/dnote/dnote/cli/utils"
"github.com/pkg/errors"
)
func TestExecute_bump_schema(t *testing.T) {
testCases := []struct {
schemaKey string
}{
{
schemaKey: infra.SystemSchema,
},
{
schemaKey: infra.SystemRemoteSchema,
},
}
for _, tc := range testCases {
func() {
// set up
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", false)
defer testutils.TeardownEnv(ctx)
db := ctx.DB
testutils.MustExec(t, "inserting a schema", db, "INSERT INTO system (key, value) VALUES (?, ?)", tc.schemaKey, 8)
m1 := migration{
name: "noop",
run: func(ctx infra.DnoteCtx, db *infra.DB) error {
return nil
},
}
m2 := migration{
name: "noop",
run: func(ctx infra.DnoteCtx, db *infra.DB) error {
return nil
},
}
// execute
err := execute(ctx, m1, tc.schemaKey)
if err != nil {
t.Fatal(errors.Wrap(err, "failed to execute"))
}
err = execute(ctx, m2, tc.schemaKey)
if err != nil {
t.Fatal(errors.Wrap(err, "failed to execute"))
}
// test
var schema int
testutils.MustScan(t, "getting schema", db.QueryRow("SELECT value FROM system WHERE key = ?", tc.schemaKey), &schema)
testutils.AssertEqual(t, schema, 10, "schema was not incremented properly")
}()
}
}
func TestRun_nonfresh(t *testing.T) {
testCases := []struct {
mode int
schemaKey string
}{
{
mode: LocalMode,
schemaKey: infra.SystemSchema,
},
{
mode: RemoteMode,
schemaKey: infra.SystemRemoteSchema,
},
}
for _, tc := range testCases {
func() {
// set up
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", false)
defer testutils.TeardownEnv(ctx)
db := ctx.DB
testutils.MustExec(t, "inserting a schema", db, "INSERT INTO system (key, value) VALUES (?, ?)", tc.schemaKey, 2)
testutils.MustExec(t, "creating a temporary table for testing", db,
"CREATE TABLE migrate_run_test ( name string )")
sequence := []migration{
migration{
name: "v1",
run: func(ctx infra.DnoteCtx, db *infra.DB) error {
testutils.MustExec(t, "marking v1 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v1")
return nil
},
},
migration{
name: "v2",
run: func(ctx infra.DnoteCtx, db *infra.DB) error {
testutils.MustExec(t, "marking v2 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v2")
return nil
},
},
migration{
name: "v3",
run: func(ctx infra.DnoteCtx, db *infra.DB) error {
testutils.MustExec(t, "marking v3 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v3")
return nil
},
},
migration{
name: "v4",
run: func(ctx infra.DnoteCtx, db *infra.DB) error {
testutils.MustExec(t, "marking v4 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v4")
return nil
},
},
}
// execute
err := Run(ctx, sequence, tc.mode)
if err != nil {
t.Fatal(errors.Wrap(err, "failed to run"))
}
// test
var schema int
testutils.MustScan(t, fmt.Sprintf("getting schema for %s", tc.schemaKey), db.QueryRow("SELECT value FROM system WHERE key = ?", tc.schemaKey), &schema)
testutils.AssertEqual(t, schema, 4, fmt.Sprintf("schema was not updated for %s", tc.schemaKey))
var testRunCount int
testutils.MustScan(t, "counting test runs", db.QueryRow("SELECT count(*) FROM migrate_run_test"), &testRunCount)
testutils.AssertEqual(t, testRunCount, 2, "test run count mismatch")
var testRun1, testRun2 string
testutils.MustScan(t, "finding test run 1", db.QueryRow("SELECT name FROM migrate_run_test WHERE name = ?", "v3"), &testRun1)
testutils.MustScan(t, "finding test run 2", db.QueryRow("SELECT name FROM migrate_run_test WHERE name = ?", "v4"), &testRun2)
}()
}
}
func TestRun_fresh(t *testing.T) {
testCases := []struct {
mode int
schemaKey string
}{
{
mode: LocalMode,
schemaKey: infra.SystemSchema,
},
{
mode: RemoteMode,
schemaKey: infra.SystemRemoteSchema,
},
}
for _, tc := range testCases {
func() {
// set up
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", false)
defer testutils.TeardownEnv(ctx)
db := ctx.DB
testutils.MustExec(t, "creating a temporary table for testing", db,
"CREATE TABLE migrate_run_test ( name string )")
sequence := []migration{
migration{
name: "v1",
run: func(ctx infra.DnoteCtx, db *infra.DB) error {
testutils.MustExec(t, "marking v1 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v1")
return nil
},
},
migration{
name: "v2",
run: func(ctx infra.DnoteCtx, db *infra.DB) error {
testutils.MustExec(t, "marking v2 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v2")
return nil
},
},
migration{
name: "v3",
run: func(ctx infra.DnoteCtx, db *infra.DB) error {
testutils.MustExec(t, "marking v3 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v3")
return nil
},
},
}
// execute
err := Run(ctx, sequence, tc.mode)
if err != nil {
t.Fatal(errors.Wrap(err, "failed to run"))
}
// test
var schema int
testutils.MustScan(t, "getting schema", db.QueryRow("SELECT value FROM system WHERE key = ?", tc.schemaKey), &schema)
testutils.AssertEqual(t, schema, 3, "schema was not updated")
var testRunCount int
testutils.MustScan(t, "counting test runs", db.QueryRow("SELECT count(*) FROM migrate_run_test"), &testRunCount)
testutils.AssertEqual(t, testRunCount, 3, "test run count mismatch")
var testRun1, testRun2, testRun3 string
testutils.MustScan(t, "finding test run 1", db.QueryRow("SELECT name FROM migrate_run_test WHERE name = ?", "v1"), &testRun1)
testutils.MustScan(t, "finding test run 2", db.QueryRow("SELECT name FROM migrate_run_test WHERE name = ?", "v2"), &testRun2)
testutils.MustScan(t, "finding test run 2", db.QueryRow("SELECT name FROM migrate_run_test WHERE name = ?", "v3"), &testRun3)
}()
}
}
func TestRun_up_to_date(t *testing.T) {
testCases := []struct {
mode int
schemaKey string
}{
{
mode: LocalMode,
schemaKey: infra.SystemSchema,
},
{
mode: RemoteMode,
schemaKey: infra.SystemRemoteSchema,
},
}
for _, tc := range testCases {
func() {
// set up
ctx := testutils.InitEnv(t, "../tmp", "../testutils/fixtures/schema.sql", false)
defer testutils.TeardownEnv(ctx)
db := ctx.DB
testutils.MustExec(t, "creating a temporary table for testing", db,
"CREATE TABLE migrate_run_test ( name string )")
testutils.MustExec(t, "inserting a schema", db, "INSERT INTO system (key, value) VALUES (?, ?)", tc.schemaKey, 3)
sequence := []migration{
migration{
name: "v1",
run: func(ctx infra.DnoteCtx, db *infra.DB) error {
testutils.MustExec(t, "marking v1 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v1")
return nil
},
},
migration{
name: "v2",
run: func(ctx infra.DnoteCtx, db *infra.DB) error {
testutils.MustExec(t, "marking v2 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v2")
return nil
},
},
migration{
name: "v3",
run: func(ctx infra.DnoteCtx, db *infra.DB) error {
testutils.MustExec(t, "marking v3 completed", db, "INSERT INTO migrate_run_test (name) VALUES (?)", "v3")
return nil
},
},
}
// execute
err := Run(ctx, sequence, tc.mode)
if err != nil {
t.Fatal(errors.Wrap(err, "failed to run"))
}
// test
var schema int
testutils.MustScan(t, "getting schema", db.QueryRow("SELECT value FROM system WHERE key = ?", tc.schemaKey), &schema)
testutils.AssertEqual(t, schema, 3, "schema was not updated")
var testRunCount int
testutils.MustScan(t, "counting test runs", db.QueryRow("SELECT count(*) FROM migrate_run_test"), &testRunCount)
testutils.AssertEqual(t, testRunCount, 0, "test run count mismatch")
}()
}
}
func TestLocalMigration1(t *testing.T) {
// set up
ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-1-pre-schema.sql", false)
defer testutils.TeardownEnv(ctx)
db := ctx.DB
data := testutils.MustMarshalJSON(t, actions.AddBookDataV1{BookName: "js"})
a1UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting action", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a1UUID, 1, "add_book", string(data), 1537829463)
data = testutils.MustMarshalJSON(t, actions.EditNoteDataV1{NoteUUID: "note-1-uuid", FromBook: "js", ToBook: "", Content: "note 1"})
a2UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting action", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a2UUID, 1, "edit_note", string(data), 1537829463)
data = testutils.MustMarshalJSON(t, actions.EditNoteDataV1{NoteUUID: "note-2-uuid", FromBook: "js", ToBook: "", Content: "note 2"})
a3UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting action", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a3UUID, 1, "edit_note", string(data), 1537829463)
// Execute
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, "beginning a transaction"))
}
err = lm1.run(ctx, tx)
if err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, "failed to run"))
}
tx.Commit()
// Test
var actionCount int
testutils.MustScan(t, "counting actions", db.QueryRow("SELECT count(*) FROM actions"), &actionCount)
testutils.AssertEqual(t, actionCount, 3, "action count mismatch")
var a1, a2, a3 actions.Action
testutils.MustScan(t, "getting action 1", db.QueryRow("SELECT schema, type, data, timestamp FROM actions WHERE uuid = ?", a1UUID),
&a1.Schema, &a1.Type, &a1.Data, &a1.Timestamp)
testutils.MustScan(t, "getting action 2", db.QueryRow("SELECT schema, type, data, timestamp FROM actions WHERE uuid = ?", a2UUID),
&a2.Schema, &a2.Type, &a2.Data, &a2.Timestamp)
testutils.MustScan(t, "getting action 3", db.QueryRow("SELECT schema, type, data, timestamp FROM actions WHERE uuid = ?", a3UUID),
&a3.Schema, &a3.Type, &a3.Data, &a3.Timestamp)
var a1Data actions.AddBookDataV1
var a2Data, a3Data actions.EditNoteDataV3
testutils.MustUnmarshalJSON(t, a1.Data, &a1Data)
testutils.MustUnmarshalJSON(t, a2.Data, &a2Data)
testutils.MustUnmarshalJSON(t, a3.Data, &a3Data)
testutils.AssertEqual(t, a1.Schema, 1, "a1 schema mismatch")
testutils.AssertEqual(t, a1.Type, "add_book", "a1 type mismatch")
testutils.AssertEqual(t, a1.Timestamp, int64(1537829463), "a1 timestamp mismatch")
testutils.AssertEqual(t, a1Data.BookName, "js", "a1 data book_name mismatch")
testutils.AssertEqual(t, a2.Schema, 3, "a2 schema mismatch")
testutils.AssertEqual(t, a2.Type, "edit_note", "a2 type mismatch")
testutils.AssertEqual(t, a2.Timestamp, int64(1537829463), "a2 timestamp mismatch")
testutils.AssertEqual(t, a2Data.NoteUUID, "note-1-uuid", "a2 data note_uuid mismatch")
testutils.AssertEqual(t, a2Data.BookName, (*string)(nil), "a2 data book_name mismatch")
testutils.AssertEqual(t, *a2Data.Content, "note 1", "a2 data content mismatch")
testutils.AssertEqual(t, *a2Data.Public, false, "a2 data public mismatch")
testutils.AssertEqual(t, a3.Schema, 3, "a3 schema mismatch")
testutils.AssertEqual(t, a3.Type, "edit_note", "a3 type mismatch")
testutils.AssertEqual(t, a3.Timestamp, int64(1537829463), "a3 timestamp mismatch")
testutils.AssertEqual(t, a3Data.NoteUUID, "note-2-uuid", "a3 data note_uuid mismatch")
testutils.AssertEqual(t, a3Data.BookName, (*string)(nil), "a3 data book_name mismatch")
testutils.AssertEqual(t, *a3Data.Content, "note 2", "a3 data content mismatch")
testutils.AssertEqual(t, *a3Data.Public, false, "a3 data public mismatch")
}
func TestLocalMigration2(t *testing.T) {
// set up
ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-1-pre-schema.sql", false)
defer testutils.TeardownEnv(ctx)
db := ctx.DB
c1 := "note 1 - v1"
c2 := "note 1 - v2"
css := "css"
b1UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting css book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "css")
data := testutils.MustMarshalJSON(t, actions.AddNoteDataV2{NoteUUID: "note-1-uuid", BookName: "js", Content: "note 1", Public: false})
a1UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting action", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a1UUID, 2, "add_note", string(data), 1537829463)
data = testutils.MustMarshalJSON(t, actions.EditNoteDataV2{NoteUUID: "note-1-uuid", FromBook: "js", ToBook: nil, Content: &c1, Public: nil})
a2UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting action", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a2UUID, 2, "edit_note", string(data), 1537829463)
data = testutils.MustMarshalJSON(t, actions.EditNoteDataV2{NoteUUID: "note-1-uuid", FromBook: "js", ToBook: &css, Content: &c2, Public: nil})
a3UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting action", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a3UUID, 2, "edit_note", string(data), 1537829463)
// Execute
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, "beginning a transaction"))
}
err = lm2.run(ctx, tx)
if err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, "failed to run"))
}
tx.Commit()
// Test
var actionCount int
testutils.MustScan(t, "counting actions", db.QueryRow("SELECT count(*) FROM actions"), &actionCount)
testutils.AssertEqual(t, actionCount, 3, "action count mismatch")
var a1, a2, a3 actions.Action
testutils.MustScan(t, "getting action 1", db.QueryRow("SELECT schema, type, data, timestamp FROM actions WHERE uuid = ?", a1UUID),
&a1.Schema, &a1.Type, &a1.Data, &a1.Timestamp)
testutils.MustScan(t, "getting action 2", db.QueryRow("SELECT schema, type, data, timestamp FROM actions WHERE uuid = ?", a2UUID),
&a2.Schema, &a2.Type, &a2.Data, &a2.Timestamp)
testutils.MustScan(t, "getting action 3", db.QueryRow("SELECT schema, type, data, timestamp FROM actions WHERE uuid = ?", a3UUID),
&a3.Schema, &a3.Type, &a3.Data, &a3.Timestamp)
var a1Data actions.AddNoteDataV2
var a2Data, a3Data actions.EditNoteDataV3
testutils.MustUnmarshalJSON(t, a1.Data, &a1Data)
testutils.MustUnmarshalJSON(t, a2.Data, &a2Data)
testutils.MustUnmarshalJSON(t, a3.Data, &a3Data)
testutils.AssertEqual(t, a1.Schema, 2, "a1 schema mismatch")
testutils.AssertEqual(t, a1.Type, "add_note", "a1 type mismatch")
testutils.AssertEqual(t, a1.Timestamp, int64(1537829463), "a1 timestamp mismatch")
testutils.AssertEqual(t, a1Data.NoteUUID, "note-1-uuid", "a1 data note_uuid mismatch")
testutils.AssertEqual(t, a1Data.BookName, "js", "a1 data book_name mismatch")
testutils.AssertEqual(t, a1Data.Public, false, "a1 data public mismatch")
testutils.AssertEqual(t, a2.Schema, 3, "a2 schema mismatch")
testutils.AssertEqual(t, a2.Type, "edit_note", "a2 type mismatch")
testutils.AssertEqual(t, a2.Timestamp, int64(1537829463), "a2 timestamp mismatch")
testutils.AssertEqual(t, a2Data.NoteUUID, "note-1-uuid", "a2 data note_uuid mismatch")
testutils.AssertEqual(t, a2Data.BookName, (*string)(nil), "a2 data book_name mismatch")
testutils.AssertEqual(t, *a2Data.Content, c1, "a2 data content mismatch")
testutils.AssertEqual(t, a2Data.Public, (*bool)(nil), "a2 data public mismatch")
testutils.AssertEqual(t, a3.Schema, 3, "a3 schema mismatch")
testutils.AssertEqual(t, a3.Type, "edit_note", "a3 type mismatch")
testutils.AssertEqual(t, a3.Timestamp, int64(1537829463), "a3 timestamp mismatch")
testutils.AssertEqual(t, a3Data.NoteUUID, "note-1-uuid", "a3 data note_uuid mismatch")
testutils.AssertEqual(t, *a3Data.BookName, "css", "a3 data book_name mismatch")
testutils.AssertEqual(t, *a3Data.Content, c2, "a3 data content mismatch")
testutils.AssertEqual(t, a3Data.Public, (*bool)(nil), "a3 data public mismatch")
}
func TestLocalMigration3(t *testing.T) {
// set up
ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-1-pre-schema.sql", false)
defer testutils.TeardownEnv(ctx)
db := ctx.DB
data := testutils.MustMarshalJSON(t, actions.AddNoteDataV2{NoteUUID: "note-1-uuid", BookName: "js", Content: "note 1", Public: false})
a1UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting action", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a1UUID, 2, "add_note", string(data), 1537829463)
data = testutils.MustMarshalJSON(t, actions.RemoveNoteDataV1{NoteUUID: "note-1-uuid", BookName: "js"})
a2UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting action", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a2UUID, 1, "remove_note", string(data), 1537829463)
data = testutils.MustMarshalJSON(t, actions.RemoveNoteDataV1{NoteUUID: "note-2-uuid", BookName: "js"})
a3UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting action", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a3UUID, 1, "remove_note", string(data), 1537829463)
// Execute
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, "beginning a transaction"))
}
err = lm3.run(ctx, tx)
if err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, "failed to run"))
}
tx.Commit()
// Test
var actionCount int
testutils.MustScan(t, "counting actions", db.QueryRow("SELECT count(*) FROM actions"), &actionCount)
testutils.AssertEqual(t, actionCount, 3, "action count mismatch")
var a1, a2, a3 actions.Action
testutils.MustScan(t, "getting action 1", db.QueryRow("SELECT schema, type, data, timestamp FROM actions WHERE uuid = ?", a1UUID),
&a1.Schema, &a1.Type, &a1.Data, &a1.Timestamp)
testutils.MustScan(t, "getting action 2", db.QueryRow("SELECT schema, type, data, timestamp FROM actions WHERE uuid = ?", a2UUID),
&a2.Schema, &a2.Type, &a2.Data, &a2.Timestamp)
testutils.MustScan(t, "getting action 3", db.QueryRow("SELECT schema, type, data, timestamp FROM actions WHERE uuid = ?", a3UUID),
&a3.Schema, &a3.Type, &a3.Data, &a3.Timestamp)
var a1Data actions.AddNoteDataV2
var a2Data, a3Data actions.RemoveNoteDataV2
testutils.MustUnmarshalJSON(t, a1.Data, &a1Data)
testutils.MustUnmarshalJSON(t, a2.Data, &a2Data)
testutils.MustUnmarshalJSON(t, a3.Data, &a3Data)
testutils.AssertEqual(t, a1.Schema, 2, "a1 schema mismatch")
testutils.AssertEqual(t, a1.Type, "add_note", "a1 type mismatch")
testutils.AssertEqual(t, a1.Timestamp, int64(1537829463), "a1 timestamp mismatch")
testutils.AssertEqual(t, a1Data.NoteUUID, "note-1-uuid", "a1 data note_uuid mismatch")
testutils.AssertEqual(t, a1Data.BookName, "js", "a1 data book_name mismatch")
testutils.AssertEqual(t, a1Data.Content, "note 1", "a1 data content mismatch")
testutils.AssertEqual(t, a1Data.Public, false, "a1 data public mismatch")
testutils.AssertEqual(t, a2.Schema, 2, "a2 schema mismatch")
testutils.AssertEqual(t, a2.Type, "remove_note", "a2 type mismatch")
testutils.AssertEqual(t, a2.Timestamp, int64(1537829463), "a2 timestamp mismatch")
testutils.AssertEqual(t, a2Data.NoteUUID, "note-1-uuid", "a2 data note_uuid mismatch")
testutils.AssertEqual(t, a3.Schema, 2, "a3 schema mismatch")
testutils.AssertEqual(t, a3.Type, "remove_note", "a3 type mismatch")
testutils.AssertEqual(t, a3.Timestamp, int64(1537829463), "a3 timestamp mismatch")
testutils.AssertEqual(t, a3Data.NoteUUID, "note-2-uuid", "a3 data note_uuid mismatch")
}
func TestLocalMigration4(t *testing.T) {
// set up
ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-1-pre-schema.sql", false)
defer testutils.TeardownEnv(ctx)
db := ctx.DB
b1UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting css book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "css")
n1UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting css note", db, "INSERT INTO notes (uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?)", n1UUID, b1UUID, "n1 content", time.Now().UnixNano())
// Execute
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, "beginning a transaction"))
}
err = lm4.run(ctx, tx)
if err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, "failed to run"))
}
tx.Commit()
// Test
var n1Dirty, b1Dirty bool
var n1Deleted, b1Deleted bool
var n1USN, b1USN int
testutils.MustScan(t, "scanning the newly added dirty flag of n1", db.QueryRow("SELECT dirty, deleted, usn FROM notes WHERE uuid = ?", n1UUID), &n1Dirty, &n1Deleted, &n1USN)
testutils.MustScan(t, "scanning the newly added dirty flag of b1", db.QueryRow("SELECT dirty, deleted, usn FROM books WHERE uuid = ?", b1UUID), &b1Dirty, &b1Deleted, &b1USN)
testutils.AssertEqual(t, n1Dirty, false, "n1 dirty flag should be false by default")
testutils.AssertEqual(t, b1Dirty, false, "b1 dirty flag should be false by default")
testutils.AssertEqual(t, n1Deleted, false, "n1 deleted flag should be false by default")
testutils.AssertEqual(t, b1Deleted, false, "b1 deleted flag should be false by default")
testutils.AssertEqual(t, n1USN, 0, "n1 usn flag should be 0 by default")
testutils.AssertEqual(t, b1USN, 0, "b1 usn flag should be 0 by default")
}
func TestLocalMigration5(t *testing.T) {
// set up
ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-5-pre-schema.sql", false)
defer testutils.TeardownEnv(ctx)
db := ctx.DB
b1UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting css book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "css")
b2UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting js book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "js")
n1UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting css note", db, "INSERT INTO notes (uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?)", n1UUID, b1UUID, "n1 content", time.Now().UnixNano())
n2UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting css note", db, "INSERT INTO notes (uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?)", n2UUID, b1UUID, "n2 content", time.Now().UnixNano())
n3UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting css note", db, "INSERT INTO notes (uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?)", n3UUID, b1UUID, "n3 content", time.Now().UnixNano())
data := testutils.MustMarshalJSON(t, actions.AddBookDataV1{BookName: "js"})
testutils.MustExec(t, "inserting a1", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", "a1-uuid", 1, "add_book", string(data), 1537829463)
data = testutils.MustMarshalJSON(t, actions.AddNoteDataV2{NoteUUID: n1UUID, BookName: "css", Content: "n1 content", Public: false})
testutils.MustExec(t, "inserting a2", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", "a2-uuid", 1, "add_note", string(data), 1537829463)
updatedContent := "updated content"
data = testutils.MustMarshalJSON(t, actions.EditNoteDataV3{NoteUUID: n2UUID, BookName: (*string)(nil), Content: &updatedContent, Public: (*bool)(nil)})
testutils.MustExec(t, "inserting a3", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", "a3-uuid", 1, "edit_note", string(data), 1537829463)
// Execute
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, "beginning a transaction"))
}
err = lm5.run(ctx, tx)
if err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, "failed to run"))
}
tx.Commit()
// Test
var b1Dirty, b2Dirty, n1Dirty, n2Dirty, n3Dirty bool
testutils.MustScan(t, "scanning the newly added dirty flag of b1", db.QueryRow("SELECT dirty FROM books WHERE uuid = ?", b1UUID), &b1Dirty)
testutils.MustScan(t, "scanning the newly added dirty flag of b2", db.QueryRow("SELECT dirty FROM books WHERE uuid = ?", b2UUID), &b2Dirty)
testutils.MustScan(t, "scanning the newly added dirty flag of n1", db.QueryRow("SELECT dirty FROM notes WHERE uuid = ?", n1UUID), &n1Dirty)
testutils.MustScan(t, "scanning the newly added dirty flag of n2", db.QueryRow("SELECT dirty FROM notes WHERE uuid = ?", n2UUID), &n2Dirty)
testutils.MustScan(t, "scanning the newly added dirty flag of n3", db.QueryRow("SELECT dirty FROM notes WHERE uuid = ?", n3UUID), &n3Dirty)
testutils.AssertEqual(t, b1Dirty, false, "b1 dirty flag should be false by default")
testutils.AssertEqual(t, b2Dirty, true, "b2 dirty flag should be false by default")
testutils.AssertEqual(t, n1Dirty, true, "n1 dirty flag should be false by default")
testutils.AssertEqual(t, n2Dirty, true, "n2 dirty flag should be false by default")
testutils.AssertEqual(t, n3Dirty, false, "n3 dirty flag should be false by default")
}
func TestLocalMigration6(t *testing.T) {
// set up
ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-5-pre-schema.sql", false)
defer testutils.TeardownEnv(ctx)
db := ctx.DB
data := testutils.MustMarshalJSON(t, actions.AddBookDataV1{BookName: "js"})
a1UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting action", db,
"INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a1UUID, 1, "add_book", string(data), 1537829463)
// Execute
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, "beginning a transaction"))
}
err = lm5.run(ctx, tx)
if err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, "failed to run"))
}
tx.Commit()
// Test
var count int
err = db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name = ?;", "actions").Scan(&count)
testutils.AssertEqual(t, count, 0, "actions table should have been deleted")
}
func TestLocalMigration7_trash(t *testing.T) {
// set up
ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-7-pre-schema.sql", false)
defer testutils.TeardownEnv(ctx)
db := ctx.DB
b1UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting trash book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "trash")
// Execute
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, "beginning a transaction"))
}
err = lm7.run(ctx, tx)
if err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, "failed to run"))
}
tx.Commit()
// Test
var b1Label string
var b1Dirty bool
testutils.MustScan(t, "scanning b1 label", db.QueryRow("SELECT label, dirty FROM books WHERE uuid = ?", b1UUID), &b1Label, &b1Dirty)
testutils.AssertEqual(t, b1Label, "trash (2)", "b1 label was not migrated")
testutils.AssertEqual(t, b1Dirty, true, "b1 was not marked dirty")
}
func TestLocalMigration7_conflicts(t *testing.T) {
// set up
ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-7-pre-schema.sql", false)
defer testutils.TeardownEnv(ctx)
db := ctx.DB
b1UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "conflicts")
// Execute
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, "beginning a transaction"))
}
err = lm7.run(ctx, tx)
if err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, "failed to run"))
}
tx.Commit()
// Test
var b1Label string
var b1Dirty bool
testutils.MustScan(t, "scanning b1 label", db.QueryRow("SELECT label, dirty FROM books WHERE uuid = ?", b1UUID), &b1Label, &b1Dirty)
testutils.AssertEqual(t, b1Label, "conflicts (2)", "b1 label was not migrated")
testutils.AssertEqual(t, b1Dirty, true, "b1 was not marked dirty")
}
func TestLocalMigration7_conflicts_dup(t *testing.T) {
// set up
ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-7-pre-schema.sql", false)
defer testutils.TeardownEnv(ctx)
db := ctx.DB
b1UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "conflicts")
b2UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "conflicts (2)")
// Execute
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, "beginning a transaction"))
}
err = lm7.run(ctx, tx)
if err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, "failed to run"))
}
tx.Commit()
// Test
var b1Label, b2Label string
var b1Dirty, b2Dirty bool
testutils.MustScan(t, "scanning b1 label", db.QueryRow("SELECT label, dirty FROM books WHERE uuid = ?", b1UUID), &b1Label, &b1Dirty)
testutils.MustScan(t, "scanning b2 label", db.QueryRow("SELECT label, dirty FROM books WHERE uuid = ?", b2UUID), &b2Label, &b2Dirty)
testutils.AssertEqual(t, b1Label, "conflicts (3)", "b1 label was not migrated")
testutils.AssertEqual(t, b2Label, "conflicts (2)", "b1 label was not migrated")
testutils.AssertEqual(t, b1Dirty, true, "b1 was not marked dirty")
testutils.AssertEqual(t, b2Dirty, false, "b2 should not have been marked dirty")
}
func TestLocalMigration8(t *testing.T) {
// set up
ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-8-pre-schema.sql", false)
defer testutils.TeardownEnv(ctx)
db := ctx.DB
b1UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting book 1", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1")
n1UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting n1", db, `INSERT INTO notes
(id, uuid, book_uuid, content, added_on, edited_on, public, dirty, usn, deleted) VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 1, n1UUID, b1UUID, "n1 Body", 1, 2, true, true, 20, false)
n2UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting n2", db, `INSERT INTO notes
(id, uuid, book_uuid, content, added_on, edited_on, public, dirty, usn, deleted) VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 2, n2UUID, b1UUID, "", 3, 4, false, true, 21, true)
// Execute
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, "beginning a transaction"))
}
err = lm8.run(ctx, tx)
if err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, "failed to run"))
}
tx.Commit()
// Test
var n1BookUUID, n1Body string
var n1AddedOn, n1EditedOn int64
var n1USN int
var n1Public, n1Dirty, n1Deleted bool
testutils.MustScan(t, "scanning n1", db.QueryRow("SELECT book_uuid, body, added_on, edited_on, usn, public, dirty, deleted FROM notes WHERE uuid = ?", n1UUID), &n1BookUUID, &n1Body, &n1AddedOn, &n1EditedOn, &n1USN, &n1Public, &n1Dirty, &n1Deleted)
var n2BookUUID, n2Body string
var n2AddedOn, n2EditedOn int64
var n2USN int
var n2Public, n2Dirty, n2Deleted bool
testutils.MustScan(t, "scanning n2", db.QueryRow("SELECT book_uuid, body, added_on, edited_on, usn, public, dirty, deleted FROM notes WHERE uuid = ?", n2UUID), &n2BookUUID, &n2Body, &n2AddedOn, &n2EditedOn, &n2USN, &n2Public, &n2Dirty, &n2Deleted)
testutils.AssertEqual(t, n1BookUUID, b1UUID, "n1 BookUUID mismatch")
testutils.AssertEqual(t, n1Body, "n1 Body", "n1 Body mismatch")
testutils.AssertEqual(t, n1AddedOn, int64(1), "n1 AddedOn mismatch")
testutils.AssertEqual(t, n1EditedOn, int64(2), "n1 EditedOn mismatch")
testutils.AssertEqual(t, n1USN, 20, "n1 USN mismatch")
testutils.AssertEqual(t, n1Public, true, "n1 Public mismatch")
testutils.AssertEqual(t, n1Dirty, true, "n1 Dirty mismatch")
testutils.AssertEqual(t, n1Deleted, false, "n1 Deleted mismatch")
testutils.AssertEqual(t, n2BookUUID, b1UUID, "n2 BookUUID mismatch")
testutils.AssertEqual(t, n2Body, "", "n2 Body mismatch")
testutils.AssertEqual(t, n2AddedOn, int64(3), "n2 AddedOn mismatch")
testutils.AssertEqual(t, n2EditedOn, int64(4), "n2 EditedOn mismatch")
testutils.AssertEqual(t, n2USN, 21, "n2 USN mismatch")
testutils.AssertEqual(t, n2Public, false, "n2 Public mismatch")
testutils.AssertEqual(t, n2Dirty, true, "n2 Dirty mismatch")
testutils.AssertEqual(t, n2Deleted, true, "n2 Deleted mismatch")
}
func TestLocalMigration9(t *testing.T) {
// set up
ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-9-pre-schema.sql", false)
defer testutils.TeardownEnv(ctx)
db := ctx.DB
b1UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting book 1", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1")
n1UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting n1", db, `INSERT INTO notes
(uuid, book_uuid, body, added_on, edited_on, public, dirty, usn, deleted) VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?)`, n1UUID, b1UUID, "n1 Body", 1, 2, true, true, 20, false)
n2UUID := utils.GenerateUUID()
testutils.MustExec(t, "inserting n2", db, `INSERT INTO notes
(uuid, book_uuid, body, added_on, edited_on, public, dirty, usn, deleted) VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?)`, n2UUID, b1UUID, "n2 Body", 3, 4, false, true, 21, false)
// Execute
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, "beginning a transaction"))
}
err = lm9.run(ctx, tx)
if err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, "failed to run"))
}
tx.Commit()
// Test
// assert that note_fts was populated with correct values
var noteFtsCount int
testutils.MustScan(t, "counting note_fts", db.QueryRow("SELECT count(*) FROM note_fts;"), &noteFtsCount)
testutils.AssertEqual(t, noteFtsCount, 2, "noteFtsCount mismatch")
var resCount int
testutils.MustScan(t, "counting result", db.QueryRow("SELECT count(*) FROM note_fts WHERE note_fts MATCH ?", "n1"), &resCount)
testutils.AssertEqual(t, resCount, 1, "noteFtsCount mismatch")
}
func TestRemoteMigration1(t *testing.T) {
// set up
ctx := testutils.InitEnv(t, "../tmp", "./fixtures/remote-1-pre-schema.sql", false)
testutils.Login(t, &ctx)
defer testutils.TeardownEnv(ctx)
JSBookUUID := "existing-js-book-uuid"
CSSBookUUID := "existing-css-book-uuid"
linuxBookUUID := "existing-linux-book-uuid"
newJSBookUUID := "new-js-book-uuid"
newCSSBookUUID := "new-css-book-uuid"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.String() == "/v1/books" {
res := []struct {
UUID string `json:"uuid"`
Label string `json:"label"`
}{
{
UUID: newJSBookUUID,
Label: "js",
},
{
UUID: newCSSBookUUID,
Label: "css",
},
// book that only exists on the server. client must ignore.
{
UUID: "golang-book-uuid",
Label: "golang",
},
}
if err := json.NewEncoder(w).Encode(res); err != nil {
t.Fatal(errors.Wrap(err, "encoding response"))
}
}
}))
defer server.Close()
ctx.APIEndpoint = server.URL
db := ctx.DB
testutils.MustExec(t, "inserting js book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", JSBookUUID, "js")
testutils.MustExec(t, "inserting css book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", CSSBookUUID, "css")
testutils.MustExec(t, "inserting linux book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", linuxBookUUID, "linux")
testutils.MustExec(t, "inserting sessionKey", db, "INSERT INTO system (key, value) VALUES (?, ?)", infra.SystemSessionKey, "someSessionKey")
testutils.MustExec(t, "inserting sessionKeyExpiry", db, "INSERT INTO system (key, value) VALUES (?, ?)", infra.SystemSessionKeyExpiry, time.Now().Add(24*time.Hour).Unix())
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, "beginning a transaction"))
}
err = rm1.run(ctx, tx)
if err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, "failed to run"))
}
tx.Commit()
// test
var postJSBookUUID, postCSSBookUUID, postLinuxBookUUID string
testutils.MustScan(t, "getting js book uuid", db.QueryRow("SELECT uuid FROM books WHERE label = ?", "js"), &postJSBookUUID)
testutils.MustScan(t, "getting css book uuid", db.QueryRow("SELECT uuid FROM books WHERE label = ?", "css"), &postCSSBookUUID)
testutils.MustScan(t, "getting linux book uuid", db.QueryRow("SELECT uuid FROM books WHERE label = ?", "linux"), &postLinuxBookUUID)
testutils.AssertEqual(t, postJSBookUUID, newJSBookUUID, "js book uuid was not updated correctly")
testutils.AssertEqual(t, postCSSBookUUID, newCSSBookUUID, "css book uuid was not updated correctly")
testutils.AssertEqual(t, postLinuxBookUUID, linuxBookUUID, "linux book uuid changed")
}

View file

@ -1,106 +0,0 @@
#!/bin/bash
#
# build.sh compiles dnote binary for target platforms
# it is resonsible for creating distributable files that can
# be released by a human or a script
# use: ./scripts/build.sh 0.4.8
set -eu
version="$1"
projectDir="$GOPATH/src/github.com/dnote/dnote"
basedir="$GOPATH/src/github.com/dnote/dnote/cli"
TMP="$basedir/build"
command_exists () {
command -v "$1" >/dev/null 2>&1;
}
if ! command_exists shasum; then
echo "please install shasum"
exit 1
fi
if [ $# -eq 0 ]; then
echo "no version specified."
exit 1
fi
if [[ $1 == v* ]]; then
echo "do not prefix version with v"
exit 1
fi
build() {
# init build dir
rm -rf "$TMP"
mkdir "$TMP"
# fetch tool
go get -u github.com/karalabe/xgo
pushd "$basedir"
# build linux
xgo --targets="linux/amd64"\
--tags "linux fts5"\
-ldflags "-X main.apiEndpoint=https://api.dnote.io -X main.versionTag=$version" .
mkdir "$TMP/linux"
mv cli-linux-amd64 "$TMP/linux/dnote"
# build darwin
xgo --targets="darwin/amd64"\
--tags "darwin fts5"\
-ldflags "-X main.apiEndpoint=https://api.dnote.io -X main.versionTag=$version" .
mkdir "$TMP/darwin"
mv cli-darwin-10.6-amd64 "$TMP/darwin/dnote"
# build windows
xgo --targets="windows/amd64"\
--tags "fts5"\
-ldflags "-X main.apiEndpoint=https://api.dnote.io -X main.versionTag=$version" .
mkdir "$TMP/windows"
mv cli-windows-4.0-amd64.exe "$TMP/windows/dnote.exe"
popd
}
get_buildname() {
os=$1
echo "dnote_${version}_${os}_amd64"
}
calc_checksum() {
os=$1
pushd "$TMP/$os"
buildname=$(get_buildname "$os")
mv dnote "$buildname"
shasum -a 256 "$buildname" >> "$TMP/dnote_${version}_checksums.txt"
mv "$buildname" dnote
popd
}
build_tarball() {
os=$1
buildname=$(get_buildname "$os")
pushd "$TMP/$os"
cp "$projectDir/licenses/GPLv3.txt" .
cp "$basedir/README.md" .
tar -zcvf "../${buildname}.tar.gz" ./*
popd
}
build
calc_checksum darwin
calc_checksum linux
build_tarball windows
build_tarball darwin
build_tarball linux

View file

@ -1,5 +0,0 @@
#!/bin/bash
# dump_schema.sh dumps the current system's dnote schema to testutils package
# to be used while setting up tests
sqlite3 ~/.dnote/dnote.db .schema > ./testutils/fixtures/schema.sql

View file

@ -1,58 +0,0 @@
#!/bin/bash
#
# release.sh releases the tarballs and checksum in the build directory
# to GitHub and brew. It is important to build those files using build.sh
# use: ./scripts/release.sh v0.4.8
set -eu
homebrewRepoDir="$GOPATH"/src/github.com/dnote/homebrew-dnote
command_exists () {
command -v "$1" >/dev/null 2>&1;
}
if [ $# -eq 0 ]; then
echo "no version specified."
exit 1
fi
if [[ $1 == v* ]]; then
echo "do not prefix version with v"
exit 1
fi
if ! command_exists hub; then
echo "please install hub"
exit 1
fi
if [ ! -d "$homebrewRepoDir" ]; then
echo "homebrew-dnote not found locally. did you clone it?"
exit 1
fi
# 1. push tag
version=$1
version_tag="cli-v$version"
echo "* tagging and pushing the tag"
git tag -a "$version_tag" -m "Release $version_tag"
git push --tags
# 2. release on GitHub
files=(./build/*.tar.gz ./build/*.txt)
file_args=()
for file in "${files[@]}"; do
file_args+=("--attach=$file")
done
echo "* creating release"
set -x
hub release create \
"${file_args[@]}" \
--message="$version_tag"\
"$version_tag"
# 3. Release on Homebrew
homebrew_sha256=$(shasum -a 256 "./build/dnote_${version}_darwin_amd64.tar.gz" | cut -d ' ' -f 1)
(cd "$homebrewRepoDir" && ./release.sh "$version" "$homebrew_sha256")

View file

@ -1,19 +0,0 @@
#!/bin/bash
# run_server_test.sh runs server test files sequentially
# https://stackoverflow.com/questions/23715302/go-how-to-run-tests-for-multiple-packages
set -eux
basePath="$GOPATH/src/github.com/dnote/dnote/cli"
# clear tmp dir in case not properly torn down
rm -rf "$basePath/tmp"
# run test
pushd "$basePath"
go test -a ./... \
-p 1\
--tags "fts5"
popd

View file

@ -1,363 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
// Package testutils provides utilities used in tests
package testutils
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/dnote/dnote/cli/infra"
"github.com/dnote/dnote/cli/utils"
"github.com/pkg/errors"
)
// InitEnv sets up a test env and returns a new dnote context
func InitEnv(t *testing.T, dnotehomePath string, fixturePath string, migrated bool) infra.DnoteCtx {
os.Setenv("DNOTE_HOME_DIR", dnotehomePath)
ctx, err := infra.NewCtx("", "")
if err != nil {
t.Fatal(errors.Wrap(err, "getting new ctx"))
}
// set up directory
if err := os.MkdirAll(ctx.DnoteDir, 0755); err != nil {
t.Fatal(err)
}
// set up db
b := ReadFileAbs(fixturePath)
setupSQL := string(b)
db := ctx.DB
if _, err := db.Exec(setupSQL); err != nil {
t.Fatal(errors.Wrap(err, "running schema sql"))
}
if migrated {
// mark migrations as done. When adding new migrations, bump the numbers here.
if _, err := db.Exec("INSERT INTO system (key, value) VALUES (? , ?);", infra.SystemSchema, 9); err != nil {
t.Fatal(errors.Wrap(err, "inserting schema"))
}
if _, err := db.Exec("INSERT INTO system (key, value) VALUES (? , ?);", infra.SystemRemoteSchema, 1); err != nil {
t.Fatal(errors.Wrap(err, "inserting remote schema"))
}
}
return ctx
}
// Login simulates a logged in user by inserting credentials in the local database
func Login(t *testing.T, ctx *infra.DnoteCtx) {
db := ctx.DB
MustExec(t, "inserting sessionKey", db, "INSERT INTO system (key, value) VALUES (?, ?)", infra.SystemSessionKey, "someSessionKey")
MustExec(t, "inserting sessionKeyExpiry", db, "INSERT INTO system (key, value) VALUES (?, ?)", infra.SystemSessionKeyExpiry, time.Now().Add(24*time.Hour).Unix())
MustExec(t, "inserting cipherKey", db, "INSERT INTO system (key, value) VALUES (?, ?)", infra.SystemCipherKey, "QUVTMjU2S2V5LTMyQ2hhcmFjdGVyczEyMzQ1Njc4OTA=")
ctx.SessionKey = "someSessionKey"
ctx.SessionKeyExpiry = time.Now().Add(24 * time.Hour).Unix()
ctx.CipherKey = []byte("AES256Key-32Characters1234567890")
}
// TeardownEnv cleans up the test env represented by the given context
func TeardownEnv(ctx infra.DnoteCtx) {
ctx.DB.Close()
if err := os.RemoveAll(ctx.DnoteDir); err != nil {
panic(err)
}
}
// CopyFixture writes the content of the given fixture to the filename inside the dnote dir
func CopyFixture(ctx infra.DnoteCtx, fixturePath string, filename string) {
fp, err := filepath.Abs(fixturePath)
if err != nil {
panic(err)
}
dp, err := filepath.Abs(filepath.Join(ctx.DnoteDir, filename))
if err != nil {
panic(err)
}
err = utils.CopyFile(fp, dp)
if err != nil {
panic(err)
}
}
// WriteFile writes a file with the given content and filename inside the dnote dir
func WriteFile(ctx infra.DnoteCtx, content []byte, filename string) {
dp, err := filepath.Abs(filepath.Join(ctx.DnoteDir, filename))
if err != nil {
panic(err)
}
if err := ioutil.WriteFile(dp, content, 0644); err != nil {
panic(err)
}
}
// ReadFile reads the content of the file with the given name in dnote dir
func ReadFile(ctx infra.DnoteCtx, filename string) []byte {
path := filepath.Join(ctx.DnoteDir, filename)
b, err := ioutil.ReadFile(path)
if err != nil {
panic(err)
}
return b
}
// ReadFileAbs reads the content of the file with the given file path by resolving
// it as an absolute path
func ReadFileAbs(relpath string) []byte {
fp, err := filepath.Abs(relpath)
if err != nil {
panic(err)
}
b, err := ioutil.ReadFile(fp)
if err != nil {
panic(err)
}
return b
}
func checkEqual(a interface{}, b interface{}, message string) (bool, string) {
if a == b {
return true, ""
}
var m string
if len(message) == 0 {
m = fmt.Sprintf("%v != %v", a, b)
} else {
m = message
}
errorMessage := fmt.Sprintf("%s. Actual: %+v. Expected: %+v.", m, a, b)
return false, errorMessage
}
// AssertEqual errors a test if the actual does not match the expected
func AssertEqual(t *testing.T, a interface{}, b interface{}, message string) {
ok, m := checkEqual(a, b, message)
if !ok {
t.Error(m)
}
}
// AssertEqualf fails a test if the actual does not match the expected
func AssertEqualf(t *testing.T, a interface{}, b interface{}, message string) {
ok, m := checkEqual(a, b, message)
if !ok {
t.Fatal(m)
}
}
// AssertNotEqual fails a test if the actual matches the expected
func AssertNotEqual(t *testing.T, a interface{}, b interface{}, message string) {
if a != b {
return
}
if len(message) == 0 {
message = fmt.Sprintf("%v == %v", a, b)
}
t.Errorf("%s. Actual: %+v. Expected: %+v.", message, a, b)
}
// AssertDeepEqual fails a test if the actual does not deeply equal the expected
func AssertDeepEqual(t *testing.T, a interface{}, b interface{}, message string) {
if reflect.DeepEqual(a, b) {
return
}
if len(message) == 0 {
message = fmt.Sprintf("%v != %v", a, b)
}
t.Errorf("%s.\nActual: %+v.\nExpected: %+v.", message, a, b)
}
// ReadJSON reads JSON fixture to the struct at the destination address
func ReadJSON(path string, destination interface{}) {
var dat []byte
dat, err := ioutil.ReadFile(path)
if err != nil {
panic(errors.Wrap(err, "Failed to load fixture payload"))
}
if err := json.Unmarshal(dat, destination); err != nil {
panic(errors.Wrap(err, "Failed to get event"))
}
}
// IsEqualJSON deeply compares two JSON byte slices
func IsEqualJSON(s1, s2 []byte) (bool, error) {
var o1 interface{}
var o2 interface{}
if err := json.Unmarshal(s1, &o1); err != nil {
return false, errors.Wrap(err, "unmarshalling first JSON")
}
if err := json.Unmarshal(s2, &o2); err != nil {
return false, errors.Wrap(err, "unmarshalling second JSON")
}
return reflect.DeepEqual(o1, o2), nil
}
// MustExec executes the given SQL query and fails a test if an error occurs
func MustExec(t *testing.T, message string, db *infra.DB, query string, args ...interface{}) sql.Result {
result, err := db.Exec(query, args...)
if err != nil {
t.Fatal(errors.Wrap(errors.Wrap(err, "executing sql"), message))
}
return result
}
// MustScan scans the given row and fails a test in case of any errors
func MustScan(t *testing.T, message string, row *sql.Row, args ...interface{}) {
err := row.Scan(args...)
if err != nil {
t.Fatal(errors.Wrap(errors.Wrap(err, "scanning a row"), message))
}
}
// NewDnoteCmd returns a new Dnote command and a pointer to stderr
func NewDnoteCmd(ctx infra.DnoteCtx, binaryName string, arg ...string) (*exec.Cmd, *bytes.Buffer, *bytes.Buffer, error) {
var stderr, stdout bytes.Buffer
binaryPath, err := filepath.Abs(binaryName)
if err != nil {
return &exec.Cmd{}, &stderr, &stdout, errors.Wrap(err, "getting the absolute path to the test binary")
}
cmd := exec.Command(binaryPath, arg...)
cmd.Env = []string{fmt.Sprintf("DNOTE_DIR=%s", ctx.DnoteDir), fmt.Sprintf("DNOTE_HOME_DIR=%s", ctx.HomeDir)}
cmd.Stderr = &stderr
cmd.Stdout = &stdout
return cmd, &stderr, &stdout, nil
}
// RunDnoteCmd runs a dnote command
func RunDnoteCmd(t *testing.T, ctx infra.DnoteCtx, binaryName string, arg ...string) {
t.Logf("running: %s %s", binaryName, strings.Join(arg, " "))
cmd, stderr, stdout, err := NewDnoteCmd(ctx, binaryName, arg...)
if err != nil {
t.Logf("\n%s", stdout)
t.Fatal(errors.Wrap(err, "getting command").Error())
}
cmd.Env = append(cmd.Env, "DNOTE_DEBUG=1")
if err := cmd.Run(); err != nil {
t.Logf("\n%s", stdout)
t.Fatal(errors.Wrapf(err, "running command %s", stderr.String()))
}
// Print stdout if and only if test fails later
t.Logf("\n%s", stdout)
}
// WaitDnoteCmd runs a dnote command and waits until the command is exited
func WaitDnoteCmd(t *testing.T, ctx infra.DnoteCtx, runFunc func(io.WriteCloser) error, binaryName string, arg ...string) {
t.Logf("running: %s %s", binaryName, strings.Join(arg, " "))
cmd, stderr, stdout, err := NewDnoteCmd(ctx, binaryName, arg...)
if err != nil {
t.Logf("\n%s", stdout)
t.Fatal(errors.Wrap(err, "getting command").Error())
}
stdin, err := cmd.StdinPipe()
if err != nil {
t.Logf("\n%s", stdout)
t.Fatal(errors.Wrap(err, "getting stdin %s"))
}
defer stdin.Close()
// Start the program
err = cmd.Start()
if err != nil {
t.Logf("\n%s", stdout)
t.Fatal(errors.Wrap(err, "starting command"))
}
err = runFunc(stdin)
if err != nil {
t.Logf("\n%s", stdout)
t.Fatal(errors.Wrap(err, "running with stdin"))
}
err = cmd.Wait()
if err != nil {
t.Logf("\n%s", stdout)
t.Fatal(errors.Wrapf(err, "running command %s", stderr.String()))
}
// Print stdout if and only if test fails later
t.Logf("\n%s", stdout)
}
// UserConfirm simulates confirmation from the user by writing to stdin
func UserConfirm(stdin io.WriteCloser) error {
// confirm
if _, err := io.WriteString(stdin, "y\n"); err != nil {
return errors.Wrap(err, "indicating confirmation in stdin")
}
return nil
}
// MustMarshalJSON marshalls the given interface into JSON.
// If there is any error, it fails the test.
func MustMarshalJSON(t *testing.T, v interface{}) []byte {
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("%s: marshalling data", t.Name())
}
return b
}
// MustUnmarshalJSON marshalls the given interface into JSON.
// If there is any error, it fails the test.
func MustUnmarshalJSON(t *testing.T, data []byte, v interface{}) {
err := json.Unmarshal(data, v)
if err != nil {
t.Fatalf("%s: unmarshalling data", t.Name())
}
}

View file

@ -1,79 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package testutils
import (
"github.com/dnote/dnote/cli/infra"
"testing"
)
// Setup1 sets up a dnote env #1
// dnote4.json
func Setup1(t *testing.T, ctx infra.DnoteCtx) {
db := ctx.DB
b1UUID := "js-book-uuid"
b2UUID := "linux-book-uuid"
MustExec(t, "setting up book 1", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "js")
MustExec(t, "setting up book 2", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "linux")
MustExec(t, "setting up note 1", db, "INSERT INTO notes (uuid, book_uuid, body, added_on) VALUES (?, ?, ?, ?)", "43827b9a-c2b0-4c06-a290-97991c896653", b1UUID, "Booleans have toString()", 1515199943)
}
// Setup2 sets up a dnote env #2
// dnote3.json
func Setup2(t *testing.T, ctx infra.DnoteCtx) {
db := ctx.DB
b1UUID := "js-book-uuid"
b2UUID := "linux-book-uuid"
MustExec(t, "setting up book 1", db, "INSERT INTO books (uuid, label, usn) VALUES (?, ?, ?)", b1UUID, "js", 111)
MustExec(t, "setting up book 2", db, "INSERT INTO books (uuid, label, usn) VALUES (?, ?, ?)", b2UUID, "linux", 122)
MustExec(t, "setting up note 1", db, "INSERT INTO notes (uuid, book_uuid, body, added_on, usn) VALUES (?, ?, ?, ?, ?)", "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", b1UUID, "n1 body", 1515199951, 11)
MustExec(t, "setting up note 2", db, "INSERT INTO notes (uuid, book_uuid, body, added_on, usn) VALUES (?, ?, ?, ?, ?)", "43827b9a-c2b0-4c06-a290-97991c896653", b1UUID, "n2 body", 1515199943, 12)
MustExec(t, "setting up note 3", db, "INSERT INTO notes (uuid, book_uuid, body, added_on, usn) VALUES (?, ?, ?, ?, ?)", "3e065d55-6d47-42f2-a6bf-f5844130b2d2", b2UUID, "n3 body", 1515199961, 13)
}
// Setup3 sets up a dnote env #1
// dnote1.json
func Setup3(t *testing.T, ctx infra.DnoteCtx) {
db := ctx.DB
b1UUID := "js-book-uuid"
MustExec(t, "setting up book 1", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "js")
MustExec(t, "setting up note 1", db, "INSERT INTO notes (uuid, book_uuid, body, added_on) VALUES (?, ?, ?, ?)", "43827b9a-c2b0-4c06-a290-97991c896653", b1UUID, "Booleans have toString()", 1515199943)
}
// Setup4 sets up a dnote env #1
// dnote2.json
func Setup4(t *testing.T, ctx infra.DnoteCtx) {
db := ctx.DB
b1UUID := "js-book-uuid"
MustExec(t, "setting up book 1", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "js")
MustExec(t, "setting up note 1", db, "INSERT INTO notes (rowid, uuid, book_uuid, body, added_on) VALUES (?, ?, ?, ?, ?)", 1, "43827b9a-c2b0-4c06-a290-97991c896653", b1UUID, "Booleans have toString()", 1515199943)
MustExec(t, "setting up note 2", db, "INSERT INTO notes (rowid, uuid, book_uuid, body, added_on) VALUES (?, ?, ?, ?, ?)", 2, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", b1UUID, "Date object implements mathematical comparisons", 1515199951)
}

View file

@ -1,250 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote CLI.
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
*/
package utils
import (
"bufio"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
"syscall"
"github.com/dnote/dnote/cli/infra"
"github.com/dnote/dnote/cli/log"
"github.com/pkg/errors"
"github.com/satori/go.uuid"
"golang.org/x/crypto/ssh/terminal"
)
// GenerateUUID returns a uid
func GenerateUUID() string {
return uuid.NewV4().String()
}
func getInput() (string, error) {
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString('\n')
if err != nil {
return "", errors.Wrap(err, "reading stdin")
}
return strings.Trim(input, "\r\n"), nil
}
// PromptInput prompts the user input and saves the result to the destination
func PromptInput(message string, dest *string) error {
log.Askf(message, false)
input, err := getInput()
if err != nil {
return errors.Wrap(err, "getting user input")
}
*dest = input
return nil
}
// PromptPassword prompts the user input a password and saves the result to the destination.
// The input is masked, meaning it is not echoed on the terminal.
func PromptPassword(message string, dest *string) error {
log.Askf(message, true)
password, err := terminal.ReadPassword(int(syscall.Stdin))
if err != nil {
return errors.Wrap(err, "getting user input")
}
fmt.Println("")
*dest = string(password)
return nil
}
// AskConfirmation prompts for user input to confirm a choice
func AskConfirmation(question string, optimistic bool) (bool, error) {
var choices string
if optimistic {
choices = "(Y/n)"
} else {
choices = "(y/N)"
}
message := fmt.Sprintf("%s %s", question, choices)
var input string
if err := PromptInput(message, &input); err != nil {
return false, errors.Wrap(err, "Failed to get user input")
}
confirmed := input == "y"
if optimistic {
confirmed = confirmed || input == ""
}
return confirmed, nil
}
// FileExists checks if the file exists at the given path
func FileExists(filepath string) bool {
_, err := os.Stat(filepath)
return !os.IsNotExist(err)
}
// CopyDir copies a directory from src to dest, recursively copying nested
// directories
func CopyDir(src, dest string) error {
srcPath := filepath.Clean(src)
destPath := filepath.Clean(dest)
fi, err := os.Stat(srcPath)
if err != nil {
return errors.Wrap(err, "getting the file info for the input")
}
if !fi.IsDir() {
return errors.New("source is not a directory")
}
_, err = os.Stat(dest)
if err != nil && !os.IsNotExist(err) {
return errors.Wrap(err, "looking up the destination")
}
err = os.MkdirAll(dest, fi.Mode())
if err != nil {
return errors.Wrap(err, "creating destination")
}
entries, err := ioutil.ReadDir(src)
if err != nil {
return errors.Wrap(err, "reading the directory listing for the input")
}
for _, entry := range entries {
srcEntryPath := filepath.Join(srcPath, entry.Name())
destEntryPath := filepath.Join(destPath, entry.Name())
if entry.IsDir() {
if err = CopyDir(srcEntryPath, destEntryPath); err != nil {
return errors.Wrapf(err, "copying %s", entry.Name())
}
} else {
if err = CopyFile(srcEntryPath, destEntryPath); err != nil {
return errors.Wrapf(err, "copying %s", entry.Name())
}
}
}
return nil
}
func getReq(ctx infra.DnoteCtx, path, method, body string) (*http.Request, error) {
endpoint := fmt.Sprintf("%s%s", ctx.APIEndpoint, path)
req, err := http.NewRequest(method, endpoint, strings.NewReader(body))
if err != nil {
return nil, errors.Wrap(err, "constructing http request")
}
req.Header.Set("CLI-Version", ctx.Version)
return req, nil
}
// DoAuthorizedReq does a http request to the given path in the api endpoint as a user,
// with the appropriate headers. The given path should include the preceding slash.
func DoAuthorizedReq(ctx infra.DnoteCtx, hc http.Client, method, path, body string) (*http.Response, error) {
if ctx.SessionKey == "" {
return nil, errors.New("no session key found")
}
req, err := getReq(ctx, path, method, body)
if err != nil {
return nil, errors.Wrap(err, "getting request")
}
credential := fmt.Sprintf("Bearer %s", ctx.SessionKey)
req.Header.Set("Authorization", credential)
res, err := hc.Do(req)
if err != nil {
return res, errors.Wrap(err, "making http request")
}
return res, nil
}
// DoReq does a http request to the given path in the api endpoint
func DoReq(ctx infra.DnoteCtx, method, path, body string) (*http.Response, error) {
req, err := getReq(ctx, path, method, body)
if err != nil {
return nil, errors.Wrap(err, "getting request")
}
hc := http.Client{}
res, err := hc.Do(req)
if err != nil {
return res, errors.Wrap(err, "making http request")
}
return res, nil
}
// CopyFile copies a file from the src to dest
func CopyFile(src, dest string) error {
in, err := os.Open(src)
if err != nil {
return errors.Wrap(err, "opening the input file")
}
defer in.Close()
out, err := os.Create(dest)
if err != nil {
return errors.Wrap(err, "creating the output file")
}
if _, err = io.Copy(out, in); err != nil {
return errors.Wrap(err, "copying the file content")
}
if err = out.Sync(); err != nil {
return errors.Wrap(err, "flushing the output file to disk")
}
fi, err := os.Stat(src)
if err != nil {
return errors.Wrap(err, "getting the file info for the input file")
}
if err = os.Chmod(dest, fi.Mode()); err != nil {
return errors.Wrap(err, "copying permission to the output file")
}
// Close the output file
if err = out.Close(); err != nil {
return errors.Wrap(err, "closing the output file")
}
return nil
}

43
go.mod Normal file
View file

@ -0,0 +1,43 @@
module github.com/dnote/dnote
go 1.25
require (
github.com/dnote/actions v0.2.0
github.com/fatih/color v1.18.0
github.com/google/go-cmp v0.7.0
github.com/google/go-github v17.0.0+incompatible
github.com/google/uuid v1.6.0
github.com/gorilla/csrf v1.7.3
github.com/gorilla/mux v1.8.1
github.com/gorilla/schema v1.4.1
github.com/mattn/go-sqlite3 v1.14.32
github.com/pkg/errors v0.9.1
github.com/radovskyb/watcher v1.0.7
github.com/sergi/go-diff v1.3.1
github.com/spf13/cobra v1.10.1
golang.org/x/crypto v0.45.0
golang.org/x/time v0.13.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gopkg.in/yaml.v2 v2.4.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.30.0
)
require (
github.com/google/go-querystring v1.1.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/testify v1.8.1 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
)

103
go.sum Normal file
View file

@ -0,0 +1,103 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dnote/actions v0.2.0 h1:P1ut2/QRKwfAzIIB374vN9A4IanU94C/payEocvngYo=
github.com/dnote/actions v0.2.0/go.mod h1:bBIassLhppVQdbC3iaE92SHBpM1HOVe+xZoAlj9ROxw=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY=
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/radovskyb/watcher v1.0.7 h1:AYePLih6dpmS32vlHfhCeli8127LzkIgwJGcwwe8tUE=
github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=

2
host/docker/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*.tar.gz
.env

40
host/docker/Dockerfile Normal file
View file

@ -0,0 +1,40 @@
FROM busybox:glibc
ARG TARGETPLATFORM
ARG version
RUN test -n "$TARGETPLATFORM" || (echo "TARGETPLATFORM is required" && exit 1)
RUN test -n "$version" || (echo "version is required" && exit 1)
WORKDIR /tmp/tarballs
# Copy all architecture tarballs
COPY dnote_server_*.tar.gz ./
# Select and extract the correct tarball based on target platform
RUN case "$TARGETPLATFORM" in \
"linux/amd64") ARCH="amd64" ;; \
"linux/arm64") ARCH="arm64" ;; \
"linux/arm/v7") ARCH="arm" ;; \
"linux/386") ARCH="386" ;; \
*) echo "Unsupported platform: $TARGETPLATFORM" && exit 1 ;; \
esac && \
TARBALL="dnote_server_${version}_linux_${ARCH}.tar.gz" && \
echo "Extracting $TARBALL for $TARGETPLATFORM" && \
mkdir -p /dnote && \
tar -xvzf "$TARBALL" -C /dnote
WORKDIR /dnote
# Set default database path for all processes (main server, docker exec, shells)
ENV DBPath=/data/dnote.db
COPY entrypoint.sh .
ENTRYPOINT ["./entrypoint.sh"]
CMD ./dnote-server start
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD wget --no-verbose --tries=1 -O /dev/null http://localhost:3001/health || exit 1

85
host/docker/build.sh Executable file
View file

@ -0,0 +1,85 @@
#!/usr/bin/env bash
# Build Docker image for local testing
#
# Usage:
# # builds for host platform (auto-detected)
# ./build.sh 1.0.0
#
# # builds arm64
# ./build.sh 1.0.0 linux/arm64
#
# # builds multiple platforms
# ./build.sh 1.0.0 "linux/amd64,linux/arm64,linux/arm/v7,linux/386"
set -eux
version=$1
# Detect host platform if not specified
if [ -z "${2:-}" ]; then
HOST_ARCH=$(uname -m)
case "$HOST_ARCH" in
x86_64) platform="linux/amd64" ;;
aarch64|arm64) platform="linux/arm64" ;;
armv7l) platform="linux/arm/v7" ;;
i386|i686) platform="linux/386" ;;
*)
echo "Warning: Unsupported architecture: $HOST_ARCH, defaulting to linux/amd64"
platform="linux/amd64"
;;
esac
echo "Auto-detected platform: $platform"
else
platform=$2
fi
dir=$(dirname "${BASH_SOURCE[0]}")
projectDir="$dir/../.."
# Copy all Linux tarballs to Docker build context
cp "$projectDir/build/server/dnote_server_${version}_linux_amd64.tar.gz" "$dir/"
cp "$projectDir/build/server/dnote_server_${version}_linux_arm64.tar.gz" "$dir/"
cp "$projectDir/build/server/dnote_server_${version}_linux_arm.tar.gz" "$dir/"
cp "$projectDir/build/server/dnote_server_${version}_linux_386.tar.gz" "$dir/"
# Count platforms (check for comma)
if [[ "$platform" == *","* ]]; then
echo "Building for multiple platforms: $platform"
# Check if multiarch builder exists, create if not
if ! docker buildx ls | grep -q "multiarch"; then
echo "Creating multiarch builder for multi-platform builds..."
docker buildx create --name multiarch --use
docker buildx inspect --bootstrap
else
echo "Using existing multiarch builder"
docker buildx use multiarch
fi
echo ""
docker buildx build \
--platform "$platform" \
-t dnote/dnote:"$version" \
-t dnote/dnote:latest \
--build-arg version="$version" \
"$dir"
# Switch back to default builder
docker buildx use default
else
echo "Building for single platform: $platform"
echo "Image will be loaded to local Docker daemon"
docker buildx build \
--platform "$platform" \
-t dnote/dnote:"$version" \
-t dnote/dnote:latest \
--build-arg version="$version" \
--load \
"$dir"
fi
echo ""
echo "Build complete!"
if [[ "$platform" != *","* ]]; then
echo "Test with: docker run --rm dnote/dnote:$version ./dnote-server version"
fi

9
host/docker/compose.yml Normal file
View file

@ -0,0 +1,9 @@
services:
dnote:
image: dnote/dnote:latest
container_name: dnote
ports:
- 3001:3001
volumes:
- ./dnote_data:/data
restart: unless-stopped

6
host/docker/entrypoint.sh Executable file
View file

@ -0,0 +1,6 @@
#!/bin/sh
# Set default DBPath to /data if not specified
export DBPath=${DBPath:-/data/dnote.db}
exec "$@"

View file

@ -2,7 +2,7 @@
#
# This script installs Dnote into your PATH (/usr/bin/local)
# Use it like this:
# $ curl https://raw.githubusercontent.com/dnote/dnote/master/cli/install.sh | sh
# $ curl https://raw.githubusercontent.com/dnote/dnote/master/install.sh | sh
#
set -eu
@ -68,8 +68,14 @@ uname_os() {
uname_arch() {
arch=$(uname -m)
case $arch in
case $arch in
x86_64) arch="amd64" ;;
aarch64) arch="arm64" ;;
arm64) arch="arm64" ;;
armv7l) arch="arm" ;;
armv6l) arch="arm" ;;
armv5l) arch="arm" ;;
arm) arch="arm" ;;
x86) arch="386" ;;
i686) arch="386" ;;
i386) arch="386" ;;
@ -85,8 +91,17 @@ check_platform() {
found=1
case "$platform" in
darwin/amd64) found=0;;
# Linux
linux/amd64) found=0 ;;
linux/arm64) found=0 ;;
linux/arm) found=0 ;;
# macOS
darwin/amd64) found=0 ;;
darwin/arm64) found=0 ;;
# Windows
windows/amd64) found=0 ;;
# FreeBSD
freebsd/amd64) found=0 ;;
esac
return $found
@ -113,23 +128,26 @@ hash_sha256() {
}
verify_checksum() {
binary_path=$1
filename=$2
checksums=$3
filepath=$1
checksums=$2
filename=$(basename "$filepath")
want=$(grep "${filename}" "${checksums}" 2>/dev/null | cut -d ' ' -f 1)
if [ -z "$want" ]; then
print_error "unable to find checksum for '${filename}' in '${checksums}'"
exit 1
fi
got=$(hash_sha256 "$binary_path")
got=$(hash_sha256 "$filepath")
if [ "$want" != "$got" ]; then
print_error "checksum for '$binary_path' did not verify ${want} vs $got"
print_error "checksum for '$filepath' did not verify ${want} vs $got"
exit 1
fi
}
install_dnote() {
sudo_cmd=""
os=$(uname_os)
arch=$(uname_arch)
@ -146,10 +164,22 @@ install_dnote() {
tmpdir="$(mktemp -d)"
bindir=${bindir:-/usr/local/bin}
if hash sudo 2>/dev/null; then
sudo_cmd="sudo"
echo "You need a root privilege to install Dnote binary to $bindir"
if ! is_command "$sudo_cmd"; then
print_error "command not found: sudo. You need a root privilege to continue the installation."
exit 1;
fi
fi
# create destination directory if not exists
$sudo_cmd mkdir -p "$bindir"
# get the latest version
resp=$(http_get "https://api.github.com/repos/$owner/$repo/tags")
version=$(echo "$resp" | tr ',' '\n' | grep -m 1 "\"name\": \"cli" | cut -f4 -d'"')
resp=$(http_get "https://api.github.com/repos/$owner/$repo/releases")
version=$(echo "$resp" | tr ',' '\n' | grep -m 1 "\"tag_name\": \"cli" | cut -f4 -d'"')
if [ -z "$version" ]; then
print_error "Error fetching latest version. Please try again."
@ -173,20 +203,19 @@ install_dnote() {
print_step "Downloading the checksum file for v$version"
http_download "$tmpdir/$checksum" "$checksum_url"
print_step "Comparing checksums for binaries."
verify_checksum "$tmpdir/$tarball" "$tmpdir/$checksum"
# unzip tar
print_step "Inflating the binary."
(cd "${tmpdir}" && tar -xzf "${tarball}")
print_step "Comparing checksums for binaries."
verify_checksum "${tmpdir}/${binary}" "$filename" "$tmpdir/$checksum"
install -d "${bindir}"
install "${tmpdir}/${binary}" "${bindir}/"
$sudo_cmd install -d "${bindir}"
$sudo_cmd install "${tmpdir}/${binary}" "${bindir}/"
print_success "dnote v${version} was successfully installed in $bindir."
}
exit_error() {
# shellcheck disable=SC2181
if [ "$?" -ne 0 ]; then

View file

@ -1,661 +0,0 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View file

@ -1,674 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

145
pkg/assert/assert.go Normal file
View file

@ -0,0 +1,145 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Package assert provides functions to assert a condition in tests
package assert
import (
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"runtime/debug"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/pkg/errors"
)
func getErrorMessage(m string, a, b interface{}) string {
return fmt.Sprintf(`%s.
Actual:
========================
%+v
========================
Expected:
========================
%+v
========================
%s`, m, a, b, string(debug.Stack()))
}
func checkEqual(a, b interface{}, message string) (bool, string) {
if a == b {
return true, ""
}
var m string
if len(message) == 0 {
m = fmt.Sprintf("%v != %v", a, b)
} else {
m = message
}
errorMessage := getErrorMessage(m, a, b)
return false, errorMessage
}
// Equal errors a test if the actual does not match the expected
func Equal(t *testing.T, a, b interface{}, message string) {
ok, m := checkEqual(a, b, message)
if !ok {
t.Error(m)
}
}
// Equalf fails a test if the actual does not match the expected
func Equalf(t *testing.T, a, b interface{}, message string) {
ok, m := checkEqual(a, b, message)
if !ok {
t.Fatal(m)
}
}
// NotEqual fails a test if the actual matches the expected
func NotEqual(t *testing.T, a, b interface{}, message string) {
ok, m := checkEqual(a, b, message)
if ok {
t.Error(m)
}
}
// NotEqualf fails a test if the actual matches the expected
func NotEqualf(t *testing.T, a, b interface{}, message string) {
ok, m := checkEqual(a, b, message)
if ok {
t.Fatal(m)
}
}
// DeepEqual fails a test if the actual does not deeply equal the expected
func DeepEqual(t *testing.T, a, b interface{}, message string) {
if cmp.Equal(a, b) {
return
}
if len(message) == 0 {
message = fmt.Sprintf("%v != %v", a, b)
}
errorMessage := getErrorMessage(message, a, b)
errorMessage = fmt.Sprintf("%v\n%v", errorMessage, cmp.Diff(a, b))
t.Error(errorMessage)
}
// EqualJSON asserts that two JSON strings are equal
func EqualJSON(t *testing.T, a, b, message string) {
var o1 interface{}
var o2 interface{}
err := json.Unmarshal([]byte(a), &o1)
if err != nil {
panic(fmt.Errorf("Error mashalling string 1 :: %s", err.Error()))
}
err = json.Unmarshal([]byte(b), &o2)
if err != nil {
panic(fmt.Errorf("Error mashalling string 2 :: %s", err.Error()))
}
if reflect.DeepEqual(o1, o2) {
return
}
if len(message) == 0 {
message = fmt.Sprintf("%v != %v", a, b)
}
t.Errorf("%s.\nActual: %+v.\nExpected: %+v.", message, a, b)
}
// StatusCodeEquals asserts that the reponse's status code is equal to the
// expected
func StatusCodeEquals(t *testing.T, res *http.Response, expected int, message string) {
if res.StatusCode != expected {
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(errors.Wrap(err, "reading body"))
}
t.Errorf("status code mismatch. %s: got %v want %v. Message was: '%s'", message, res.StatusCode, expected, string(body))
}
}

84
pkg/assert/prompt.go Normal file
View file

@ -0,0 +1,84 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package assert
import (
"bufio"
"io"
"strings"
"time"
"github.com/pkg/errors"
)
// WaitForPrompt waits for an expected prompt to appear in stdout with a timeout.
// Returns an error if the prompt is not found within the timeout period.
// Handles prompts with or without newlines by reading character by character.
func WaitForPrompt(stdout io.Reader, expectedPrompt string, timeout time.Duration) error {
type result struct {
found bool
err error
}
resultCh := make(chan result, 1)
go func() {
reader := bufio.NewReader(stdout)
var buffer strings.Builder
found := false
for {
b, err := reader.ReadByte()
if err != nil {
resultCh <- result{found: found, err: err}
return
}
buffer.WriteByte(b)
if strings.Contains(buffer.String(), expectedPrompt) {
found = true
break
}
}
resultCh <- result{found: found, err: nil}
}()
select {
case res := <-resultCh:
if res.err != nil && res.err != io.EOF {
return errors.Wrap(res.err, "reading stdout")
}
if !res.found {
return errors.Errorf("expected prompt '%s' not found in stdout", expectedPrompt)
}
return nil
case <-time.After(timeout):
return errors.Errorf("timeout waiting for prompt '%s'", expectedPrompt)
}
}
// RespondToPrompt is a helper that waits for a prompt and sends a response.
func RespondToPrompt(stdout io.Reader, stdin io.WriteCloser, expectedPrompt, response string, timeout time.Duration) error {
if err := WaitForPrompt(stdout, expectedPrompt, timeout); err != nil {
return err
}
if _, err := io.WriteString(stdin, response); err != nil {
return errors.Wrap(err, "writing response to stdin")
}
return nil
}

View file

@ -1,6 +1,6 @@
main
*.swo
tmp/
tmp*/
/vendor
test-dnote
/dist

View file

@ -38,35 +38,41 @@ dnote view
dnote view golang
# See details of a note
dnote view golang 12
dnote view 12
```
## dnote edit
_alias: e_
Edit a note.
Edit a note or a book.
```bash
# Launch a text editor to edit a note with the given index.
dnote edit linux 1
# Launch a text editor to edit a note with the given id.
dnote edit 12
# Edit a note with the given index in the specified book with a content.
dnote edit linux 1 -c "New Content"
# Edit a note with the given id in the specified book with a content.
dnote edit 12 -c "New Content"
# Launch a text editor to edit a book name.
dnote edit js
# Edit a book name by using a flag.
dnote edit js -n "javascript"
```
## dnote remove
_alias: d_
_alias: rm, d_
Remove either a note or a book.
```bash
# Remove the note with `index` in the specified book.
dnote remove JS 1
# Remove a note with an id.
dnote remove 1
# Remove the book with the `book name`.
dnote remove -b JS
# Remove a book with the `book name`.
dnote remove js
```
## dnote find
@ -88,20 +94,14 @@ dnote find "merge sort" -b algorithm
## dnote sync
_Dnote Pro only_
_alias: s_
Sync notes with Dnote server. All your data is encrypted before being sent to the server.
Sync notes with Dnote server.
## dnote login
_Dnote Pro only_
Start a login prompt.
## dnote logout
_Dnote Pro only_
Log out of Dnote.

1
pkg/cli/README.md Normal file
View file

@ -0,0 +1 @@
This directory contains the command line interface project for Dnote.

View file

@ -1,19 +1,16 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
/* Copyright 2025 Dnote Authors
*
* This file is part of Dnote CLI.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* http://www.apache.org/licenses/LICENSE-2.0
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Package client provides interfaces for interacting with the Dnote server
@ -23,22 +20,190 @@ package client
import (
"encoding/json"
"fmt"
"io/ioutil"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/dnote/dnote/cli/crypt"
"github.com/dnote/dnote/cli/infra"
"github.com/dnote/dnote/cli/utils"
"github.com/dnote/dnote/pkg/cli/context"
"github.com/dnote/dnote/pkg/cli/log"
"github.com/pkg/errors"
"golang.org/x/time/rate"
)
// ErrInvalidLogin is an error for invalid credentials for login
var ErrInvalidLogin = errors.New("wrong credentials")
// ErrContentTypeMismatch is an error for invalid credentials for login
var ErrContentTypeMismatch = errors.New("content type mismatch")
// HTTPError represents an HTTP error response from the server
type HTTPError struct {
StatusCode int
Message string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf(`response %d "%s"`, e.StatusCode, e.Message)
}
// IsConflict returns true if the error is a 409 Conflict error
func (e *HTTPError) IsConflict() bool {
return e.StatusCode == 409
}
var contentTypeApplicationJSON = "application/json"
var contentTypeNone = ""
// requestOptions contains options for requests
type requestOptions struct {
HTTPClient *http.Client
// ExpectedContentType is the Content-Type that the client is expecting from the server
ExpectedContentType *string
}
const (
// clientRateLimitPerSecond is the max requests per second the client will make
clientRateLimitPerSecond = 50
// clientRateLimitBurst is the burst capacity for rate limiting
clientRateLimitBurst = 100
)
// rateLimitedTransport wraps an http.RoundTripper with rate limiting
type rateLimitedTransport struct {
transport http.RoundTripper
limiter *rate.Limiter
}
func (t *rateLimitedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Wait for rate limiter to allow the request
if err := t.limiter.Wait(req.Context()); err != nil {
return nil, err
}
return t.transport.RoundTrip(req)
}
// NewRateLimitedHTTPClient creates an HTTP client with rate limiting
func NewRateLimitedHTTPClient() *http.Client {
// Calculate interval from rate: 1 second / requests per second
interval := time.Second / time.Duration(clientRateLimitPerSecond)
transport := &rateLimitedTransport{
transport: http.DefaultTransport,
limiter: rate.NewLimiter(rate.Every(interval), clientRateLimitBurst),
}
return &http.Client{
Transport: transport,
}
}
func getHTTPClient(ctx context.DnoteCtx, options *requestOptions) *http.Client {
if options != nil && options.HTTPClient != nil {
return options.HTTPClient
}
if ctx.HTTPClient != nil {
return ctx.HTTPClient
}
return &http.Client{}
}
func getExpectedContentType(options *requestOptions) string {
if options != nil && options.ExpectedContentType != nil {
return *options.ExpectedContentType
}
return contentTypeApplicationJSON
}
func getReq(ctx context.DnoteCtx, path, method, body string) (*http.Request, error) {
endpoint := fmt.Sprintf("%s%s", ctx.APIEndpoint, path)
req, err := http.NewRequest(method, endpoint, strings.NewReader(body))
if err != nil {
return nil, errors.Wrap(err, "constructing http request")
}
req.Header.Set("CLI-Version", ctx.Version)
if ctx.SessionKey != "" {
credential := fmt.Sprintf("Bearer %s", ctx.SessionKey)
req.Header.Set("Authorization", credential)
}
return req, nil
}
// checkRespErr checks if the given http response indicates an error. It returns a boolean indicating
// if the response is an error, and a decoded error message.
func checkRespErr(res *http.Response) error {
if res.StatusCode < 400 {
return nil
}
body, err := io.ReadAll(res.Body)
if err != nil {
return errors.Wrapf(err, "server responded with %d but client could not read the response body", res.StatusCode)
}
bodyStr := string(body)
return &HTTPError{
StatusCode: res.StatusCode,
Message: strings.TrimRight(bodyStr, "\n"),
}
}
func checkContentType(res *http.Response, options *requestOptions) error {
expected := getExpectedContentType(options)
got := res.Header.Get("Content-Type")
if got != expected {
return errors.Wrapf(ErrContentTypeMismatch, "got: '%s' want: '%s'. Did you configure your endpoint correctly?", got, expected)
}
return nil
}
// doReq does a http request to the given path in the api endpoint
func doReq(ctx context.DnoteCtx, method, path, body string, options *requestOptions) (*http.Response, error) {
req, err := getReq(ctx, path, method, body)
if err != nil {
return nil, errors.Wrap(err, "getting request")
}
log.Debug("HTTP %s %s\n", method, path)
hc := getHTTPClient(ctx, options)
res, err := hc.Do(req)
if err != nil {
return res, errors.Wrap(err, "making http request")
}
log.Debug("HTTP %d %s\n", res.StatusCode, res.Status)
if err = checkRespErr(res); err != nil {
return res, errors.Wrap(err, "server responded with an error")
}
if err = checkContentType(res, options); err != nil {
return res, errors.Wrap(err, "unexpected Content-Type")
}
return res, nil
}
// doAuthorizedReq does a http request to the given path in the api endpoint as a user,
// with the appropriate headers. The given path should include the preceding slash.
func doAuthorizedReq(ctx context.DnoteCtx, method, path, body string, options *requestOptions) (*http.Response, error) {
if ctx.SessionKey == "" {
return nil, errors.New("no session key found")
}
return doReq(ctx, method, path, body, options)
}
// GetSyncStateResp is the response get sync state endpoint
type GetSyncStateResp struct {
FullSyncBefore int `json:"full_sync_before"`
@ -47,16 +212,15 @@ type GetSyncStateResp struct {
}
// GetSyncState gets the sync state response from the server
func GetSyncState(ctx infra.DnoteCtx) (GetSyncStateResp, error) {
func GetSyncState(ctx context.DnoteCtx) (GetSyncStateResp, error) {
var ret GetSyncStateResp
hc := http.Client{}
res, err := utils.DoAuthorizedReq(ctx, hc, "GET", "/v1/sync/state", "")
res, err := doAuthorizedReq(ctx, "GET", "/v3/sync/state", "", nil)
if err != nil {
return ret, errors.Wrap(err, "constructing http request")
}
body, err := ioutil.ReadAll(res.Body)
body, err := io.ReadAll(res.Body)
if err != nil {
return ret, errors.Wrap(err, "reading the response body")
}
@ -79,7 +243,6 @@ type SyncFragNote struct {
AddedOn int64 `json:"added_on"`
EditedOn int64 `json:"edited_on"`
Body string `json:"content"`
Public bool `json:"public"`
Deleted bool `json:"deleted"`
}
@ -112,16 +275,18 @@ type GetSyncFragmentResp struct {
}
// GetSyncFragment gets a sync fragment response from the server
func GetSyncFragment(ctx infra.DnoteCtx, afterUSN int) (GetSyncFragmentResp, error) {
func GetSyncFragment(ctx context.DnoteCtx, afterUSN int) (GetSyncFragmentResp, error) {
v := url.Values{}
v.Set("after_usn", strconv.Itoa(afterUSN))
queryStr := v.Encode()
path := fmt.Sprintf("/v1/sync/fragment?%s", queryStr)
hc := http.Client{}
res, err := utils.DoAuthorizedReq(ctx, hc, "GET", path, "")
path := fmt.Sprintf("/v3/sync/fragment?%s", queryStr)
res, err := doAuthorizedReq(ctx, "GET", path, "", nil)
if err != nil {
return GetSyncFragmentResp{}, errors.Wrap(err, "making the request")
}
body, err := ioutil.ReadAll(res.Body)
body, err := io.ReadAll(res.Body)
if err != nil {
return GetSyncFragmentResp{}, errors.Wrap(err, "reading the response body")
}
@ -154,52 +319,21 @@ type CreateBookResp struct {
Book RespBook `json:"book"`
}
// checkRespErr checks if the given http response indicates an error. It returns a boolean indicating
// if the response is an error, and a decoded error message.
func checkRespErr(res *http.Response) (bool, string, error) {
if res.StatusCode < 400 {
return false, "", nil
}
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return true, "", errors.Wrapf(err, "server responded with %d but could not read the response body", res.StatusCode)
}
bodyStr := string(body)
message := fmt.Sprintf(`response %d "%s"`, res.StatusCode, strings.TrimRight(bodyStr, "\n"))
return true, message, nil
}
// CreateBook creates a new book in the server
func CreateBook(ctx infra.DnoteCtx, label string) (CreateBookResp, error) {
encLabel, err := crypt.AesGcmEncrypt(ctx.CipherKey, []byte(label))
if err != nil {
return CreateBookResp{}, errors.Wrap(err, "encrypting the label")
}
func CreateBook(ctx context.DnoteCtx, label string) (CreateBookResp, error) {
payload := CreateBookPayload{
Name: encLabel,
Name: label,
}
b, err := json.Marshal(payload)
if err != nil {
return CreateBookResp{}, errors.Wrap(err, "marshaling payload")
}
hc := http.Client{}
res, err := utils.DoAuthorizedReq(ctx, hc, "POST", "/v2/books", string(b))
res, err := doAuthorizedReq(ctx, "POST", "/v3/books", string(b), nil)
if err != nil {
return CreateBookResp{}, errors.Wrap(err, "posting a book to the server")
}
hasErr, message, err := checkRespErr(res)
if err != nil {
return CreateBookResp{}, errors.Wrap(err, "checking repsonse error")
}
if hasErr {
return CreateBookResp{}, errors.New(message)
}
var resp CreateBookResp
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
return resp, errors.Wrap(err, "decoding response payload")
@ -218,35 +352,21 @@ type UpdateBookResp struct {
}
// UpdateBook updates a book in the server
func UpdateBook(ctx infra.DnoteCtx, label, uuid string) (UpdateBookResp, error) {
encName, err := crypt.AesGcmEncrypt(ctx.CipherKey, []byte(label))
if err != nil {
return UpdateBookResp{}, errors.Wrap(err, "encrypting the content")
}
func UpdateBook(ctx context.DnoteCtx, label, uuid string) (UpdateBookResp, error) {
payload := updateBookPayload{
Name: &encName,
Name: &label,
}
b, err := json.Marshal(payload)
if err != nil {
return UpdateBookResp{}, errors.Wrap(err, "marshaling payload")
}
hc := http.Client{}
endpoint := fmt.Sprintf("/v1/books/%s", uuid)
res, err := utils.DoAuthorizedReq(ctx, hc, "PATCH", endpoint, string(b))
endpoint := fmt.Sprintf("/v3/books/%s", uuid)
res, err := doAuthorizedReq(ctx, "PATCH", endpoint, string(b), nil)
if err != nil {
return UpdateBookResp{}, errors.Wrap(err, "posting a book to the server")
}
hasErr, message, err := checkRespErr(res)
if err != nil {
return UpdateBookResp{}, errors.Wrap(err, "checking repsonse error")
}
if hasErr {
return UpdateBookResp{}, errors.New(message)
}
var resp UpdateBookResp
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
return resp, errors.Wrap(err, "decoding payload")
@ -262,22 +382,13 @@ type DeleteBookResp struct {
}
// DeleteBook deletes a book in the server
func DeleteBook(ctx infra.DnoteCtx, uuid string) (DeleteBookResp, error) {
hc := http.Client{}
endpoint := fmt.Sprintf("/v1/books/%s", uuid)
res, err := utils.DoAuthorizedReq(ctx, hc, "DELETE", endpoint, "")
func DeleteBook(ctx context.DnoteCtx, uuid string) (DeleteBookResp, error) {
endpoint := fmt.Sprintf("/v3/books/%s", uuid)
res, err := doAuthorizedReq(ctx, "DELETE", endpoint, "", nil)
if err != nil {
return DeleteBookResp{}, errors.Wrap(err, "deleting a book in the server")
}
hasErr, message, err := checkRespErr(res)
if err != nil {
return DeleteBookResp{}, errors.Wrap(err, "checking repsonse error")
}
if hasErr {
return DeleteBookResp{}, errors.New(message)
}
var resp DeleteBookResp
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
return resp, errors.Wrap(err, "decoding the response")
@ -320,35 +431,21 @@ type RespNote struct {
}
// CreateNote creates a note in the server
func CreateNote(ctx infra.DnoteCtx, bookUUID, content string) (CreateNoteResp, error) {
encBody, err := crypt.AesGcmEncrypt(ctx.CipherKey, []byte(content))
if err != nil {
return CreateNoteResp{}, errors.Wrap(err, "encrypting the content")
}
func CreateNote(ctx context.DnoteCtx, bookUUID, content string) (CreateNoteResp, error) {
payload := CreateNotePayload{
BookUUID: bookUUID,
Body: encBody,
Body: content,
}
b, err := json.Marshal(payload)
if err != nil {
return CreateNoteResp{}, errors.Wrap(err, "marshaling payload")
}
hc := http.Client{}
res, err := utils.DoAuthorizedReq(ctx, hc, "POST", "/v2/notes", string(b))
res, err := doAuthorizedReq(ctx, "POST", "/v3/notes", string(b), nil)
if err != nil {
return CreateNoteResp{}, errors.Wrap(err, "posting a book to the server")
}
hasErr, message, err := checkRespErr(res)
if err != nil {
return CreateNoteResp{}, errors.Wrap(err, "checking repsonse error")
}
if hasErr {
return CreateNoteResp{}, errors.New(message)
}
var resp CreateNoteResp
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
return CreateNoteResp{}, errors.Wrap(err, "decoding payload")
@ -360,7 +457,6 @@ func CreateNote(ctx infra.DnoteCtx, bookUUID, content string) (CreateNoteResp, e
type updateNotePayload struct {
BookUUID *string `json:"book_uuid"`
Body *string `json:"content"`
Public *bool `json:"public"`
}
// UpdateNoteResp is the response from create book api
@ -370,37 +466,22 @@ type UpdateNoteResp struct {
}
// UpdateNote updates a note in the server
func UpdateNote(ctx infra.DnoteCtx, uuid, bookUUID, content string, public bool) (UpdateNoteResp, error) {
encBody, err := crypt.AesGcmEncrypt(ctx.CipherKey, []byte(content))
if err != nil {
return UpdateNoteResp{}, errors.Wrap(err, "encrypting the content")
}
func UpdateNote(ctx context.DnoteCtx, uuid, bookUUID, content string) (UpdateNoteResp, error) {
payload := updateNotePayload{
BookUUID: &bookUUID,
Body: &encBody,
Public: &public,
Body: &content,
}
b, err := json.Marshal(payload)
if err != nil {
return UpdateNoteResp{}, errors.Wrap(err, "marshaling payload")
}
hc := http.Client{}
endpoint := fmt.Sprintf("/v1/notes/%s", uuid)
res, err := utils.DoAuthorizedReq(ctx, hc, "PATCH", endpoint, string(b))
endpoint := fmt.Sprintf("/v3/notes/%s", uuid)
res, err := doAuthorizedReq(ctx, "PATCH", endpoint, string(b), nil)
if err != nil {
return UpdateNoteResp{}, errors.Wrap(err, "patching a note to the server")
}
hasErr, message, err := checkRespErr(res)
if err != nil {
return UpdateNoteResp{}, errors.Wrap(err, "checking repsonse error")
}
if hasErr {
return UpdateNoteResp{}, errors.New(message)
}
var resp UpdateNoteResp
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
return UpdateNoteResp{}, errors.Wrap(err, "decoding payload")
@ -416,22 +497,13 @@ type DeleteNoteResp struct {
}
// DeleteNote removes a note in the server
func DeleteNote(ctx infra.DnoteCtx, uuid string) (DeleteNoteResp, error) {
hc := http.Client{}
endpoint := fmt.Sprintf("/v1/notes/%s", uuid)
res, err := utils.DoAuthorizedReq(ctx, hc, "DELETE", endpoint, "")
func DeleteNote(ctx context.DnoteCtx, uuid string) (DeleteNoteResp, error) {
endpoint := fmt.Sprintf("/v3/notes/%s", uuid)
res, err := doAuthorizedReq(ctx, "DELETE", endpoint, "", nil)
if err != nil {
return DeleteNoteResp{}, errors.Wrap(err, "patching a note to the server")
}
hasErr, message, err := checkRespErr(res)
if err != nil {
return DeleteNoteResp{}, errors.Wrap(err, "checking repsonse error")
}
if hasErr {
return DeleteNoteResp{}, errors.New(message)
}
var resp DeleteNoteResp
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
return DeleteNoteResp{}, errors.Wrap(err, "decoding payload")
@ -447,21 +519,12 @@ type GetBooksResp []struct {
}
// GetBooks gets books from the server
func GetBooks(ctx infra.DnoteCtx, sessionKey string) (GetBooksResp, error) {
hc := http.Client{}
res, err := utils.DoAuthorizedReq(ctx, hc, "GET", "/v1/books", "")
func GetBooks(ctx context.DnoteCtx, sessionKey string) (GetBooksResp, error) {
res, err := doAuthorizedReq(ctx, "GET", "/v3/books", "", nil)
if err != nil {
return GetBooksResp{}, errors.Wrap(err, "making http request")
}
hasErr, message, err := checkRespErr(res)
if err != nil {
return GetBooksResp{}, errors.Wrap(err, "checking repsonse error")
}
if hasErr {
return GetBooksResp{}, errors.New(message)
}
var resp GetBooksResp
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
return GetBooksResp{}, errors.Wrap(err, "decoding payload")
@ -470,26 +533,18 @@ func GetBooks(ctx infra.DnoteCtx, sessionKey string) (GetBooksResp, error) {
return resp, nil
}
// PresigninResponse is a reponse from /v1/presignin endpoint
// PresigninResponse is a reponse from /v3/presignin endpoint
type PresigninResponse struct {
Iteration int `json:"iteration"`
}
// GetPresignin gets presignin credentials
func GetPresignin(ctx infra.DnoteCtx, email string) (PresigninResponse, error) {
res, err := utils.DoReq(ctx, "GET", fmt.Sprintf("/v1/presignin?email=%s", email), "")
func GetPresignin(ctx context.DnoteCtx, email string) (PresigninResponse, error) {
res, err := doReq(ctx, "GET", fmt.Sprintf("/v3/presignin?email=%s", email), "", nil)
if err != nil {
return PresigninResponse{}, errors.Wrap(err, "making http request")
}
hasErr, message, err := checkRespErr(res)
if err != nil {
return PresigninResponse{}, errors.Wrap(err, "checking repsonse error")
}
if hasErr {
return PresigninResponse{}, errors.New(message)
}
var resp PresigninResponse
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
return PresigninResponse{}, errors.Wrap(err, "decoding payload")
@ -498,46 +553,38 @@ func GetPresignin(ctx infra.DnoteCtx, email string) (PresigninResponse, error) {
return resp, nil
}
// SigninPayload is a payload for /v1/signin
// SigninPayload is a payload for /v3/signin
type SigninPayload struct {
Email string `json:"email"`
AuthKey string `json:"auth_key"`
Email string `json:"email"`
Passowrd string `json:"password"`
}
// SigninResponse is a response from /v1/signin endpoint
// SigninResponse is a response from /v3/signin endpoint
type SigninResponse struct {
Key string `json:"key"`
ExpiresAt int64 `json:"expires_at"`
CipherKeyEnc string `json:"cipher_key_enc"`
Key string `json:"key"`
ExpiresAt int64 `json:"expires_at"`
}
// Signin requests a session token
func Signin(ctx infra.DnoteCtx, email, authKey string) (SigninResponse, error) {
func Signin(ctx context.DnoteCtx, email, password string) (SigninResponse, error) {
payload := SigninPayload{
Email: email,
AuthKey: authKey,
Email: email,
Passowrd: password,
}
b, err := json.Marshal(payload)
if err != nil {
return SigninResponse{}, errors.Wrap(err, "marshaling payload")
}
res, err := utils.DoReq(ctx, "POST", "/v1/signin", string(b))
res, err := doReq(ctx, "POST", "/v3/signin", string(b), nil)
if err != nil {
// Check if this is a 401 Unauthorized error
var httpErr *HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusUnauthorized {
return SigninResponse{}, ErrInvalidLogin
}
return SigninResponse{}, errors.Wrap(err, "making http request")
}
if res.StatusCode == http.StatusUnauthorized {
return SigninResponse{}, ErrInvalidLogin
}
hasErr, message, err := checkRespErr(res)
if err != nil {
return SigninResponse{}, errors.Wrap(err, "checking repsonse error")
}
if hasErr {
return SigninResponse{}, errors.New(message)
}
var resp SigninResponse
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
return SigninResponse{}, errors.Wrap(err, "decoding payload")
@ -547,26 +594,34 @@ func Signin(ctx infra.DnoteCtx, email, authKey string) (SigninResponse, error) {
}
// Signout deletes a user session on the server side
func Signout(ctx infra.DnoteCtx, sessionKey string) error {
hc := http.Client{
// No need to follow redirect
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
func Signout(ctx context.DnoteCtx, sessionKey string) error {
// Create a client that shares the transport (and thus rate limiter) from ctx.HTTPClient
// but doesn't follow redirects
var hc *http.Client
if ctx.HTTPClient != nil {
hc = &http.Client{
Transport: ctx.HTTPClient.Transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
} else {
log.Warnf("No HTTP client configured for signout - falling back\n")
hc = &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
res, err := utils.DoAuthorizedReq(ctx, hc, "POST", "/v1/signout", "")
opts := requestOptions{
HTTPClient: hc,
ExpectedContentType: &contentTypeNone,
}
_, err := doAuthorizedReq(ctx, "POST", "/v3/signout", "", &opts)
if err != nil {
return errors.Wrap(err, "making http request")
}
hasErr, message, err := checkRespErr(res)
if err != nil {
return errors.Wrap(err, "checking repsonse error")
}
if hasErr {
return errors.New(message)
}
return nil
}

View file

@ -0,0 +1,234 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package client
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/dnote/dnote/pkg/assert"
"github.com/dnote/dnote/pkg/cli/context"
"github.com/dnote/dnote/pkg/cli/testutils"
"github.com/pkg/errors"
"golang.org/x/time/rate"
)
// startCommonTestServer starts a test HTTP server that simulates a common set of senarios
func startCommonTestServer() *httptest.Server {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// internal server error
if r.URL.String() == "/bad-api/v3/signout" && r.Method == "POST" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
return
}
// catch-all
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<html><body><div id="app-root"></div></body></html>`))
}))
return ts
}
func TestSignIn(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.String() == "/api/v3/signin" && r.Method == "POST" {
var payload SigninPayload
err := json.NewDecoder(r.Body).Decode(&payload)
if err != nil {
t.Fatal(errors.Wrap(err, "decoding payload in the test server").Error())
return
}
if payload.Email == "alice@example.com" && payload.Passowrd == "pass1234" {
resp := testutils.MustMarshalJSON(t, SigninResponse{
Key: "somekey",
ExpiresAt: int64(1596439890),
})
w.Header().Set("Content-Type", "application/json")
w.Write(resp)
} else {
w.WriteHeader(http.StatusUnauthorized)
}
return
}
}))
defer ts.Close()
commonTs := startCommonTestServer()
defer commonTs.Close()
correctEndpoint := fmt.Sprintf("%s/api", ts.URL)
testClient := NewRateLimitedHTTPClient()
t.Run("success", func(t *testing.T) {
result, err := Signin(context.DnoteCtx{APIEndpoint: correctEndpoint, HTTPClient: testClient}, "alice@example.com", "pass1234")
if err != nil {
t.Errorf("got signin request error: %+v", err.Error())
}
assert.Equal(t, result.Key, "somekey", "Key mismatch")
assert.Equal(t, result.ExpiresAt, int64(1596439890), "ExpiresAt mismatch")
})
t.Run("failure", func(t *testing.T) {
result, err := Signin(context.DnoteCtx{APIEndpoint: correctEndpoint, HTTPClient: testClient}, "alice@example.com", "incorrectpassword")
assert.Equal(t, err, ErrInvalidLogin, "err mismatch")
assert.Equal(t, result.Key, "", "Key mismatch")
assert.Equal(t, result.ExpiresAt, int64(0), "ExpiresAt mismatch")
})
t.Run("server error", func(t *testing.T) {
endpoint := fmt.Sprintf("%s/bad-api", ts.URL)
result, err := Signin(context.DnoteCtx{APIEndpoint: endpoint, HTTPClient: testClient}, "alice@example.com", "pass1234")
if err == nil {
t.Error("error should have been returned")
}
assert.Equal(t, result.Key, "", "Key mismatch")
assert.Equal(t, result.ExpiresAt, int64(0), "ExpiresAt mismatch")
})
t.Run("accidentally pointing to a catch-all handler", func(t *testing.T) {
endpoint := fmt.Sprintf("%s", ts.URL)
result, err := Signin(context.DnoteCtx{APIEndpoint: endpoint, HTTPClient: testClient}, "alice@example.com", "pass1234")
assert.Equal(t, errors.Cause(err), ErrContentTypeMismatch, "error cause mismatch")
assert.Equal(t, result.Key, "", "Key mismatch")
assert.Equal(t, result.ExpiresAt, int64(0), "ExpiresAt mismatch")
})
t.Run("network error", func(t *testing.T) {
// Use an invalid endpoint that will fail to connect
endpoint := "http://localhost:99999/api"
result, err := Signin(context.DnoteCtx{APIEndpoint: endpoint, HTTPClient: testClient}, "alice@example.com", "pass1234")
if err == nil {
t.Error("error should have been returned for network failure")
}
assert.Equal(t, result.Key, "", "Key mismatch")
assert.Equal(t, result.ExpiresAt, int64(0), "ExpiresAt mismatch")
})
}
func TestSignOut(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.String() == "/api/v3/signout" && r.Method == "POST" {
w.WriteHeader(http.StatusNoContent)
}
}))
defer ts.Close()
commonTs := startCommonTestServer()
defer commonTs.Close()
correctEndpoint := fmt.Sprintf("%s/api", ts.URL)
testClient := NewRateLimitedHTTPClient()
t.Run("success", func(t *testing.T) {
err := Signout(context.DnoteCtx{SessionKey: "somekey", APIEndpoint: correctEndpoint, HTTPClient: testClient}, "alice@example.com")
if err != nil {
t.Errorf("got signout request error: %+v", err.Error())
}
})
t.Run("server error", func(t *testing.T) {
endpoint := fmt.Sprintf("%s/bad-api", commonTs.URL)
err := Signout(context.DnoteCtx{SessionKey: "somekey", APIEndpoint: endpoint, HTTPClient: testClient}, "alice@example.com")
if err == nil {
t.Error("error should have been returned")
}
})
t.Run("accidentally pointing to a catch-all handler", func(t *testing.T) {
endpoint := fmt.Sprintf("%s", commonTs.URL)
err := Signout(context.DnoteCtx{SessionKey: "somekey", APIEndpoint: endpoint, HTTPClient: testClient}, "alice@example.com")
assert.Equal(t, errors.Cause(err), ErrContentTypeMismatch, "error cause mismatch")
})
// Gracefully handle a case where http client was not initialized in the context.
t.Run("nil HTTPClient", func(t *testing.T) {
err := Signout(context.DnoteCtx{SessionKey: "somekey", APIEndpoint: correctEndpoint}, "alice@example.com")
if err != nil {
t.Errorf("got signout request error: %+v", err.Error())
}
})
}
func TestRateLimitedTransport(t *testing.T) {
var requestCount atomic.Int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount.Add(1)
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
transport := &rateLimitedTransport{
transport: http.DefaultTransport,
limiter: rate.NewLimiter(10, 5),
}
client := &http.Client{Transport: transport}
// Make 10 requests
start := time.Now()
numRequests := 10
for i := range numRequests {
req, _ := http.NewRequest("GET", ts.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Request %d failed: %v", i, err)
}
resp.Body.Close()
}
elapsed := time.Since(start)
// Burst of 5, then 5 more at 10 req/s = 500ms minimum
if elapsed < 500*time.Millisecond {
t.Errorf("Rate limit not enforced: 10 requests took %v, expected >= 500ms", elapsed)
}
assert.Equal(t, int(requestCount.Load()), 10, "request count mismatch")
}
func TestHTTPError(t *testing.T) {
t.Run("IsConflict returns true for 409", func(t *testing.T) {
conflictErr := &HTTPError{
StatusCode: 409,
Message: "Conflict",
}
assert.Equal(t, conflictErr.IsConflict(), true, "IsConflict() should return true for 409")
notFoundErr := &HTTPError{
StatusCode: 404,
Message: "Not Found",
}
assert.Equal(t, notFoundErr.IsConflict(), false, "IsConflict() should return false for 404")
})
}

198
pkg/cli/cmd/add/add.go Normal file
View file

@ -0,0 +1,198 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package add
import (
"database/sql"
"time"
"os"
"github.com/dnote/dnote/pkg/cli/context"
"github.com/dnote/dnote/pkg/cli/database"
"github.com/dnote/dnote/pkg/cli/infra"
"github.com/dnote/dnote/pkg/cli/log"
"github.com/dnote/dnote/pkg/cli/output"
"github.com/dnote/dnote/pkg/cli/ui"
"github.com/dnote/dnote/pkg/cli/upgrade"
"github.com/dnote/dnote/pkg/cli/utils"
"github.com/dnote/dnote/pkg/cli/validate"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var contentFlag string
var example = `
* Open an editor to write content
dnote add git
* Skip the editor by providing content directly
dnote add git -c "time is a part of the commit hash"
* Send stdin content to a note
echo "a branch is just a pointer to a commit" | dnote add git
# or
dnote add git << EOF
pull is fetch with a merge
EOF`
func preRun(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
return errors.New("Incorrect number of argument")
}
return nil
}
// NewCmd returns a new add command
func NewCmd(ctx context.DnoteCtx) *cobra.Command {
cmd := &cobra.Command{
Use: "add <book>",
Short: "Add a new note",
Aliases: []string{"a", "n", "new"},
Example: example,
PreRunE: preRun,
RunE: newRun(ctx),
}
f := cmd.Flags()
f.StringVarP(&contentFlag, "content", "c", "", "The new content for the note")
return cmd
}
func getContent(ctx context.DnoteCtx) (string, error) {
if contentFlag != "" {
return contentFlag, nil
}
// check for piped content
fInfo, _ := os.Stdin.Stat()
if fInfo.Mode() & os.ModeCharDevice == 0 {
c, err := ui.ReadStdInput()
if err != nil {
return "", errors.Wrap(err, "Failed to get piped input")
}
return c, nil
}
fpath, err := ui.GetTmpContentPath(ctx)
if err != nil {
return "", errors.Wrap(err, "getting temporarily content file path")
}
c, err := ui.GetEditorInput(ctx, fpath)
if err != nil {
return "", errors.Wrap(err, "Failed to get editor input")
}
return c, nil
}
func newRun(ctx context.DnoteCtx) infra.RunEFunc {
return func(cmd *cobra.Command, args []string) error {
bookName := args[0]
if err := validate.BookName(bookName); err != nil {
return errors.Wrap(err, "invalid book name")
}
content, err := getContent(ctx)
if err != nil {
return errors.Wrap(err, "getting content")
}
if content == "" {
return errors.New("Empty content")
}
ts := time.Now().UnixNano()
noteRowID, err := writeNote(ctx, bookName, content, ts)
if err != nil {
return errors.Wrap(err, "Failed to write note")
}
log.Successf("added to %s\n", bookName)
db := ctx.DB
info, err := database.GetNoteInfo(db, noteRowID)
if err != nil {
return err
}
output.NoteInfo(os.Stdout, info)
if err := upgrade.Check(ctx); err != nil {
log.Error(errors.Wrap(err, "automatically checking updates").Error())
}
return nil
}
}
func writeNote(ctx context.DnoteCtx, bookLabel string, content string, ts int64) (int, error) {
tx, err := ctx.DB.Begin()
if err != nil {
return 0, errors.Wrap(err, "beginning a transaction")
}
var bookUUID string
err = tx.QueryRow("SELECT uuid FROM books WHERE label = ?", bookLabel).Scan(&bookUUID)
if err == sql.ErrNoRows {
bookUUID, err = utils.GenerateUUID()
if err != nil {
return 0, errors.Wrap(err, "generating uuid")
}
b := database.NewBook(bookUUID, bookLabel, 0, false, true)
err = b.Insert(tx)
if err != nil {
tx.Rollback()
return 0, errors.Wrap(err, "creating the book")
}
} else if err != nil {
return 0, errors.Wrap(err, "finding the book")
}
noteUUID, err := utils.GenerateUUID()
if err != nil {
return 0, errors.Wrap(err, "generating uuid")
}
n := database.NewNote(noteUUID, bookUUID, content, ts, 0, 0, false, true)
err = n.Insert(tx)
if err != nil {
tx.Rollback()
return 0, errors.Wrap(err, "creating the note")
}
var noteRowID int
err = tx.QueryRow(`SELECT notes.rowid
FROM notes
WHERE notes.uuid = ?`, noteUUID).
Scan(&noteRowID)
if err != nil {
tx.Rollback()
return noteRowID, errors.Wrap(err, "getting the note rowid")
}
err = tx.Commit()
if err != nil {
tx.Rollback()
return noteRowID, errors.Wrap(err, "committing a transaction")
}
return noteRowID, nil
}

121
pkg/cli/cmd/edit/book.go Normal file
View file

@ -0,0 +1,121 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package edit
import (
"strings"
"github.com/dnote/dnote/pkg/cli/context"
"github.com/dnote/dnote/pkg/cli/database"
"github.com/dnote/dnote/pkg/cli/log"
"github.com/dnote/dnote/pkg/cli/output"
"github.com/dnote/dnote/pkg/cli/ui"
"github.com/dnote/dnote/pkg/cli/validate"
"github.com/pkg/errors"
)
func validateRunBookFlags() error {
if contentFlag != "" {
return errors.New("--content is invalid for editing a book")
}
if bookFlag != "" {
return errors.New("--book is invalid for editing a book")
}
return nil
}
func waitEditorBookName(ctx context.DnoteCtx) (string, error) {
fpath, err := ui.GetTmpContentPath(ctx)
if err != nil {
return "", errors.Wrap(err, "getting temporarily content file path")
}
c, err := ui.GetEditorInput(ctx, fpath)
if err != nil {
return "", errors.Wrap(err, "getting editor input")
}
// remove the newline at the end because files end with linebreaks in POSIX
c = strings.TrimSuffix(c, "\n")
c = strings.TrimSuffix(c, "\r\n")
return c, nil
}
func getName(ctx context.DnoteCtx) (string, error) {
if nameFlag != "" {
return nameFlag, nil
}
c, err := waitEditorBookName(ctx)
if err != nil {
return "", errors.Wrap(err, "Failed to get editor input")
}
return c, nil
}
func runBook(ctx context.DnoteCtx, bookName string) error {
err := validateRunBookFlags()
if err != nil {
return errors.Wrap(err, "validating flags.")
}
db := ctx.DB
uuid, err := database.GetBookUUID(db, bookName)
if err != nil {
return errors.Wrap(err, "getting book uuid")
}
name, err := getName(ctx)
if err != nil {
return errors.Wrap(err, "getting name")
}
err = validate.BookName(name)
if err != nil {
return errors.Wrap(err, "validating book name")
}
tx, err := ctx.DB.Begin()
if err != nil {
return errors.Wrap(err, "beginning a transaction")
}
err = database.UpdateBookName(tx, uuid, name)
if err != nil {
tx.Rollback()
return errors.Wrap(err, "updating the book name")
}
bookInfo, err := database.GetBookInfo(tx, uuid)
if err != nil {
tx.Rollback()
return errors.Wrap(err, "getting book info")
}
err = tx.Commit()
if err != nil {
tx.Rollback()
return errors.Wrap(err, "committing a transaction")
}
log.Success("edited the book\n")
output.BookInfo(bookInfo)
return nil
}

104
pkg/cli/cmd/edit/edit.go Normal file
View file

@ -0,0 +1,104 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package edit
import (
"github.com/dnote/dnote/pkg/cli/context"
"github.com/dnote/dnote/pkg/cli/infra"
"github.com/dnote/dnote/pkg/cli/log"
"github.com/dnote/dnote/pkg/cli/utils"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var contentFlag string
var bookFlag string
var nameFlag string
var example = `
* Edit a note by id
dnote edit 3
* Edit a note without launching an editor
dnote edit 3 -c "new content"
* Move a note to another book
dnote edit 3 -b javascript
* Rename a book
dnote edit javascript
* Rename a book without launching an editor
dnote edit javascript -n js
`
// NewCmd returns a new edit command
func NewCmd(ctx context.DnoteCtx) *cobra.Command {
cmd := &cobra.Command{
Use: "edit <note id|book name>",
Short: "Edit a note or a book",
Aliases: []string{"e"},
Example: example,
PreRunE: preRun,
RunE: newRun(ctx),
}
f := cmd.Flags()
f.StringVarP(&contentFlag, "content", "c", "", "a new content for the note")
f.StringVarP(&bookFlag, "book", "b", "", "the name of the book to move the note to")
f.StringVarP(&nameFlag, "name", "n", "", "a new name for a book")
return cmd
}
func preRun(cmd *cobra.Command, args []string) error {
if len(args) != 1 && len(args) != 2 {
return errors.New("Incorrect number of argument")
}
return nil
}
func newRun(ctx context.DnoteCtx) infra.RunEFunc {
return func(cmd *cobra.Command, args []string) error {
// DEPRECATED: Remove in 1.0.0
if len(args) == 2 {
log.Plain(log.ColorYellow.Sprintf("DEPRECATED: you no longer need to pass book name to the view command. e.g. `dnote view 123`.\n\n"))
target := args[1]
if err := runNote(ctx, target); err != nil {
return errors.Wrap(err, "editing note")
}
return nil
}
target := args[0]
if utils.IsNumber(target) {
if err := runNote(ctx, target); err != nil {
return errors.Wrap(err, "editing note")
}
} else {
if err := runBook(ctx, target); err != nil {
return errors.Wrap(err, "editing book")
}
}
return nil
}
}

172
pkg/cli/cmd/edit/note.go Normal file
View file

@ -0,0 +1,172 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package edit
import (
"database/sql"
"os"
"strconv"
"github.com/dnote/dnote/pkg/cli/context"
"github.com/dnote/dnote/pkg/cli/database"
"github.com/dnote/dnote/pkg/cli/log"
"github.com/dnote/dnote/pkg/cli/output"
"github.com/dnote/dnote/pkg/cli/ui"
"github.com/pkg/errors"
)
func validateRunNoteFlags() error {
if nameFlag != "" {
return errors.New("--name is invalid for editing a book")
}
return nil
}
func waitEditorNoteContent(ctx context.DnoteCtx, note database.Note) (string, error) {
fpath, err := ui.GetTmpContentPath(ctx)
if err != nil {
return "", errors.Wrap(err, "getting temporarily content file path")
}
if err := os.WriteFile(fpath, []byte(note.Body), 0644); err != nil {
return "", errors.Wrap(err, "preparing tmp content file")
}
c, err := ui.GetEditorInput(ctx, fpath)
if err != nil {
return "", errors.Wrap(err, "getting editor input")
}
return c, nil
}
func getContent(ctx context.DnoteCtx, note database.Note) (string, error) {
if contentFlag != "" {
return contentFlag, nil
}
c, err := waitEditorNoteContent(ctx, note)
if err != nil {
return "", errors.Wrap(err, "getting content from editor")
}
return c, nil
}
func changeContent(ctx context.DnoteCtx, tx *database.DB, note database.Note, content string) error {
if note.Body == content {
return errors.New("Nothing changed")
}
if err := database.UpdateNoteContent(tx, ctx.Clock, note.RowID, content); err != nil {
return errors.Wrap(err, "updating the note")
}
return nil
}
func moveBook(ctx context.DnoteCtx, tx *database.DB, note database.Note, bookName string) error {
targetBookUUID, err := database.GetBookUUID(tx, bookName)
if err != nil {
return errors.Wrap(err, "finding book uuid")
}
if note.BookUUID == targetBookUUID {
return errors.New("book has not changed")
}
if err := database.UpdateNoteBook(tx, ctx.Clock, note.RowID, targetBookUUID); err != nil {
return errors.Wrap(err, "moving book")
}
return nil
}
func updateNote(ctx context.DnoteCtx, tx *database.DB, note database.Note, bookName, content string) error {
if bookName != "" {
if err := moveBook(ctx, tx, note, bookName); err != nil {
return errors.Wrap(err, "moving book")
}
}
if content != "" {
if err := changeContent(ctx, tx, note, content); err != nil {
return errors.Wrap(err, "changing content")
}
}
return nil
}
func runNote(ctx context.DnoteCtx, rowIDArg string) error {
err := validateRunNoteFlags()
if err != nil {
return errors.Wrap(err, "validating flags.")
}
rowID, err := strconv.Atoi(rowIDArg)
if err != nil {
return errors.Wrap(err, "invalid rowid")
}
db := ctx.DB
note, err := database.GetActiveNote(db, rowID)
if err == sql.ErrNoRows {
return errors.Errorf("note %d not found", rowID)
} else if err != nil {
return errors.Wrap(err, "querying the book")
}
content := contentFlag
// If no flag was provided, launch an editor to get the content
if bookFlag == "" && contentFlag == "" {
c, err := getContent(ctx, note)
if err != nil {
return errors.Wrap(err, "getting content from editor")
}
content = c
}
tx, err := ctx.DB.Begin()
if err != nil {
return errors.Wrap(err, "beginning a transaction")
}
err = updateNote(ctx, tx, note, bookFlag, content)
if err != nil {
tx.Rollback()
return errors.Wrap(err, "updating note fields")
}
noteInfo, err := database.GetNoteInfo(tx, rowID)
if err != nil {
tx.Rollback()
return errors.Wrap(err, "getting note info")
}
err = tx.Commit()
if err != nil {
tx.Rollback()
return errors.Wrap(err, "committing a transaction")
}
log.Success("edited the note\n")
output.NoteInfo(os.Stdout, noteInfo)
return nil
}

View file

@ -1,19 +1,16 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
/* Copyright 2025 Dnote Authors
*
* This file is part of Dnote CLI.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* http://www.apache.org/licenses/LICENSE-2.0
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package find
@ -23,9 +20,9 @@ import (
"fmt"
"strings"
"github.com/dnote/dnote/cli/core"
"github.com/dnote/dnote/cli/infra"
"github.com/dnote/dnote/cli/log"
"github.com/dnote/dnote/pkg/cli/context"
"github.com/dnote/dnote/pkg/cli/infra"
"github.com/dnote/dnote/pkg/cli/log"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
@ -52,7 +49,7 @@ func preRun(cmd *cobra.Command, args []string) error {
}
// NewCmd returns a new remove command
func NewCmd(ctx infra.DnoteCtx) *cobra.Command {
func NewCmd(ctx context.DnoteCtx) *cobra.Command {
cmd := &cobra.Command{
Use: "find",
Short: "Find notes by keywords",
@ -130,7 +127,7 @@ func escapePhrase(s string) (string, error) {
return b.String(), nil
}
func doQuery(ctx infra.DnoteCtx, query, bookName string) (*sql.Rows, error) {
func doQuery(ctx context.DnoteCtx, query, bookName string) (*sql.Rows, error) {
db := ctx.DB
sql := `SELECT
@ -153,7 +150,7 @@ func doQuery(ctx infra.DnoteCtx, query, bookName string) (*sql.Rows, error) {
return rows, err
}
func newRun(ctx infra.DnoteCtx) core.RunEFunc {
func newRun(ctx context.DnoteCtx) infra.RunEFunc {
return func(cmd *cobra.Command, args []string) error {
phrase, err := escapePhrase(args[0])
if err != nil {

View file

@ -1,19 +1,16 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
/* Copyright 2025 Dnote Authors
*
* This file is part of Dnote CLI.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* http://www.apache.org/licenses/LICENSE-2.0
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package find

View file

@ -1,19 +1,16 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
/* Copyright 2025 Dnote Authors
*
* This file is part of Dnote CLI.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* http://www.apache.org/licenses/LICENSE-2.0
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package find
@ -22,7 +19,7 @@ import (
"fmt"
"testing"
"github.com/dnote/dnote/cli/testutils"
"github.com/dnote/dnote/pkg/assert"
)
func TestScanToken(t *testing.T) {
@ -117,8 +114,8 @@ func TestScanToken(t *testing.T) {
t.Run(fmt.Sprintf("test case %d", tcIdx), func(t *testing.T) {
tok, nextIdx := scanToken(tc.idx, tc.input)
testutils.AssertEqual(t, nextIdx, tc.retIdx, "retIdx mismatch")
testutils.AssertDeepEqual(t, tok, tc.retTok, "retTok mismatch")
assert.Equal(t, nextIdx, tc.retIdx, "retIdx mismatch")
assert.DeepEqual(t, tok, tc.retTok, "retTok mismatch")
})
}
}
@ -225,7 +222,7 @@ func TestTokenize(t *testing.T) {
t.Run(fmt.Sprintf("test case %d", tcIdx), func(t *testing.T) {
tokens := tokenize(tc.input)
testutils.AssertDeepEqual(t, tokens, tc.tokens, "tokens mismatch")
assert.DeepEqual(t, tokens, tc.tokens, "tokens mismatch")
})
}
}

184
pkg/cli/cmd/login/login.go Normal file
View file

@ -0,0 +1,184 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package login
import (
"fmt"
"net/url"
"strconv"
"github.com/dnote/dnote/pkg/cli/client"
"github.com/dnote/dnote/pkg/cli/consts"
"github.com/dnote/dnote/pkg/cli/context"
"github.com/dnote/dnote/pkg/cli/database"
"github.com/dnote/dnote/pkg/cli/infra"
"github.com/dnote/dnote/pkg/cli/log"
"github.com/dnote/dnote/pkg/cli/ui"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var example = `
dnote login`
var usernameFlag, passwordFlag, apiEndpointFlag string
// NewCmd returns a new login command
func NewCmd(ctx context.DnoteCtx) *cobra.Command {
cmd := &cobra.Command{
Use: "login",
Short: "Login to dnote server",
Example: example,
RunE: newRun(ctx),
}
f := cmd.Flags()
f.StringVarP(&usernameFlag, "username", "u", "", "email address for authentication")
f.StringVarP(&passwordFlag, "password", "p", "", "password for authentication")
f.StringVar(&apiEndpointFlag, "apiEndpoint", "", "API endpoint to connect to (defaults to value in config)")
return cmd
}
// Do dervies credentials on the client side and requests a session token from the server
func Do(ctx context.DnoteCtx, email, password string) error {
signinResp, err := client.Signin(ctx, email, password)
if err != nil {
return errors.Wrap(err, "requesting session")
}
db := ctx.DB
tx, err := db.Begin()
if err != nil {
return errors.Wrap(err, "beginning a transaction")
}
if err := database.UpsertSystem(tx, consts.SystemSessionKey, signinResp.Key); err != nil {
return errors.Wrap(err, "saving session key")
}
if err := database.UpsertSystem(tx, consts.SystemSessionKeyExpiry, strconv.FormatInt(signinResp.ExpiresAt, 10)); err != nil {
return errors.Wrap(err, "saving session key")
}
tx.Commit()
return nil
}
func getUsername() (string, error) {
if usernameFlag != "" {
return usernameFlag, nil
}
var email string
if err := ui.PromptInput("email", &email); err != nil {
return "", errors.Wrap(err, "getting email input")
}
if email == "" {
return "", errors.New("Email is empty")
}
return email, nil
}
func getPassword() (string, error) {
if passwordFlag != "" {
return passwordFlag, nil
}
var password string
if err := ui.PromptPassword("password", &password); err != nil {
return "", errors.Wrap(err, "getting password input")
}
if password == "" {
return "", errors.New("Password is empty")
}
return password, nil
}
func getBaseURL(rawURL string) (string, error) {
u, err := url.Parse(rawURL)
if err != nil {
return "", errors.Wrap(err, "parsing url")
}
if u.Scheme == "" || u.Host == "" {
return "", nil
}
return fmt.Sprintf("%s://%s", u.Scheme, u.Host), nil
}
func getServerDisplayURL(ctx context.DnoteCtx) string {
baseURL, err := getBaseURL(ctx.APIEndpoint)
if err != nil {
return ""
}
return baseURL
}
func getGreeting(ctx context.DnoteCtx) string {
base := "Welcome to Dnote"
serverURL := getServerDisplayURL(ctx)
if serverURL == "" {
return fmt.Sprintf("%s\n", base)
}
return fmt.Sprintf("%s (%s)\n", base, serverURL)
}
func newRun(ctx context.DnoteCtx) infra.RunEFunc {
return func(cmd *cobra.Command, args []string) error {
// Override APIEndpoint if flag was provided
if apiEndpointFlag != "" {
ctx.APIEndpoint = apiEndpointFlag
}
greeting := getGreeting(ctx)
log.Plain(greeting)
email, err := getUsername()
if err != nil {
return errors.Wrap(err, "getting email input")
}
password, err := getPassword()
if err != nil {
return errors.Wrap(err, "getting password input")
}
if password == "" {
return errors.New("Password is empty")
}
log.Debug("Logging in with email: %s and password: (length %d)\n", email, len(password))
err = Do(ctx, email, password)
if errors.Cause(err) == client.ErrInvalidLogin {
log.Error("wrong login\n")
return nil
} else if err != nil {
return errors.Wrap(err, "logging in")
}
log.Success("logged in\n")
return nil
}
}

View file

@ -0,0 +1,67 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package login
import (
"fmt"
"testing"
"github.com/dnote/dnote/pkg/assert"
"github.com/dnote/dnote/pkg/cli/context"
)
func TestGetServerDisplayURL(t *testing.T) {
testCases := []struct {
apiEndpoint string
expected string
}{
{
apiEndpoint: "https://dnote.mydomain.com/api",
expected: "https://dnote.mydomain.com",
},
{
apiEndpoint: "https://mysubdomain.mydomain.com/dnote/api",
expected: "https://mysubdomain.mydomain.com",
},
{
apiEndpoint: "https://dnote.mysubdomain.mydomain.com/api",
expected: "https://dnote.mysubdomain.mydomain.com",
},
{
apiEndpoint: "some-string",
expected: "",
},
{
apiEndpoint: "",
expected: "",
},
{
apiEndpoint: "https://",
expected: "",
},
{
apiEndpoint: "https://abc",
expected: "https://abc",
},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("for input %s", tc.apiEndpoint), func(t *testing.T) {
got := getServerDisplayURL(context.DnoteCtx{APIEndpoint: tc.apiEndpoint})
assert.Equal(t, got, tc.expected, "result mismatch")
})
}
}

View file

@ -0,0 +1,106 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package logout
import (
"database/sql"
"github.com/dnote/dnote/pkg/cli/client"
"github.com/dnote/dnote/pkg/cli/consts"
"github.com/dnote/dnote/pkg/cli/context"
"github.com/dnote/dnote/pkg/cli/database"
"github.com/dnote/dnote/pkg/cli/infra"
"github.com/dnote/dnote/pkg/cli/log"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
// ErrNotLoggedIn is an error for logging out when not logged in
var ErrNotLoggedIn = errors.New("not logged in")
var example = `
dnote logout`
var apiEndpointFlag string
// NewCmd returns a new logout command
func NewCmd(ctx context.DnoteCtx) *cobra.Command {
cmd := &cobra.Command{
Use: "logout",
Short: "Logout from the server",
Example: example,
RunE: newRun(ctx),
}
f := cmd.Flags()
f.StringVar(&apiEndpointFlag, "apiEndpoint", "", "API endpoint to connect to (defaults to value in config)")
return cmd
}
// Do performs logout
func Do(ctx context.DnoteCtx) error {
db := ctx.DB
tx, err := db.Begin()
if err != nil {
return errors.Wrap(err, "beginning a transaction")
}
var key string
err = database.GetSystem(tx, consts.SystemSessionKey, &key)
if errors.Cause(err) == sql.ErrNoRows {
return ErrNotLoggedIn
} else if err != nil {
return errors.Wrap(err, "getting session key")
}
err = client.Signout(ctx, key)
if err != nil {
return errors.Wrap(err, "requesting logout")
}
if err := database.DeleteSystem(tx, consts.SystemSessionKey); err != nil {
return errors.Wrap(err, "deleting session key")
}
if err := database.DeleteSystem(tx, consts.SystemSessionKeyExpiry); err != nil {
return errors.Wrap(err, "deleting session key expiry")
}
tx.Commit()
return nil
}
func newRun(ctx context.DnoteCtx) infra.RunEFunc {
return func(cmd *cobra.Command, args []string) error {
// Override APIEndpoint if flag was provided
if apiEndpointFlag != "" {
ctx.APIEndpoint = apiEndpointFlag
}
err := Do(ctx)
if err == ErrNotLoggedIn {
log.Error("not logged in\n")
return nil
} else if err != nil {
return errors.Wrap(err, "logging out")
}
log.Success("logged out\n")
return nil
}
}

View file

@ -0,0 +1,212 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package remove
import (
"fmt"
"os"
"strconv"
"github.com/dnote/dnote/pkg/cli/context"
"github.com/dnote/dnote/pkg/cli/database"
"github.com/dnote/dnote/pkg/cli/infra"
"github.com/dnote/dnote/pkg/cli/log"
"github.com/dnote/dnote/pkg/cli/output"
"github.com/dnote/dnote/pkg/cli/ui"
"github.com/dnote/dnote/pkg/cli/utils"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var bookFlag string
var yesFlag bool
var example = `
* Delete a note by id
dnote delete 2
* Delete a book by name
dnote delete js
`
// NewCmd returns a new remove command
func NewCmd(ctx context.DnoteCtx) *cobra.Command {
cmd := &cobra.Command{
Use: "remove <note id|book name>",
Short: "Remove a note or a book",
Aliases: []string{"rm", "d", "delete"},
Example: example,
PreRunE: preRun,
RunE: newRun(ctx),
}
f := cmd.Flags()
f.StringVarP(&bookFlag, "book", "b", "", "The book name to delete")
f.BoolVarP(&yesFlag, "yes", "y", false, "Assume yes to the prompts and run in non-interactive mode")
f.MarkDeprecated("book", "Pass the book name as an argument. e.g. `dnote rm book_name`")
return cmd
}
func preRun(cmd *cobra.Command, args []string) error {
if len(args) != 1 && len(args) != 2 {
return errors.New("Incorrect number of argument")
}
return nil
}
func maybeConfirm(message string, defaultValue bool) (bool, error) {
if yesFlag {
return true, nil
}
return ui.Confirm(message, defaultValue)
}
func newRun(ctx context.DnoteCtx) infra.RunEFunc {
return func(cmd *cobra.Command, args []string) error {
// DEPRECATED: Remove in 1.0.0
if bookFlag != "" {
if err := runBook(ctx, bookFlag); err != nil {
return errors.Wrap(err, "removing the book")
}
return nil
}
// DEPRECATED: Remove in 1.0.0
if len(args) == 2 {
log.Plain(log.ColorYellow.Sprintf("DEPRECATED: you no longer need to pass book name to the remove command. e.g. `dnote remove 123`.\n\n"))
target := args[1]
if err := runNote(ctx, target); err != nil {
return errors.Wrap(err, "removing the note")
}
return nil
}
target := args[0]
if utils.IsNumber(target) {
if err := runNote(ctx, target); err != nil {
return errors.Wrap(err, "removing the note")
}
} else {
if err := runBook(ctx, target); err != nil {
return errors.Wrap(err, "removing the book")
}
}
return nil
}
}
func runNote(ctx context.DnoteCtx, rowIDArg string) error {
db := ctx.DB
noteRowID, err := strconv.Atoi(rowIDArg)
if err != nil {
return errors.Wrap(err, "invalid rowid")
}
noteInfo, err := database.GetNoteInfo(db, noteRowID)
if err != nil {
return err
}
output.NoteInfo(os.Stdout, noteInfo)
ok, err := maybeConfirm("remove this note?", false)
if err != nil {
return errors.Wrap(err, "getting confirmation")
}
if !ok {
log.Warnf("aborted by user\n")
return nil
}
tx, err := db.Begin()
if err != nil {
return errors.Wrap(err, "beginning a transaction")
}
if _, err = tx.Exec("UPDATE notes SET deleted = ?, dirty = ?, body = ? WHERE uuid = ?", true, true, "", noteInfo.UUID); err != nil {
tx.Rollback()
return errors.Wrap(err, "removing the note")
}
err = tx.Commit()
if err != nil {
tx.Rollback()
return errors.Wrap(err, "comitting transaction")
}
log.Successf("removed from %s\n", noteInfo.BookLabel)
return nil
}
func runBook(ctx context.DnoteCtx, bookLabel string) error {
db := ctx.DB
bookUUID, err := database.GetBookUUID(db, bookLabel)
if err != nil {
return errors.Wrap(err, "finding book uuid")
}
ok, err := maybeConfirm(fmt.Sprintf("delete book '%s' and all its notes?", bookLabel), false)
if err != nil {
return errors.Wrap(err, "getting confirmation")
}
if !ok {
log.Warnf("aborted by user\n")
return nil
}
tx, err := db.Begin()
if err != nil {
return errors.Wrap(err, "beginning a transaction")
}
if _, err = tx.Exec("UPDATE notes SET deleted = ?, dirty = ?, body = ? WHERE book_uuid = ?", true, true, "", bookUUID); err != nil {
tx.Rollback()
return errors.Wrap(err, "removing notes in the book")
}
// override the label with a random string
uniqLabel, err := utils.GenerateUUID()
if err != nil {
return errors.Wrap(err, "generating uuid to override with")
}
if _, err = tx.Exec("UPDATE books SET deleted = ?, dirty = ?, label = ? WHERE uuid = ?", true, true, uniqLabel, bookUUID); err != nil {
tx.Rollback()
return errors.Wrap(err, "removing the book")
}
err = tx.Commit()
if err != nil {
tx.Rollback()
return errors.Wrap(err, "committing transaction")
}
log.Success("removed book\n")
return nil
}

56
pkg/cli/cmd/root/root.go Normal file
View file

@ -0,0 +1,56 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package root
import (
"github.com/spf13/cobra"
)
var dbPathFlag string
var root = &cobra.Command{
Use: "dnote",
Short: "Dnote - a simple command line notebook",
SilenceErrors: true,
SilenceUsage: true,
CompletionOptions: cobra.CompletionOptions{
DisableDefaultCmd: true,
},
}
func init() {
root.PersistentFlags().StringVar(&dbPathFlag, "dbPath", "", "the path to the database file (defaults to standard location)")
}
// GetRoot returns the root command
func GetRoot() *cobra.Command {
return root
}
// GetDBPathFlag returns the value of the --dbPath flag
func GetDBPathFlag() string {
return dbPathFlag
}
// Register adds a new command
func Register(cmd *cobra.Command) {
root.AddCommand(cmd)
}
// Execute runs the main command
func Execute() error {
return root.Execute()
}

201
pkg/cli/cmd/sync/merge.go Normal file
View file

@ -0,0 +1,201 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sync
import (
"database/sql"
"fmt"
"strings"
"github.com/dnote/dnote/pkg/cli/client"
"github.com/dnote/dnote/pkg/cli/database"
"github.com/dnote/dnote/pkg/cli/utils"
"github.com/dnote/dnote/pkg/cli/utils/diff"
"github.com/pkg/errors"
)
const (
modeNormal = iota
modeRemote
modeLocal
)
const (
conflictLabelLocal = "<<<<<<< Local\n"
conflictLabelServer = ">>>>>>> Server\n"
conflictLabelDivide = "=======\n"
)
func sanitize(s string) string {
var textBuilder strings.Builder
textBuilder.WriteString(s)
if !strings.HasSuffix(s, "\n") {
textBuilder.WriteString("\n")
}
return textBuilder.String()
}
// reportBodyConflict returns a conflict report of the local and the remote version
// of a body
func reportBodyConflict(localBody, remoteBody string) string {
diffs := diff.Do(localBody, remoteBody)
var ret strings.Builder
mode := modeNormal
maxIdx := len(diffs) - 1
for idx, d := range diffs {
if d.Type == diff.DiffEqual {
if mode != modeNormal {
mode = modeNormal
ret.WriteString(conflictLabelServer)
}
ret.WriteString(d.Text)
}
// within the conflict area, append a linebreak to the text if it is missing one
// to make sure conflict labels are separated by new lines
sanitized := sanitize(d.Text)
if d.Type == diff.DiffDelete {
if mode == modeNormal {
mode = modeLocal
ret.WriteString(conflictLabelLocal)
}
ret.WriteString(sanitized)
}
if d.Type == diff.DiffInsert {
if mode == modeLocal {
mode = modeRemote
ret.WriteString(conflictLabelDivide)
}
ret.WriteString(sanitized)
if idx == maxIdx {
ret.WriteString(conflictLabelServer)
}
}
}
return ret.String()
}
func maxInt64(a, b int64) int64 {
if a > b {
return a
}
return b
}
func reportBookConflict(tx *database.DB, body, localBookUUID, serverBookUUID string) (string, error) {
var builder strings.Builder
var localBookName, serverBookName string
if err := tx.QueryRow("SELECT label FROM books WHERE uuid = ?", localBookUUID).Scan(&localBookName); err != nil {
return "", errors.Wrapf(err, "getting book label for %s", localBookUUID)
}
if err := tx.QueryRow("SELECT label FROM books WHERE uuid = ?", serverBookUUID).Scan(&serverBookName); err != nil {
return "", errors.Wrapf(err, "getting book label for %s", serverBookUUID)
}
builder.WriteString(conflictLabelLocal)
builder.WriteString(fmt.Sprintf("Moved to the book %s\n", localBookName))
builder.WriteString(conflictLabelDivide)
builder.WriteString(fmt.Sprintf("Moved to the book %s\n", serverBookName))
builder.WriteString(conflictLabelServer)
builder.WriteString("\n")
builder.WriteString(body)
return builder.String(), nil
}
func getConflictsBookUUID(tx *database.DB) (string, error) {
var ret string
err := tx.QueryRow("SELECT uuid FROM books WHERE label = ?", "conflicts").Scan(&ret)
if err == sql.ErrNoRows {
// Create a conflicts book
ret, err = utils.GenerateUUID()
if err != nil {
return "", err
}
b := database.NewBook(ret, "conflicts", 0, false, true)
err = b.Insert(tx)
if err != nil {
tx.Rollback()
return "", errors.Wrap(err, "creating the conflicts book")
}
} else if err != nil {
return "", errors.Wrap(err, "getting uuid for conflicts book")
}
return ret, nil
}
// noteMergeReport holds the result of a field-by-field merge of two copies of notes
type noteMergeReport struct {
body string
bookUUID string
editedOn int64
}
// mergeNoteFields performs a field-by-field merge between the local and the server copy. It returns a merge report
// between the local and the server copy of the note.
func mergeNoteFields(tx *database.DB, localNote database.Note, serverNote client.SyncFragNote) (*noteMergeReport, error) {
if !localNote.Dirty {
return &noteMergeReport{
body: serverNote.Body,
bookUUID: serverNote.BookUUID,
editedOn: serverNote.EditedOn,
}, nil
}
body := reportBodyConflict(localNote.Body, serverNote.Body)
var bookUUID string
if serverNote.BookUUID != localNote.BookUUID {
b, err := reportBookConflict(tx, body, localNote.BookUUID, serverNote.BookUUID)
if err != nil {
return nil, errors.Wrapf(err, "reporting book conflict for note %s", localNote.UUID)
}
body = b
conflictsBookUUID, err := getConflictsBookUUID(tx)
if err != nil {
return nil, errors.Wrap(err, "getting the conflicts book uuid")
}
bookUUID = conflictsBookUUID
} else {
bookUUID = serverNote.BookUUID
}
ret := noteMergeReport{
body: body,
bookUUID: bookUUID,
editedOn: maxInt64(localNote.EditedOn, serverNote.EditedOn),
}
return &ret, nil
}

View file

@ -0,0 +1,151 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sync
import (
"fmt"
"testing"
"github.com/dnote/dnote/pkg/assert"
)
func TestReportConflict(t *testing.T) {
testCases := []struct {
local string
server string
expected string
}{
{
local: "\n",
server: "\n",
expected: "\n",
},
{
local: "",
server: "",
expected: "",
},
{
local: "foo",
server: "foo",
expected: "foo",
},
{
local: "foo\nbar",
server: "foo\nbar",
expected: "foo\nbar",
},
{
local: "foo-local",
server: "foo-server",
expected: `<<<<<<< Local
foo-local
=======
foo-server
>>>>>>> Server
`,
},
{
local: "foo\n",
server: "bar\n",
expected: `<<<<<<< Local
foo
=======
bar
>>>>>>> Server
`,
},
{
local: "foo\n",
server: "\n",
expected: `<<<<<<< Local
foo
=======
>>>>>>> Server
`,
},
{
local: "\n",
server: "foo\n",
expected: `<<<<<<< Local
=======
foo
>>>>>>> Server
`,
},
{
local: "foo\n\nquz\nbaz\n",
server: "foo\n\nbar\nbaz\n",
expected: `foo
<<<<<<< Local
quz
=======
bar
>>>>>>> Server
baz
`,
},
{
local: "foo\n\nquz\nbaz\n\nqux quz\nfuz\n",
server: "foo\n\nbar\nbaz\n\nqux quz\nfuuz\n",
expected: `foo
<<<<<<< Local
quz
=======
bar
>>>>>>> Server
baz
qux quz
<<<<<<< Local
fuz
=======
fuuz
>>>>>>> Server
`,
},
{
local: "foo\nquz\nbaz\nbar\n",
server: "foo\nquzz\nbazz\nbar\n",
expected: `foo
<<<<<<< Local
quz
=======
quzz
>>>>>>> Server
<<<<<<< Local
baz
=======
bazz
>>>>>>> Server
bar
`,
},
}
for idx, tc := range testCases {
result := reportBodyConflict(tc.local, tc.server)
t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) {
assert.DeepEqual(t, result, tc.expected, "result mismatch")
})
}
}

View file

@ -1,19 +1,16 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
/* Copyright 2025 Dnote Authors
*
* This file is part of Dnote CLI.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* http://www.apache.org/licenses/LICENSE-2.0
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sync
@ -22,12 +19,15 @@ import (
"database/sql"
"fmt"
"github.com/dnote/dnote/cli/client"
"github.com/dnote/dnote/cli/core"
"github.com/dnote/dnote/cli/crypt"
"github.com/dnote/dnote/cli/infra"
"github.com/dnote/dnote/cli/log"
"github.com/dnote/dnote/cli/migrate"
"github.com/dnote/dnote/pkg/cli/client"
"github.com/dnote/dnote/pkg/cli/consts"
"github.com/dnote/dnote/pkg/cli/context"
"github.com/dnote/dnote/pkg/cli/database"
"github.com/dnote/dnote/pkg/cli/infra"
"github.com/dnote/dnote/pkg/cli/log"
"github.com/dnote/dnote/pkg/cli/migrate"
"github.com/dnote/dnote/pkg/cli/ui"
"github.com/dnote/dnote/pkg/cli/upgrade"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
@ -41,9 +41,10 @@ var example = `
dnote sync`
var isFullSync bool
var apiEndpointFlag string
// NewCmd returns a new sync command
func NewCmd(ctx infra.DnoteCtx) *cobra.Command {
func NewCmd(ctx context.DnoteCtx) *cobra.Command {
cmd := &cobra.Command{
Use: "sync",
Aliases: []string{"s"},
@ -54,24 +55,25 @@ func NewCmd(ctx infra.DnoteCtx) *cobra.Command {
f := cmd.Flags()
f.BoolVarP(&isFullSync, "full", "f", false, "perform a full sync instead of incrementally syncing only the changed data.")
f.StringVar(&apiEndpointFlag, "apiEndpoint", "", "API endpoint to connect to (defaults to value in config)")
return cmd
}
func getLastSyncAt(tx *infra.DB) (int, error) {
func getLastSyncAt(tx *database.DB) (int, error) {
var ret int
if err := core.GetSystem(tx, infra.SystemLastSyncAt, &ret); err != nil {
if err := database.GetSystem(tx, consts.SystemLastSyncAt, &ret); err != nil {
return ret, errors.Wrap(err, "querying last sync time")
}
return ret, nil
}
func getLastMaxUSN(tx *infra.DB) (int, error) {
func getLastMaxUSN(tx *database.DB) (int, error) {
var ret int
if err := core.GetSystem(tx, infra.SystemLastMaxUSN, &ret); err != nil {
if err := database.GetSystem(tx, consts.SystemLastMaxUSN, &ret); err != nil {
return ret, errors.Wrap(err, "querying last user max_usn")
}
@ -85,6 +87,7 @@ type syncList struct {
ExpungedNotes map[string]bool
ExpungedBooks map[string]bool
MaxUSN int
UserMaxUSN int // Server's actual max USN (for distinguishing empty fragment vs empty server)
MaxCurrentTime int64
}
@ -92,35 +95,21 @@ func (l syncList) getLength() int {
return len(l.Notes) + len(l.Books) + len(l.ExpungedNotes) + len(l.ExpungedBooks)
}
// processFragments categorizes items in sync fragments into a sync list. It also decrypts any
// encrypted data in sync fragments.
func processFragments(fragments []client.SyncFragment, cipherKey []byte) (syncList, error) {
// processFragments categorizes items in sync fragments into a sync list.
func processFragments(fragments []client.SyncFragment) (syncList, error) {
notes := map[string]client.SyncFragNote{}
books := map[string]client.SyncFragBook{}
expungedNotes := map[string]bool{}
expungedBooks := map[string]bool{}
var maxUSN int
var userMaxUSN int
var maxCurrentTime int64
for _, fragment := range fragments {
for _, note := range fragment.Notes {
log.Debug("decrypting note %s\n", note.UUID)
bodyDec, err := crypt.AesGcmDecrypt(cipherKey, note.Body)
if err != nil {
return syncList{}, errors.Wrapf(err, "decrypting body for note %s", note.UUID)
}
note.Body = string(bodyDec)
notes[note.UUID] = note
}
for _, book := range fragment.Books {
log.Debug("decrypting book %s\n", book.UUID)
labelDec, err := crypt.AesGcmDecrypt(cipherKey, book.Label)
if err != nil {
return syncList{}, errors.Wrapf(err, "decrypting label for book %s", book.UUID)
}
book.Label = string(labelDec)
books[book.UUID] = book
}
for _, uuid := range fragment.ExpungedBooks {
@ -133,6 +122,9 @@ func processFragments(fragments []client.SyncFragment, cipherKey []byte) (syncLi
if fragment.FragMaxUSN > maxUSN {
maxUSN = fragment.FragMaxUSN
}
if fragment.UserMaxUSN > userMaxUSN {
userMaxUSN = fragment.UserMaxUSN
}
if fragment.CurrentTime > maxCurrentTime {
maxCurrentTime = fragment.CurrentTime
}
@ -144,6 +136,7 @@ func processFragments(fragments []client.SyncFragment, cipherKey []byte) (syncLi
ExpungedNotes: expungedNotes,
ExpungedBooks: expungedBooks,
MaxUSN: maxUSN,
UserMaxUSN: userMaxUSN,
MaxCurrentTime: maxCurrentTime,
}
@ -152,13 +145,13 @@ func processFragments(fragments []client.SyncFragment, cipherKey []byte) (syncLi
// getSyncList gets a list of all sync fragments after the specified usn
// and aggregates them into a syncList data structure
func getSyncList(ctx infra.DnoteCtx, afterUSN int) (syncList, error) {
func getSyncList(ctx context.DnoteCtx, afterUSN int) (syncList, error) {
fragments, err := getSyncFragments(ctx, afterUSN)
if err != nil {
return syncList{}, errors.Wrap(err, "getting sync fragments")
}
ret, err := processFragments(fragments, ctx.CipherKey)
ret, err := processFragments(fragments)
if err != nil {
return syncList{}, errors.Wrap(err, "making sync list")
}
@ -168,7 +161,7 @@ func getSyncList(ctx infra.DnoteCtx, afterUSN int) (syncList, error) {
// getSyncFragments repeatedly gets all sync fragments after the specified usn until there is no more new data
// remaining and returns the buffered list
func getSyncFragments(ctx infra.DnoteCtx, afterUSN int) ([]client.SyncFragment, error) {
func getSyncFragments(ctx context.DnoteCtx, afterUSN int) ([]client.SyncFragment, error) {
var buf []client.SyncFragment
nextAfterUSN := afterUSN
@ -190,18 +183,75 @@ func getSyncFragments(ctx infra.DnoteCtx, afterUSN int) ([]client.SyncFragment,
}
}
log.Debug("received sync fragments: %+v\n", buf)
log.Debug("received sync fragments: %+v\n", redactSyncFragments(buf))
return buf, nil
}
// redactSyncFragments returns a deep copy of sync fragments with sensitive fields (note body, book label) removed for safe logging
func redactSyncFragments(fragments []client.SyncFragment) []client.SyncFragment {
redacted := make([]client.SyncFragment, len(fragments))
for i, frag := range fragments {
// Create new notes with redacted bodies
notes := make([]client.SyncFragNote, len(frag.Notes))
for j, note := range frag.Notes {
notes[j] = client.SyncFragNote{
UUID: note.UUID,
BookUUID: note.BookUUID,
USN: note.USN,
CreatedAt: note.CreatedAt,
UpdatedAt: note.UpdatedAt,
AddedOn: note.AddedOn,
EditedOn: note.EditedOn,
Body: func() string {
if note.Body != "" {
return "<redacted>"
}
return ""
}(),
Deleted: note.Deleted,
}
}
// Create new books with redacted labels
books := make([]client.SyncFragBook, len(frag.Books))
for j, book := range frag.Books {
books[j] = client.SyncFragBook{
UUID: book.UUID,
USN: book.USN,
CreatedAt: book.CreatedAt,
UpdatedAt: book.UpdatedAt,
AddedOn: book.AddedOn,
Label: func() string {
if book.Label != "" {
return "<redacted>"
}
return ""
}(),
Deleted: book.Deleted,
}
}
redacted[i] = client.SyncFragment{
FragMaxUSN: frag.FragMaxUSN,
UserMaxUSN: frag.UserMaxUSN,
CurrentTime: frag.CurrentTime,
Notes: notes,
Books: books,
ExpungedNotes: frag.ExpungedNotes,
ExpungedBooks: frag.ExpungedBooks,
}
}
return redacted
}
// resolveLabel resolves a book label conflict by repeatedly appending an increasing integer
// to the label until it finds a unique label. It returns the first non-conflicting label.
func resolveLabel(tx *infra.DB, label string) (string, error) {
func resolveLabel(tx *database.DB, label string) (string, error) {
var ret string
for i := 2; ; i++ {
ret = fmt.Sprintf("%s (%d)", label, i)
ret = fmt.Sprintf("%s_%d", label, i)
var cnt int
if err := tx.QueryRow("SELECT count(*) FROM books WHERE label = ?", ret).Scan(&cnt); err != nil {
@ -218,7 +268,7 @@ func resolveLabel(tx *infra.DB, label string) (string, error) {
// mergeBook inserts or updates the given book in the local database.
// If a book with a duplicate label exists locally, it renames the duplicate by appending a number.
func mergeBook(tx *infra.DB, b client.SyncFragBook, mode int) error {
func mergeBook(tx *database.DB, b client.SyncFragBook, mode int) error {
var count int
if err := tx.QueryRow("SELECT count(*) FROM books WHERE label = ?", b.Label).Scan(&count); err != nil {
return errors.Wrapf(err, "checking for books with a duplicate label %s", b.Label)
@ -231,18 +281,18 @@ func mergeBook(tx *infra.DB, b client.SyncFragBook, mode int) error {
return errors.Wrap(err, "getting a new book label for conflict resolution")
}
if _, err := tx.Exec("UPDATE books SET label = ?, dirty = ? WHERE label = ?", newLabel, true, b.Label); err != nil {
if _, err := tx.Exec("UPDATE books SET label = ?, dirty = ? WHERE label = ? AND uuid != ?", newLabel, true, b.Label, b.UUID); err != nil {
return errors.Wrap(err, "resolving duplicate book label")
}
}
if mode == modeInsert {
book := core.NewBook(b.UUID, b.Label, b.USN, false, false)
book := database.NewBook(b.UUID, b.Label, b.USN, false, false)
if err := book.Insert(tx); err != nil {
return errors.Wrapf(err, "inserting note with uuid %s", b.UUID)
}
} else if mode == modeUpdate {
// TODO: if the client copy is dirty, perform field-by-field merge and report conflict instead of overwriting
// The state from the server overwrites the local state. In other words, the server change always wins.
if _, err := tx.Exec("UPDATE books SET usn = ?, uuid = ?, label = ?, deleted = ? WHERE uuid = ?",
b.USN, b.UUID, b.Label, b.Deleted, b.UUID); err != nil {
return errors.Wrapf(err, "updating local book %s", b.UUID)
@ -252,7 +302,7 @@ func mergeBook(tx *infra.DB, b client.SyncFragBook, mode int) error {
return nil
}
func stepSyncBook(tx *infra.DB, b client.SyncFragBook) error {
func stepSyncBook(tx *database.DB, b client.SyncFragBook) error {
var localUSN int
var dirty bool
err := tx.QueryRow("SELECT usn, dirty FROM books WHERE uuid = ?", b.UUID).Scan(&localUSN, &dirty)
@ -276,7 +326,7 @@ func stepSyncBook(tx *infra.DB, b client.SyncFragBook) error {
return nil
}
func mergeNote(tx *infra.DB, serverNote client.SyncFragNote, localNote core.Note) error {
func mergeNote(tx *database.DB, serverNote client.SyncFragNote, localNote database.Note) error {
var bookDeleted bool
err := tx.QueryRow("SELECT deleted FROM books WHERE uuid = ?", localNote.BookUUID).Scan(&bookDeleted)
if err != nil {
@ -288,36 +338,40 @@ func mergeNote(tx *infra.DB, serverNote client.SyncFragNote, localNote core.Note
return nil
}
// if the local copy is deleted, and the it was edited on the server, override with server values and mark it not dirty.
// if the local copy is deleted, and it was edited on the server, override with server values and mark it not dirty.
if localNote.Deleted {
if _, err := tx.Exec("UPDATE notes SET usn = ?, book_uuid = ?, body = ?, edited_on = ?, deleted = ?, public = ?, dirty = ? WHERE uuid = ?",
serverNote.USN, serverNote.BookUUID, serverNote.Body, serverNote.EditedOn, serverNote.Deleted, serverNote.Public, false, serverNote.UUID); err != nil {
if _, err := tx.Exec("UPDATE notes SET usn = ?, book_uuid = ?, body = ?, edited_on = ?, deleted = ?, dirty = ? WHERE uuid = ?",
serverNote.USN, serverNote.BookUUID, serverNote.Body, serverNote.EditedOn, serverNote.Deleted, false, serverNote.UUID); err != nil {
return errors.Wrapf(err, "updating local note %s", serverNote.UUID)
}
return nil
}
// TODO: if the client copy is dirty, perform field-by-field merge and report conflict instead of overwriting
if _, err := tx.Exec("UPDATE notes SET usn = ?, book_uuid = ?, body = ?, edited_on = ?, deleted = ?, public = ? WHERE uuid = ?",
serverNote.USN, serverNote.BookUUID, serverNote.Body, serverNote.EditedOn, serverNote.Deleted, serverNote.Public, serverNote.UUID); err != nil {
mr, err := mergeNoteFields(tx, localNote, serverNote)
if err != nil {
return errors.Wrapf(err, "reporting note conflict for note %s", localNote.UUID)
}
if _, err := tx.Exec("UPDATE notes SET usn = ?, book_uuid = ?, body = ?, edited_on = ?, deleted = ? WHERE uuid = ?",
serverNote.USN, mr.bookUUID, mr.body, mr.editedOn, serverNote.Deleted, serverNote.UUID); err != nil {
return errors.Wrapf(err, "updating local note %s", serverNote.UUID)
}
return nil
}
func stepSyncNote(tx *infra.DB, n client.SyncFragNote) error {
var localNote core.Note
err := tx.QueryRow("SELECT usn, book_uuid, dirty, deleted FROM notes WHERE uuid = ?", n.UUID).
Scan(&localNote.USN, &localNote.BookUUID, &localNote.Dirty, &localNote.Deleted)
func stepSyncNote(tx *database.DB, n client.SyncFragNote) error {
var localNote database.Note
err := tx.QueryRow("SELECT body, usn, book_uuid, dirty, deleted FROM notes WHERE uuid = ?", n.UUID).
Scan(&localNote.Body, &localNote.USN, &localNote.BookUUID, &localNote.Dirty, &localNote.Deleted)
if err != nil && err != sql.ErrNoRows {
return errors.Wrapf(err, "getting local note %s", n.UUID)
}
// if note exists in the server and does not exist in the client, insert the note.
if err == sql.ErrNoRows {
note := core.NewNote(n.UUID, n.BookUUID, n.Body, n.AddedOn, n.EditedOn, n.USN, n.Public, n.Deleted, false)
note := database.NewNote(n.UUID, n.BookUUID, n.Body, n.AddedOn, n.EditedOn, n.USN, n.Deleted, false)
if err := note.Insert(tx); err != nil {
return errors.Wrapf(err, "inserting note with uuid %s", n.UUID)
@ -331,17 +385,17 @@ func stepSyncNote(tx *infra.DB, n client.SyncFragNote) error {
return nil
}
func fullSyncNote(tx *infra.DB, n client.SyncFragNote) error {
var localNote core.Note
err := tx.QueryRow("SELECT usn,book_uuid, dirty, deleted FROM notes WHERE uuid = ?", n.UUID).
Scan(&localNote.USN, &localNote.BookUUID, &localNote.Dirty, &localNote.Deleted)
func fullSyncNote(tx *database.DB, n client.SyncFragNote) error {
var localNote database.Note
err := tx.QueryRow("SELECT body, usn, book_uuid, dirty, deleted FROM notes WHERE uuid = ?", n.UUID).
Scan(&localNote.Body, &localNote.USN, &localNote.BookUUID, &localNote.Dirty, &localNote.Deleted)
if err != nil && err != sql.ErrNoRows {
return errors.Wrapf(err, "getting local note %s", n.UUID)
}
// if note exists in the server and does not exist in the client, insert the note.
if err == sql.ErrNoRows {
note := core.NewNote(n.UUID, n.BookUUID, n.Body, n.AddedOn, n.EditedOn, n.USN, n.Public, n.Deleted, false)
note := database.NewNote(n.UUID, n.BookUUID, n.Body, n.AddedOn, n.EditedOn, n.USN, n.Deleted, false)
if err := note.Insert(tx); err != nil {
return errors.Wrapf(err, "inserting note with uuid %s", n.UUID)
@ -355,7 +409,7 @@ func fullSyncNote(tx *infra.DB, n client.SyncFragNote) error {
return nil
}
func syncDeleteNote(tx *infra.DB, noteUUID string) error {
func syncDeleteNote(tx *database.DB, noteUUID string) error {
var localUSN int
var dirty bool
err := tx.QueryRow("SELECT usn, dirty FROM notes WHERE uuid = ?", noteUUID).Scan(&localUSN, &dirty)
@ -380,7 +434,7 @@ func syncDeleteNote(tx *infra.DB, noteUUID string) error {
}
// checkNotesPristine checks that none of the notes in the given book are dirty
func checkNotesPristine(tx *infra.DB, bookUUID string) (bool, error) {
func checkNotesPristine(tx *database.DB, bookUUID string) (bool, error) {
var count int
if err := tx.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ? AND dirty = ?", bookUUID, true).Scan(&count); err != nil {
return false, errors.Wrapf(err, "counting notes that are dirty in book %s", bookUUID)
@ -393,7 +447,7 @@ func checkNotesPristine(tx *infra.DB, bookUUID string) (bool, error) {
return true, nil
}
func syncDeleteBook(tx *infra.DB, bookUUID string) error {
func syncDeleteBook(tx *database.DB, bookUUID string) error {
var localUSN int
var dirty bool
err := tx.QueryRow("SELECT usn, dirty FROM books WHERE uuid = ?", bookUUID).Scan(&localUSN, &dirty)
@ -439,7 +493,7 @@ func syncDeleteBook(tx *infra.DB, bookUUID string) error {
return nil
}
func fullSyncBook(tx *infra.DB, b client.SyncFragBook) error {
func fullSyncBook(tx *database.DB, b client.SyncFragBook) error {
var localUSN int
var dirty bool
err := tx.QueryRow("SELECT usn, dirty FROM books WHERE uuid = ?", b.UUID).Scan(&localUSN, &dirty)
@ -491,7 +545,7 @@ func checkBookInList(uuid string, list *syncList) bool {
// judging by the full list of resources in the server. Concretely, the only acceptable
// situation in which a local note is not present in the server is if it is new and has not been
// uploaded (i.e. dirty and usn is 0). Otherwise, it is a result of some kind of error and should be cleaned.
func cleanLocalNotes(tx *infra.DB, fullList *syncList) error {
func cleanLocalNotes(tx *database.DB, fullList *syncList) error {
rows, err := tx.Query("SELECT uuid, usn, dirty FROM notes")
if err != nil {
return errors.Wrap(err, "getting local notes")
@ -499,7 +553,7 @@ func cleanLocalNotes(tx *infra.DB, fullList *syncList) error {
defer rows.Close()
for rows.Next() {
var note core.Note
var note database.Note
if err := rows.Scan(&note.UUID, &note.USN, &note.Dirty); err != nil {
return errors.Wrap(err, "scanning a row for local note")
}
@ -517,7 +571,7 @@ func cleanLocalNotes(tx *infra.DB, fullList *syncList) error {
}
// cleanLocalBooks deletes from the local database any books that are in invalid state
func cleanLocalBooks(tx *infra.DB, fullList *syncList) error {
func cleanLocalBooks(tx *database.DB, fullList *syncList) error {
rows, err := tx.Query("SELECT uuid, usn, dirty FROM books")
if err != nil {
return errors.Wrap(err, "getting local books")
@ -525,7 +579,7 @@ func cleanLocalBooks(tx *infra.DB, fullList *syncList) error {
defer rows.Close()
for rows.Next() {
var book core.Book
var book database.Book
if err := rows.Scan(&book.UUID, &book.USN, &book.Dirty); err != nil {
return errors.Wrap(err, "scanning a row for local book")
}
@ -542,10 +596,12 @@ func cleanLocalBooks(tx *infra.DB, fullList *syncList) error {
return nil
}
func fullSync(ctx infra.DnoteCtx, tx *infra.DB) error {
func fullSync(ctx context.DnoteCtx, tx *database.DB) error {
log.Debug("performing a full sync\n")
log.Info("resolving delta.")
log.DebugNewline()
list, err := getSyncList(ctx, 0)
if err != nil {
return errors.Wrap(err, "getting sync list")
@ -553,6 +609,8 @@ func fullSync(ctx infra.DnoteCtx, tx *infra.DB) error {
fmt.Printf(" (total %d).", list.getLength())
log.DebugNewline()
// clean resources that are in erroneous states
if err := cleanLocalNotes(tx, &list); err != nil {
return errors.Wrap(err, "cleaning up local notes")
@ -583,7 +641,7 @@ func fullSync(ctx infra.DnoteCtx, tx *infra.DB) error {
}
}
err = saveSyncState(tx, list.MaxCurrentTime, list.MaxUSN)
err = saveSyncState(tx, list.MaxCurrentTime, list.MaxUSN, list.UserMaxUSN)
if err != nil {
return errors.Wrap(err, "saving sync state")
}
@ -593,11 +651,13 @@ func fullSync(ctx infra.DnoteCtx, tx *infra.DB) error {
return nil
}
func stepSync(ctx infra.DnoteCtx, tx *infra.DB, afterUSN int) error {
func stepSync(ctx context.DnoteCtx, tx *database.DB, afterUSN int) error {
log.Debug("performing a step sync\n")
log.Info("resolving delta.")
log.DebugNewline()
list, err := getSyncList(ctx, afterUSN)
if err != nil {
return errors.Wrap(err, "getting sync list")
@ -627,7 +687,7 @@ func stepSync(ctx infra.DnoteCtx, tx *infra.DB, afterUSN int) error {
}
}
err = saveSyncState(tx, list.MaxCurrentTime, list.MaxUSN)
err = saveSyncState(tx, list.MaxCurrentTime, list.MaxUSN, list.UserMaxUSN)
if err != nil {
return errors.Wrap(err, "saving sync state")
}
@ -637,7 +697,21 @@ func stepSync(ctx infra.DnoteCtx, tx *infra.DB, afterUSN int) error {
return nil
}
func sendBooks(ctx infra.DnoteCtx, tx *infra.DB) (bool, error) {
// isConflictError checks if an error is a 409 Conflict error from the server
func isConflictError(err error) bool {
if err == nil {
return false
}
var httpErr *client.HTTPError
if errors.As(err, &httpErr) {
return httpErr.IsConflict()
}
return false
}
func sendBooks(ctx context.DnoteCtx, tx *database.DB) (bool, error) {
isBehind := false
rows, err := tx.Query("SELECT uuid, label, usn, deleted FROM books WHERE dirty")
@ -647,7 +721,7 @@ func sendBooks(ctx infra.DnoteCtx, tx *infra.DB) (bool, error) {
defer rows.Close()
for rows.Next() {
var book core.Book
var book database.Book
if err = rows.Scan(&book.UUID, &book.Label, &book.USN, &book.Deleted); err != nil {
return isBehind, errors.Wrap(err, "scanning a syncable book")
@ -669,7 +743,9 @@ func sendBooks(ctx infra.DnoteCtx, tx *infra.DB) (bool, error) {
} else {
resp, err := client.CreateBook(ctx, book.Label)
if err != nil {
return isBehind, errors.Wrap(err, "creating a book")
log.Debug("error creating book (will retry after stepSync): %v\n", err)
isBehind = true
continue
}
_, err = tx.Exec("UPDATE notes SET book_uuid = ? WHERE book_uuid = ?", resp.Book.UUID, book.UUID)
@ -741,23 +817,105 @@ func sendBooks(ctx infra.DnoteCtx, tx *infra.DB) (bool, error) {
return isBehind, nil
}
func sendNotes(ctx infra.DnoteCtx, tx *infra.DB) (bool, error) {
// findOrphanedNotes returns a list of all orphaned notes
func findOrphanedNotes(db *database.DB) (int, []struct{ noteUUID, bookUUID string }, error) {
var orphanCount int
err := db.QueryRow(`
SELECT COUNT(*) FROM notes n
WHERE NOT EXISTS (
SELECT 1 FROM books b
WHERE b.uuid = n.book_uuid
AND NOT b.deleted
)
`).Scan(&orphanCount)
if err != nil {
return 0, nil, err
}
if orphanCount == 0 {
return 0, nil, nil
}
rows, err := db.Query(`
SELECT n.uuid, n.book_uuid
FROM notes n
WHERE NOT EXISTS (
SELECT 1 FROM books b
WHERE b.uuid = n.book_uuid
AND NOT b.deleted
)
`)
if err != nil {
return orphanCount, nil, err
}
defer rows.Close()
var orphans []struct{ noteUUID, bookUUID string }
for rows.Next() {
var noteUUID, bookUUID string
if err := rows.Scan(&noteUUID, &bookUUID); err != nil {
continue
}
orphans = append(orphans, struct{ noteUUID, bookUUID string }{noteUUID, bookUUID})
}
return orphanCount, orphans, nil
}
func warnOrphanedNotes(tx *database.DB) {
count, orphans, err := findOrphanedNotes(tx)
if err != nil {
log.Debug("error checking orphaned notes: %v\n", err)
return
}
if count == 0 {
return
}
log.Debug("Found %d orphaned notes (book doesn't exist locally):\n", count)
for _, o := range orphans {
log.Debug("note %s (book %s)\n", o.noteUUID, o.bookUUID)
}
}
// checkPostSyncIntegrity checks for data integrity issues after sync and warns the user
func checkPostSyncIntegrity(db *database.DB) {
count, orphans, err := findOrphanedNotes(db)
if err != nil {
log.Debug("error checking orphaned notes: %v\n", err)
return
}
if count == 0 {
return
}
log.Warnf("Found %d orphaned notes (referencing non-existent or deleted books):\n", count)
for _, o := range orphans {
log.Plainf(" - note %s (missing book: %s)\n", o.noteUUID, o.bookUUID)
}
}
func sendNotes(ctx context.DnoteCtx, tx *database.DB) (bool, error) {
isBehind := false
rows, err := tx.Query("SELECT uuid, book_uuid, body, public, deleted, usn, added_on FROM notes WHERE dirty")
warnOrphanedNotes(tx)
rows, err := tx.Query("SELECT uuid, book_uuid, body, deleted, usn, added_on FROM notes WHERE dirty")
if err != nil {
return isBehind, errors.Wrap(err, "getting syncable notes")
}
defer rows.Close()
for rows.Next() {
var note core.Note
var note database.Note
if err = rows.Scan(&note.UUID, &note.BookUUID, &note.Body, &note.Public, &note.Deleted, &note.USN, &note.AddedOn); err != nil {
if err = rows.Scan(&note.UUID, &note.BookUUID, &note.Body, &note.Deleted, &note.USN, &note.AddedOn); err != nil {
return isBehind, errors.Wrap(err, "scanning a syncable note")
}
log.Debug("sending note %s\n", note.UUID)
log.Debug("sending note %s (book: %s)\n", note.UUID, note.BookUUID)
var respUSN int
@ -774,7 +932,9 @@ func sendNotes(ctx infra.DnoteCtx, tx *infra.DB) (bool, error) {
} else {
resp, err := client.CreateNote(ctx, note.BookUUID, note.Body)
if err != nil {
return isBehind, errors.Wrap(err, "creating a note")
log.Debug("failed to create note %s (book: %s): %v\n", note.UUID, note.BookUUID, err)
isBehind = true
continue
}
note.Dirty = false
@ -805,7 +965,7 @@ func sendNotes(ctx infra.DnoteCtx, tx *infra.DB) (bool, error) {
respUSN = resp.Result.USN
} else {
resp, err := client.UpdateNote(ctx, note.UUID, note.BookUUID, note.Body, note.Public)
resp, err := client.UpdateNote(ctx, note.UUID, note.BookUUID, note.Body)
if err != nil {
return isBehind, errors.Wrap(err, "updating a note")
}
@ -841,7 +1001,7 @@ func sendNotes(ctx infra.DnoteCtx, tx *infra.DB) (bool, error) {
return isBehind, nil
}
func sendChanges(ctx infra.DnoteCtx, tx *infra.DB) (bool, error) {
func sendChanges(ctx context.DnoteCtx, tx *database.DB) (bool, error) {
log.Info("sending changes.")
var delta int
@ -849,6 +1009,8 @@ func sendChanges(ctx infra.DnoteCtx, tx *infra.DB) (bool, error) {
fmt.Printf(" (total %d).", delta)
log.DebugNewline()
behind1, err := sendBooks(ctx, tx)
if err != nil {
return behind1, errors.Wrap(err, "sending books")
@ -866,26 +1028,40 @@ func sendChanges(ctx infra.DnoteCtx, tx *infra.DB) (bool, error) {
return isBehind, nil
}
func updateLastMaxUSN(tx *infra.DB, val int) error {
if err := core.UpdateSystem(tx, infra.SystemLastMaxUSN, val); err != nil {
return errors.Wrapf(err, "updating %s", infra.SystemLastMaxUSN)
func updateLastMaxUSN(tx *database.DB, val int) error {
if err := database.UpdateSystem(tx, consts.SystemLastMaxUSN, val); err != nil {
return errors.Wrapf(err, "updating %s", consts.SystemLastMaxUSN)
}
return nil
}
func updateLastSyncAt(tx *infra.DB, val int64) error {
if err := core.UpdateSystem(tx, infra.SystemLastSyncAt, val); err != nil {
return errors.Wrapf(err, "updating %s", infra.SystemLastSyncAt)
func updateLastSyncAt(tx *database.DB, val int64) error {
if err := database.UpdateSystem(tx, consts.SystemLastSyncAt, val); err != nil {
return errors.Wrapf(err, "updating %s", consts.SystemLastSyncAt)
}
return nil
}
func saveSyncState(tx *infra.DB, serverTime int64, serverMaxUSN int) error {
if err := updateLastMaxUSN(tx, serverMaxUSN); err != nil {
return errors.Wrap(err, "updating last max usn")
func saveSyncState(tx *database.DB, serverTime int64, serverMaxUSN int, userMaxUSN int) error {
// Handle last_max_usn update based on server state:
// - If serverMaxUSN > 0: we got data, update to serverMaxUSN
// - If serverMaxUSN == 0 && userMaxUSN > 0: empty fragment (caught up), preserve existing
// - If serverMaxUSN == 0 && userMaxUSN == 0: empty server, reset to 0
if serverMaxUSN > 0 {
if err := updateLastMaxUSN(tx, serverMaxUSN); err != nil {
return errors.Wrap(err, "updating last max usn")
}
} else if userMaxUSN == 0 {
// Server is empty, reset to 0
if err := updateLastMaxUSN(tx, 0); err != nil {
return errors.Wrap(err, "updating last max usn")
}
}
// else: empty fragment but server has data, preserve existing last_max_usn
// Always update last_sync_at (we did communicate with server)
if err := updateLastSyncAt(tx, serverTime); err != nil {
return errors.Wrap(err, "updating last sync at")
}
@ -893,9 +1069,34 @@ func saveSyncState(tx *infra.DB, serverTime int64, serverMaxUSN int) error {
return nil
}
func newRun(ctx infra.DnoteCtx) core.RunEFunc {
// prepareEmptyServerSync marks all local books and notes as dirty when syncing to an empty server.
// This is typically used when switching to a new empty server but wanting to upload existing local data.
// Returns true if preparation was done, false otherwise.
func prepareEmptyServerSync(tx *database.DB) error {
// Mark all books and notes as dirty and reset USN to 0
if _, err := tx.Exec("UPDATE books SET usn = 0, dirty = 1 WHERE deleted = 0"); err != nil {
return errors.Wrap(err, "marking books as dirty")
}
if _, err := tx.Exec("UPDATE notes SET usn = 0, dirty = 1 WHERE deleted = 0"); err != nil {
return errors.Wrap(err, "marking notes as dirty")
}
// Reset lastMaxUSN to 0 to match the server
if err := updateLastMaxUSN(tx, 0); err != nil {
return errors.Wrap(err, "resetting last max usn")
}
return nil
}
func newRun(ctx context.DnoteCtx) infra.RunEFunc {
return func(cmd *cobra.Command, args []string) error {
if ctx.SessionKey == "" || ctx.CipherKey == nil {
// Override APIEndpoint if flag was provided
if apiEndpointFlag != "" {
ctx.APIEndpoint = apiEndpointFlag
}
if ctx.SessionKey == "" {
return errors.New("not logged in")
}
@ -923,6 +1124,74 @@ func newRun(ctx infra.DnoteCtx) core.RunEFunc {
log.Debug("lastSyncAt: %d, lastMaxUSN: %d, syncState: %+v\n", lastSyncAt, lastMaxUSN, syncState)
// Handle a case where server has MaxUSN=0 but local has data (server switch)
var bookCount, noteCount int
if err := tx.QueryRow("SELECT count(*) FROM books WHERE deleted = 0").Scan(&bookCount); err != nil {
return errors.Wrap(err, "counting local books")
}
if err := tx.QueryRow("SELECT count(*) FROM notes WHERE deleted = 0").Scan(&noteCount); err != nil {
return errors.Wrap(err, "counting local notes")
}
// If a client has previously synced (lastMaxUSN > 0) but the server was never synced to (MaxUSN = 0),
// and the client has undeleted books or notes, allow to upload all data to the server.
// The client might have switched servers or the server might need to be restored for any reasons.
if syncState.MaxUSN == 0 && lastMaxUSN > 0 && (bookCount > 0 || noteCount > 0) {
log.Debug("empty server detected: server.MaxUSN=%d, local.MaxUSN=%d, books=%d, notes=%d\n",
syncState.MaxUSN, lastMaxUSN, bookCount, noteCount)
log.Warnf("The server is empty but you have local data. Maybe you switched servers?\n")
log.Debug("server state: MaxUSN = 0 (empty)\n")
log.Debug("local state: %d books, %d notes (MaxUSN = %d)\n", bookCount, noteCount, lastMaxUSN)
confirmed, err := ui.Confirm(fmt.Sprintf("Upload %d books and %d notes to the server?", bookCount, noteCount), false)
if err != nil {
tx.Rollback()
return errors.Wrap(err, "getting user confirmation")
}
if !confirmed {
tx.Rollback()
return errors.New("sync cancelled by user")
}
fmt.Println() // Add newline after confirmation.
if err := prepareEmptyServerSync(tx); err != nil {
return errors.Wrap(err, "preparing for empty server sync")
}
// Re-fetch lastMaxUSN after prepareEmptyServerSync
lastMaxUSN, err = getLastMaxUSN(tx)
if err != nil {
return errors.Wrap(err, "getting the last max_usn after prepare")
}
log.Debug("prepared empty server sync: marked %d books and %d notes as dirty\n", bookCount, noteCount)
}
// If full sync will be triggered by FullSyncBefore (not manual --full flag),
// and client has more data than server, prepare local data for upload to avoid orphaning notes.
// The lastMaxUSN > syncState.MaxUSN check prevents duplicate uploads when switching
// back to a server that already has our data.
if !isFullSync && lastSyncAt < syncState.FullSyncBefore && lastMaxUSN > syncState.MaxUSN {
log.Debug("full sync triggered by FullSyncBefore: preparing local data for upload\n")
log.Debug("server.FullSyncBefore=%d, local.lastSyncAt=%d, local.MaxUSN=%d, server.MaxUSN=%d, books=%d, notes=%d\n",
syncState.FullSyncBefore, lastSyncAt, lastMaxUSN, syncState.MaxUSN, bookCount, noteCount)
if err := prepareEmptyServerSync(tx); err != nil {
return errors.Wrap(err, "preparing local data for full sync")
}
// Re-fetch lastMaxUSN after prepareEmptyServerSync
lastMaxUSN, err = getLastMaxUSN(tx)
if err != nil {
return errors.Wrap(err, "getting the last max_usn after prepare")
}
log.Debug("prepared for full sync: marked %d books and %d notes as dirty\n", bookCount, noteCount)
}
var syncErr error
if isFullSync || lastSyncAt < syncState.FullSyncBefore {
syncErr = fullSync(ctx, tx)
@ -961,13 +1230,25 @@ func newRun(ctx infra.DnoteCtx) core.RunEFunc {
tx.Rollback()
return errors.Wrap(err, "performing the follow-up step sync")
}
// After syncing server changes (which resolves conflicts), send local changes again
// This uploads books/notes that were skipped due to 409 conflicts
_, err = sendChanges(ctx, tx)
if err != nil {
tx.Rollback()
return errors.Wrap(err, "sending changes after conflict resolution")
}
}
tx.Commit()
if err := tx.Commit(); err != nil {
return errors.Wrap(err, "committing transaction")
}
log.Success("success\n")
if err := core.CheckUpdate(ctx); err != nil {
checkPostSyncIntegrity(ctx.DB)
if err := upgrade.Check(ctx); err != nil {
log.Error(errors.Wrap(err, "automatically checking updates").Error())
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,37 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package version
import (
"fmt"
"github.com/dnote/dnote/pkg/cli/context"
"github.com/spf13/cobra"
)
// NewCmd returns a new version command
func NewCmd(ctx context.DnoteCtx) *cobra.Command {
cmd := &cobra.Command{
Use: "version",
Short: "Print the version number of Dnote",
Long: "Print the version number of Dnote",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("dnote %s\n", ctx.Version)
},
}
return cmd
}

149
pkg/cli/cmd/view/book.go Normal file
View file

@ -0,0 +1,149 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package view
import (
"database/sql"
"fmt"
"io"
"strings"
"github.com/dnote/dnote/pkg/cli/context"
"github.com/dnote/dnote/pkg/cli/log"
"github.com/pkg/errors"
)
// bookInfo is an information about the book to be printed on screen
type bookInfo struct {
BookLabel string
NoteCount int
}
// noteInfo is an information about the note to be printed on screen
type noteInfo struct {
RowID int
Body string
}
// getNewlineIdx returns the index of newline character in a string
func getNewlineIdx(str string) int {
// Check for \r\n first
if idx := strings.Index(str, "\r\n"); idx != -1 {
return idx
}
// Then check for \n
return strings.Index(str, "\n")
}
// formatBody returns an excerpt of the given raw note content and a boolean
// indicating if the returned string has been excertped
func formatBody(noteBody string) (string, bool) {
trimmed := strings.TrimRight(noteBody, "\r\n")
newlineIdx := getNewlineIdx(trimmed)
if newlineIdx > -1 {
ret := strings.Trim(trimmed[0:newlineIdx], " ")
return ret, true
}
return strings.Trim(trimmed, " "), false
}
func printBookLine(w io.Writer, info bookInfo, nameOnly bool) {
if nameOnly {
fmt.Fprintln(w, info.BookLabel)
} else {
fmt.Fprintf(w, "%s %s\n", info.BookLabel, log.ColorYellow.Sprintf("(%d)", info.NoteCount))
}
}
func listBooks(ctx context.DnoteCtx, w io.Writer, nameOnly bool) error {
db := ctx.DB
rows, err := db.Query(`SELECT books.label, count(notes.uuid) note_count
FROM books
LEFT JOIN notes ON notes.book_uuid = books.uuid AND notes.deleted = false
WHERE books.deleted = false
GROUP BY books.uuid
ORDER BY books.label ASC;`)
if err != nil {
return errors.Wrap(err, "querying books")
}
defer rows.Close()
infos := []bookInfo{}
for rows.Next() {
var info bookInfo
err = rows.Scan(&info.BookLabel, &info.NoteCount)
if err != nil {
return errors.Wrap(err, "scanning a row")
}
infos = append(infos, info)
}
for _, info := range infos {
printBookLine(w, info, nameOnly)
}
return nil
}
func listNotes(ctx context.DnoteCtx, w io.Writer, bookName string) error {
db := ctx.DB
var bookUUID string
err := db.QueryRow("SELECT uuid FROM books WHERE label = ?", bookName).Scan(&bookUUID)
if err == sql.ErrNoRows {
return errors.New("book not found")
} else if err != nil {
return errors.Wrap(err, "querying the book")
}
rows, err := db.Query(`SELECT rowid, body FROM notes WHERE book_uuid = ? AND deleted = ? ORDER BY added_on ASC;`, bookUUID, false)
if err != nil {
return errors.Wrap(err, "querying notes")
}
defer rows.Close()
infos := []noteInfo{}
for rows.Next() {
var info noteInfo
err = rows.Scan(&info.RowID, &info.Body)
if err != nil {
return errors.Wrap(err, "scanning a row")
}
infos = append(infos, info)
}
fmt.Fprintf(w, "on book %s\n", bookName)
for _, info := range infos {
body, isExcerpt := formatBody(info.Body)
rowid := log.ColorYellow.Sprintf("(%d)", info.RowID)
if isExcerpt {
body = fmt.Sprintf("%s %s", body, log.ColorYellow.Sprintf("[---More---]"))
}
fmt.Fprintf(w, "%s %s\n", rowid, body)
}
return nil
}

View file

@ -0,0 +1,184 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package view
import (
"bytes"
"fmt"
"strings"
"testing"
"github.com/dnote/dnote/pkg/assert"
"github.com/dnote/dnote/pkg/cli/context"
"github.com/dnote/dnote/pkg/cli/database"
)
func TestGetNewlineIdx(t *testing.T) {
testCases := []struct {
input string
expected int
}{
{
input: "hello\nworld",
expected: 5,
},
{
input: "hello\r\nworld",
expected: 5,
},
{
input: "no newline here",
expected: -1,
},
{
input: "",
expected: -1,
},
{
input: "\n",
expected: 0,
},
{
input: "\r\n",
expected: 0,
},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("input: %q", tc.input), func(t *testing.T) {
got := getNewlineIdx(tc.input)
assert.Equal(t, got, tc.expected, "newline index mismatch")
})
}
}
func TestFormatBody(t *testing.T) {
testCases := []struct {
input string
expectedBody string
expectedExcerpt bool
}{
{
input: "single line",
expectedBody: "single line",
expectedExcerpt: false,
},
{
input: "first line\nsecond line",
expectedBody: "first line",
expectedExcerpt: true,
},
{
input: "first line\r\nsecond line",
expectedBody: "first line",
expectedExcerpt: true,
},
{
input: " spaced line ",
expectedBody: "spaced line",
expectedExcerpt: false,
},
{
input: " first line \nsecond line",
expectedBody: "first line",
expectedExcerpt: true,
},
{
input: "",
expectedBody: "",
expectedExcerpt: false,
},
{
input: "line with trailing newline\n",
expectedBody: "line with trailing newline",
expectedExcerpt: false,
},
{
input: "line with trailing newlines\n\n",
expectedBody: "line with trailing newlines",
expectedExcerpt: false,
},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("input: %q", tc.input), func(t *testing.T) {
gotBody, gotExcerpt := formatBody(tc.input)
assert.Equal(t, gotBody, tc.expectedBody, "formatted body mismatch")
assert.Equal(t, gotExcerpt, tc.expectedExcerpt, "excerpt flag mismatch")
})
}
}
func TestListNotes(t *testing.T) {
// Setup
db := database.InitTestMemoryDB(t)
defer db.Close()
bookUUID := "js-book-uuid"
database.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", bookUUID, "javascript")
database.MustExec(t, "inserting note 1", db, "INSERT INTO notes (uuid, book_uuid, body, added_on) VALUES (?, ?, ?, ?)", "note-1", bookUUID, "first note", 1515199943)
database.MustExec(t, "inserting note 2", db, "INSERT INTO notes (uuid, book_uuid, body, added_on) VALUES (?, ?, ?, ?)", "note-2", bookUUID, "multiline note\nwith second line", 1515199945)
ctx := context.DnoteCtx{DB: db}
var buf bytes.Buffer
// Execute
err := listNotes(ctx, &buf, "javascript")
if err != nil {
t.Fatal(err)
}
got := buf.String()
// Verify output
assert.Equal(t, strings.Contains(got, "on book javascript"), true, "should show book name")
assert.Equal(t, strings.Contains(got, "first note"), true, "should contain first note")
assert.Equal(t, strings.Contains(got, "multiline note"), true, "should show first line of multiline note")
assert.Equal(t, strings.Contains(got, "[---More---]"), true, "should show more indicator for multiline note")
assert.Equal(t, strings.Contains(got, "with second line"), false, "should not show second line of multiline note")
}
func TestListBooks(t *testing.T) {
// Setup
db := database.InitTestMemoryDB(t)
defer db.Close()
b1UUID := "js-book-uuid"
b2UUID := "linux-book-uuid"
database.MustExec(t, "inserting book 1", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "javascript")
database.MustExec(t, "inserting book 2", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "linux")
// Add notes to test count
database.MustExec(t, "inserting note 1", db, "INSERT INTO notes (uuid, book_uuid, body, added_on) VALUES (?, ?, ?, ?)", "note-1", b1UUID, "note body 1", 1515199943)
database.MustExec(t, "inserting note 2", db, "INSERT INTO notes (uuid, book_uuid, body, added_on) VALUES (?, ?, ?, ?)", "note-2", b1UUID, "note body 2", 1515199944)
ctx := context.DnoteCtx{DB: db}
var buf bytes.Buffer
// Execute
err := listBooks(ctx, &buf, false)
if err != nil {
t.Fatal(err)
}
got := buf.String()
// Verify output
assert.Equal(t, strings.Contains(got, "javascript"), true, "should contain javascript book")
assert.Equal(t, strings.Contains(got, "linux"), true, "should contain linux book")
assert.Equal(t, strings.Contains(got, "(2)"), true, "should show 2 notes for javascript")
}

47
pkg/cli/cmd/view/note.go Normal file
View file

@ -0,0 +1,47 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package view
import (
"io"
"strconv"
"github.com/dnote/dnote/pkg/cli/context"
"github.com/dnote/dnote/pkg/cli/database"
"github.com/dnote/dnote/pkg/cli/output"
"github.com/pkg/errors"
)
func viewNote(ctx context.DnoteCtx, w io.Writer, noteRowIDArg string, contentOnly bool) error {
noteRowID, err := strconv.Atoi(noteRowIDArg)
if err != nil {
return errors.Wrap(err, "invalid rowid")
}
db := ctx.DB
info, err := database.GetNoteInfo(db, noteRowID)
if err != nil {
return err
}
if contentOnly {
output.NoteContent(w, info)
} else {
output.NoteInfo(w, info)
}
return nil
}

View file

@ -0,0 +1,90 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package view
import (
"bytes"
"strings"
"testing"
"github.com/dnote/dnote/pkg/assert"
"github.com/dnote/dnote/pkg/cli/context"
"github.com/dnote/dnote/pkg/cli/database"
)
func TestViewNote(t *testing.T) {
db := database.InitTestMemoryDB(t)
defer db.Close()
bookUUID := "test-book-uuid"
database.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", bookUUID, "golang")
database.MustExec(t, "inserting note", db, "INSERT INTO notes (uuid, book_uuid, body, added_on) VALUES (?, ?, ?, ?)",
"note-uuid", bookUUID, "test note content", 1515199943000000000)
ctx := context.DnoteCtx{DB: db}
var buf bytes.Buffer
err := viewNote(ctx, &buf, "1", false)
if err != nil {
t.Fatal(err)
}
got := buf.String()
assert.Equal(t, strings.Contains(got, "test note content"), true, "should contain note content")
}
func TestViewNoteContentOnly(t *testing.T) {
db := database.InitTestMemoryDB(t)
defer db.Close()
bookUUID := "test-book-uuid"
database.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", bookUUID, "golang")
database.MustExec(t, "inserting note", db, "INSERT INTO notes (uuid, book_uuid, body, added_on) VALUES (?, ?, ?, ?)",
"note-uuid", bookUUID, "test note content", 1515199943000000000)
ctx := context.DnoteCtx{DB: db}
var buf bytes.Buffer
err := viewNote(ctx, &buf, "1", true)
if err != nil {
t.Fatal(err)
}
got := buf.String()
assert.Equal(t, got, "test note content", "should contain only note content")
}
func TestViewNoteInvalidRowID(t *testing.T) {
db := database.InitTestMemoryDB(t)
defer db.Close()
ctx := context.DnoteCtx{DB: db}
var buf bytes.Buffer
err := viewNote(ctx, &buf, "not-a-number", false)
assert.NotEqual(t, err, nil, "should return error for invalid rowid")
}
func TestViewNoteNotFound(t *testing.T) {
db := database.InitTestMemoryDB(t)
defer db.Close()
ctx := context.DnoteCtx{DB: db}
var buf bytes.Buffer
err := viewNote(ctx, &buf, "999", false)
assert.NotEqual(t, err, nil, "should return error for non-existent note")
}

92
pkg/cli/cmd/view/view.go Normal file
View file

@ -0,0 +1,92 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package view
import (
"os"
"github.com/dnote/dnote/pkg/cli/context"
"github.com/dnote/dnote/pkg/cli/infra"
"github.com/dnote/dnote/pkg/cli/utils"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var example = `
* View all books
dnote view
* List notes in a book
dnote view javascript
* View a particular note in a book
dnote view javascript 0
`
var nameOnly bool
var contentOnly bool
func preRun(cmd *cobra.Command, args []string) error {
if len(args) > 2 {
return errors.New("Incorrect number of argument")
}
return nil
}
// NewCmd returns a new view command
func NewCmd(ctx context.DnoteCtx) *cobra.Command {
cmd := &cobra.Command{
Use: "view <book name?> <note index?>",
Aliases: []string{"v"},
Short: "List books, notes or view a content",
Example: example,
RunE: newRun(ctx),
PreRunE: preRun,
}
f := cmd.Flags()
f.BoolVarP(&nameOnly, "name-only", "", false, "print book names only")
f.BoolVarP(&contentOnly, "content-only", "", false, "print the note content only")
return cmd
}
func newRun(ctx context.DnoteCtx) infra.RunEFunc {
return func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
// List all books
return listBooks(ctx, os.Stdout, nameOnly)
} else if len(args) == 1 {
if nameOnly {
return errors.New("--name-only flag is only valid when viewing books")
}
if utils.IsNumber(args[0]) {
// View a note by index
return viewNote(ctx, os.Stdout, args[0], contentOnly)
} else {
// List notes in a book
return listNotes(ctx, os.Stdout, args[0])
}
} else if len(args) == 2 {
// View a note in a book (book name + note index)
return viewNote(ctx, os.Stdout, args[1], contentOnly)
}
return errors.New("Incorrect number of arguments")
}
}

94
pkg/cli/config/config.go Normal file
View file

@ -0,0 +1,94 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package config
import (
"fmt"
"os"
"github.com/dnote/dnote/pkg/cli/consts"
"github.com/dnote/dnote/pkg/cli/context"
"github.com/dnote/dnote/pkg/cli/log"
"github.com/dnote/dnote/pkg/cli/utils"
"github.com/pkg/errors"
"gopkg.in/yaml.v2"
)
// Config holds dnote configuration
type Config struct {
Editor string `yaml:"editor"`
APIEndpoint string `yaml:"apiEndpoint"`
EnableUpgradeCheck bool `yaml:"enableUpgradeCheck"`
}
func checkLegacyPath(ctx context.DnoteCtx) (string, bool) {
legacyPath := fmt.Sprintf("%s/%s", ctx.Paths.LegacyDnote, consts.ConfigFilename)
ok, err := utils.FileExists(legacyPath)
if err != nil {
log.Error(errors.Wrapf(err, "checking legacy dnote directory at %s", legacyPath).Error())
}
if ok {
return legacyPath, true
}
return "", false
}
// GetPath returns the path to the dnote config file
func GetPath(ctx context.DnoteCtx) string {
legacyPath, ok := checkLegacyPath(ctx)
if ok {
return legacyPath
}
return fmt.Sprintf("%s/%s/%s", ctx.Paths.Config, consts.DnoteDirName, consts.ConfigFilename)
}
// Read reads the config file
func Read(ctx context.DnoteCtx) (Config, error) {
var ret Config
configPath := GetPath(ctx)
b, err := os.ReadFile(configPath)
if err != nil {
return ret, errors.Wrap(err, "reading config file")
}
err = yaml.Unmarshal(b, &ret)
if err != nil {
return ret, errors.Wrap(err, "unmarshalling config")
}
return ret, nil
}
// Write writes the config to the config file
func Write(ctx context.DnoteCtx, cf Config) error {
path := GetPath(ctx)
b, err := yaml.Marshal(cf)
if err != nil {
return errors.Wrap(err, "marshalling config into YAML")
}
err = os.WriteFile(path, b, 0644)
if err != nil {
return errors.Wrap(err, "writing the config file")
}
return nil
}

47
pkg/cli/consts/consts.go Normal file
View file

@ -0,0 +1,47 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Package consts provides definitions of constants
package consts
var (
// LegacyDnoteDirName is the name of the legacy directory containing dnote files
LegacyDnoteDirName = ".dnote"
// DnoteDirName is the name of the directory containing dnote files
DnoteDirName = "dnote"
// DnoteDBFileName is a filename for the Dnote SQLite database
DnoteDBFileName = "dnote.db"
// TmpContentFileBase is the base for the filename for a temporary content
TmpContentFileBase = "DNOTE_TMPCONTENT"
// TmpContentFileExt is the extension for the temporary content file
TmpContentFileExt = "md"
// ConfigFilename is the name of the config file
ConfigFilename = "dnoterc"
// SystemSchema is the key for schema in the system table
SystemSchema = "schema"
// SystemRemoteSchema is the key for remote schema in the system table
SystemRemoteSchema = "remote_schema"
// SystemLastSyncAt is the timestamp of the server at the last sync
SystemLastSyncAt = "last_sync_time"
// SystemLastMaxUSN is the user's max_usn from the server at the alst sync
SystemLastMaxUSN = "last_max_usn"
// SystemLastUpgrade is the timestamp at which the system more recently checked for an upgrade
SystemLastUpgrade = "last_upgrade"
// SystemSessionKey is the session key
SystemSessionKey = "session_token"
// SystemSessionKeyExpiry is the timestamp at which the session key will expire
SystemSessionKeyExpiry = "session_token_expiry"
)

61
pkg/cli/context/ctx.go Normal file
View file

@ -0,0 +1,61 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Package context defines dnote context
package context
import (
"net/http"
"github.com/dnote/dnote/pkg/cli/database"
"github.com/dnote/dnote/pkg/clock"
)
// Paths contain directory definitions
type Paths struct {
Home string
Config string
Data string
Cache string
LegacyDnote string
}
// DnoteCtx is a context holding the information of the current runtime
type DnoteCtx struct {
Paths Paths
APIEndpoint string
Version string
DB *database.DB
SessionKey string
SessionKeyExpiry int64
Editor string
Clock clock.Clock
EnableUpgradeCheck bool
HTTPClient *http.Client
}
// Redact replaces private information from the context with a set of
// placeholder values.
func Redact(ctx DnoteCtx) DnoteCtx {
var sessionKey string
if ctx.SessionKey != "" {
sessionKey = "1"
} else {
sessionKey = "0"
}
ctx.SessionKey = sessionKey
return ctx
}

48
pkg/cli/context/files.go Normal file
View file

@ -0,0 +1,48 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package context
import (
"path/filepath"
"github.com/dnote/dnote/pkg/cli/consts"
"github.com/dnote/dnote/pkg/cli/utils"
"github.com/pkg/errors"
)
// InitDnoteDirs creates the dnote directories if they don't already exist.
func InitDnoteDirs(paths Paths) error {
if paths.Config != "" {
configDir := filepath.Join(paths.Config, consts.DnoteDirName)
if err := utils.EnsureDir(configDir); err != nil {
return errors.Wrap(err, "initializing config dir")
}
}
if paths.Data != "" {
dataDir := filepath.Join(paths.Data, consts.DnoteDirName)
if err := utils.EnsureDir(dataDir); err != nil {
return errors.Wrap(err, "initializing data dir")
}
}
if paths.Cache != "" {
cacheDir := filepath.Join(paths.Cache, consts.DnoteDirName)
if err := utils.EnsureDir(cacheDir); err != nil {
return errors.Wrap(err, "initializing cache dir")
}
}
return nil
}

View file

@ -0,0 +1,62 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package context
import (
"os"
"path/filepath"
"testing"
"github.com/dnote/dnote/pkg/assert"
"github.com/dnote/dnote/pkg/cli/consts"
)
func assertDirsExist(t *testing.T, paths Paths) {
configDir := filepath.Join(paths.Config, consts.DnoteDirName)
info, err := os.Stat(configDir)
assert.Equal(t, err, nil, "config dir should exist")
assert.Equal(t, info.IsDir(), true, "config should be a directory")
dataDir := filepath.Join(paths.Data, consts.DnoteDirName)
info, err = os.Stat(dataDir)
assert.Equal(t, err, nil, "data dir should exist")
assert.Equal(t, info.IsDir(), true, "data should be a directory")
cacheDir := filepath.Join(paths.Cache, consts.DnoteDirName)
info, err = os.Stat(cacheDir)
assert.Equal(t, err, nil, "cache dir should exist")
assert.Equal(t, info.IsDir(), true, "cache should be a directory")
}
func TestInitDnoteDirs(t *testing.T) {
tmpDir := t.TempDir()
paths := Paths{
Config: filepath.Join(tmpDir, "config"),
Data: filepath.Join(tmpDir, "data"),
Cache: filepath.Join(tmpDir, "cache"),
}
// Initialize directories
err := InitDnoteDirs(paths)
assert.Equal(t, err, nil, "InitDnoteDirs should succeed")
assertDirsExist(t, paths)
// Call again - should be idempotent
err = InitDnoteDirs(paths)
assert.Equal(t, err, nil, "InitDnoteDirs should succeed when dirs already exist")
assertDirsExist(t, paths)
}

View file

@ -0,0 +1,100 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package context
import (
"path/filepath"
"testing"
"github.com/dnote/dnote/pkg/cli/consts"
"github.com/dnote/dnote/pkg/cli/database"
"github.com/dnote/dnote/pkg/clock"
"github.com/pkg/errors"
)
// getDefaultTestPaths creates default test paths with all paths pointing to a temp directory
func getDefaultTestPaths(t *testing.T) Paths {
tmpDir := t.TempDir()
return Paths{
Home: tmpDir,
Cache: tmpDir,
Config: tmpDir,
Data: tmpDir,
}
}
// InitTestCtx initializes a test context with an in-memory database
// and a temporary directory for all paths
func InitTestCtx(t *testing.T) DnoteCtx {
paths := getDefaultTestPaths(t)
db := database.InitTestMemoryDB(t)
if err := InitDnoteDirs(paths); err != nil {
t.Fatal(errors.Wrap(err, "creating test directories"))
}
return DnoteCtx{
DB: db,
Paths: paths,
Clock: clock.NewMock(), // Use a mock clock to test times
}
}
// InitTestCtxWithDB initializes a test context with the provided database
// and a temporary directory for all paths.
// Used when you need full control over database initialization (e.g. migration tests).
func InitTestCtxWithDB(t *testing.T, db *database.DB) DnoteCtx {
paths := getDefaultTestPaths(t)
if err := InitDnoteDirs(paths); err != nil {
t.Fatal(errors.Wrap(err, "creating test directories"))
}
return DnoteCtx{
DB: db,
Paths: paths,
Clock: clock.NewMock(), // Use a mock clock to test times
}
}
// InitTestCtxWithFileDB initializes a test context with a file-based database
// at the expected path.
func InitTestCtxWithFileDB(t *testing.T) DnoteCtx {
paths := getDefaultTestPaths(t)
if err := InitDnoteDirs(paths); err != nil {
t.Fatal(errors.Wrap(err, "creating test directories"))
}
dbPath := filepath.Join(paths.Data, consts.DnoteDirName, consts.DnoteDBFileName)
db, err := database.Open(dbPath)
if err != nil {
t.Fatal(errors.Wrap(err, "opening database"))
}
if _, err := db.Exec(database.GetDefaultSchemaSQL()); err != nil {
t.Fatal(errors.Wrap(err, "running schema sql"))
}
t.Cleanup(func() { db.Close() })
return DnoteCtx{
DB: db,
Paths: paths,
Clock: clock.NewMock(), // Use a mock clock to test times
}
}

View file

@ -1,25 +1,21 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
/* Copyright 2025 Dnote Authors
*
* This file is part of Dnote CLI.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* Dnote CLI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* http://www.apache.org/licenses/LICENSE-2.0
*
* Dnote CLI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Dnote CLI. If not, see <https://www.gnu.org/licenses/>.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package core
package database
import (
"github.com/dnote/dnote/cli/infra"
"github.com/pkg/errors"
)
@ -35,19 +31,19 @@ type Book struct {
// Note represents a note
type Note struct {
RowID int `json:"rowid"`
UUID string `json:"uuid"`
BookUUID string `json:"book_uuid"`
Body string `json:"content"`
AddedOn int64 `json:"added_on"`
EditedOn int64 `json:"edited_on"`
USN int `json:"usn"`
Public bool `json:"public"`
Deleted bool `json:"deleted"`
Dirty bool `json:"dirty"`
}
// NewNote constructs a note with the given data
func NewNote(uuid, bookUUID, body string, addedOn, editedOn int64, usn int, public, deleted, dirty bool) Note {
func NewNote(uuid, bookUUID, body string, addedOn, editedOn int64, usn int, deleted, dirty bool) Note {
return Note{
UUID: uuid,
BookUUID: bookUUID,
@ -55,16 +51,15 @@ func NewNote(uuid, bookUUID, body string, addedOn, editedOn int64, usn int, publ
AddedOn: addedOn,
EditedOn: editedOn,
USN: usn,
Public: public,
Deleted: deleted,
Dirty: dirty,
}
}
// Insert inserts a new note
func (n Note) Insert(db *infra.DB) error {
_, err := db.Exec("INSERT INTO notes (uuid, book_uuid, body, added_on, edited_on, usn, public, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
n.UUID, n.BookUUID, n.Body, n.AddedOn, n.EditedOn, n.USN, n.Public, n.Deleted, n.Dirty)
func (n Note) Insert(db *DB) error {
_, err := db.Exec("INSERT INTO notes (uuid, book_uuid, body, added_on, edited_on, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
n.UUID, n.BookUUID, n.Body, n.AddedOn, n.EditedOn, n.USN, n.Deleted, n.Dirty)
if err != nil {
return errors.Wrapf(err, "inserting note with uuid %s", n.UUID)
@ -74,9 +69,9 @@ func (n Note) Insert(db *infra.DB) error {
}
// Update updates the note with the given data
func (n Note) Update(db *infra.DB) error {
_, err := db.Exec("UPDATE notes SET book_uuid = ?, body = ?, added_on = ?, edited_on = ?, usn = ?, public = ?, deleted = ?, dirty = ? WHERE uuid = ?",
n.BookUUID, n.Body, n.AddedOn, n.EditedOn, n.USN, n.Public, n.Deleted, n.Dirty, n.UUID)
func (n Note) Update(db *DB) error {
_, err := db.Exec("UPDATE notes SET book_uuid = ?, body = ?, added_on = ?, edited_on = ?, usn = ?, deleted = ?, dirty = ? WHERE uuid = ?",
n.BookUUID, n.Body, n.AddedOn, n.EditedOn, n.USN, n.Deleted, n.Dirty, n.UUID)
if err != nil {
return errors.Wrapf(err, "updating the note with uuid %s", n.UUID)
@ -86,7 +81,7 @@ func (n Note) Update(db *infra.DB) error {
}
// UpdateUUID updates the uuid of a book
func (n *Note) UpdateUUID(db *infra.DB, newUUID string) error {
func (n *Note) UpdateUUID(db *DB, newUUID string) error {
_, err := db.Exec("UPDATE notes SET uuid = ? WHERE uuid = ?", newUUID, n.UUID)
if err != nil {
@ -99,7 +94,7 @@ func (n *Note) UpdateUUID(db *infra.DB, newUUID string) error {
}
// Expunge hard-deletes the note from the database
func (n Note) Expunge(db *infra.DB) error {
func (n Note) Expunge(db *DB) error {
_, err := db.Exec("DELETE FROM notes WHERE uuid = ?", n.UUID)
if err != nil {
return errors.Wrap(err, "expunging a note locally")
@ -120,7 +115,7 @@ func NewBook(uuid, label string, usn int, deleted, dirty bool) Book {
}
// Insert inserts a new book
func (b Book) Insert(db *infra.DB) error {
func (b Book) Insert(db *DB) error {
_, err := db.Exec("INSERT INTO books (uuid, label, usn, dirty, deleted) VALUES (?, ?, ?, ?, ?)",
b.UUID, b.Label, b.USN, b.Dirty, b.Deleted)
@ -132,7 +127,7 @@ func (b Book) Insert(db *infra.DB) error {
}
// Update updates the book with the given data
func (b Book) Update(db *infra.DB) error {
func (b Book) Update(db *DB) error {
_, err := db.Exec("UPDATE books SET label = ?, usn = ?, dirty = ?, deleted = ? WHERE uuid = ?",
b.Label, b.USN, b.Dirty, b.Deleted, b.UUID)
@ -144,7 +139,7 @@ func (b Book) Update(db *infra.DB) error {
}
// UpdateUUID updates the uuid of a book
func (b *Book) UpdateUUID(db *infra.DB, newUUID string) error {
func (b *Book) UpdateUUID(db *DB, newUUID string) error {
_, err := db.Exec("UPDATE books SET uuid = ? WHERE uuid = ?", newUUID, b.UUID)
if err != nil {
@ -157,7 +152,7 @@ func (b *Book) UpdateUUID(db *infra.DB, newUUID string) error {
}
// Expunge hard-deletes the book from the database
func (b Book) Expunge(db *infra.DB) error {
func (b Book) Expunge(db *DB) error {
_, err := db.Exec("DELETE FROM books WHERE uuid = ?", b.UUID)
if err != nil {
return errors.Wrap(err, "expunging a book locally")

View file

@ -0,0 +1,851 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package database
import (
"fmt"
"testing"
"github.com/dnote/dnote/pkg/assert"
"github.com/pkg/errors"
)
func TestNewNote(t *testing.T) {
testCases := []struct {
uuid string
bookUUID string
body string
addedOn int64
editedOn int64
usn int
deleted bool
dirty bool
}{
{
uuid: "n1-uuid",
bookUUID: "b1-uuid",
body: "n1-body",
addedOn: 1542058875,
editedOn: 0,
usn: 0,
deleted: false,
dirty: false,
},
{
uuid: "n2-uuid",
bookUUID: "b2-uuid",
body: "n2-body",
addedOn: 1542058875,
editedOn: 1542058876,
usn: 1008,
deleted: true,
dirty: true,
},
}
for idx, tc := range testCases {
got := NewNote(tc.uuid, tc.bookUUID, tc.body, tc.addedOn, tc.editedOn, tc.usn, tc.deleted, tc.dirty)
assert.Equal(t, got.UUID, tc.uuid, fmt.Sprintf("UUID mismatch for test case %d", idx))
assert.Equal(t, got.BookUUID, tc.bookUUID, fmt.Sprintf("BookUUID mismatch for test case %d", idx))
assert.Equal(t, got.Body, tc.body, fmt.Sprintf("Body mismatch for test case %d", idx))
assert.Equal(t, got.AddedOn, tc.addedOn, fmt.Sprintf("AddedOn mismatch for test case %d", idx))
assert.Equal(t, got.EditedOn, tc.editedOn, fmt.Sprintf("EditedOn mismatch for test case %d", idx))
assert.Equal(t, got.USN, tc.usn, fmt.Sprintf("USN mismatch for test case %d", idx))
assert.Equal(t, got.Deleted, tc.deleted, fmt.Sprintf("Deleted mismatch for test case %d", idx))
assert.Equal(t, got.Dirty, tc.dirty, fmt.Sprintf("Dirty mismatch for test case %d", idx))
}
}
func TestNoteInsert(t *testing.T) {
testCases := []struct {
uuid string
bookUUID string
body string
addedOn int64
editedOn int64
usn int
deleted bool
dirty bool
}{
{
uuid: "n1-uuid",
bookUUID: "b1-uuid",
body: "n1-body",
addedOn: 1542058875,
editedOn: 0,
usn: 0,
deleted: false,
dirty: false,
},
{
uuid: "n2-uuid",
bookUUID: "b2-uuid",
body: "n2-body",
addedOn: 1542058875,
editedOn: 1542058876,
usn: 1008,
deleted: true,
dirty: true,
},
}
for idx, tc := range testCases {
func() {
// Setup
db := InitTestMemoryDB(t)
n := Note{
UUID: tc.uuid,
BookUUID: tc.bookUUID,
Body: tc.body,
AddedOn: tc.addedOn,
EditedOn: tc.editedOn,
USN: tc.usn,
Deleted: tc.deleted,
Dirty: tc.dirty,
}
// execute
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error())
}
if err := n.Insert(tx); err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error())
}
tx.Commit()
// test
var uuid, bookUUID, body string
var addedOn, editedOn int64
var usn int
var deleted, dirty bool
MustScan(t, "getting n1",
db.QueryRow("SELECT uuid, book_uuid, body, added_on, edited_on, usn, deleted, dirty FROM notes WHERE uuid = ?", tc.uuid),
&uuid, &bookUUID, &body, &addedOn, &editedOn, &usn, &deleted, &dirty)
assert.Equal(t, uuid, tc.uuid, fmt.Sprintf("uuid mismatch for test case %d", idx))
assert.Equal(t, bookUUID, tc.bookUUID, fmt.Sprintf("bookUUID mismatch for test case %d", idx))
assert.Equal(t, body, tc.body, fmt.Sprintf("body mismatch for test case %d", idx))
assert.Equal(t, addedOn, tc.addedOn, fmt.Sprintf("addedOn mismatch for test case %d", idx))
assert.Equal(t, editedOn, tc.editedOn, fmt.Sprintf("editedOn mismatch for test case %d", idx))
assert.Equal(t, usn, tc.usn, fmt.Sprintf("usn mismatch for test case %d", idx))
assert.Equal(t, deleted, tc.deleted, fmt.Sprintf("deleted mismatch for test case %d", idx))
assert.Equal(t, dirty, tc.dirty, fmt.Sprintf("dirty mismatch for test case %d", idx))
}()
}
}
func TestNoteUpdate(t *testing.T) {
testCases := []struct {
uuid string
bookUUID string
body string
addedOn int64
editedOn int64
usn int
deleted bool
dirty bool
newBookUUID string
newBody string
newEditedOn int64
newUSN int
newDeleted bool
newDirty bool
}{
{
uuid: "n1-uuid",
bookUUID: "b1-uuid",
body: "n1-body",
addedOn: 1542058875,
editedOn: 0,
usn: 0,
deleted: false,
dirty: false,
newBookUUID: "b1-uuid",
newBody: "n1-body edited",
newEditedOn: 1542058879,
newUSN: 0,
newDeleted: false,
newDirty: false,
},
{
uuid: "n1-uuid",
bookUUID: "b1-uuid",
body: "n1-body",
addedOn: 1542058875,
editedOn: 0,
usn: 0,
deleted: false,
dirty: true,
newBookUUID: "b2-uuid",
newBody: "n1-body",
newEditedOn: 1542058879,
newUSN: 0,
newDeleted: false,
newDirty: false,
},
{
uuid: "n1-uuid",
bookUUID: "b1-uuid",
body: "n1-body",
addedOn: 1542058875,
editedOn: 0,
usn: 10,
deleted: false,
dirty: false,
newBookUUID: "",
newBody: "",
newEditedOn: 1542058879,
newUSN: 151,
newDeleted: true,
newDirty: false,
},
{
uuid: "n1-uuid",
bookUUID: "b1-uuid",
body: "n1-body",
addedOn: 1542058875,
editedOn: 0,
usn: 0,
deleted: false,
dirty: false,
newBookUUID: "",
newBody: "",
newEditedOn: 1542058879,
newUSN: 15,
newDeleted: true,
newDirty: false,
},
}
for idx, tc := range testCases {
func() {
// Setup
db := InitTestMemoryDB(t)
n1 := Note{
UUID: tc.uuid,
BookUUID: tc.bookUUID,
Body: tc.body,
AddedOn: tc.addedOn,
EditedOn: tc.editedOn,
USN: tc.usn,
Deleted: tc.deleted,
Dirty: tc.dirty,
}
n2 := Note{
UUID: "n2-uuid",
BookUUID: "b10-uuid",
Body: "n2 body",
AddedOn: 1542058875,
EditedOn: 0,
USN: 39,
Deleted: false,
Dirty: false,
}
MustExec(t, fmt.Sprintf("inserting n1 for test case %d", idx), db, "INSERT INTO notes (uuid, book_uuid, usn, added_on, edited_on, body, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", n1.UUID, n1.BookUUID, n1.USN, n1.AddedOn, n1.EditedOn, n1.Body, n1.Deleted, n1.Dirty)
MustExec(t, fmt.Sprintf("inserting n2 for test case %d", idx), db, "INSERT INTO notes (uuid, book_uuid, usn, added_on, edited_on, body, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", n2.UUID, n2.BookUUID, n2.USN, n2.AddedOn, n2.EditedOn, n2.Body, n2.Deleted, n2.Dirty)
// execute
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error())
}
n1.BookUUID = tc.newBookUUID
n1.Body = tc.newBody
n1.EditedOn = tc.newEditedOn
n1.USN = tc.newUSN
n1.Deleted = tc.newDeleted
n1.Dirty = tc.newDirty
if err := n1.Update(tx); err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error())
}
tx.Commit()
// test
var n1Record, n2Record Note
MustScan(t, "getting n1",
db.QueryRow("SELECT uuid, book_uuid, body, added_on, edited_on, usn, deleted, dirty FROM notes WHERE uuid = ?", tc.uuid),
&n1Record.UUID, &n1Record.BookUUID, &n1Record.Body, &n1Record.AddedOn, &n1Record.EditedOn, &n1Record.USN, &n1Record.Deleted, &n1Record.Dirty)
MustScan(t, "getting n2",
db.QueryRow("SELECT uuid, book_uuid, body, added_on, edited_on, usn, deleted, dirty FROM notes WHERE uuid = ?", n2.UUID),
&n2Record.UUID, &n2Record.BookUUID, &n2Record.Body, &n2Record.AddedOn, &n2Record.EditedOn, &n2Record.USN, &n2Record.Deleted, &n2Record.Dirty)
assert.Equal(t, n1Record.UUID, n1.UUID, fmt.Sprintf("n1 uuid mismatch for test case %d", idx))
assert.Equal(t, n1Record.BookUUID, tc.newBookUUID, fmt.Sprintf("n1 bookUUID mismatch for test case %d", idx))
assert.Equal(t, n1Record.Body, tc.newBody, fmt.Sprintf("n1 body mismatch for test case %d", idx))
assert.Equal(t, n1Record.AddedOn, n1.AddedOn, fmt.Sprintf("n1 addedOn mismatch for test case %d", idx))
assert.Equal(t, n1Record.EditedOn, tc.newEditedOn, fmt.Sprintf("n1 editedOn mismatch for test case %d", idx))
assert.Equal(t, n1Record.USN, tc.newUSN, fmt.Sprintf("n1 usn mismatch for test case %d", idx))
assert.Equal(t, n1Record.Deleted, tc.newDeleted, fmt.Sprintf("n1 deleted mismatch for test case %d", idx))
assert.Equal(t, n1Record.Dirty, tc.newDirty, fmt.Sprintf("n1 dirty mismatch for test case %d", idx))
assert.Equal(t, n2Record.UUID, n2.UUID, fmt.Sprintf("n2 uuid mismatch for test case %d", idx))
assert.Equal(t, n2Record.BookUUID, n2.BookUUID, fmt.Sprintf("n2 bookUUID mismatch for test case %d", idx))
assert.Equal(t, n2Record.Body, n2.Body, fmt.Sprintf("n2 body mismatch for test case %d", idx))
assert.Equal(t, n2Record.AddedOn, n2.AddedOn, fmt.Sprintf("n2 addedOn mismatch for test case %d", idx))
assert.Equal(t, n2Record.EditedOn, n2.EditedOn, fmt.Sprintf("n2 editedOn mismatch for test case %d", idx))
assert.Equal(t, n2Record.USN, n2.USN, fmt.Sprintf("n2 usn mismatch for test case %d", idx))
assert.Equal(t, n2Record.Deleted, n2.Deleted, fmt.Sprintf("n2 deleted mismatch for test case %d", idx))
assert.Equal(t, n2Record.Dirty, n2.Dirty, fmt.Sprintf("n2 dirty mismatch for test case %d", idx))
}()
}
}
func TestNoteUpdateUUID(t *testing.T) {
testCases := []struct {
newUUID string
}{
{
newUUID: "n1-new-uuid",
},
{
newUUID: "n2-new-uuid",
},
}
for idx, tc := range testCases {
t.Run(fmt.Sprintf("testCase%d", idx), func(t *testing.T) {
// Setup
db := InitTestMemoryDB(t)
n1 := Note{
UUID: "n1-uuid",
BookUUID: "b1-uuid",
AddedOn: 1542058874,
Body: "n1-body",
USN: 1,
Deleted: true,
Dirty: false,
}
n2 := Note{
UUID: "n2-uuid",
BookUUID: "b1-uuid",
AddedOn: 1542058874,
Body: "n2-body",
USN: 1,
Deleted: true,
Dirty: false,
}
MustExec(t, "inserting n1", db, "INSERT INTO notes (uuid, book_uuid, body, added_on, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", n1.UUID, n1.BookUUID, n1.Body, n1.AddedOn, n1.USN, n1.Deleted, n1.Dirty)
MustExec(t, "inserting n2", db, "INSERT INTO notes (uuid, book_uuid, body, added_on, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?)", n2.UUID, n2.BookUUID, n2.Body, n2.AddedOn, n2.USN, n2.Deleted, n2.Dirty)
// execute
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, "beginning a transaction").Error())
}
if err := n1.UpdateUUID(tx, tc.newUUID); err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, "executing").Error())
}
tx.Commit()
// test
var n1Record, n2Record Note
MustScan(t, "getting n1",
db.QueryRow("SELECT uuid, body, usn, deleted, dirty FROM notes WHERE body = ?", "n1-body"),
&n1Record.UUID, &n1Record.Body, &n1Record.USN, &n1Record.Deleted, &n1Record.Dirty)
MustScan(t, "getting n2",
db.QueryRow("SELECT uuid, body, usn, deleted, dirty FROM notes WHERE body = ?", "n2-body"),
&n2Record.UUID, &n2Record.Body, &n2Record.USN, &n2Record.Deleted, &n2Record.Dirty)
assert.Equal(t, n1.UUID, tc.newUUID, "n1 original reference uuid mismatch")
assert.Equal(t, n1Record.UUID, tc.newUUID, "n1 uuid mismatch")
assert.Equal(t, n2Record.UUID, n2.UUID, "n2 uuid mismatch")
})
}
}
func TestNoteExpunge(t *testing.T) {
// Setup
db := InitTestMemoryDB(t)
n1 := Note{
UUID: "n1-uuid",
BookUUID: "b9-uuid",
Body: "n1 body",
AddedOn: 1542058874,
EditedOn: 0,
USN: 22,
Deleted: false,
Dirty: false,
}
n2 := Note{
UUID: "n2-uuid",
BookUUID: "b10-uuid",
Body: "n2 body",
AddedOn: 1542058875,
EditedOn: 0,
USN: 39,
Deleted: false,
Dirty: false,
}
MustExec(t, "inserting n1", db, "INSERT INTO notes (uuid, book_uuid, usn, added_on, edited_on, body, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", n1.UUID, n1.BookUUID, n1.USN, n1.AddedOn, n1.EditedOn, n1.Body, n1.Deleted, n1.Dirty)
MustExec(t, "inserting n2", db, "INSERT INTO notes (uuid, book_uuid, usn, added_on, edited_on, body, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", n2.UUID, n2.BookUUID, n2.USN, n2.AddedOn, n2.EditedOn, n2.Body, n2.Deleted, n2.Dirty)
// execute
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, "beginning a transaction").Error())
}
if err := n1.Expunge(tx); err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, "executing").Error())
}
tx.Commit()
// test
var noteCount int
MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), &noteCount)
assert.Equalf(t, noteCount, 1, "note count mismatch")
var n2Record Note
MustScan(t, "getting n2",
db.QueryRow("SELECT uuid, book_uuid, body, added_on, edited_on, usn, deleted, dirty FROM notes WHERE uuid = ?", n2.UUID),
&n2Record.UUID, &n2Record.BookUUID, &n2Record.Body, &n2Record.AddedOn, &n2Record.EditedOn, &n2Record.USN, &n2Record.Deleted, &n2Record.Dirty)
assert.Equal(t, n2Record.UUID, n2.UUID, "n2 uuid mismatch")
assert.Equal(t, n2Record.BookUUID, n2.BookUUID, "n2 bookUUID mismatch")
assert.Equal(t, n2Record.Body, n2.Body, "n2 body mismatch")
assert.Equal(t, n2Record.AddedOn, n2.AddedOn, "n2 addedOn mismatch")
assert.Equal(t, n2Record.EditedOn, n2.EditedOn, "n2 editedOn mismatch")
assert.Equal(t, n2Record.USN, n2.USN, "n2 usn mismatch")
assert.Equal(t, n2Record.Deleted, n2.Deleted, "n2 deleted mismatch")
assert.Equal(t, n2Record.Dirty, n2.Dirty, "n2 dirty mismatch")
}
func TestNewBook(t *testing.T) {
testCases := []struct {
uuid string
label string
usn int
deleted bool
dirty bool
}{
{
uuid: "b1-uuid",
label: "b1-label",
usn: 0,
deleted: false,
dirty: false,
},
{
uuid: "b2-uuid",
label: "b2-label",
usn: 1008,
deleted: false,
dirty: true,
},
}
for idx, tc := range testCases {
got := NewBook(tc.uuid, tc.label, tc.usn, tc.deleted, tc.dirty)
assert.Equal(t, got.UUID, tc.uuid, fmt.Sprintf("UUID mismatch for test case %d", idx))
assert.Equal(t, got.Label, tc.label, fmt.Sprintf("Label mismatch for test case %d", idx))
assert.Equal(t, got.USN, tc.usn, fmt.Sprintf("USN mismatch for test case %d", idx))
assert.Equal(t, got.Deleted, tc.deleted, fmt.Sprintf("Deleted mismatch for test case %d", idx))
assert.Equal(t, got.Dirty, tc.dirty, fmt.Sprintf("Dirty mismatch for test case %d", idx))
}
}
func TestBookInsert(t *testing.T) {
testCases := []struct {
uuid string
label string
usn int
deleted bool
dirty bool
}{
{
uuid: "b1-uuid",
label: "b1-label",
usn: 10808,
deleted: false,
dirty: false,
},
{
uuid: "b1-uuid",
label: "b1-label",
usn: 10808,
deleted: false,
dirty: true,
},
}
for idx, tc := range testCases {
func() {
// Setup
db := InitTestMemoryDB(t)
b := Book{
UUID: tc.uuid,
Label: tc.label,
USN: tc.usn,
Dirty: tc.dirty,
Deleted: tc.deleted,
}
// execute
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error())
}
if err := b.Insert(tx); err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error())
}
tx.Commit()
// test
var uuid, label string
var usn int
var deleted, dirty bool
MustScan(t, "getting b1",
db.QueryRow("SELECT uuid, label, usn, deleted, dirty FROM books WHERE uuid = ?", tc.uuid),
&uuid, &label, &usn, &deleted, &dirty)
assert.Equal(t, uuid, tc.uuid, fmt.Sprintf("uuid mismatch for test case %d", idx))
assert.Equal(t, label, tc.label, fmt.Sprintf("label mismatch for test case %d", idx))
assert.Equal(t, usn, tc.usn, fmt.Sprintf("usn mismatch for test case %d", idx))
assert.Equal(t, deleted, tc.deleted, fmt.Sprintf("deleted mismatch for test case %d", idx))
assert.Equal(t, dirty, tc.dirty, fmt.Sprintf("dirty mismatch for test case %d", idx))
}()
}
}
func TestBookUpdate(t *testing.T) {
testCases := []struct {
uuid string
label string
usn int
deleted bool
dirty bool
newLabel string
newUSN int
newDeleted bool
newDirty bool
}{
{
uuid: "b1-uuid",
label: "b1-label",
usn: 0,
deleted: false,
dirty: false,
newLabel: "b1-label-edited",
newUSN: 0,
newDeleted: false,
newDirty: true,
},
{
uuid: "b1-uuid",
label: "b1-label",
usn: 0,
deleted: false,
dirty: false,
newLabel: "",
newUSN: 10,
newDeleted: true,
newDirty: false,
},
}
for idx, tc := range testCases {
func() {
// Setup
db := InitTestMemoryDB(t)
b1 := Book{
UUID: "b1-uuid",
Label: "b1-label",
USN: 1,
Deleted: true,
Dirty: false,
}
b2 := Book{
UUID: "b2-uuid",
Label: "b2-label",
USN: 1,
Deleted: true,
Dirty: false,
}
MustExec(t, fmt.Sprintf("inserting b1 for test case %d", idx), db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", b1.UUID, b1.Label, b1.USN, b1.Deleted, b1.Dirty)
MustExec(t, fmt.Sprintf("inserting b2 for test case %d", idx), db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", b2.UUID, b2.Label, b2.USN, b2.Deleted, b2.Dirty)
// execute
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, fmt.Sprintf("beginning a transaction for test case %d", idx)).Error())
}
b1.Label = tc.newLabel
b1.USN = tc.newUSN
b1.Deleted = tc.newDeleted
b1.Dirty = tc.newDirty
if err := b1.Update(tx); err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, fmt.Sprintf("executing for test case %d", idx)).Error())
}
tx.Commit()
// test
var b1Record, b2Record Book
MustScan(t, "getting b1",
db.QueryRow("SELECT uuid, label, usn, deleted, dirty FROM books WHERE uuid = ?", tc.uuid),
&b1Record.UUID, &b1Record.Label, &b1Record.USN, &b1Record.Deleted, &b1Record.Dirty)
MustScan(t, "getting b2",
db.QueryRow("SELECT uuid, label, usn, deleted, dirty FROM books WHERE uuid = ?", b2.UUID),
&b2Record.UUID, &b2Record.Label, &b2Record.USN, &b2Record.Deleted, &b2Record.Dirty)
assert.Equal(t, b1Record.UUID, b1.UUID, fmt.Sprintf("b1 uuid mismatch for test case %d", idx))
assert.Equal(t, b1Record.Label, tc.newLabel, fmt.Sprintf("b1 label mismatch for test case %d", idx))
assert.Equal(t, b1Record.USN, tc.newUSN, fmt.Sprintf("b1 usn mismatch for test case %d", idx))
assert.Equal(t, b1Record.Deleted, tc.newDeleted, fmt.Sprintf("b1 deleted mismatch for test case %d", idx))
assert.Equal(t, b1Record.Dirty, tc.newDirty, fmt.Sprintf("b1 dirty mismatch for test case %d", idx))
assert.Equal(t, b2Record.UUID, b2.UUID, fmt.Sprintf("b2 uuid mismatch for test case %d", idx))
assert.Equal(t, b2Record.Label, b2.Label, fmt.Sprintf("b2 label mismatch for test case %d", idx))
assert.Equal(t, b2Record.USN, b2.USN, fmt.Sprintf("b2 usn mismatch for test case %d", idx))
assert.Equal(t, b2Record.Deleted, b2.Deleted, fmt.Sprintf("b2 deleted mismatch for test case %d", idx))
assert.Equal(t, b2Record.Dirty, b2.Dirty, fmt.Sprintf("b2 dirty mismatch for test case %d", idx))
}()
}
}
func TestBookUpdateUUID(t *testing.T) {
testCases := []struct {
newUUID string
}{
{
newUUID: "b1-new-uuid",
},
{
newUUID: "b2-new-uuid",
},
}
for idx, tc := range testCases {
t.Run(fmt.Sprintf("testCase%d", idx), func(t *testing.T) {
// Setup
db := InitTestMemoryDB(t)
b1 := Book{
UUID: "b1-uuid",
Label: "b1-label",
USN: 1,
Deleted: true,
Dirty: false,
}
b2 := Book{
UUID: "b2-uuid",
Label: "b2-label",
USN: 1,
Deleted: true,
Dirty: false,
}
MustExec(t, "inserting b1", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", b1.UUID, b1.Label, b1.USN, b1.Deleted, b1.Dirty)
MustExec(t, "inserting b2", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", b2.UUID, b2.Label, b2.USN, b2.Deleted, b2.Dirty)
// execute
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, "beginning a transaction").Error())
}
if err := b1.UpdateUUID(tx, tc.newUUID); err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, "executing").Error())
}
tx.Commit()
// test
var b1Record, b2Record Book
MustScan(t, "getting b1",
db.QueryRow("SELECT uuid, label, usn, deleted, dirty FROM books WHERE label = ?", "b1-label"),
&b1Record.UUID, &b1Record.Label, &b1Record.USN, &b1Record.Deleted, &b1Record.Dirty)
MustScan(t, "getting b2",
db.QueryRow("SELECT uuid, label, usn, deleted, dirty FROM books WHERE label = ?", "b2-label"),
&b2Record.UUID, &b2Record.Label, &b2Record.USN, &b2Record.Deleted, &b2Record.Dirty)
assert.Equal(t, b1.UUID, tc.newUUID, "b1 original reference uuid mismatch")
assert.Equal(t, b1Record.UUID, tc.newUUID, "b1 uuid mismatch")
assert.Equal(t, b2Record.UUID, b2.UUID, "b2 uuid mismatch")
})
}
}
func TestBookExpunge(t *testing.T) {
// Setup
db := InitTestMemoryDB(t)
b1 := Book{
UUID: "b1-uuid",
Label: "b1-label",
USN: 1,
Deleted: true,
Dirty: false,
}
b2 := Book{
UUID: "b2-uuid",
Label: "b2-label",
USN: 1,
Deleted: true,
Dirty: false,
}
MustExec(t, "inserting b1", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", b1.UUID, b1.Label, b1.USN, b1.Deleted, b1.Dirty)
MustExec(t, "inserting b2", db, "INSERT INTO books (uuid, label, usn, deleted, dirty) VALUES (?, ?, ?, ?, ?)", b2.UUID, b2.Label, b2.USN, b2.Deleted, b2.Dirty)
// execute
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, "beginning a transaction").Error())
}
if err := b1.Expunge(tx); err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, "executing").Error())
}
tx.Commit()
// test
var bookCount int
MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount)
assert.Equalf(t, bookCount, 1, "book count mismatch")
var b2Record Book
MustScan(t, "getting b2",
db.QueryRow("SELECT uuid, label, usn, deleted, dirty FROM books WHERE uuid = ?", "b2-uuid"),
&b2Record.UUID, &b2Record.Label, &b2Record.USN, &b2Record.Deleted, &b2Record.Dirty)
assert.Equal(t, b2Record.UUID, b2.UUID, "b2 uuid mismatch")
assert.Equal(t, b2Record.Label, b2.Label, "b2 label mismatch")
assert.Equal(t, b2Record.USN, b2.USN, "b2 usn mismatch")
assert.Equal(t, b2Record.Deleted, b2.Deleted, "b2 deleted mismatch")
assert.Equal(t, b2Record.Dirty, b2.Dirty, "b2 dirty mismatch")
}
// TestNoteFTS tests that note full text search indices stay in sync with the notes after insert, update and delete
func TestNoteFTS(t *testing.T) {
// set up
db := InitTestMemoryDB(t)
// execute - insert
n := Note{
UUID: "n1-uuid",
BookUUID: "b1-uuid",
Body: "foo bar",
AddedOn: 1542058875,
EditedOn: 0,
USN: 0,
Deleted: false,
Dirty: false,
}
tx, err := db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, "beginning a transaction").Error())
}
if err := n.Insert(tx); err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, "inserting").Error())
}
tx.Commit()
// test
var noteCount, noteFtsCount, noteSearchCount int
MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), &noteCount)
MustScan(t, "counting note_fts", db.QueryRow("SELECT count(*) FROM note_fts"), &noteFtsCount)
MustScan(t, "counting search results", db.QueryRow("SELECT count(*) FROM note_fts WHERE note_fts MATCH ?", "foo"), &noteSearchCount)
assert.Equal(t, noteCount, 1, "noteCount mismatch")
assert.Equal(t, noteFtsCount, 1, "noteFtsCount mismatch")
assert.Equal(t, noteSearchCount, 1, "noteSearchCount mismatch")
// execute - update
tx, err = db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, "beginning a transaction").Error())
}
n.Body = "baz quz"
if err := n.Update(tx); err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, "updating").Error())
}
tx.Commit()
// test
MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), &noteCount)
MustScan(t, "counting note_fts", db.QueryRow("SELECT count(*) FROM note_fts"), &noteFtsCount)
assert.Equal(t, noteCount, 1, "noteCount mismatch")
assert.Equal(t, noteFtsCount, 1, "noteFtsCount mismatch")
MustScan(t, "counting search results", db.QueryRow("SELECT count(*) FROM note_fts WHERE note_fts MATCH ?", "foo"), &noteSearchCount)
assert.Equal(t, noteSearchCount, 0, "noteSearchCount for foo mismatch")
MustScan(t, "counting search results", db.QueryRow("SELECT count(*) FROM note_fts WHERE note_fts MATCH ?", "baz"), &noteSearchCount)
assert.Equal(t, noteSearchCount, 1, "noteSearchCount for baz mismatch")
// execute - delete
tx, err = db.Begin()
if err != nil {
t.Fatal(errors.Wrap(err, "beginning a transaction").Error())
}
if err := n.Expunge(tx); err != nil {
tx.Rollback()
t.Fatal(errors.Wrap(err, "expunging").Error())
}
tx.Commit()
// test
MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), &noteCount)
MustScan(t, "counting note_fts", db.QueryRow("SELECT count(*) FROM note_fts"), &noteFtsCount)
assert.Equal(t, noteCount, 0, "noteCount mismatch")
assert.Equal(t, noteFtsCount, 0, "noteFtsCount mismatch")
}

219
pkg/cli/database/queries.go Normal file
View file

@ -0,0 +1,219 @@
/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package database
import (
"database/sql"
"github.com/dnote/dnote/pkg/clock"
"github.com/pkg/errors"
)
// GetSystem scans the given system configuration record onto the destination
func GetSystem(db *DB, key string, dest interface{}) error {
if err := db.QueryRow("SELECT value FROM system WHERE key = ?", key).Scan(dest); err != nil {
return errors.Wrap(err, "finding system configuration record")
}
return nil
}
// InsertSystem inserets a system configuration
func InsertSystem(db *DB, key, val string) error {
if _, err := db.Exec("INSERT INTO system (key, value) VALUES (? , ?);", key, val); err != nil {
return errors.Wrap(err, "saving system config")
}
return nil
}
// UpsertSystem inserts or updates a system configuration
func UpsertSystem(db *DB, key, val string) error {
var count int
if err := db.QueryRow("SELECT count(*) FROM system WHERE key = ?", key).Scan(&count); err != nil {
return errors.Wrap(err, "counting system record")
}
if count == 0 {
if _, err := db.Exec("INSERT INTO system (key, value) VALUES (? , ?);", key, val); err != nil {
return errors.Wrap(err, "saving system config")
}
} else {
if _, err := db.Exec("UPDATE system SET value = ? WHERE key = ?", val, key); err != nil {
return errors.Wrap(err, "updating system config")
}
}
return nil
}
// UpdateSystem updates a system configuration
func UpdateSystem(db *DB, key, val interface{}) error {
if _, err := db.Exec("UPDATE system SET value = ? WHERE key = ?", val, key); err != nil {
return errors.Wrap(err, "updating system config")
}
return nil
}
// DeleteSystem delets the given system record
func DeleteSystem(db *DB, key string) error {
if _, err := db.Exec("DELETE FROM system WHERE key = ?", key); err != nil {
return errors.Wrap(err, "deleting system config")
}
return nil
}
// NoteInfo is a basic information about a note
type NoteInfo struct {
RowID int
BookLabel string
UUID string
Content string
AddedOn int64
EditedOn int64
}
// GetNoteInfo returns a NoteInfo for the note with the given noteRowID
func GetNoteInfo(db *DB, noteRowID int) (NoteInfo, error) {
var ret NoteInfo
err := db.QueryRow(`SELECT books.label, notes.uuid, notes.body, notes.added_on, notes.edited_on, notes.rowid
FROM notes
INNER JOIN books ON books.uuid = notes.book_uuid
WHERE notes.rowid = ? AND notes.deleted = false`, noteRowID).
Scan(&ret.BookLabel, &ret.UUID, &ret.Content, &ret.AddedOn, &ret.EditedOn, &ret.RowID)
if err == sql.ErrNoRows {
return ret, errors.Errorf("note %d not found", noteRowID)
} else if err != nil {
return ret, errors.Wrap(err, "querying the note")
}
return ret, nil
}
// BookInfo is a basic information about a book
type BookInfo struct {
RowID int
UUID string
Name string
}
// GetBookInfo returns a BookInfo for the book with the given uuid
func GetBookInfo(db *DB, uuid string) (BookInfo, error) {
var ret BookInfo
err := db.QueryRow(`SELECT books.rowid, books.uuid, books.label
FROM books
WHERE books.uuid = ? AND books.deleted = false`, uuid).
Scan(&ret.RowID, &ret.UUID, &ret.Name)
if err == sql.ErrNoRows {
return ret, errors.Errorf("book %s not found", uuid)
} else if err != nil {
return ret, errors.Wrap(err, "querying the note")
}
return ret, nil
}
// GetBookUUID returns a uuid of a book given a label
func GetBookUUID(db *DB, label string) (string, error) {
var ret string
err := db.QueryRow("SELECT uuid FROM books WHERE label = ?", label).Scan(&ret)
if err == sql.ErrNoRows {
return ret, errors.Errorf("book '%s' not found", label)
} else if err != nil {
return ret, errors.Wrap(err, "querying the book")
}
return ret, nil
}
// UpdateBookName updates a book name
func UpdateBookName(db *DB, uuid string, name string) error {
_, err := db.Exec(`UPDATE books
SET label = ?, dirty = ?
WHERE uuid = ?`, name, true, uuid)
if err != nil {
return errors.Wrap(err, "updating the book")
}
return nil
}
// GetActiveNote gets the note which has the given rowid and is not deleted
func GetActiveNote(db *DB, rowid int) (Note, error) {
var ret Note
err := db.QueryRow(`SELECT
rowid,
uuid,
book_uuid,
body,
added_on,
edited_on,
usn,
deleted,
dirty
FROM notes WHERE rowid = ? AND deleted = false;`, rowid).Scan(
&ret.RowID,
&ret.UUID,
&ret.BookUUID,
&ret.Body,
&ret.AddedOn,
&ret.EditedOn,
&ret.USN,
&ret.Deleted,
&ret.Dirty,
)
if err == sql.ErrNoRows {
return ret, err
} else if err != nil {
return ret, errors.Wrap(err, "finding the note")
}
return ret, nil
}
// UpdateNoteContent updates the note content and marks the note as dirty
func UpdateNoteContent(db *DB, c clock.Clock, rowID int, content string) error {
ts := c.Now().UnixNano()
_, err := db.Exec(`UPDATE notes
SET body = ?, edited_on = ?, dirty = ?
WHERE rowid = ?`, content, ts, true, rowID)
if err != nil {
return errors.Wrap(err, "updating the note")
}
return nil
}
// UpdateNoteBook moves the note to a different book and marks the note as dirty
func UpdateNoteBook(db *DB, c clock.Clock, rowID int, bookUUID string) error {
ts := c.Now().UnixNano()
_, err := db.Exec(`UPDATE notes
SET book_uuid = ?, edited_on = ?, dirty = ?
WHERE rowid = ?`, bookUUID, ts, true, rowID)
if err != nil {
return errors.Wrap(err, "updating the note")
}
return nil
}

Some files were not shown because too many files have changed in this diff Show more