Skip to content

Commit

Permalink
Add a route to copy an image from a note (#4298)
Browse files Browse the repository at this point in the history
  • Loading branch information
nono committed Jan 18, 2024
2 parents 233d79b + 7a76af4 commit e2c8bda
Show file tree
Hide file tree
Showing 4 changed files with 241 additions and 71 deletions.
65 changes: 65 additions & 0 deletions docs/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -1243,6 +1243,71 @@ Content-Type: application/vnd.api+json
}
```

### POST /notes/:id/:image-id/copy

Copy an existing image to another note. It is similar to `POST
/notes/:id/images` as creating an image, but can be useful to avoid downloading
and then reuploading the image content when the user makes a copy/paste.

The `:id` and `:image-id` path parameters identify the source image. The
destination note will be specified in the query-string, as `To`.

#### Query-String

| Parameter | Description |
| ---------- | ------------------------------------------------- |
| To | the ID of the note where the image will be copied |

#### Request

```http
POST /notes/f48d9370-e1ec-0137-8547-543d7eb8149c/e57d2ec0-d281-0139-2bed-543d7eb8149c/copy?To=76ddf590-905e-013c-5ff2-18c04daba326 HTTP/1.1
Accept: application/vnd.api+json
Host: cozy.example.com
```

#### Response

```http
HTTP/1.1 201 Created
Content-Type: application/vnd.api+json
```

```json
{
"data": {
"type": "io.cozy.notes.images",
"id": "76ddf590-905e-013c-5ff2-18c04daba326/8d146530-905e-013c-5ff3-98b45e10905e",
"meta": {
"rev": "1-18c04dab"
},
"attributes": {
"name": "diagram.jpg",
"mime": "image/jpeg",
"width": 1000,
"height": 1000,
"willBeResized": true,
"cozyMetadata": {
"doctypeVersion": "1",
"metadataVersion": 1,
"createdAt": "2024-01-08T15:18:00Z",
"createdByApp": "notes",
"createdOn": "https://cozy.example.com/",
"updatedAt": "2024-01-08T15:18:00Z",
"uploadedAt": "2024-01-08T15:18:00Z",
"uploadedOn": "https://cozy.example.com/",
"uploadedBy": {
"slug": "notes"
}
}
},
"links": {
"self": "/notes/76ddf590-905e-013c-5ff2-18c04daba326/images/8d146530-905e-013c-5ff3-98b45e10905e/d251f620d98e1740"
}
}
}
```

## Real-time via websockets

You can subscribe to the [realtime](realtime.md) API for a document with the
Expand Down
31 changes: 31 additions & 0 deletions model/note/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,37 @@ func (u *ImageUpload) Close() error {
return nil
}

// CopyImageToAnotherNote makes a copy of an image from one note to be used in
// another note.
func CopyImageToAnotherNote(inst *instance.Instance, imageID string, dstDoc *vfs.FileDoc) (*Image, error) {
// Open the existing image
var image Image
if err := couchdb.GetDoc(inst, consts.NotesImages, imageID, &image); err != nil {
return nil, err
}
thumb, err := inst.ThumbsFS().OpenNoteThumb(imageID, consts.NoteImageOriginalFormat)
if err != nil {
return nil, err
}
defer thumb.Close()

// Prepare the new image document
upload, err := NewImageUpload(inst, dstDoc, image.Name, image.Mime)
if err != nil {
return nil, err
}

// Copy the content
_, err = io.Copy(upload, thumb)
if cerr := upload.Close(); cerr != nil && (err == nil || errors.Is(err, io.ErrUnexpectedEOF)) {
err = cerr
}
if err != nil {
return nil, err
}
return upload.Image, nil
}

func contains(haystack []string, needle string) bool {
for _, v := range haystack {
if needle == v {
Expand Down
33 changes: 33 additions & 0 deletions web/notes/notes.go
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,38 @@ func UploadImage(c echo.Context) error {
return jsonapi.Data(c, http.StatusCreated, image, nil)
}

// CopyImage is the API handler for POST /notes/:id/:image-id/copy. It copies
// an existing image to another note.
func CopyImage(c echo.Context) error {
// Check permission
inst := middlewares.GetInstance(c)
srcDoc, err := inst.VFS().FileByID(c.Param("id"))
if err != nil {
return wrapError(err)
}
if err := middlewares.AllowVFS(c, permission.POST, srcDoc); err != nil {
return err
}

dstDoc, err := inst.VFS().FileByID(c.QueryParam("To"))
if err != nil {
return wrapError(err)
}
if err := middlewares.AllowVFS(c, permission.POST, dstDoc); err != nil {
return err
}

imageID := c.Param("id") + "/" + c.Param("image-id")
image, err := note.CopyImageToAnotherNote(inst, imageID, dstDoc)
if err != nil {
inst.Logger().WithNamespace("notes").Infof("Image copy has failed: %s", err)
return wrapError(err)
}

apiImage := files.NewNoteImage(inst, image)
return jsonapi.Data(c, http.StatusCreated, apiImage, nil)
}

// GetImage returns the image for a note, possibly resized.
func GetImage(c echo.Context) error {
inst := middlewares.GetInstance(c)
Expand Down Expand Up @@ -457,6 +489,7 @@ func Routes(router *echo.Group) {
router.GET("/:id/open", OpenNoteURL)
router.PUT("/:id/schema", UpdateNoteSchema)
router.POST("/:id/images", UploadImage)
router.POST("/:id/:image-id/copy", CopyImage)
router.GET("/:id/images/:image-id/:secret", GetImage)
}

Expand Down
183 changes: 112 additions & 71 deletions web/notes/notes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func TestNotes(t *testing.T) {
t.Skip("an instance is required for this test: test skipped due to the use of --short flag")
}

var noteID string
var noteID, otherNoteID string
var version int64

config.UseTestFile(t)
Expand Down Expand Up @@ -684,6 +684,76 @@ func TestNotes(t *testing.T) {
assert.EqualValues(t, file.Metadata["version"], vers5)
})

t.Run("CreateNote with a content", func(t *testing.T) {
e := testutils.CreateTestClient(t, ts.URL)

obj := e.POST("/notes").
WithHeader("Authorization", "Bearer "+token).
WithHeader("Content-Type", "application/json").
WithBytes([]byte(`{
"data": {
"type": "io.cozy.notes.documents",
"attributes": {
"title": "A note with some content",
"schema": {
"nodes": [
["doc", { "content": "block+" }],
["paragraph", { "content": "inline*", "group": "block" }],
["text", { "group": "inline" }],
["bullet_list", { "content": "list_item+", "group": "block" }],
["list_item", { "content": "paragraph block*" }]
],
"marks": [
["em", {}],
["strong", {}]
],
"topNode": "doc"
},
"content": {
"content": [
{
"content": [{ "text": "Hello world", "type": "text" }],
"type": "paragraph"
}
],
"type": "doc"
}
}
}
}`)).
Expect().Status(201).
JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
Object()

data := obj.Value("data").Object()

data.ValueEqual("type", "io.cozy.files")
otherNoteID = data.Value("id").String().NotEmpty().Raw()

attrs := data.Value("attributes").Object()
attrs.ValueEqual("type", "file")
attrs.ValueEqual("name", "A note with some content.cozy-note")
attrs.ValueEqual("mime", "text/vnd.cozy.note+markdown")

meta := attrs.Value("metadata").Object()
meta.ValueEqual("title", "A note with some content")
meta.ValueEqual("version", 0)
meta.Value("schema").Object().NotEmpty()

expected := map[string]interface{}{
"content": []interface{}{
map[string]interface{}{
"content": []interface{}{
map[string]interface{}{"text": "Hello world", "type": "text"},
},
"type": "paragraph",
},
},
"type": "doc",
}
meta.Value("content").Object().IsEqual(expected)
})

t.Run("UploadImage", func(t *testing.T) {
e := testutils.CreateTestClient(t, ts.URL)

Expand Down Expand Up @@ -721,6 +791,47 @@ func TestNotes(t *testing.T) {
}
})

t.Run("CopyImage", func(t *testing.T) {
e := testutils.CreateTestClient(t, ts.URL)

rawFile, err := os.ReadFile("../../tests/fixtures/wet-cozy_20160910__M4Dz.jpg")
require.NoError(t, err)

obj := e.POST("/notes/"+noteID+"/images").
WithQuery("Name", "tobecopied.jpg").
WithHeader("Authorization", "Bearer "+token).
WithHeader("Content-Type", "image/jpeg").
WithBytes(rawFile).
Expect().Status(201).
JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
Object()

data := obj.Value("data").Object()
imgID := data.Value("id").String().NotEmpty().Raw()

obj = e.POST("/notes/"+imgID+"/copy").
WithQuery("To", otherNoteID).
WithHeader("Authorization", "Bearer "+token).
Expect().Status(201).
JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
Object()

data = obj.Value("data").Object()
data.ValueEqual("type", consts.NotesImages)
data.Value("id").String().NotEmpty().NotEqual(imgID)
data.Value("meta").Object().NotEmpty()

attrs := data.Value("attributes").Object()
attrs.ValueEqual("name", "tobecopied.jpg")

attrs.Value("cozyMetadata").Object().NotEmpty()
attrs.ValueEqual("mime", "image/jpeg")
attrs.ValueEqual("width", 440)
attrs.ValueEqual("height", 294)

data.Path("$.links.self").String().NotEmpty()
})

t.Run("GetImage", func(t *testing.T) {
e := testutils.CreateTestClient(t, ts.URL)

Expand Down Expand Up @@ -813,76 +924,6 @@ func TestNotes(t *testing.T) {
data.ValueEqual("id", fileID)
data.Path("$.attributes.instance").Equal(inst.Domain)
})

t.Run("CreateNote with a content", func(t *testing.T) {
e := testutils.CreateTestClient(t, ts.URL)

obj := e.POST("/notes").
WithHeader("Authorization", "Bearer "+token).
WithHeader("Content-Type", "application/json").
WithBytes([]byte(`{
"data": {
"type": "io.cozy.notes.documents",
"attributes": {
"title": "A note with some content",
"schema": {
"nodes": [
["doc", { "content": "block+" }],
["paragraph", { "content": "inline*", "group": "block" }],
["text", { "group": "inline" }],
["bullet_list", { "content": "list_item+", "group": "block" }],
["list_item", { "content": "paragraph block*" }]
],
"marks": [
["em", {}],
["strong", {}]
],
"topNode": "doc"
},
"content": {
"content": [
{
"content": [{ "text": "Hello world", "type": "text" }],
"type": "paragraph"
}
],
"type": "doc"
}
}
}
}`)).
Expect().Status(201).
JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
Object()

data := obj.Value("data").Object()

data.ValueEqual("type", "io.cozy.files")
data.Value("id").String().NotEmpty()

attrs := data.Value("attributes").Object()
attrs.ValueEqual("type", "file")
attrs.ValueEqual("name", "A note with some content.cozy-note")
attrs.ValueEqual("mime", "text/vnd.cozy.note+markdown")

meta := attrs.Value("metadata").Object()
meta.ValueEqual("title", "A note with some content")
meta.ValueEqual("version", 0)
meta.Value("schema").Object().NotEmpty()

expected := map[string]interface{}{
"content": []interface{}{
map[string]interface{}{
"content": []interface{}{
map[string]interface{}{"text": "Hello world", "type": "text"},
},
"type": "paragraph",
},
},
"type": "doc",
}
meta.Value("content").Object().IsEqual(expected)
})
}

func assertInitialNote(t *testing.T, obj *httpexpect.Object) {
Expand Down

0 comments on commit e2c8bda

Please sign in to comment.