From eb4aa2c0265ab44969f275fb78a27bc6604989bf Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Sat, 29 Sep 2018 12:54:44 +0100 Subject: [PATCH] API: Added more calls --- CliClient/app/command-apidoc.js | 78 ++++++++++++++++++-- CliClient/tests/services_rest_Api.js | 29 ++++++-- ReactNativeClient/lib/joplin-database.js | 1 + ReactNativeClient/lib/services/rest/Api.js | 48 +++++++++--- docs/api/index.html | 48 +++++++++++- docs/clipper/index.html | 85 +--------------------- readme/api.md | 42 ++++++++++- readme/clipper.md | 62 +--------------- 8 files changed, 215 insertions(+), 178 deletions(-) diff --git a/CliClient/app/command-apidoc.js b/CliClient/app/command-apidoc.js index ef011e79c..f5d0969bd 100644 --- a/CliClient/app/command-apidoc.js +++ b/CliClient/app/command-apidoc.js @@ -52,9 +52,6 @@ class Command extends BaseCommand { const lines = []; - // Get list of note tags - // Get list of folder notes - lines.push('# Joplin API'); lines.push(''); @@ -98,7 +95,7 @@ class Command extends BaseCommand { lines.push('The four verbs supported by the API are the following ones:'); lines.push(''); lines.push('* **GET**: To retrieve items (notes, notebooks, etc.).'); - lines.push('* **POST**: To create new items.'); + lines.push('* **POST**: To create new items. In general most item properties are optional. If you omit any, a default value will be used.'); lines.push('* **PUT**: To update an item. Note in a REST API, traditionally PUT is used to completely replace an item, however in this API it will only replace the properties that are provided. For example if you PUT {"title": "my new title"}, only the "title" property will be changed. The other properties will be left untouched (they won\'t be cleared nor changed).'); lines.push('* **DELETE**: To delete items.'); lines.push(''); @@ -110,13 +107,42 @@ class Command extends BaseCommand { lines.push('* Booleans are integer values 0 or 1.'); lines.push(''); + lines.push('# Testing if the service is available'); + lines.push(''); + lines.push('Call **GET /ping** to check if the service is available. It should return "JoplinClipperServer" if it works.'); + lines.push(''); + for (let i = 0; i < models.length; i++) { const model = models[i]; const ModelClass = BaseItem.getClassByItemType(model.type); const tableName = ModelClass.tableName(); - const tableFields = reg.db().tableFields(tableName, { includeDescription: true }); + let tableFields = reg.db().tableFields(tableName, { includeDescription: true }); const singular = tableName.substr(0, tableName.length - 1); + if (model.type === BaseModel.TYPE_NOTE) { + tableFields = tableFields.slice(); + tableFields.push({ + name: 'body_html', + type: Database.enumId('fieldType', 'text'), + description: 'Note body, in HTML format', + }); + tableFields.push({ + name: 'base_url', + type: Database.enumId('fieldType', 'text'), + description: 'If `body_html` is provided and contains relative URLs, provide the `base_url` parameter too so that all the URLs can be converted to absolute ones. The base URL is basically where the HTML was fetched from, minus the query (everything after the \'?\'). For example if the original page was `https://stackoverflow.com/search?q=%5Bjava%5D+test`, the base URL is `https://stackoverflow.com/search`.', + }); + tableFields.push({ + name: 'image_data_url', + type: Database.enumId('fieldType', 'text'), + description: 'An image to attach to the note, in [Data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format.', + }); + tableFields.push({ + name: 'crop_rect', + type: Database.enumId('fieldType', 'text'), + description: 'If an image is provided, you can also specify an optional rectangle that will be used to crop the image. In format `{ x: x, y: y, width: width, height: height }`', + }); + } + lines.push('# ' + toTitleCase(tableName)); lines.push(''); @@ -135,6 +161,11 @@ class Command extends BaseCommand { lines.push('Gets all ' + tableName); lines.push(''); + if (model.type === BaseModel.TYPE_FOLDER) { + lines.push('The folders are returned as a tree. The sub-notebooks of a notebook, if any, are under the `children` key.'); + lines.push(''); + } + lines.push('## GET /' + tableName + '/:id'); lines.push(''); lines.push('Gets ' + singular + ' with ID :id'); @@ -143,7 +174,21 @@ class Command extends BaseCommand { if (model.type === BaseModel.TYPE_TAG) { lines.push('## GET /tags/:id/notes'); lines.push(''); - lines.push('Get all the notes with this tag.'); + lines.push('Gets all the notes with this tag.'); + lines.push(''); + } + + if (model.type === BaseModel.TYPE_NOTE) { + lines.push('## GET /notes/:id/tags'); + lines.push(''); + lines.push('Gets all the tags attached to this note.'); + lines.push(''); + } + + if (model.type === BaseModel.TYPE_FOLDER) { + lines.push('## GET /folders/:id/notes'); + lines.push(''); + lines.push('Gets all the notes inside this folder.'); lines.push(''); } @@ -168,6 +213,25 @@ class Command extends BaseCommand { lines.push(''); } + if (model.type === BaseModel.TYPE_NOTE) { + lines.push('You can either specify the note body as Markdown by setting the `body` parameter, or in HTML by setting the `body_html`.'); + lines.push(''); + lines.push('Examples:'); + lines.push(''); + lines.push('* Create a note from some Markdown text'); + lines.push(''); + lines.push(' curl --data \'{ "title": "My note", "body": "Some note in **Markdown**"}\' http://127.0.0.1:41184/notes'); + lines.push(''); + lines.push('* Create a note from some HTML'); + lines.push(''); + lines.push(' curl --data \'{ "title": "My note", "body_html": "Some note in HTML"}\' http://127.0.0.1:41184/notes'); + lines.push(''); + lines.push('* Create a note and attach an image to it:'); + lines.push(''); + lines.push(' curl --data \'{ "title": "Image test", "body": "Here is Joplin icon:", "image_data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAANZJREFUeNoAyAA3/wFwtO3K6gUB/vz2+Prw9fj/+/r+/wBZKAAExOgF4/MC9ff+MRH6Ui4E+/0Bqc/zutj6AgT+/Pz7+vv7++nu82c4DlMqCvLs8goA/gL8/fz09fb59vXa6vzZ6vjT5fbn6voD/fwC8vX4UiT9Zi//APHyAP8ACgUBAPv5APz7BPj2+DIaC2o3E+3o6ywaC5fT6gD6/QD9/QEVf9kD+/dcLQgJA/7v8vqfwOf18wA1IAIEVycAyt//v9XvAPv7APz8LhoIAPz9Ri4OAgwARgx4W/6fVeEAAAAASUVORK5CYII="}\' http://127.0.0.1:41184/notes'); + lines.push(''); + } + lines.push('## PUT /' + tableName + '/:id'); lines.push(''); lines.push('Sets the properties of the ' + singular + ' with ID :id'); @@ -181,7 +245,7 @@ class Command extends BaseCommand { if (model.type === BaseModel.TYPE_TAG) { lines.push('## DELETE /tags/:id/notes/:note_id'); lines.push(''); - lines.push('Remove the tag from the note..'); + lines.push('Remove the tag from the note.'); lines.push(''); } } diff --git a/CliClient/tests/services_rest_Api.js b/CliClient/tests/services_rest_Api.js index e932b7567..e2c2ae8b9 100644 --- a/CliClient/tests/services_rest_Api.js +++ b/CliClient/tests/services_rest_Api.js @@ -93,6 +93,19 @@ describe('services_rest_Api', function() { done(); }); + it('should get the folder notes', async (done) => { + let f1 = await Folder.save({ title: "mon carnet" }); + const response2 = await api.route('GET', 'folders/' + f1.id + '/notes'); + expect(response2.length).toBe(0); + + const n1 = await Note.save({ title: 'un', parent_id: f1.id }); + const n2 = await Note.save({ title: 'deux', parent_id: f1.id }); + const response = await api.route('GET', 'folders/' + f1.id + '/notes'); + expect(response.length).toBe(2); + + done(); + }); + it('should fail on invalid paths', async (done) => { const hasThrown = await checkThrowAsync(async () => await api.route('GET', 'schtroumpf')); expect(hasThrown).toBe(true); @@ -111,13 +124,6 @@ describe('services_rest_Api', function() { response = await api.route('GET', 'notes'); expect(response.length).toBe(3); - response = await api.route('GET', 'notes', { parent_id: f1.id }); - expect(response.length).toBe(2); - - response = await api.route('GET', 'notes', { parent_id: f2.id }); - expect(response.length).toBe(1); - expect(response[0].id).toBe(n3.id); - response = await api.route('GET', 'notes/' + n1.id); expect(response.id).toBe(n1.id); @@ -243,6 +249,7 @@ describe('services_rest_Api', function() { it('should list all tag notes', async (done) => { const tag = await Tag.save({ title: "mon étiquette" }); + const tag2 = await Tag.save({ title: "mon étiquette 2" }); const note1 = await Note.save({ title: "ma note un" }); const note2 = await Note.save({ title: "ma note deux" }); await Tag.addNote(tag.id, note1.id); @@ -250,6 +257,14 @@ describe('services_rest_Api', function() { const response = await api.route('GET', 'tags/' + tag.id + '/notes'); expect(response.length).toBe(2); + expect('id' in response[0]).toBe(true); + expect('title' in response[0]).toBe(true); + + const response2 = await api.route('GET', 'notes/' + note1.id + '/tags'); + expect(response2.length).toBe(1); + await Tag.addNote(tag2.id, note1.id); + const response3 = await api.route('GET', 'notes/' + note1.id + '/tags'); + expect(response3.length).toBe(2); done(); }); diff --git a/ReactNativeClient/lib/joplin-database.js b/ReactNativeClient/lib/joplin-database.js index 124e4cf25..62ceea1eb 100644 --- a/ReactNativeClient/lib/joplin-database.js +++ b/ReactNativeClient/lib/joplin-database.js @@ -170,6 +170,7 @@ class JoplinDatabase extends Database { is_todo: _('Tells whether this note is a todo or not.'), todo_due: _('When the todo is due. An alarm will be triggered on that date.'), todo_completed: _('Tells whether todo is completed or not. This is a timestamp in milliseconds.'), + source_url: _('The full URL where the note comes from.'), }, folders: {}, resources: {}, diff --git a/ReactNativeClient/lib/services/rest/Api.js b/ReactNativeClient/lib/services/rest/Api.js index 5b8669923..e6bf1523a 100644 --- a/ReactNativeClient/lib/services/rest/Api.js +++ b/ReactNativeClient/lib/services/rest/Api.js @@ -231,6 +231,15 @@ class Api { return await Folder.allAsTree({ fields: this.fields_(request, ['id', 'parent_id', 'title']) }); } + if (request.method === 'GET' && id) { + if (link && link === 'notes') { + const options = this.notePreviewsOptions_(request); + return Note.previews(id, options); + } else if (link) { + throw new ErrorNotFound(); + } + } + return this.defaultAction_(BaseModel.TYPE_FOLDER, request, id, link); } @@ -253,7 +262,15 @@ class Api { } if (request.method === 'GET') { - return await Tag.noteIds(tag.id); + // Ideally we should get all this in one SQL query but for now that will do + const noteIds = await Tag.noteIds(tag.id); + const output = []; + for (let i = 0; i < noteIds.length; i++) { + const n = await Note.preview(noteIds[i], this.notePreviewsOptions_(request)); + if (!n) continue; + output.push(n); + } + return output; } } @@ -282,20 +299,29 @@ class Api { return this.defaultAction_(BaseModel.TYPE_RESOURCE, request, id, link); } + notePreviewsOptions_(request) { + const fields = this.fields_(request, []); // previews() already returns default fields + const options = {}; + if (fields.length) options.fields = fields; + return options; + } + async action_notes(request, id = null, link = null) { + this.checkToken_(request); + if (request.method === 'GET') { - this.checkToken_(request); + + if (link && link === 'tags') { + return Tag.tagsByNoteId(id); + } else if (link) { + throw new ErrorNotFound(); + } - const noteId = id; - const parentId = request.query.parent_id ? request.query.parent_id : null; - const fields = this.fields_(request, []); // previews() already returns default fields - const options = {}; - if (fields.length) options.fields = fields; - - if (noteId) { - return await Note.preview(noteId, options); + const options = this.notePreviewsOptions_(request); + if (id) { + return await Note.preview(id, options); } else { - return await Note.previews(parentId, options); + return await Note.previews(null, options); } } diff --git a/docs/api/index.html b/docs/api/index.html index 16e187cbd..e244b252e 100644 --- a/docs/api/index.html +++ b/docs/api/index.html @@ -276,7 +276,7 @@ for (let portToTest = 41184; portToTest <= 41194; portToTest++) {

The four verbs supported by the API are the following ones:

@@ -286,6 +286,8 @@ for (let portToTest = 41184; portToTest <= 41194; portToTest++) {
  • All date/time are Unix timestamps in milliseconds.
  • Booleans are integer values 0 or 1.
  • +

    Testing if the service is available

    +

    Call GET /ping to check if the service is available. It should return "JoplinClipperServer" if it works.

    Notes

    Properties

    @@ -355,7 +357,7 @@ for (let portToTest = 41184; portToTest <= 41194; portToTest++) { - + @@ -412,14 +414,49 @@ for (let portToTest = 41184; portToTest <= 41194; portToTest++) { + + + + + + + + + + + + + + + + + + + +
    source_url textThe full URL where the note comes from.
    is_todoint
    body_htmltextNote body, in HTML format
    base_urltextIf body_html is provided and contains relative URLs, provide the base_url parameter too so that all the URLs can be converted to absolute ones. The base URL is basically where the HTML was fetched from, minus the query (everything after the '?'). For example if the original page was https://stackoverflow.com/search?q=%5Bjava%5D+test, the base URL is https://stackoverflow.com/search.
    image_data_urltextAn image to attach to the note, in Data URL format.
    crop_recttextIf an image is provided, you can also specify an optional rectangle that will be used to crop the image. In format { x: x, y: y, width: width, height: height }

    GET /notes

    Gets all notes

    GET /notes/:id

    Gets note with ID :id

    +

    GET /notes/:id/tags

    +

    Gets all the tags attached to this note.

    POST /notes

    Creates a new note

    +

    You can either specify the note body as Markdown by setting the body parameter, or in HTML by setting the body_html.

    +

    Examples:

    +

    PUT /notes/:id

    Sets the properties of the note with ID :id

    DELETE /notes/:id

    @@ -485,8 +522,11 @@ for (let portToTest = 41184; portToTest <= 41194; portToTest++) {

    GET /folders

    Gets all folders

    +

    The folders are returned as a tree. The sub-notebooks of a notebook, if any, are under the children key.

    GET /folders/:id

    Gets folder with ID :id

    +

    GET /folders/:id/notes

    +

    Gets all the notes inside this folder.

    POST /folders

    Creates a new folder

    PUT /folders/:id

    @@ -637,7 +677,7 @@ for (let portToTest = 41184; portToTest <= 41194; portToTest++) {

    GET /tags/:id

    Gets tag with ID :id

    GET /tags/:id/notes

    -

    Get all the notes with this tag.

    +

    Gets all the notes with this tag.

    POST /tags

    Creates a new tag

    POST /tags/:id/notes

    @@ -647,7 +687,7 @@ for (let portToTest = 41184; portToTest <= 41194; portToTest++) {

    DELETE /tags/:id

    Deletes the tag with ID :id

    DELETE /tags/:id/notes/:note_id

    -

    Remove the tag from the note..

    +

    Remove the tag from the note.