Compare commits

...

5 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
23 changed files with 632 additions and 234 deletions

View file

@ -6,27 +6,28 @@ Please see the [doc](https://www.getdnote.com/docs/server) for more.
1. Install [Docker](https://docs.docker.com/install/).
2. Install Docker [Compose plugin](https://docs.docker.com/compose/install/linux/).
3. Download the [compose.yml](https://raw.githubusercontent.com/dnote/dnote/master/host/docker/compose.yml) file by running:
3. Create a `compose.yml` file with the following content:
```
curl https://raw.githubusercontent.com/dnote/dnote/master/host/docker/compose.yml > compose.yml
```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 images and run the containers
4. Run the following to download the image and start the container
```
docker compose pull
docker compose up -d
```
Visit http://localhost:3001 in your browser to see Dnote running.
### Supported platform
Currently, the official Docker image for Dnote supports Linux running AMD64 CPU architecture.
If you run ARM64, please install Dnote server by downloading a binary distribution (see below).
## Manual Installation
Download from [releases](https://github.com/dnote/dnote/releases), extract, and run:
@ -34,7 +35,7 @@ Download from [releases](https://github.com/dnote/dnote/releases), extract, and
```bash
tar -xzf dnote-server-$version-$os.tar.gz
mv ./dnote-server /usr/local/bin
dnote-server start --webUrl=https://your.server
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.

9
go.mod
View file

@ -14,10 +14,9 @@ require (
github.com/mattn/go-sqlite3 v1.14.32
github.com/pkg/errors v0.9.1
github.com/radovskyb/watcher v1.0.7
github.com/robfig/cron v1.2.0
github.com/sergi/go-diff v1.3.1
github.com/spf13/cobra v1.10.1
golang.org/x/crypto v0.42.0
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
@ -36,9 +35,9 @@ require (
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.36.0 // indirect
golang.org/x/term v0.35.0 // indirect
golang.org/x/text v0.29.0 // 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
)

18
go.sum
View file

@ -53,8 +53,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
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/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
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=
@ -73,15 +71,15 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
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.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
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.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
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=

View file

@ -131,7 +131,7 @@ func newRun(ctx context.DnoteCtx) infra.RunEFunc {
return err
}
output.NoteInfo(info)
output.NoteInfo(os.Stdout, info)
if err := upgrade.Check(ctx); err != nil {
log.Error(errors.Wrap(err, "automatically checking updates").Error())

View file

@ -1,95 +0,0 @@
/* 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 cat
import (
"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/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 the future version.
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 context.DnoteCtx) *cobra.Command {
cmd := &cobra.Command{
Use: "cat <book name> <note index>",
Aliases: []string{"c"},
Short: "See a note",
Example: example,
RunE: NewRun(ctx, false),
PreRunE: preRun,
Deprecated: deprecationWarning,
}
return cmd
}
// NewRun returns a new run function
func NewRun(ctx context.DnoteCtx, contentOnly bool) infra.RunEFunc {
return func(cmd *cobra.Command, args []string) error {
var noteRowIDArg string
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"))
noteRowIDArg = args[1]
} else {
noteRowIDArg = args[0]
}
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(info)
} else {
output.NoteInfo(info)
}
return nil
}
}

View file

@ -166,7 +166,7 @@ func runNote(ctx context.DnoteCtx, rowIDArg string) error {
}
log.Success("edited the note\n")
output.NoteInfo(noteInfo)
output.NoteInfo(os.Stdout, noteInfo)
return nil
}

View file

@ -17,6 +17,7 @@ package remove
import (
"fmt"
"os"
"strconv"
"github.com/dnote/dnote/pkg/cli/context"
@ -129,7 +130,7 @@ func runNote(ctx context.DnoteCtx, rowIDArg string) error {
return err
}
output.NoteInfo(noteInfo)
output.NoteInfo(os.Stdout, noteInfo)
ok, err := maybeConfirm("remove this note?", false)
if err != nil {

View file

@ -13,76 +13,19 @@
* limitations under the License.
*/
package ls
package view
import (
"database/sql"
"fmt"
"io"
"strings"
"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"
)
var example = `
* List all books
dnote ls
* List notes in a book
dnote ls javascript
`
var deprecationWarning = `and "view" will replace it in the future version.
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 context.DnoteCtx) *cobra.Command {
cmd := &cobra.Command{
Use: "ls <book name?>",
Aliases: []string{"l", "notes"},
Short: "List all notes",
Example: example,
RunE: NewRun(ctx, false),
PreRunE: preRun,
Deprecated: deprecationWarning,
}
return cmd
}
// NewRun returns a new run function for ls
func NewRun(ctx context.DnoteCtx, nameOnly bool) infra.RunEFunc {
return func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
if err := printBooks(ctx, nameOnly); 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
@ -97,15 +40,13 @@ type noteInfo struct {
// 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")
// Check for \r\n first
if idx := strings.Index(str, "\r\n"); idx != -1 {
return idx
}
return ret
// Then check for \n
return strings.Index(str, "\n")
}
// formatBody returns an excerpt of the given raw note content and a boolean
@ -123,15 +64,15 @@ func formatBody(noteBody string) (string, bool) {
return strings.Trim(trimmed, " "), false
}
func printBookLine(info bookInfo, nameOnly bool) {
func printBookLine(w io.Writer, info bookInfo, nameOnly bool) {
if nameOnly {
fmt.Println(info.BookLabel)
fmt.Fprintln(w, info.BookLabel)
} else {
log.Printf("%s %s\n", info.BookLabel, log.ColorYellow.Sprintf("(%d)", info.NoteCount))
fmt.Fprintf(w, "%s %s\n", info.BookLabel, log.ColorYellow.Sprintf("(%d)", info.NoteCount))
}
}
func printBooks(ctx context.DnoteCtx, nameOnly bool) error {
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
@ -157,13 +98,13 @@ func printBooks(ctx context.DnoteCtx, nameOnly bool) error {
}
for _, info := range infos {
printBookLine(info, nameOnly)
printBookLine(w, info, nameOnly)
}
return nil
}
func printNotes(ctx context.DnoteCtx, bookName string) error {
func listNotes(ctx context.DnoteCtx, w io.Writer, bookName string) error {
db := ctx.DB
var bookUUID string
@ -191,7 +132,7 @@ func printNotes(ctx context.DnoteCtx, bookName string) error {
infos = append(infos, info)
}
log.Infof("on book %s\n", bookName)
fmt.Fprintf(w, "on book %s\n", bookName)
for _, info := range infos {
body, isExcerpt := formatBody(info.Body)
@ -201,7 +142,7 @@ func printNotes(ctx context.DnoteCtx, bookName string) error {
body = fmt.Sprintf("%s %s", body, log.ColorYellow.Sprintf("[---More---]"))
}
log.Plainf("%s %s\n", rowid, body)
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")
}

View file

@ -16,14 +16,13 @@
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"
"github.com/dnote/dnote/pkg/cli/cmd/cat"
"github.com/dnote/dnote/pkg/cli/cmd/ls"
"github.com/dnote/dnote/pkg/cli/utils"
)
var example = `
@ -68,27 +67,26 @@ func NewCmd(ctx context.DnoteCtx) *cobra.Command {
func newRun(ctx context.DnoteCtx) infra.RunEFunc {
return func(cmd *cobra.Command, args []string) error {
var run infra.RunEFunc
if len(args) == 0 {
run = ls.NewRun(ctx, nameOnly)
// 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]) {
run = cat.NewRun(ctx, contentOnly)
// View a note by index
return viewNote(ctx, os.Stdout, args[0], contentOnly)
} else {
run = ls.NewRun(ctx, false)
// List notes in a book
return listNotes(ctx, os.Stdout, args[0])
}
} else if len(args) == 2 {
// DEPRECATED: passing book name to view command is deprecated
run = cat.NewRun(ctx, false)
} else {
return errors.New("Incorrect number of arguments")
// View a note in a book (book name + note index)
return viewNote(ctx, os.Stdout, args[1], contentOnly)
}
return run(cmd, args)
return errors.New("Incorrect number of arguments")
}
}

View file

@ -26,12 +26,10 @@ import (
// commands
"github.com/dnote/dnote/pkg/cli/cmd/add"
"github.com/dnote/dnote/pkg/cli/cmd/cat"
"github.com/dnote/dnote/pkg/cli/cmd/edit"
"github.com/dnote/dnote/pkg/cli/cmd/find"
"github.com/dnote/dnote/pkg/cli/cmd/login"
"github.com/dnote/dnote/pkg/cli/cmd/logout"
"github.com/dnote/dnote/pkg/cli/cmd/ls"
"github.com/dnote/dnote/pkg/cli/cmd/remove"
"github.com/dnote/dnote/pkg/cli/cmd/root"
"github.com/dnote/dnote/pkg/cli/cmd/sync"
@ -79,10 +77,8 @@ func main() {
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))

View file

@ -20,6 +20,7 @@ import (
"log"
"os"
"os/exec"
"strings"
"testing"
"github.com/dnote/dnote/pkg/assert"
@ -568,3 +569,65 @@ func TestDBPathFlag(t *testing.T) {
db2.QueryRow("SELECT count(*) FROM books WHERE label = ?", "db1-book").Scan(&db2HasDB1Book)
assert.Equal(t, db2HasDB1Book, 0, "db2 should not have db1's book")
}
func TestView(t *testing.T) {
t.Run("view note by rowid", func(t *testing.T) {
_, opts := setupTestEnv(t)
db, dbPath := database.InitTestFileDB(t)
testutils.Setup4(t, db)
output := testutils.RunDnoteCmd(t, opts, binaryName, "--dbPath", dbPath, "view", "1")
assert.Equal(t, strings.Contains(output, "Booleans have toString()"), true, "should contain note content")
assert.Equal(t, strings.Contains(output, "book name"), true, "should show metadata")
})
t.Run("view note content only", func(t *testing.T) {
_, opts := setupTestEnv(t)
db, dbPath := database.InitTestFileDB(t)
testutils.Setup4(t, db)
output := testutils.RunDnoteCmd(t, opts, binaryName, "--dbPath", dbPath, "view", "1", "--content-only")
assert.Equal(t, strings.Contains(output, "Booleans have toString()"), true, "should contain note content")
assert.Equal(t, strings.Contains(output, "book name"), false, "should not show metadata")
})
t.Run("list books", func(t *testing.T) {
_, opts := setupTestEnv(t)
db, dbPath := database.InitTestFileDB(t)
testutils.Setup1(t, db)
output := testutils.RunDnoteCmd(t, opts, binaryName, "--dbPath", dbPath, "view")
assert.Equal(t, strings.Contains(output, "js"), true, "should list js book")
assert.Equal(t, strings.Contains(output, "linux"), true, "should list linux book")
})
t.Run("list notes in book", func(t *testing.T) {
_, opts := setupTestEnv(t)
db, dbPath := database.InitTestFileDB(t)
testutils.Setup2(t, db)
output := testutils.RunDnoteCmd(t, opts, binaryName, "--dbPath", dbPath, "view", "js")
assert.Equal(t, strings.Contains(output, "n1 body"), true, "should list note 1")
assert.Equal(t, strings.Contains(output, "n2 body"), true, "should list note 2")
})
t.Run("view note by book name and rowid", func(t *testing.T) {
_, opts := setupTestEnv(t)
db, dbPath := database.InitTestFileDB(t)
testutils.Setup4(t, db)
output := testutils.RunDnoteCmd(t, opts, binaryName, "--dbPath", dbPath, "view", "js", "2")
assert.Equal(t, strings.Contains(output, "Date object implements mathematical comparisons"), true, "should contain note content")
assert.Equal(t, strings.Contains(output, "book name"), true, "should show metadata")
})
}

View file

@ -19,6 +19,7 @@ package output
import (
"fmt"
"io"
"time"
"github.com/dnote/dnote/pkg/cli/database"
@ -26,7 +27,7 @@ import (
)
// NoteInfo prints a note information
func NoteInfo(info database.NoteInfo) {
func NoteInfo(w io.Writer, info database.NoteInfo) {
log.Infof("book name: %s\n", info.BookLabel)
log.Infof("created at: %s\n", time.Unix(0, info.AddedOn).Format("Jan 2, 2006 3:04pm (MST)"))
if info.EditedOn != 0 {
@ -35,13 +36,13 @@ func NoteInfo(info database.NoteInfo) {
log.Infof("note id: %d\n", info.RowID)
log.Infof("note uuid: %s\n", info.UUID)
fmt.Printf("\n------------------------content------------------------\n")
fmt.Printf("%s", info.Content)
fmt.Printf("\n-------------------------------------------------------\n")
fmt.Fprintf(w, "\n------------------------content------------------------\n")
fmt.Fprintf(w, "%s", info.Content)
fmt.Fprintf(w, "\n-------------------------------------------------------\n")
}
func NoteContent(info database.NoteInfo) {
fmt.Printf("%s", info.Content)
func NoteContent(w io.Writer, info database.NoteInfo) {
fmt.Fprintf(w, "%s", info.Content)
}
// BookInfo prints a note information

View file

@ -144,7 +144,7 @@ type RunDnoteCmdOptions struct {
}
// RunDnoteCmd runs a dnote command
func RunDnoteCmd(t *testing.T, opts RunDnoteCmdOptions, binaryName string, arg ...string) {
func RunDnoteCmd(t *testing.T, opts RunDnoteCmdOptions, binaryName string, arg ...string) string {
t.Logf("running: %s %s", binaryName, strings.Join(arg, " "))
cmd, stderr, stdout, err := NewDnoteCmd(opts, binaryName, arg...)
@ -162,6 +162,8 @@ func RunDnoteCmd(t *testing.T, opts RunDnoteCmdOptions, binaryName string, arg .
// Print stdout if and only if test fails later
t.Logf("\n%s", stdout)
return stdout.String()
}
// WaitDnoteCmd runs a dnote command and passes stdout to the callback.

View file

@ -331,3 +331,23 @@ func TestServerUserCreateHelp(t *testing.T) {
assert.Equal(t, strings.Contains(outputStr, "--password"), true, "help should show --password (double dash)")
assert.Equal(t, strings.Contains(outputStr, "--dbPath"), true, "help should show --dbPath (double dash)")
}
func TestServerUserList(t *testing.T) {
tmpDB := t.TempDir() + "/test.db"
// Create two users
exec.Command(testServerBinary, "user", "create", "--dbPath", tmpDB, "--email", "alice@example.com", "--password", "password123").CombinedOutput()
exec.Command(testServerBinary, "user", "create", "--dbPath", tmpDB, "--email", "bob@example.com", "--password", "password123").CombinedOutput()
// List users
listCmd := exec.Command(testServerBinary, "user", "list", "--dbPath", tmpDB)
output, err := listCmd.CombinedOutput()
if err != nil {
t.Fatalf("user list failed: %v\nOutput: %s", err, output)
}
outputStr := string(output)
assert.Equal(t, strings.Contains(outputStr, "alice@example.com"), true, "output should have alice")
assert.Equal(t, strings.Contains(outputStr, "bob@example.com"), true, "output should have bob")
}

View file

@ -116,6 +116,17 @@ func (a *App) GetUserByEmail(email string) (*database.User, error) {
return &user, nil
}
// GetAllUsers retrieves all users from the database
func (a *App) GetAllUsers() ([]database.User, error) {
var users []database.User
err := a.DB.Find(&users).Error
if err != nil {
return nil, pkgErrors.Wrap(err, "finding users")
}
return users, nil
}
// Authenticate authenticates a user
func (a *App) Authenticate(email, password string) (*database.User, error) {
user, err := a.GetUserByEmail(email)

View file

@ -108,6 +108,72 @@ func TestGetUserByEmail(t *testing.T) {
})
}
func TestGetAllUsers(t *testing.T) {
t.Run("success with multiple users", func(t *testing.T) {
db := testutils.InitMemoryDB(t)
user1 := testutils.SetupUserData(db, "alice@example.com", "password123")
user2 := testutils.SetupUserData(db, "bob@example.com", "password123")
user3 := testutils.SetupUserData(db, "charlie@example.com", "password123")
a := NewTest()
a.DB = db
users, err := a.GetAllUsers()
assert.Equal(t, err, nil, "should not error")
assert.Equal(t, len(users), 3, "should return 3 users")
// Verify all users are returned
emails := make(map[string]bool)
for _, user := range users {
emails[user.Email.String] = true
}
assert.Equal(t, emails["alice@example.com"], true, "alice should be in results")
assert.Equal(t, emails["bob@example.com"], true, "bob should be in results")
assert.Equal(t, emails["charlie@example.com"], true, "charlie should be in results")
// Verify user details match
for _, user := range users {
if user.Email.String == "alice@example.com" {
assert.Equal(t, user.ID, user1.ID, "alice ID mismatch")
} else if user.Email.String == "bob@example.com" {
assert.Equal(t, user.ID, user2.ID, "bob ID mismatch")
} else if user.Email.String == "charlie@example.com" {
assert.Equal(t, user.ID, user3.ID, "charlie ID mismatch")
}
}
})
t.Run("empty database", func(t *testing.T) {
db := testutils.InitMemoryDB(t)
a := NewTest()
a.DB = db
users, err := a.GetAllUsers()
assert.Equal(t, err, nil, "should not error")
assert.Equal(t, len(users), 0, "should return 0 users")
})
t.Run("single user", func(t *testing.T) {
db := testutils.InitMemoryDB(t)
user := testutils.SetupUserData(db, "alice@example.com", "password123")
a := NewTest()
a.DB = db
users, err := a.GetAllUsers()
assert.Equal(t, err, nil, "should not error")
assert.Equal(t, len(users), 1, "should return 1 user")
assert.Equal(t, users[0].Email.String, "alice@example.com", "email mismatch")
assert.Equal(t, users[0].ID, user.ID, "user ID mismatch")
})
}
func TestCreateUser(t *testing.T) {
t.Run("success", func(t *testing.T) {
db := testutils.InitMemoryDB(t)

View file

@ -363,10 +363,11 @@
}
},
"node_modules/immutable": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz",
"integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==",
"dev": true
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
"dev": true,
"license": "MIT"
},
"node_modules/is-extglob": {
"version": "2.1.1",

View file

@ -111,8 +111,8 @@ func requireString(fs *flag.FlagSet, value, fieldName string) {
}
}
// setupAppWithDB creates config, initializes app, and returns cleanup function
func setupAppWithDB(fs *flag.FlagSet, dbPath string) (*app.App, func()) {
// createApp creates config, initializes app, and returns cleanup function
func createApp(fs *flag.FlagSet, dbPath string) (*app.App, func()) {
cfg, err := config.New(config.Params{
DBPath: dbPath,
})

View file

@ -51,7 +51,7 @@ func userCreateCmd(args []string) {
requireString(fs, *email, "email")
requireString(fs, *password, "password")
a, cleanup := setupAppWithDB(fs, *dbPath)
a, cleanup := createApp(fs, *dbPath)
defer cleanup()
_, err := a.CreateUser(*email, *password, *password)
@ -74,7 +74,7 @@ func userRemoveCmd(args []string, stdin io.Reader) {
requireString(fs, *email, "email")
a, cleanup := setupAppWithDB(fs, *dbPath)
a, cleanup := createApp(fs, *dbPath)
defer cleanup()
// Check if user exists first
@ -127,7 +127,7 @@ func userResetPasswordCmd(args []string) {
requireString(fs, *email, "email")
requireString(fs, *password, "password")
a, cleanup := setupAppWithDB(fs, *dbPath)
a, cleanup := createApp(fs, *dbPath)
defer cleanup()
// Find the user
@ -151,6 +151,27 @@ func userResetPasswordCmd(args []string) {
fmt.Printf("Email: %s\n", *email)
}
func userListCmd(args []string, output io.Writer) {
fs := setupFlagSet("list", "dnote-server user list")
dbPath := fs.String("dbPath", "", "Path to SQLite database file (env: DBPath, default: $XDG_DATA_HOME/dnote/server.db)")
fs.Parse(args)
a, cleanup := createApp(fs, *dbPath)
defer cleanup()
users, err := a.GetAllUsers()
if err != nil {
log.ErrorWrap(err, "listing users")
os.Exit(1)
}
for _, user := range users {
fmt.Fprintf(output, "%s,%s,%s\n", user.UUID, user.Email.String, user.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"))
}
}
func userCmd(args []string) {
if len(args) < 1 {
fmt.Println(`Usage:
@ -158,6 +179,7 @@ func userCmd(args []string) {
Available commands:
create: Create a new user
list: List all users
remove: Remove a user
reset-password: Reset a user's password`)
os.Exit(1)
@ -172,6 +194,8 @@ Available commands:
switch subcommand {
case "create":
userCreateCmd(subArgs)
case "list":
userListCmd(subArgs, os.Stdout)
case "remove":
userRemoveCmd(subArgs, os.Stdin)
case "reset-password":
@ -180,6 +204,7 @@ Available commands:
fmt.Printf("Unknown subcommand: %s\n\n", subcommand)
fmt.Println(`Available commands:
create: Create a new user
list: List all users
remove: Remove a user (only if they have no notes or books)
reset-password: Reset a user's password`)
os.Exit(1)

View file

@ -16,6 +16,8 @@
package cmd
import (
"bytes"
"fmt"
"strings"
"testing"
@ -107,3 +109,50 @@ func TestUserResetPasswordCmd(t *testing.T) {
err = bcrypt.CompareHashAndPassword([]byte(updatedUser.Password.String), []byte("oldpassword123"))
assert.Equal(t, err != nil, true, "old password should not match")
}
func TestUserListCmd(t *testing.T) {
t.Run("multiple users", func(t *testing.T) {
tmpDB := t.TempDir() + "/test.db"
// Create multiple users
db := testutils.InitDB(tmpDB)
user1 := testutils.SetupUserData(db, "alice@example.com", "password123")
user2 := testutils.SetupUserData(db, "bob@example.com", "password123")
user3 := testutils.SetupUserData(db, "charlie@example.com", "password123")
sqlDB, _ := db.DB()
sqlDB.Close()
// Capture output
var buf bytes.Buffer
userListCmd([]string{"--dbPath", tmpDB}, &buf)
// Verify output matches expected format
output := strings.TrimSpace(buf.String())
lines := strings.Split(output, "\n")
expectedLine1 := fmt.Sprintf("%s,alice@example.com,%s", user1.UUID, user1.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"))
expectedLine2 := fmt.Sprintf("%s,bob@example.com,%s", user2.UUID, user2.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"))
expectedLine3 := fmt.Sprintf("%s,charlie@example.com,%s", user3.UUID, user3.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"))
assert.Equal(t, lines[0], expectedLine1, "line 1 should match")
assert.Equal(t, lines[1], expectedLine2, "line 2 should match")
assert.Equal(t, lines[2], expectedLine3, "line 3 should match")
})
t.Run("empty database", func(t *testing.T) {
tmpDB := t.TempDir() + "/test.db"
// Initialize empty database
db := testutils.InitDB(tmpDB)
sqlDB, _ := db.DB()
sqlDB.Close()
// Capture output
var buf bytes.Buffer
userListCmd([]string{"--dbPath", tmpDB}, &buf)
// Verify no output
output := buf.String()
assert.Equal(t, output, "", "should have no output for empty database")
})
}