diff --git a/.vscode/launch.json b/.vscode/launch.json index f8b0debe..8efee189 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -20,6 +20,25 @@ "sourceMaps": false, "outDir": null }, + { + "name": "Gulp", + "type": "node", + "request": "launch", + "program": "${workspaceRoot}/node_modules/gulp/bin/gulp.js", + "stopOnEntry": false, + "args": ["test"], + "cwd": "${workspaceRoot}", + "runtimeExecutable": null, + "runtimeArgs": [ + "--nolazy" + ], + "env": { + "NODE_ENV": "development" + }, + "externalConsole": false, + "sourceMaps": false, + "outDir": null + }, { "name": "Attach", "type": "node", diff --git a/README.md b/README.md index 631341b3..96d9ea89 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # JavaScript Core Component -[![npm version](https://badge.fury.io/js/sp-pnp-js.svg)](https://badge.fury.io/js/sp-pnp-js) [![Join the chat at https://gitter.im/OfficeDev/PnP-JS-Core](https://badges.gitter.im/OfficeDev/PnP-JS-Core.svg)](https://gitter.im/OfficeDev/PnP-JS-Core?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Downloads](https://img.shields.io/npm/dm/sp-pnp-js.svg)](https://www.npmjs.com/package/sp-pnp-js) [![bitHound Overall Score](https://www.bithound.io/github/OfficeDev/PnP-JS-Core/badges/score.svg)](https://www.bithound.io/github/OfficeDev/PnP-JS-Core) +[![npm version](https://badge.fury.io/js/sp-pnp-js.svg)](https://badge.fury.io/js/sp-pnp-js) [![Join the chat at https://gitter.im/OfficeDev/PnP-JS-Core](https://badges.gitter.im/OfficeDev/PnP-JS-Core.svg)](https://gitter.im/OfficeDev/PnP-JS-Core?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Downloads](https://img.shields.io/npm/dm/sp-pnp-js.svg)](https://www.npmjs.com/package/sp-pnp-js) [![bitHound Overall Score](https://www.bithound.io/github/SharePoint/PnP-JS-Core/badges/score.svg)](https://www.bithound.io/github/SharePoint/PnP-JS-Core) The Patterns and Practices JavaScript Core Library was created to help developers by simplifying common operations within SharePoint and the SharePoint Framework. Currently it contains a fluent API for working with the full SharePoint REST API as well as utility and helper functions. This takes the guess work out of creating REST requests, letting developers focus on the what and less on the how. @@ -26,7 +26,7 @@ Add the package from bower ### Wiki -Please see [the wiki](https://github.com/OfficeDev/PnP-JS-Core/wiki) for detailed guides on getting started both using and contributing to the library. +Please see [the wiki](https://github.com/SharePoint/PnP-JS-Core/wiki) for detailed guides on getting started both using and contributing to the library. ### API Documentation diff --git a/buildtasks/test.js b/buildtasks/test.js index ea895bec..2b9c7c3e 100644 --- a/buildtasks/test.js +++ b/buildtasks/test.js @@ -14,7 +14,8 @@ var gulp = require("gulp"), mocha = require("gulp-mocha"), istanbul = require("gulp-istanbul"), - tsc = require("gulp-typescript"); + tsc = require("gulp-typescript"), + yargs = require('yargs').argv; //****************************************************************************** //* TEST @@ -29,7 +30,8 @@ gulp.task("build-tests", ["clean"], function() { }); gulp.task("test", ["build", "build-tests", "istanbul:hook"], function() { - return gulp.src(global.TSCompiledOutput.JSTestFiles) + let path = './build/tests/{path}.test.js'; + return gulp.src(yargs.single ? path.replace('{path}', yargs.single) : global.TSCompiledOutput.JSTestFiles) .pipe(mocha({ ui: 'bdd', reporter: 'dot', timeout: 10000 })) .pipe(istanbul.writeReports()); -}); +}); \ No newline at end of file diff --git a/package.json b/package.json index 6918e049..42e3c5dc 100644 --- a/package.json +++ b/package.json @@ -44,14 +44,15 @@ "typescript": "^2.0.3", "typings": "^1.4.0", "vinyl-buffer": "^1.0.0", - "vinyl-source-stream": "^1.1.0" + "vinyl-source-stream": "^1.1.0", + "yargs": "^6.0.0" }, "scripts": { "test": "gulp test" }, "repository": { "type": "git", - "url": "git://github.com/OfficeDev/PnP-JS-Core" + "url": "git://github.com/SharePoint/PnP-JS-Core" }, "author": { "name": "Microsoft and other contributors" @@ -60,12 +61,14 @@ "keywords": [ "sharepoint", "office365", - "tools" + "tools", + "spfx", + "sharepoint framework" ], "bugs": { - "url": "https://github.com/OfficeDev/PnP-JS-Core/issues" + "url": "https://github.com/SharePoint/PnP-JS-Core/issues" }, - "homepage": "https://github.com/OfficeDev/PnP-JS-Core", + "homepage": "https://github.com/SharePoint/PnP-JS-Core", "browser": { "./build/src/net/nodefetchclient.js": "./build/src/net/nodefetchclientbrowser.js", "./lib/net/nodefetchclient.js": "./lib/net/nodefetchclientbrowser.js" diff --git a/server-root/scratchpad.js b/server-root/scratchpad.js index fdc46b84..ad80f53a 100644 --- a/server-root/scratchpad.js +++ b/server-root/scratchpad.js @@ -45,16 +45,16 @@ require(["pnp"], function (pnp) { // pnp.sp.web.lists.getByTitle("Config3").items.orderBy("Title").top(1).getPaged().then(d => { // show(d); // d.getNext().then(d => show(d)); - // }); + // }); // pnp.sp.web.siteGroups.get().then(show); // pnp.sp.web.siteGroups.add({ "Title": "Test Group 1" }).then(show); // pnp.sp.web.siteGroups.getById(11).get().then(show); // pnp.sp.web.siteGroups.removeById(13).get().then(show); - // pnp.sp.web.siteGroups.removeByLoginName("Delete My By Name").then(show); + // pnp.sp.web.siteGroups.removeByLoginName("Delete My By Name").then(show); // pnp.sp.web.siteGroups.getByName("Test Group 1").get().then(show); // pnp.sp.web.siteGroups.getById(11).users.get().then(show); - // pnp.sp.web.siteGroups.getByName("Test Group 1").update({ Title: "Test Group 1-2" }).then((r) => { - // r.group.users.get().then(show); + // pnp.sp.web.siteGroups.getByName("Test Group 1").update({ Title: "Test Group 1-2" }).then((r) => { + // r.group.users.get().then(show); // }); //pnp.sp.web.roleDefinitions.add("Test1", "Description", 180, { High: '176', Low: '138612801' }).then(show); //pnp.sp.web.roleDefinitions.getByName("Test1").update({ BasePermissions: { High: '0', Low: '138612801' }, Name: "Fred" }).then(show); @@ -66,11 +66,11 @@ require(["pnp"], function (pnp) { // pnp.sp.web.lists.getByTitle("Documents").items.get().then(show); // pnp.sp.web.roleAssignments.get().then(show); // pnp.sp.web.lists.getByTitle("Documents").views.get().then(show); - // pnp.sp.site.rootWeb.folders.get().then(show); - // pnp.sp.site.rootWeb.folders.getByName("Shared Documents").select("Title").get().then(show); + // pnp.sp.site.rootWeb.folders.get().then(show); + // pnp.sp.site.rootWeb.folders.getByName("Shared Documents").select("Title").get().then(show); // pnp.sp.site.rootWeb.getFolderByServerRelativeUrl("sites/dev/Style Library/test").select("Name").get().then(show); // pnp.sp.site.rootWeb.getFolderByServerRelativeUrl("/sites/dev/Style Library/test").parentFolder.select("Name").get().then(show); - // pnp.sp.site.rootWeb.getFolderByServerRelativeUrl("/sites/dev/Style Library").folders.get().then(show); + // pnp.sp.site.rootWeb.getFolderByServerRelativeUrl("/sites/dev/Style Library").folders.get().then(show); // pnp.sp.site.rootWeb.getFolderByServerRelativeUrl("/sites/dev/Style Library").name.get().then(show); // pnp.sp.site.rootWeb.getFolderByServerRelativeUrl("/sites/dev/Style Library").properties.select("vti_x005f_dirlateststamp").get().then(show); // pnp.sp.site.rootWeb.getFolderByServerRelativeUrl("/sites/dev/Style Library/test").files.get().then(show); @@ -82,13 +82,13 @@ require(["pnp"], function (pnp) { // pnp.sp.web.lists.getByTitle("Documents").eventReceivers.get().then(show); // pnp.sp.web.lists.getByTitle("Documents").eventReceivers.select("ReceiverName").filter("SequenceNumber ne 10000").get().then(show); // pnp.sp.web.lists.getByTitle("Documents").getUserEffectivePermissions("i:0h.f|membership|10037ffe82c3e2e1@live.com").get().then(show); - // pnp.sp.web.lists.getByTitle("Test List").items.getById(1).attachmentFiles.get().then(show); + // pnp.sp.web.lists.getByTitle("Test List").items.getById(1).attachmentFiles.get().then(show); // pnp.sp.web.lists.getByTitle("Test List").items.getById(1).attachmentFiles.select("ServerRelativeUrl").filter("FileName eq 'SP Customizations.pptx'").get().then(show); // pnp.sp.web.lists.getByTitle("Test List").items.getById(1).contentType.select("Id", "Name").get().then(show); // pnp.sp.web.lists.getByTitle("Test List").items.getById(1).effectiveBasePermissions.get().then(show); // pnp.sp.web.lists.getByTitle("Test List").items.getById(1).effectiveBasePermissionsForUI.get().then(show); // pnp.sp.web.lists.getByTitle("Test List").items.getById(1).fieldValuesAsHTML.get().then(show); - // pnp.sp.web.lists.getByTitle("Test List").items.getById(1).fieldValuesAsHTML.select("GUID", "Title").get().then(show); + // pnp.sp.web.lists.getByTitle("Test List").items.getById(1).fieldValuesAsHTML.select("GUID", "Title").get().then(show); // pnp.sp.web.lists.getByTitle("Test List").items.getById(1).fieldValuesAsText.select("GUID", "Title").get().then(show); // pnp.sp.web.lists.getByTitle("Test List").items.getById(1).fieldValuesForEdit.select("GUID", "Title").get().then(show); // pnp.sp.web.lists.getByTitle("Test List").items.getById(1).firstUniqueAncestorSecurableObject.get().then(show); @@ -96,26 +96,26 @@ require(["pnp"], function (pnp) { // pnp.sp.web.lists.getByTitle("Test List").items.getById(2).folder.get().then(show); // pnp.sp.web.lists.getByTitle("Test List").items.getById(3).getUserEffectivePermissions("i:0h.f|membership|10037ffe82c3e2e1@live.com").get().then(show); // pnp.sp.web.lists.getByTitle("Test List").items.getById(3).roleAssignments.get().then(show); - // pnp.sp.web.lists.getByTitle("Test List").items.getById(3).roleAssignments.select("PrincipalId").filter("PrincipalId eq 8").get().then(show) + // pnp.sp.web.lists.getByTitle("Test List").items.getById(3).roleAssignments.select("PrincipalId").filter("PrincipalId eq 8").get().then(show) // pnp.sp.web.lists.add("My first list").then(function(result) { // result.list.update({ Title: "New Title!!" }).then(function(result) { // show(result.data); // }); - // }); + // }); // pnp.sp.web.lists.getByTitle("New Title!!").delete().then(show); // pnp.sp.web.lists.add("My first list").then(function(result) { - // result.list.update({ Title: "New Title!!" }).then(function(result2) { + // result.list.update({ Title: "New Title!!" }).then(function(result2) { // result2.list.breakRoleInheritance().then(show); // }); - // }); + // }); // pnp.sp.web.lists.getByTitle("New Title!!").resetRoleInheritance().then(show); - // pnp.sp.web.lists.getByTitle("New Title!!").roleAssignments.get().then(show); + // pnp.sp.web.lists.getByTitle("New Title!!").roleAssignments.get().then(show); // let provider = new pnp.configuration.Providers.SPListConfigurationProvider("https://318studios.sharepoint.com/sites/dev"); - // provider.getConfiguration().then(show); + // provider.getConfiguration().then(show); // pnp.sp.web.lists.getByTitle("Config").items.add({ // Title: "Title 2", // Value: "Value 2" - // }).then(show); + // }).then(show); // pnp.sp.web.lists.getByTitle("Config").items.getById(4).update({ // Value: "Different" // }).then(show); @@ -132,11 +132,11 @@ require(["pnp"], function (pnp) { // }); // pnp.sp.web.lists.ensureSitePagesLibrary().then(function(list) { // list.items.get().then(show); - // }); + // }); //pnp.sp.web.lists.ensureSitePagesLibrary().then(show); //pnp.sp.web.lists.getByTitle("Config").items.getById(5).validateUpdateListItem([{ FieldName: "Title", FieldValue: "So different, much woot."}]).then(show); //pnp.sp.web.lists.getByTitle("Config").views.getById("66e251c3-1362-4e86-90f2-1a8dacd655a5").renderAsHtml().then(showRaw); - //pnp.sp.web.lists.getByTitle("Config").views.add("My New View").then(show); + //pnp.sp.web.lists.getByTitle("Config").views.add("My New View").then(show); // pnp.sp.web.lists.getByTitle("Config").views.add("My New View 3").then(function(result) { // result.view.fields.getSchemaXml().then(show); // }); @@ -147,7 +147,7 @@ require(["pnp"], function (pnp) { // result.view.fields.add("Modified").then(function () { // result.view.fields.move("Modified", 0).then(show); // }); - // }); + // }); //pnp.sp.web.lists.getByTitle("Config").fields.addText("MyNewField3").then(show); //pnp.sp.web.lists.getByTitle("Config").fields.addCalculated("calc", "=[Title]", 1, 2).then(show); //pnp.sp.web.lists.getByTitle("Config").fields.addDateTime("datetimefield").then(show); @@ -165,7 +165,7 @@ require(["pnp"], function (pnp) { //pnp.sp.site.getDocumentLibraries("https://318studios.sharepoint.com/sites/dev/").then(show); //pnp.sp.web.applyTheme("/sites/dev/_catalogs/theme/15/palette011.spcolor", "/sites/dev/_catalogs/theme/15/fontscheme007.spfont", "/sites/dev/Style%20Library/DSC_0024.JPG", false).then(show); //pnp.sp.site.getWebUrlFromPageUrl("https://318studios.sharepoint.com/sites/dev/SitePages/DevHome.aspx").then(show); - //pnp.sp.web.mapToIcon("blah.xlsx").then(show); + //pnp.sp.web.mapToIcon("blah.xlsx").then(show); // pnp.sp.profiles.editProfileLink.then(show); // pnp.sp.profiles.isMyPeopleListPublic.then(show); // pnp.sp.profiles.amIFollowedBy("i:0#.w|ylo001\_spocrawler_18_3996").then(show); @@ -182,13 +182,13 @@ require(["pnp"], function (pnp) { //pnp.sp.profiles.ownerUserProfile.then(show); //pnp.sp.profiles.userProfile.then(show); // test profile image upload - // $(function() { - // $("#testingshow").append("
"); + // $(function() { + // $("#testingshow").append("
"); // var div = $("#testingshow").find("#profiletest"); // var btn = div.find("button"); // btn.on('click', function(e) { - // e.preventDefault(); + // e.preventDefault(); // var file = $(this).closest("div").find("input")[0].files[0]; // pnp.sp.profiles.setMyProfilePic(file).then(show); // }); @@ -196,11 +196,46 @@ require(["pnp"], function (pnp) { // var caml = { ViewXml: "10" }; - // pnp.sp.web.lists.getByTitle("Config3").getItemsByCAMLQuery(caml, "RoleAssignments").then(show); + // pnp.sp.web.lists.getByTitle("Config3").getItemsByCAMLQuery(caml, "RoleAssignments").then(show); //pnp.sp.search("Title").then(show); + /* Webhook subscription creation */ + // var notificationUrl = "{ notification url }"; + // var today = new Date(); + // var expirationDate = new Date(today.setDate(today.getDate() + 90)).toISOString(); + // pnp.sp.web.lists.getByTitle("Documents").subscriptions.create(notificationUrl, expirationDate, 'custom').then(function (data) { + // // Show new subscription information + // show(data); + // // Check all subscriptions of the current list + // pnp.sp.web.lists.getByTitle("Documents").subscriptions.get().then(show); + // }); + + /* Show webhook subscriptions of a list/library */ + //pnp.sp.web.lists.getByTitle("Documents").subscriptions.get().then(show); + + /* Show webhook subscription by ID */ + // var subscriptionId = "820965d1-f02b-483e-8b82-c3628aa459c0"; + // pnp.sp.web.lists.getByTitle("Documents").subscriptions.getById(subscriptionId).then(show); + + /* Update a webhook subscription from a list or library */ + // var subscriptionId = "820965d1-f02b-483e-8b82-c3628aa459c0"; + // var today = new Date(); + // var expirationDate = new Date(today.setDate(today.getDate() + 90)).toISOString(); + // pnp.sp.web.lists.getByTitle("Documents").subscriptions.getById(subscriptionId).then(show); + // pnp.sp.web.lists.getByTitle("Documents").subscriptions.update(subscriptionId, expirationDate).then(function () { + // pnp.sp.web.lists.getByTitle("Documents").subscriptions.getById(subscriptionId).then(show); + // }); + + /* Delete a webhook subscription */ + // var subscriptionId = "820965d1-f02b-483e-8b82-c3628aa459c0"; + // pnp.sp.web.lists.getByTitle("Documents").subscriptions.get().then(show); + // pnp.sp.web.lists.getByTitle("Documents").subscriptions.remove(subscriptionId).then(function () { + // pnp.sp.web.lists.getByTitle("Documents").subscriptions.get().then(show); + // }); + + function syntaxHighlight(json) { json = json.replace(/&/g, '&').replace(//g, '>'); return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) { diff --git a/settings.example.js b/settings.example.js index e43eead4..dd8a1be4 100644 --- a/settings.example.js +++ b/settings.example.js @@ -10,6 +10,7 @@ var settings = { clientSecret: "{ client secret }", enableWebTests: true, siteUrl: "{ site collection url }", + notificationUrl: "{ notification url }", } } diff --git a/src/net/httpclient.ts b/src/net/httpclient.ts index be91bf79..0e2eae1c 100644 --- a/src/net/httpclient.ts +++ b/src/net/httpclient.ts @@ -50,7 +50,7 @@ export class HttpClient { } if (!headers.has("X-ClientService-ClientTag")) { - headers.append("X-ClientService-ClientTag", "PnPCoreJS:1.0.5"); + headers.append("X-ClientService-ClientTag", "PnPCoreJS:1.0.6"); } opts = Util.extend(opts, { headers: headers }); @@ -131,6 +131,16 @@ export class HttpClient { return this.fetch(url, opts); } + public patch(url: string, options: FetchOptions = {}): Promise { + let opts = Util.extend(options, { method: "PATCH" }); + return this.fetch(url, opts); + } + + public delete(url: string, options: FetchOptions = {}): Promise { + let opts = Util.extend(options, { method: "DELETE" }); + return this.fetch(url, opts); + } + protected getFetchImpl(): HttpClientImpl { if (RuntimeConfig.useSPRequestExecutor) { return new SPRequestExecutorClient(); diff --git a/src/sharepoint/rest/contenttypes.ts b/src/sharepoint/rest/contenttypes.ts index b08bc366..ad83cdfd 100644 --- a/src/sharepoint/rest/contenttypes.ts +++ b/src/sharepoint/rest/contenttypes.ts @@ -1,5 +1,7 @@ "use strict"; +import { Util } from "../../utils/util"; +import { TypedHash } from "../../collections/collections"; import { Queryable, QueryableCollection, QueryableInstance } from "./queryable"; /** @@ -25,6 +27,55 @@ export class ContentTypes extends QueryableCollection { ct.concat(`('${id}')`); return ct; } + + /** + * Adds an existing contenttype to a content type collection + * + * @param contentTypeId in the following format, for example: 0x010102 + */ + public addAvailableContentType(contentTypeId: string): Promise { + + let postBody: string = JSON.stringify({ + "contentTypeId": contentTypeId, + }); + + return new ContentTypes(this, `addAvailableContentType`).postAs({ body: postBody }).then((data) => { + return { + contentType: this.getById(data.id), + data: data, + }; + }); + } + + /** + * Adds a new content type to the collection + * + * @param id The desired content type id for the new content type (also determines the parent content type) + * @param name The name of the content type + * @param description The description of the content type + * @param group The group in which to add the content type + * @param additionalSettings Any additional settings to provide when creating the content type + * + */ + public add( + id: string, + name: string, + description = "", + group = "Custom Content Types", + additionalSettings: TypedHash = {}): Promise { + + let postBody = JSON.stringify(Util.extend({ + "__metadata": { "type": "SP.ContentType" }, + "Id": { "StringValue": id }, + "Name": name, + "Group": group, + "Description": description, + }, additionalSettings)); + + return this.post({ body: postBody }).then((data) => { + return { contentType: this.getById(data.id), data: data }; + }); + } } /** @@ -70,3 +121,8 @@ export class ContentType extends QueryableInstance { return new Queryable(this, "workflowAssociations"); } } + +export interface ContentTypeAddResult { + contentType: ContentType; + data: any; +} diff --git a/src/sharepoint/rest/files.ts b/src/sharepoint/rest/files.ts index 4396d30c..c98e5f15 100644 --- a/src/sharepoint/rest/files.ts +++ b/src/sharepoint/rest/files.ts @@ -2,6 +2,17 @@ import { Queryable, QueryableCollection, QueryableInstance } from "./queryable"; import { Item } from "./items"; +import { ODataParser } from "./odata"; +import { Util } from "../../utils/util"; + +export interface ChunkedFileUploadProgressData { + stage: "starting" | "continue" | "finishing"; + blockNumber: number; + totalBlocks: number; + chunkSize: number; + currentPointer: number; + fileSize: number; +} /** * Describes a collection of File objects @@ -33,13 +44,15 @@ export class Files extends QueryableCollection { * Uploads a file. * * @param url The folder-relative url of the file. - * @param shouldOverWrite Should a file with the same name in the same location be overwritten? * @param content The file contents blob. + * @param shouldOverWrite Should a file with the same name in the same location be overwritten? (default: true) * @returns The new File and the raw response. */ public add(url: string, content: Blob, shouldOverWrite = true): Promise { return new Files(this, `add(overwrite=${shouldOverWrite},url='${url}')`) - .post({ body: content }).then((response) => { + .post({ + body: content, + }).then((response) => { return { data: response, file: this.getByName(url), @@ -47,6 +60,31 @@ export class Files extends QueryableCollection { }); } + /** + * Uploads a file. + * + * @param url The folder-relative url of the file. + * @param content The Blob file content to add + * @param progress A callback function which can be used to track the progress of the upload + * @param shouldOverWrite Should a file with the same name in the same location be overwritten? (default: true) + * @param chunkSize The size of each file slice, in bytes (default: 10485760) + * @returns The new File and the raw response. + */ + public addChunked( + url: string, + content: Blob, + progress?: (data: ChunkedFileUploadProgressData) => void, + shouldOverWrite = true, + chunkSize = 10485760): Promise { + let adder = new Files(this, `add(overwrite=${shouldOverWrite},url='${url}')`); + return adder.post().then(() => this.getByName(url)).then(file => file.setContentChunked(content, progress, chunkSize)).then((response) => { + return { + data: response, + file: this.getByName(url), + }; + }); + } + /** * Adds a ghosted file to an existing list or document library. * @@ -98,14 +136,6 @@ export class File extends QueryableInstance { return new Versions(this); } - /** - * Gets the contents of the file - If the file is not JSON a custom parser function should be used with the get call - * - */ - public get value(): Queryable { - return new Queryable(this, "$value"); - } - /** * Approves the file submitted for content approval with the specified comment. * Only documents in lists that are enabled for content approval can be approved. @@ -147,21 +177,6 @@ export class File extends QueryableInstance { return new File(this, "checkout").post(); } - /** - * Continues the chunk upload session with an additional fragment. - * The current file content is not changed. - * Use the uploadId value that was passed to the StartUpload method that started the upload session. - * This method is currently available only on Office 365. - * - * @param uploadId The unique identifier of the upload session. - * @param fileOffset The size of the offset into the file where the fragment starts. - * @param fragment The file contents. - * @returns The size of the total uploaded data in bytes. - */ - public continueUpload(uploadId: string, fileOffset: number, b: Blob): Promise { - return new File(this, `continueUpload(uploadId=guid'${uploadId}',fileOffset=${fileOffset})`).postAs({ body: b }); - } - /** * Copies the file to the destination url. * @@ -196,26 +211,6 @@ export class File extends QueryableInstance { return new File(this, `deny(comment='${comment}')`).post(); } - /** - * Uploads the last file fragment and commits the file. The current file content is changed when this method completes. - * Use the uploadId value that was passed to the StartUpload method that started the upload session. - * This method is currently available only on Office 365. - * - * @param uploadId The unique identifier of the upload session. - * @param fileOffset The size of the offset into the file where the fragment starts. - * @param fragment The file contents. - * @returns The newly uploaded file. - */ - public finishUpload(uploadId: string, fileOffset: number, fragment: Blob): Promise { - return new File(this, `finishUpload(uploadId=guid'${uploadId}',fileOffset=${fileOffset})`) - .postAs({ body: fragment }).then((response) => { - return { - data: response, - file: new File(response.ServerRelativeUrl), - }; - }); - } - /** * Specifies the control set used to access, modify, or add Web Parts associated with this Web Part Page and view. * An exception is thrown if the file is not an ASPX page. @@ -236,14 +231,6 @@ export class File extends QueryableInstance { return new File(this, `moveTo(newurl='${url}',flags=${moveOperations})`).post(); } - /** - * Opens the file as a stream. - * - */ - public openBinaryStream(): Queryable { - return new Queryable(this, "openBinaryStream"); - } - /** * Submits the file for content approval with the specified comment. * @@ -263,12 +250,120 @@ export class File extends QueryableInstance { } /** - * Uploads a binary file. + * Reverts an existing checkout for the file. * - * @data The file contents. */ - public saveBinaryStream(data: Blob): Promise { - return new File(this, "saveBinary").post({ body: data }); + public undoCheckout(): Promise { + return new File(this, "undoCheckout").post(); + } + + /** + * Removes the file from content approval or unpublish a major version. + * + * @param comment The comment for the unpublish operation. Its length must be <= 1023. + */ + public unpublish(comment = ""): Promise { + if (comment.length > 1023) { + throw new Error("The maximum comment length is 1023 characters."); + } + return new File(this, `unpublish(comment='${comment}')`).post(); + } + + /** + * Gets the contents of the file as text + * + */ + public getText(): Promise { + + return new File(this, "$value").get(new TextFileParser(), { headers: { "binaryStringResponseBody": "true" } }); + } + + /** + * Gets the contents of the file as a blob, does not work in Node.js + * + */ + public getBlob(): Promise { + + return new File(this, "$value").get(new BlobFileParser(), { headers: { "binaryStringResponseBody": "true" } }); + } + + /** + * Gets the contents of a file as an ArrayBuffer, works in Node.js + */ + public getBuffer(): Promise { + + return new File(this, "$value").get(new BufferFileParser(), { headers: { "binaryStringResponseBody": "true" } }); + } + + /** + * Sets the content of a file, for large files use setContentChunked + * + * @param content The file content + * + */ + public setContent(content: string | ArrayBuffer | Blob): Promise { + + let setter = new File(this, "$value"); + + return setter.post({ + body: content, + headers: { + "X-HTTP-Method": "PUT", + }, + }).then(_ => new File(this)); + } + + /** + * Sets the contents of a file using a chunked upload approach + * + * @param file The file to upload + * @param progress A callback function which can be used to track the progress of the upload + * @param chunkSize The size of each file slice, in bytes (default: 10485760) + */ + public setContentChunked( + file: Blob, + progress?: (data: ChunkedFileUploadProgressData) => void, + chunkSize = 10485760): Promise { + + if (typeof progress === "undefined") { + progress = (data) => null; + } + + let self = this; + let fileSize = file.size; + + let blockCount = parseInt((file.size / chunkSize).toString(), 10) + ((file.size % chunkSize === 0) ? 1 : 0); + console.log(`blockCount: ${blockCount}`); + + let uploadId = Util.getGUID(); + + // start the chain with the first fragment + progress({ blockNumber: 1, chunkSize: chunkSize, currentPointer: 0, fileSize: fileSize, stage: "starting", totalBlocks: blockCount }); + + let chain = self.startUpload(uploadId, file.slice(0, chunkSize)); + + // skip the first and last blocks + for (let i = 2; i < blockCount; i++) { + + chain = chain.then(pointer => { + + progress({ blockNumber: i, chunkSize: chunkSize, currentPointer: pointer, fileSize: fileSize, stage: "continue", totalBlocks: blockCount }); + + return self.continueUpload(uploadId, pointer, file.slice(pointer, pointer + chunkSize)); + }); + + } + + return chain.then(pointer => { + + progress({ blockNumber: blockCount, chunkSize: chunkSize, currentPointer: pointer, fileSize: fileSize, stage: "finishing", totalBlocks: blockCount }); + + return self.finishUpload(uploadId, pointer, file.slice(pointer)); + + }).then(_ => { + + return self; + }); } /** @@ -285,28 +380,69 @@ export class File extends QueryableInstance { * @param fragment The file contents. * @returns The size of the total uploaded data in bytes. */ - public startUpload(uploadId: string, fragment: Blob): Promise { - return new File(this, `startUpload(uploadId=guid'${uploadId}')`).postAs({ body: fragment }); + private startUpload(uploadId: string, fragment: ArrayBuffer | Blob): Promise { + return new File(this, `startUpload(uploadId=guid'${uploadId}')`).postAs({ body: fragment }).then(n => parseFloat(n)); } /** - * Reverts an existing checkout for the file. + * Continues the chunk upload session with an additional fragment. + * The current file content is not changed. + * Use the uploadId value that was passed to the StartUpload method that started the upload session. + * This method is currently available only on Office 365. * + * @param uploadId The unique identifier of the upload session. + * @param fileOffset The size of the offset into the file where the fragment starts. + * @param fragment The file contents. + * @returns The size of the total uploaded data in bytes. */ - public undoCheckout(): Promise { - return new File(this, "undoCheckout").post(); + private continueUpload(uploadId: string, fileOffset: number, fragment: ArrayBuffer | Blob): Promise { + return new File(this, `continueUpload(uploadId=guid'${uploadId}',fileOffset=${fileOffset})`).postAs({ body: fragment }).then(n => parseFloat(n)); } /** - * Removes the file from content approval or unpublish a major version. + * Uploads the last file fragment and commits the file. The current file content is changed when this method completes. + * Use the uploadId value that was passed to the StartUpload method that started the upload session. + * This method is currently available only on Office 365. * - * @param comment The comment for the unpublish operation. Its length must be <= 1023. + * @param uploadId The unique identifier of the upload session. + * @param fileOffset The size of the offset into the file where the fragment starts. + * @param fragment The file contents. + * @returns The newly uploaded file. */ - public unpublish(comment = ""): Promise { - if (comment.length > 1023) { - throw new Error("The maximum comment length is 1023 characters."); + private finishUpload(uploadId: string, fileOffset: number, fragment: ArrayBuffer | Blob): Promise { + return new File(this, `finishUpload(uploadId=guid'${uploadId}',fileOffset=${fileOffset})`) + .postAs({ body: fragment }).then((response) => { + return { + data: response, + file: new File(response.ServerRelativeUrl), + }; + }); + } +} + +export class TextFileParser implements ODataParser { + + public parse(r: Response): Promise { + return r.text(); + } +} + +export class BlobFileParser implements ODataParser { + + public parse(r: Response): Promise { + return r.blob(); + } +} + +export class BufferFileParser implements ODataParser { + + public parse(r: any): Promise { + + if (Util.isFunction(r.arrayBuffer)) { + return r.arrayBuffer(); } - return new File(this, `unpublish(comment='${comment}')`).post(); + + return r.buffer(); } } diff --git a/src/sharepoint/rest/index.ts b/src/sharepoint/rest/index.ts index 9d26cb04..8891f443 100644 --- a/src/sharepoint/rest/index.ts +++ b/src/sharepoint/rest/index.ts @@ -10,7 +10,11 @@ export { FileAddResult, WebPartsPersonalizationScope, MoveOperations, - TemplateFileType + TemplateFileType, + TextFileParser, + BlobFileParser, + BufferFileParser, + ChunkedFileUploadProgressData } from "./files"; export { @@ -47,8 +51,12 @@ export { } from "./roles" export { + Search, + SearchProperty, + SearchPropertyValue, SearchQuery, SearchResult, + SearchResults, Sort, SortDirection, ReorderingRule, @@ -56,6 +64,13 @@ export { QueryPropertyValueType } from "./search"; +export { + SearchSuggest, + SearchSuggestQuery, + SearchSuggestResult, + PersonalResultSuggestion +} from "./searchsuggest"; + export { Site } from "./site"; @@ -69,6 +84,11 @@ export { UserProps } from "./siteusers"; +export { + SubscriptionAddResult, + SubscriptionUpdateResult +} from "./subscriptions"; + export * from "./types"; export { diff --git a/src/sharepoint/rest/items.ts b/src/sharepoint/rest/items.ts index e5b33fb7..43995e35 100644 --- a/src/sharepoint/rest/items.ts +++ b/src/sharepoint/rest/items.ts @@ -40,7 +40,7 @@ export class Items extends QueryableCollection { * * @param skip The starting id where the page should start, use with top to specify pages */ - public skip(skip: number): QueryableCollection { + public skip(skip: number): this { this._query.add("$skiptoken", encodeURIComponent(`Paged=TRUE&p_ID=${skip}`)); return this; } @@ -60,7 +60,7 @@ export class Items extends QueryableCollection { */ public add(properties: TypedHash = {}): Promise { - this.addBatchDependency(); + let removeDependency = this.addBatchDependency(); let parentList = this.getParent(QueryableInstance); @@ -77,19 +77,13 @@ export class Items extends QueryableCollection { }; }); - this.clearBatchDependency(); + removeDependency(); return promise; }); } } -class PagedItemCollectionParser extends ODataParserBase> { - public parse(r: Response): Promise> { - return PagedItemCollection.fromResponse(r); - } -} - /** * Descrines a single Item instance * @@ -177,7 +171,7 @@ export class Item extends QueryableSecurable { */ public update(properties: TypedHash, eTag = "*"): Promise { - this.addBatchDependency(); + let removeDependency = this.addBatchDependency(); let parentList = this.getParent(QueryableInstance, this.parentUrl.substr(0, this.parentUrl.lastIndexOf("/"))); @@ -200,7 +194,7 @@ export class Item extends QueryableSecurable { }; }); - this.clearBatchDependency(); + removeDependency(); return promise; }); @@ -272,15 +266,7 @@ export interface ItemUpdateResult { */ export class PagedItemCollection { - /** - * Contains the results of the query - */ - public results: T; - - /** - * The url to the next set of results - */ - private nextUrl: string; + constructor(private nextUrl: string, public results: T) { } /** * If true there are more results available in the set, otherwise there are not @@ -289,21 +275,6 @@ export class PagedItemCollection { return typeof this.nextUrl === "string" && this.nextUrl.length > 0; } - /** - * Creats a new instance of the PagedItemCollection class from the response - * - * @param r Response instance from which this collection will be created - * - */ - public static fromResponse(r: Response): Promise> { - return r.json().then(d => { - let col = new PagedItemCollection(); - col.nextUrl = d["odata.nextLink"]; - col.results = d.value; - return col; - }); - } - /** * Gets the next set of results, or resolves to null if no results are available */ @@ -317,3 +288,14 @@ export class PagedItemCollection { return new Promise(r => r(null)); } } + +class PagedItemCollectionParser extends ODataParserBase> { + public parse(r: Response): Promise> { + + return r.json().then(json => { + let nextUrl = json.hasOwnProperty("d") && json.d.hasOwnProperty("__next") ? json.d.__next : json["odata.nextLink"]; + return new PagedItemCollection(nextUrl, this.parseODataJSON(json)); + }); + } +} + diff --git a/src/sharepoint/rest/lists.ts b/src/sharepoint/rest/lists.ts index c91eff4c..7b2a2209 100644 --- a/src/sharepoint/rest/lists.ts +++ b/src/sharepoint/rest/lists.ts @@ -5,6 +5,7 @@ import { Views, View } from "./views"; import { ContentTypes } from "./contenttypes"; import { Fields } from "./fields"; import { Forms } from "./forms"; +import { Subscriptions } from "./subscriptions"; import { Queryable, QueryableInstance, QueryableCollection } from "./queryable"; import { QueryableSecurable } from "./queryablesecurable"; import { Util } from "../../utils/util"; @@ -237,6 +238,14 @@ export class List extends QueryableSecurable { return new Queryable(this, "InformationRightsManagementSettings"); } + /** + * Gets the webhook subscriptions of this list + * + */ + public get subscriptions(): Subscriptions { + return new Subscriptions(this); + } + /** * Gets a view by view guid id * diff --git a/src/sharepoint/rest/odata.ts b/src/sharepoint/rest/odata.ts index 18c3ce72..aa65408d 100644 --- a/src/sharepoint/rest/odata.ts +++ b/src/sharepoint/rest/odata.ts @@ -30,19 +30,21 @@ export interface ODataParser { export abstract class ODataParserBase implements ODataParser { public parse(r: Response): Promise { - return r.json().then(json => { - let result = json; - if (json.hasOwnProperty("d")) { - if (json.d.hasOwnProperty("results")) { - result = json.d.results; - } else { - result = json.d; - } - } else if (json.hasOwnProperty("value")) { - result = json.value; + return r.json().then(json => this.parseODataJSON(json)); + } + + protected parseODataJSON(json: any): U { + let result = json; + if (json.hasOwnProperty("d")) { + if (json.d.hasOwnProperty("results")) { + result = json.d.results; + } else { + result = json.d; } - return result; - }); + } else if (json.hasOwnProperty("value")) { + result = json.value; + } + return result; } } @@ -126,12 +128,12 @@ export function ODataEntityArray(factory: QueryableConstructor): ODataPars */ export class ODataBatch { - private _batchDepCount: number; + private _batchDependencies: Promise; private _requests: ODataBatchRequestInfo[]; - constructor(private _batchId = Util.getGUID()) { + constructor(private baseUrl: string, private _batchId = Util.getGUID()) { this._requests = []; - this._batchDepCount = 0; + this._batchDependencies = Promise.resolve(); } /** @@ -163,12 +165,16 @@ export class ODataBatch { return p; } - public incrementBatchDep() { - this._batchDepCount++; - } + public addBatchDependency(): () => void { - public decrementBatchDep() { - this._batchDepCount--; + let resolver: () => void; + let promise = new Promise((resolve) => { + resolver = resolve; + }); + + this._batchDependencies = this._batchDependencies.then(() => promise); + + return resolver; } /** @@ -177,21 +183,15 @@ export class ODataBatch { * @returns A promise which will be resolved once all of the batch's child promises have resolved */ public execute(): Promise { - return new Promise((resolve, reject) => { - if (this._batchDepCount > 0) { - setTimeout(() => this.execute(), 100); - } else { - this.executeImpl().then(() => resolve()).catch(reject); - } - }); + return this._batchDependencies.then(() => this.executeImpl()); } - private executeImpl(): Promise { + private executeImpl(): Promise { // if we don't have any requests, don't bother sending anything // this could be due to caching further upstream, or just an empty batch if (this._requests.length < 1) { - return new Promise(r => r()); + return Promise.resolve(); } // build all the requests, send them, pipe results in order to parsers @@ -287,7 +287,8 @@ export class ODataBatch { }; let client = new HttpClient(); - return client.post(Util.makeUrlAbsolute("/_api/$batch"), batchOptions) + let requestUrl = Util.makeUrlAbsolute(Util.combinePaths(this.baseUrl, "/_api/$batch")); + return client.post(requestUrl, batchOptions) .then(r => r.text()) .then(this._parseResponse) .then(responses => { @@ -296,7 +297,7 @@ export class ODataBatch { throw new Error("Could not properly parse responses to match requests in batch."); } - let resolutions: Promise[] = []; + let chain = Promise.resolve(); for (let i = 0; i < responses.length; i++) { let request = this._requests[i]; @@ -306,10 +307,10 @@ export class ODataBatch { request.reject(new Error(response.statusText)); } - resolutions.push(request.parser.parse(response).then(request.resolve).catch(request.reject)); + chain = chain.then(_ => request.parser.parse(response).then(request.resolve).catch(request.reject)); } - return Promise.all(resolutions); + return chain; }); } diff --git a/src/sharepoint/rest/queryable.ts b/src/sharepoint/rest/queryable.ts index 326c9085..dc3f6565 100644 --- a/src/sharepoint/rest/queryable.ts +++ b/src/sharepoint/rest/queryable.ts @@ -1,14 +1,13 @@ "use strict"; import { Util } from "../../utils/util"; +import { Logger, LogLevel } from "../../utils/logging"; import { Dictionary } from "../../collections/collections"; import { FetchOptions, HttpClient } from "../../net/httpclient"; import { ODataParser, ODataDefaultParser, ODataBatch } from "./odata"; import { ICachingOptions, CachingParserWrapper, CachingOptions } from "./caching"; import { RuntimeConfig } from "../../configuration/pnplibconfig"; -declare var _spPageContextInfo: any; - export interface QueryableConstructor { new (baseUrl: string | Queryable, path?: string): T; } @@ -68,21 +67,14 @@ export class Queryable { } /** - * Blocks a batch call from occuring, MUST be cleared with clearBatchDependency before a request will execute + * Blocks a batch call from occuring, MUST be cleared by calling the returned function */ - protected addBatchDependency() { - if (this._batch !== null) { - this._batch.incrementBatchDep(); + protected addBatchDependency(): () => void { + if (this.hasBatch) { + return this._batch.addBatchDependency(); } - } - /** - * Clears a batch request dependency - */ - protected clearBatchDependency() { - if (this._batch !== null) { - this._batch.decrementBatchDep(); - } + return () => null; } /** @@ -126,15 +118,17 @@ export class Queryable { // being created from just a string. let urlStr = baseUrl as string; - if (urlStr.lastIndexOf("/") < 0) { + if (Util.isUrlAbsolute(urlStr) || urlStr.lastIndexOf("/") < 0) { this._parentUrl = urlStr; this._url = Util.combinePaths(urlStr, path); } else if (urlStr.lastIndexOf("/") > urlStr.lastIndexOf("(")) { + // .../items(19)/fields let index = urlStr.lastIndexOf("/"); this._parentUrl = urlStr.slice(0, index); path = Util.combinePaths(urlStr.slice(index), path); this._url = Util.combinePaths(this._parentUrl, path); } else { + // .../items(19) let index = urlStr.lastIndexOf("("); this._parentUrl = urlStr.slice(0, index); this._url = Util.combinePaths(urlStr, path); @@ -142,10 +136,6 @@ export class Queryable { } else { let q = baseUrl as Queryable; this._parentUrl = q._url; - // only copy batch if we don't already have one - if (!this.hasBatch && q.hasBatch) { - this._batch = q._batch; - } let target = q._query.get("@target"); if (target !== null) { this._query.add("@target", target); @@ -162,11 +152,12 @@ export class Queryable { * * let b = pnp.sp.createBatch(); * pnp.sp.web.inBatch(b).get().then(...); + * b.execute().then(...) * ``` */ public inBatch(batch: ODataBatch): this { + if (this._batch !== null) { - // TODO: what do we want to do? throw new Error("This query is already part of a batch."); } @@ -189,7 +180,7 @@ export class Queryable { } /** - * Gets the currentl url, made server relative or absolute based on the availability of the _spPageContextInfo object + * Gets the currentl url, made absolute based on the availability of the _spPageContextInfo object * */ public toUrl(): string { @@ -230,8 +221,16 @@ export class Queryable { return this.postImpl(postOptions, parser); } + protected patch(patchOptions: FetchOptions = {}, parser: ODataParser = new ODataDefaultParser()): Promise { + return this.patchImpl(patchOptions, parser); + } + + protected delete(deleteOptions: FetchOptions = {}, parser: ODataParser = new ODataDefaultParser()): Promise { + return this.deleteImpl(deleteOptions, parser); + } + /** - * Gets a parent for this isntance as specified + * Gets a parent for this instance as specified * * @param factory The contructor for the class to create */ @@ -270,54 +269,93 @@ export class Queryable { parser = new CachingParserWrapper(parser, options); } - if (this._batch === null) { + if (!this.hasBatch) { // we are not part of a batch, so proceed as normal let client = new HttpClient(); - return client.get(this.toUrlAndQuery(), getOptions).then(function (response) { - - if (!response.ok) { - throw "Error making GET request: " + response.statusText; - } - - return parser.parse(response); + return client.get(this.toUrlAndQuery(), getOptions).then((response) => { + return this.processHttpClientResponse(response, parser); }); + } else { - return this._batch.add(this.toUrlAndQuery(), "GET", {}, parser); + return this._batch.add(this.toUrlAndQuery(), "GET", getOptions, parser); } } private postImpl(postOptions: FetchOptions, parser: ODataParser): Promise { - if (this._batch === null) { + if (!this.hasBatch) { // we are not part of a batch, so proceed as normal let client = new HttpClient(); + return client.post(this.toUrlAndQuery(), postOptions).then((response) => { + return this.processHttpClientResponse(response, parser); + }); - return client.post(this.toUrlAndQuery(), postOptions).then(function (response) { + } else { + return this._batch.add(this.toUrlAndQuery(), "POST", postOptions, parser); + } + } - // 200 = OK (delete) - // 201 = Created (create) - // 204 = No Content (update) - if (!response.ok) { - throw "Error making POST request: " + response.statusText; - } + private patchImpl(patchOptions: FetchOptions, parser: ODataParser): Promise { - if ((response.headers.has("Content-Length") && parseFloat(response.headers.get("Content-Length")) === 0) - || response.status === 204) { + if (!this.hasBatch) { - // in these cases the server has returned no content, so we create an empty object - // this was done because the fetch browser methods throw exceptions with no content - return new Promise((resolve, reject) => { resolve({}); }); - } + // we are not part of a batch, so proceed as normal + let client = new HttpClient(); + return client.patch(this.toUrlAndQuery(), patchOptions).then((response) => { + return this.processHttpClientResponse(response, parser); + }); + + } else { + return this._batch.add(this.toUrlAndQuery(), "PATCH", patchOptions, parser); + } + } + + private deleteImpl(deleteOptions: FetchOptions, parser: ODataParser): Promise { + + if (!this.hasBatch) { - // pipe our parsed content - return parser.parse(response); + // we are not part of a batch, so proceed as normal + let client = new HttpClient(); + return client.delete(this.toUrlAndQuery(), deleteOptions).then((response) => { + return this.processHttpClientResponse(response, parser); }); + } else { - return this._batch.add(this.toUrlAndQuery(), "POST", postOptions, parser); + return this._batch.add(this.toUrlAndQuery(), "DELETE", deleteOptions, parser); + } + } + + private processHttpClientResponse(response: Response, parser: ODataParser): Promise { + + // 200 = OK (get, delete) + // 201 = Created (create) + // 204 = No Content (update) + if (!response.ok) { + + response.text().then(text => { + Logger.log({ + data: response, + level: LogLevel.Error, + message: text, + }); + + throw `Error making HttpClient request in queryable: ${response.statusText}`; + }); + } + + if ((response.headers.has("Content-Length") && parseFloat(response.headers.get("Content-Length")) === 0) + || response.status === 204) { + + // in these cases the server has returned no content, so we create an empty object + // this was done because the fetch browser methods throw exceptions with no content + return new Promise((resolve, reject) => { resolve({}); }); } + + // pipe our parsed content + return parser.parse(response); } } @@ -332,7 +370,7 @@ export class QueryableCollection extends Queryable { * * @param filter The string representing the filter query */ - public filter(filter: string): QueryableCollection { + public filter(filter: string): this { this._query.add("$filter", filter); return this; } @@ -342,7 +380,7 @@ export class QueryableCollection extends Queryable { * * @param selects One or more fields to return */ - public select(...selects: string[]): QueryableCollection { + public select(...selects: string[]): this { this._query.add("$select", selects.join(",")); return this; } @@ -352,7 +390,7 @@ export class QueryableCollection extends Queryable { * * @param expands The Fields for which to expand the values */ - public expand(...expands: string[]): QueryableCollection { + public expand(...expands: string[]): this { this._query.add("$expand", expands.join(",")); return this; } @@ -363,7 +401,7 @@ export class QueryableCollection extends Queryable { * @param orderby The name of the field to sort on * @param ascending If false DESC is appended, otherwise ASC (default) */ - public orderBy(orderBy: string, ascending = true): QueryableCollection { + public orderBy(orderBy: string, ascending = true): this { let keys = this._query.getKeys(); let query = []; let asc = ascending ? " asc" : " desc"; @@ -385,7 +423,7 @@ export class QueryableCollection extends Queryable { * * @param skip The number of items to skip */ - public skip(skip: number): QueryableCollection { + public skip(skip: number): this { this._query.add("$skip", skip.toString()); return this; } @@ -395,7 +433,7 @@ export class QueryableCollection extends Queryable { * * @param top The query row limit */ - public top(top: number): QueryableCollection { + public top(top: number): this { this._query.add("$top", top.toString()); return this; } @@ -413,7 +451,7 @@ export class QueryableInstance extends Queryable { * * @param selects One or more fields to return */ - public select(...selects: string[]): QueryableInstance { + public select(...selects: string[]): this { this._query.add("$select", selects.join(",")); return this; } @@ -423,7 +461,7 @@ export class QueryableInstance extends Queryable { * * @param expands The Fields for which to expand the values */ - public expand(...expands: string[]): QueryableInstance { + public expand(...expands: string[]): this { this._query.add("$expand", expands.join(",")); return this; } diff --git a/src/sharepoint/rest/rest.ts b/src/sharepoint/rest/rest.ts index 4adfb2e2..32336a88 100644 --- a/src/sharepoint/rest/rest.ts +++ b/src/sharepoint/rest/rest.ts @@ -1,6 +1,7 @@ "use strict"; -import { Search, SearchQuery, SearchResult } from "./search"; +import { Search, SearchQuery, SearchResults } from "./search"; +import { SearchSuggest, SearchSuggestQuery, SearchSuggestResult } from "./searchsuggest"; import { Site } from "./site"; import { Web } from "./webs"; import { Util } from "../../utils/util"; @@ -18,7 +19,25 @@ export class Rest { * * @param query The SearchQuery definition */ - public search(query: string | SearchQuery): Promise { + public searchSuggest(query: string | SearchSuggestQuery): Promise { + + let finalQuery: SearchSuggestQuery; + + if (typeof query === "string") { + finalQuery = { querytext: query }; + } else { + finalQuery = query; + } + + return new SearchSuggest("").execute(finalQuery); + } + + /** + * Executes a search against this web context + * + * @param query The SearchQuery definition + */ + public search(query: string | SearchQuery): Promise { let finalQuery: SearchQuery; @@ -60,7 +79,7 @@ export class Rest { * */ public createBatch(): ODataBatch { - return new ODataBatch(); + return this.web.createBatch(); } /** diff --git a/src/sharepoint/rest/search.ts b/src/sharepoint/rest/search.ts index d6a9df16..098c617f 100644 --- a/src/sharepoint/rest/search.ts +++ b/src/sharepoint/rest/search.ts @@ -1,6 +1,7 @@ "use strict"; import { Queryable, QueryableInstance } from "./queryable"; +import { Util } from "../../utils/util"; /** @@ -123,7 +124,7 @@ export interface SearchQuery { /** * The set of refiners to return in a search result. */ - Refiners?: string[]; + Refiners?: string; /** * The additional query terms to append to the query. @@ -165,9 +166,10 @@ export interface SearchQuery { */ QueryTag?: string[]; - // TODO: Properties - - // TODO: ReorderingRules + /** + * Properties to be used to configure the search query + */ + Properties?: SearchProperty[]; /** * A Boolean value that specifies whether to return personal favorites with the search results. @@ -245,7 +247,7 @@ export class Search extends QueryableInstance { * ....... * @returns Promise */ - public execute(query: SearchQuery): Promise { + public execute(query: SearchQuery): Promise { let formattedBody: any; formattedBody = query; @@ -258,10 +260,6 @@ export class Search extends QueryableInstance { formattedBody.RefinementFilters = { results: query.RefinementFilters }; } - if (formattedBody.Refiners) { - formattedBody.Refiners = { results: query.Refiners }; - } - if (formattedBody.SortList) { formattedBody.SortList = { results: query.SortList }; } @@ -274,9 +272,15 @@ export class Search extends QueryableInstance { formattedBody.ReorderingRules = { results: query.ReorderingRules }; } - // TODO: Properties & ReorderingRules + if (formattedBody.Properties) { + formattedBody.Properties = { results: query.Properties }; + } - let postBody = JSON.stringify({ request: formattedBody }); + let postBody = JSON.stringify({ + request: Util.extend({ + "__metadata": { "type": "Microsoft.Office.Server.Search.REST.SearchRequest" }, + }, formattedBody), + }); return this.post({ body: postBody }).then((data) => new SearchResults(data)); } @@ -359,6 +363,22 @@ export interface Sort { Direction: SortDirection; } +/** + * Defines one search property + */ +export interface SearchProperty { + Name: string; + Value: SearchPropertyValue; +} + +/** + * Defines one search property value + */ +export interface SearchPropertyValue { + StrVal: string; + QueryPropertyValueTypeIndex: QueryPropertyValueType; +} + /** * defines the SortDirection enum */ @@ -404,13 +424,6 @@ export enum ReorderingRuleMatchType { ManualCondition = 8 } -/** - * Defines how search results are sorted. - */ -export interface QueryProperty { - // TODO: define this interface -} - /** * Specifies the type value for the property */ diff --git a/src/sharepoint/rest/searchsuggest.ts b/src/sharepoint/rest/searchsuggest.ts new file mode 100644 index 00000000..e866c1ec --- /dev/null +++ b/src/sharepoint/rest/searchsuggest.ts @@ -0,0 +1,152 @@ +import { Queryable, QueryableInstance } from "./queryable"; + +/** + * Defines a query execute against the search/suggest endpoint (see https://msdn.microsoft.com/en-us/library/office/dn194079.aspx) + */ +export interface SearchSuggestQuery { + + /** + * A string that contains the text for the search query. + */ + querytext: string; + + /** + * The number of query suggestions to retrieve. Must be greater than zero (0). The default value is 5. + */ + count?: number; + + + /** + * The number of personal results to retrieve. Must be greater than zero (0). The default value is 5. + */ + personalCount?: number; + + /** + * A Boolean value that specifies whether to retrieve pre-query or post-query suggestions. true to return pre-query suggestions; otherwise, false. The default value is false. + */ + preQuery?: boolean; + + /** + * A Boolean value that specifies whether to hit-highlight or format in bold the query suggestions. true to format in bold the terms in the returned query suggestions + * that match terms in the specified query; otherwise, false. The default value is true. + */ + hitHighlighting?: boolean; + + /** + * A Boolean value that specifies whether to capitalize the first letter in each term in the returned query suggestions. true to capitalize the first letter in each term; + * otherwise, false. The default value is false. + */ + capitalize?: boolean; + + /** + * The locale ID (LCID) for the query (see https://msdn.microsoft.com/en-us/library/cc233982.aspx). + */ + culture?: string; + + /** + * A Boolean value that specifies whether stemming is enabled. true to enable stemming; otherwise, false. The default value is true. + */ + stemming?: boolean; + + /** + * A Boolean value that specifies whether to include people names in the returned query suggestions. true to include people names in the returned query suggestions; + * otherwise, false. The default value is true. + */ + includePeople?: boolean; + + /** + * A Boolean value that specifies whether to turn on query rules for this query. true to turn on query rules; otherwise, false. The default value is true. + */ + queryRules?: boolean; + + /** + * A Boolean value that specifies whether to return query suggestions for prefix matches. true to return query suggestions based on prefix matches, otherwise, false when + * query suggestions should match the full query word. + */ + prefixMatch?: boolean; +} + +export class SearchSuggest extends QueryableInstance { + + constructor(baseUrl: string | Queryable, path = "_api/search/suggest") { + super(baseUrl, path); + } + + public execute(query: SearchSuggestQuery): Promise { + this.mapQueryToQueryString(query); + return this.get().then(response => new SearchSuggestResult(response)); + } + + private mapQueryToQueryString(query: SearchSuggestQuery): void { + + this.query.add("querytext", `'${query.querytext}'`); + + if (query.hasOwnProperty("count")) { + this.query.add("inumberofquerysuggestions", query.count.toString()); + } + + if (query.hasOwnProperty("personalCount")) { + this.query.add("inumberofresultsuggestions", query.personalCount.toString()); + } + + if (query.hasOwnProperty("preQuery")) { + this.query.add("fprequerysuggestions", query.preQuery.toString()); + } + + if (query.hasOwnProperty("hitHighlighting")) { + this.query.add("fhithighlighting", query.hitHighlighting.toString()); + } + + if (query.hasOwnProperty("capitalize")) { + this.query.add("fcapitalizefirstletters", query.capitalize.toString()); + } + + if (query.hasOwnProperty("culture")) { + this.query.add("culture", query.culture.toString()); + } + + if (query.hasOwnProperty("stemming")) { + this.query.add("enablestemming", query.stemming.toString()); + } + + if (query.hasOwnProperty("includePeople")) { + this.query.add("showpeoplenamesuggestions", query.includePeople.toString()); + } + + if (query.hasOwnProperty("queryRules")) { + this.query.add("enablequeryrules", query.queryRules.toString()); + } + + if (query.hasOwnProperty("prefixMatch")) { + this.query.add("fprefixmatchallterms", query.prefixMatch.toString()); + } + } +} + +export class SearchSuggestResult { + + public PeopleNames: string[]; + public PersonalResults: PersonalResultSuggestion[]; + public Queries: any[]; + + constructor(json: any) { + if (json.hasOwnProperty("suggest")) { + // verbose + this.PeopleNames = json.suggest.PeopleNames.results; + this.PersonalResults = json.suggest.PersonalResults.results; + this.Queries = json.suggest.Queries.results; + } else { + this.PeopleNames = json.PeopleNames; + this.PersonalResults = json.PersonalResults; + this.Queries = json.Queries; + } + } +} + +export interface PersonalResultSuggestion { + HighlightedTitle?: string; + IsBestBet?: boolean; + Title?: string; + TypeId?: string; + Url?: string; +} diff --git a/src/sharepoint/rest/site.ts b/src/sharepoint/rest/site.ts index 2f5dbce8..7b43b080 100644 --- a/src/sharepoint/rest/site.ts +++ b/src/sharepoint/rest/site.ts @@ -4,6 +4,7 @@ import { Queryable, QueryableInstance } from "./queryable"; import { Web } from "./webs"; import { UserCustomActions } from "./usercustomactions"; import { ContextInfo, DocumentLibraryInformation } from "./types"; +import { ODataBatch } from "./odata"; /** * Describes a site collection @@ -85,4 +86,12 @@ export class Site extends QueryableInstance { } }); } + + /** + * Creates a new batch for requests within the context of context this site + * + */ + public createBatch(): ODataBatch { + return new ODataBatch(this.parentUrl); + } } diff --git a/src/sharepoint/rest/subscriptions.ts b/src/sharepoint/rest/subscriptions.ts new file mode 100644 index 00000000..16ae51d2 --- /dev/null +++ b/src/sharepoint/rest/subscriptions.ts @@ -0,0 +1,97 @@ +"use strict"; + +import { Queryable, QueryableCollection, QueryableInstance } from "./queryable"; + +/** + * Describes a collection of webhook subscriptions + * + */ +export class Subscriptions extends QueryableCollection { + + /** + * Creates a new instance of the Subscriptions class + * + * @param baseUrl - The url or Queryable which forms the parent of this webhook subscriptions collection + */ + constructor(baseUrl: string | Queryable, path = "subscriptions") { + super(baseUrl, path); + } + + /** + * Returns all the webhook subscriptions or the specified webhook subscription + * + */ + public getById(subscriptionId: string): Subscription { + let subscription = new Subscription(this); + subscription.concat(`('${subscriptionId}')`); + return subscription; + } + + /** + * Create a new webhook subscription + * + */ + public add(notificationUrl: string, expirationDate: string, clientState?: string): Promise { + + let postBody = JSON.stringify({ + "resource": this.toUrl(), + "notificationUrl": notificationUrl, + "expirationDateTime": expirationDate, + "clientState": clientState || "pnp-js-core-subscription", + }); + + return this.post({ body: postBody, headers: { "Content-Type": "application/json" } }).then(result => { + + return { data: result, subscription: this.getById(result.id) }; + }); + } +} + +/** + * Describes a single webhook subscription instance + * + */ +export class Subscription extends QueryableInstance { + + /** + * Creates a new instance of the Subscription class + * + * @param baseUrl - The url or Queryable which forms the parent of this webhook subscription instance + */ + constructor(baseUrl: string | Queryable, path?: string) { + super(baseUrl, path); + } + + /** + * Update a webhook subscription + * + */ + public update(expirationDate: string): Promise { + + let postBody = JSON.stringify({ + "expirationDateTime": expirationDate, + }); + + return this.patch({ body: postBody, headers: { "Content-Type": "application/json" } }).then(data => { + return { data: data, subscription: this }; + }); + } + + /** + * Remove a webhook subscription + * + */ + public delete(): Promise { + return super.delete(); + } +} + +export interface SubscriptionAddResult { + subscription: Subscription; + data: any; +} + +export interface SubscriptionUpdateResult { + subscription: Subscription; + data: any; +} diff --git a/src/sharepoint/rest/webs.ts b/src/sharepoint/rest/webs.ts index ef400c8c..f7c8e7e3 100644 --- a/src/sharepoint/rest/webs.ts +++ b/src/sharepoint/rest/webs.ts @@ -16,7 +16,7 @@ import * as Types from "./types"; import { List } from "./lists"; import { SiteUsers, SiteUser } from "./siteusers"; import { UserCustomActions } from "./usercustomactions"; -import { extractOdataId } from "./odata"; +import { extractOdataId, ODataBatch } from "./odata"; export class Webs extends QueryableCollection { @@ -165,6 +165,14 @@ export class Web extends QueryableSecurable { return new RoleDefinitions(this); } + /** + * Creates a new batch for requests within the context of context this web + * + */ + public createBatch(): ODataBatch { + return new ODataBatch(this.parentUrl); + } + /** * Get a folder by server relative url * diff --git a/src/utils/storage.ts b/src/utils/storage.ts index 70077ee6..e9096b18 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -99,7 +99,7 @@ export class PnPClientStorageWrapper implements PnPClientStore { if (o == null) { getter().then((d) => { - this.put(key, d); + this.put(key, d, expire); resolve(d); }); } else { diff --git a/src/utils/util.ts b/src/utils/util.ts index 358fc4f5..289e311e 100644 --- a/src/utils/util.ts +++ b/src/utils/util.ts @@ -189,7 +189,7 @@ export class Util { } /** - * Provides functionality to extend the given object by doign a shallow copy + * Provides functionality to extend the given object by doing a shallow copy * * @param target The object to which properties will be copied * @param source The source object from which properties will be copied diff --git a/tests/sharepoint/rest/subscriptions.test.ts b/tests/sharepoint/rest/subscriptions.test.ts new file mode 100644 index 00000000..122dd857 --- /dev/null +++ b/tests/sharepoint/rest/subscriptions.test.ts @@ -0,0 +1,89 @@ +"use strict"; + +import { expect } from "chai"; +import { Lists } from "../../../src/sharepoint/rest/lists"; +import { testSettings } from "../../test-config.test"; +import pnp from "../../../src/pnp"; + +describe("Lists", () => { + + let lists: Lists; + + beforeEach(() => { + lists = new Lists("_api/web"); + }); + + it("Should be an object", () => { + expect(lists).to.be.a("object"); + }); + + if (testSettings.enableWebTests) { + + describe("getByTitle", () => { + it("Should get a list by title with the expected title", () => { + + // we are expecting that the OOTB list exists + return expect(pnp.sp.web.lists.getByTitle("Documents").get()).to.eventually.have.property("Title", "Documents"); + }); + }); + + describe("getSubscriptions", () => { + it("Should return the subscriptions of the current list", () => { + let expectVal = expect(pnp.sp.web.lists.getByTitle("Documents").subscriptions.get()); + return expectVal.to.eventually.be.fulfilled; + }); + }); + + describe("createSubscription", () => { + it("Should be able to create a new webhook subscription in the current list", () => { + let today = new Date(); + let expirationDate = new Date(today.setDate(today.getDate() + 90)).toISOString(); + + let expectVal = expect(pnp.sp.web.lists.getByTitle("Documents").subscriptions.add(testSettings.notificationUrl, expirationDate)); + return expectVal.to.eventually.have.property("notificationUrl"); + }); + }); + + describe("getSubscriptionsById", () => { + it("Should return the subscription by its ID of the current list", () => { + pnp.sp.web.lists.getByTitle("Documents").subscriptions.get().then((data) => { + if (data !== null) { + if (data.length > 0) { + let expectVal = expect(pnp.sp.web.lists.getByTitle("Documents").subscriptions.getById(data[0].id)); + return expectVal.to.eventually.have.property("id", data[0].id); + } + } + }); + }); + }); + + describe("updateSubscription", () => { + it("Should be able to update an existing webhook subscription in the current list", () => { + pnp.sp.web.lists.getByTitle("Documents").subscriptions.get().then((data) => { + if (data !== null) { + if (data.length > 0) { + let today = new Date(); + let expirationDate = new Date(today.setDate(today.getDate() + 90)).toISOString(); + let expectVal = expect(pnp.sp.web.lists.getByTitle("Documents").subscriptions.getById(data[0].id).update(expirationDate)); + return expectVal.to.eventually.have.property("notificationUrl"); + } + } + }); + }); + }); + + describe("deleteSubscription", () => { + it("Should be able to delete an existing webhook subscription in the current list", () => { + pnp.sp.web.lists.getByTitle("Documents").subscriptions.get().then((data) => { + if (data !== null) { + if (data.length > 0) { + let expectVal = expect(pnp.sp.web.lists.getByTitle("Documents").subscriptions.getById(data[0].id).delete()); + return expectVal.to.eventually.be.fulfilled; + } + } + }); + }); + }); + + } +}); diff --git a/tslint.json b/tslint.json index 0a6ff453..f942676a 100644 --- a/tslint.json +++ b/tslint.json @@ -8,7 +8,7 @@ "indent": [true, "spaces"], "label-position": true, "label-undefined": true, - "max-line-length": [true, 140], + "max-line-length": [true, 180], "member-access": true, "member-ordering": [true, "public-before-private",