diff --git a/docs/notes.md b/docs/notes.md index b610041df7b..15fdd4f23d4 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -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 diff --git a/model/note/image.go b/model/note/image.go index 891eafca492..f336bd42a00 100644 --- a/model/note/image.go +++ b/model/note/image.go @@ -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 { diff --git a/web/notes/notes.go b/web/notes/notes.go index 4ec42ae2697..0b567e82f9f 100644 --- a/web/notes/notes.go +++ b/web/notes/notes.go @@ -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) @@ -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) } diff --git a/web/notes/notes_test.go b/web/notes/notes_test.go index 509acc96e4c..e85a8b0a162 100644 --- a/web/notes/notes_test.go +++ b/web/notes/notes_test.go @@ -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) @@ -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) @@ -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) @@ -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) {