Initial addition of React Rss Reader (#764)
This commit is contained in:
parent
3f30d811f5
commit
cfe16c9066
|
@ -0,0 +1,25 @@
|
|||
# EditorConfig helps developers define and maintain consistent
|
||||
# coding styles between different editors and IDEs
|
||||
# editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
|
||||
[*]
|
||||
|
||||
# change these settings to your own preference
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
# we recommend you to keep these unchanged
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[{package,bower}.json]
|
||||
indent_style = space
|
||||
indent_size = 2
|
|
@ -0,0 +1,32 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Dependency directories
|
||||
node_modules
|
||||
|
||||
# Build generated files
|
||||
dist
|
||||
lib
|
||||
solution
|
||||
temp
|
||||
*.sppkg
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# OSX
|
||||
.DS_Store
|
||||
|
||||
# Visual Studio files
|
||||
.ntvs_analysis.dat
|
||||
.vs
|
||||
bin
|
||||
obj
|
||||
|
||||
# Resx Generated Code
|
||||
*.resx.ts
|
||||
|
||||
# Styles Generated Code
|
||||
*.scss.ts
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"@microsoft/generator-sharepoint": {
|
||||
"isCreatingSolution": true,
|
||||
"environment": "spo",
|
||||
"version": "1.7.0",
|
||||
"libraryName": "react-rssreader",
|
||||
"libraryId": "fcb53167-e0d5-4ed1-9648-149146649aa1",
|
||||
"packageManager": "npm",
|
||||
"isDomainIsolated": false,
|
||||
"componentType": "webpart"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,117 @@
|
|||
## SharePoint Framework RSS Reader
|
||||
|
||||
## Summary
|
||||
|
||||
A RSS Reader original based on work completed by Olivier Carpentier's from: https://github.com/OlivierCC/spfx-40-fantastics/tree/master/src/webparts/rssReader
|
||||
Root project: https://github.com/OlivierCC/spfx-40-fantastics
|
||||
|
||||
React RSS Reader utilizes SharePoint Framework v1.7.0 with no dependency on jQuery or a RSS Feed library. This project does utilize https://sharepoint.github.io/sp-dev-fx-property-controls/, and Moment React for date manipulation. Handlebar template option derived from React Search Refiners: https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-search-refiners.
|
||||
|
||||
Main features include:
|
||||
|
||||
- Three different Rss Feed retrieval services, direct, https://feed2json.org, https://rss2json.com
|
||||
- Optionally store rss feed results to local storage for quick reload with configurable timeout window
|
||||
- Optional CORS proxy service for cross origin feeds
|
||||
- Optional View All link in header to point to custom feed source
|
||||
- Embedded feed rendering with optional parameters
|
||||
-- Feed result layout options including optional display of item publish date and description
|
||||
-- Demostration of color picker property for color control of certain aspects of webpart
|
||||
- Custom feed rendering using local or remote handlebar template
|
||||
|
||||
<p align="center">
|
||||
<img src="./images/react-rss-reader.gif"/>
|
||||
</p>
|
||||
|
||||
This sample includes the following service(s):
|
||||
|
||||
## Used SharePoint Framework Version
|
||||
![drop](https://img.shields.io/badge/drop-1.7.0-green.svg)
|
||||
|
||||
## Applies to
|
||||
|
||||
* [SharePoint Framework](https:/dev.office.com/sharepoint)
|
||||
* [Office 365 tenant](https://dev.office.com/sharepoint/docs/spfx/set-up-your-development-environment)
|
||||
|
||||
## Solution
|
||||
|
||||
Solution|Author(s)
|
||||
--------|---------
|
||||
react-rss-reader | Eric Overfield -[@ericoverfield](http://www.twitter.com/ericoverfield)
|
||||
|
||||
## Version history
|
||||
|
||||
Version|Date|Comments
|
||||
-------|----|--------
|
||||
1.0 | Jan 21, 2019 | Initial release
|
||||
|
||||
## Disclaimer
|
||||
**THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.**
|
||||
|
||||
---
|
||||
|
||||
## Minimal Path to Awesome
|
||||
|
||||
- Clone this repository
|
||||
|
||||
### SPFx
|
||||
- In the command line run:
|
||||
- `npm install`
|
||||
- `gulp serve`
|
||||
|
||||
## Web Parts Configuration
|
||||
|
||||
### Rss Reader Web Part
|
||||
|
||||
<p align="center"><img width="700px" src="./images/rss_property_pane.png"/><p>
|
||||
|
||||
#### Rss Reader Settings
|
||||
|
||||
Setting | Description
|
||||
-------|----
|
||||
Feed Url | The url of the Rss Feed for readers. Normally will url will return XML
|
||||
Feed Retrieval Service | The service to use to retrieve the feed. **Direct** = Make a direct call from the webpart to the feed. Note, may have issues with CORS depending on the feed owner. **Feed2Json** = Retrieve a JSON version of feed via feed2json.org. Note, not for production, and may have issues with CORS. For production use, host your own feed2json service. Learn more at https://github.com/appsattic/feed2json.org. **Rss2Json** = CORS safe method to retieve a feed response. Note, subject to limitations with paid options available.
|
||||
Feed Service Url | If using Feed2Json, the url of the feed2json service. Host your own service, learn more at https://github.com/appsattic/feed2json.org
|
||||
Feed Service Api Key | If using rss2json, an optional Api key for paid services
|
||||
|
||||
Max Count | The maximum results to return, default: 10
|
||||
|
||||
Cache Results | Locally store results in browser local storage, default: no
|
||||
Mins to Cache Results | If storing results in browser, number of minutes to store. Valid 1 to 1440 (one day), default: 60
|
||||
Storage Key Prefix | An optional local storage key prefix to use when storing results
|
||||
|
||||
Loading Message | An optional custom message to display while the rss feed is being loaded
|
||||
|
||||
Use a CORS proxy | Use a CORS proxy to assist with feed retrieval, default: no
|
||||
CORS Proxy Url | The url of a CORS proxy if allowed. {0} will be replaced with Feed Url, i.e. https://cors-anywhere.herokuapp.com/{0}
|
||||
Disable CORS | Set request header mode to "no-cors", thus not requesting CORS response from service. Will disable CORS request, default: no
|
||||
|
||||
#### Styling Options
|
||||
|
||||
Setting | Description
|
||||
-------|----
|
||||
Results Layout | The layout to use to display feed, Default (list) or Custom
|
||||
Template Editor | A handlebar editor for custom layouts
|
||||
External Template Url | The url of an external handlebar template to use in place of the handlebar template editor for custom layouts
|
||||
|
||||
View All Link | An optional link to view the entire feed, often a link to the rss source blog itself, default: none
|
||||
View All Link Label | An optional label for the View All Link
|
||||
|
||||
**Default** | **Default layout options**
|
||||
Show Publication Date | Display the publication date
|
||||
Show Description | Display the content or description of each feed listing
|
||||
Description Character Limit | The maximum number of description characters to display
|
||||
Link Target | The "target" of a listing link, default: _blank
|
||||
Date Format | The Momment based format format of the listing date, i.e. DD/MM/YYYY (European), default: MM/DD/YYYY
|
||||
Title Color | Color override for a listing title
|
||||
Background Color | Color override for the webpart background
|
||||
|
||||
## Features
|
||||
This Web Part illustrates the following concepts on top of the SharePoint Framework:
|
||||
|
||||
- Use HttpClient to retrieve data from an outside data source using different services
|
||||
- Utilize local storage
|
||||
- Demonstrate different method to address CORS / CORB issues
|
||||
- Handlebar based rendering with inline editor or remote template retrieval
|
||||
- Use the React container component approach inspiring by the [react-todo-basic sample](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-todo-basic).
|
||||
|
||||
<img src="https://telemetry.sharepointpnp.com/sp-dev-fx-webparts/samples/react-rss-reader" />
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
|
||||
"version": "2.0",
|
||||
"bundles": {
|
||||
"rss-reader-web-part": {
|
||||
"components": [
|
||||
{
|
||||
"entrypoint": "./lib/webparts/rssReader/RssReaderWebPart.js",
|
||||
"manifest": "./src/webparts/rssReader/RssReaderWebPart.manifest.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"externals": {
|
||||
"moment": {
|
||||
"path": "https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.22.2/moment.min.js",
|
||||
"globalName": "Moment"
|
||||
}
|
||||
},
|
||||
"localizedResources": {
|
||||
"RssReaderWebPartStrings": "lib/webparts/rssReader/loc/{locale}.js",
|
||||
"PropertyControlStrings": "node_modules/@pnp/spfx-property-controls/lib/loc/{locale}.js",
|
||||
"ControlStrings": "node_modules/@pnp/spfx-controls-react/lib/loc/{locale}.js"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json",
|
||||
"deployCdnPath": "temp/deploy"
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/deploy-azure-storage.schema.json",
|
||||
"workingDir": "./temp/deploy/",
|
||||
"account": "<!-- STORAGE ACCOUNT NAME -->",
|
||||
"container": "react-rssreader",
|
||||
"accessKey": "<!-- ACCESS KEY -->"
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
|
||||
"solution": {
|
||||
"name": "react-rssreader-client-side-solution",
|
||||
"id": "fcb53167-e0d5-4ed1-9648-149146649aa1",
|
||||
"version": "1.0.0.6",
|
||||
"includeClientSideAssets": true,
|
||||
"skipFeatureDeployment": true,
|
||||
"isDomainIsolated": false
|
||||
},
|
||||
"paths": {
|
||||
"zippedPackage": "solution/react-rssreader.sppkg"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/core-build/serve.schema.json",
|
||||
"port": 4321,
|
||||
"https": true,
|
||||
"initialPage": "https://localhost:5432/workbench",
|
||||
"api": {
|
||||
"port": 5432,
|
||||
"entryPath": "node_modules/@microsoft/sp-webpart-workbench/lib/api/"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json",
|
||||
"cdnBasePath": "<!-- PATH TO CDN -->"
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
'use strict';
|
||||
|
||||
const gulp = require('gulp');
|
||||
const path = require('path');
|
||||
const build = require('@microsoft/sp-build-web');
|
||||
const bundleAnalyzer = require('webpack-bundle-analyzer');
|
||||
|
||||
build.addSuppression(`Warning - [sass] The local CSS class 'ms-Grid' is not camelCase and will not be type-safe.`);
|
||||
|
||||
|
||||
/********************************************************************************************
|
||||
* Adds an alias for handlebars in order to avoid errors while gulping the project
|
||||
* https://github.com/wycats/handlebars.js/issues/1174
|
||||
* Adds a loader and a node setting for webpacking the handlebars-helpers correctly
|
||||
* https://github.com/helpers/handlebars-helpers/issues/263
|
||||
********************************************************************************************/
|
||||
build.configureWebpack.mergeConfig({
|
||||
additionalConfiguration: (generatedConfiguration) => {
|
||||
generatedConfiguration.resolve.alias = { handlebars: 'handlebars/dist/handlebars.min.js' };
|
||||
|
||||
generatedConfiguration.module.rules.push(
|
||||
{
|
||||
test: /utils\.js$/,
|
||||
loader: 'unlazy-loader',
|
||||
include: [
|
||||
/node_modules/,
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
generatedConfiguration.node = {
|
||||
fs: 'empty'
|
||||
}
|
||||
|
||||
const lastDirName = path.basename(__dirname);
|
||||
const dropPath = path.join(__dirname, 'temp', 'stats');
|
||||
generatedConfiguration.plugins.push(new bundleAnalyzer.BundleAnalyzerPlugin({
|
||||
openAnalyzer: false,
|
||||
analyzerMode: 'static',
|
||||
reportFilename: path.join(dropPath, `${lastDirName}.stats.html`),
|
||||
generateStatsFile: true,
|
||||
statsFilename: path.join(dropPath, `${lastDirName}.stats.json`),
|
||||
logLevel: 'error'
|
||||
}));
|
||||
|
||||
return generatedConfiguration;
|
||||
}
|
||||
});
|
||||
|
||||
build.initialize(gulp);
|
Binary file not shown.
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
After Width: | Height: | Size: 48 KiB |
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"name": "react-rssreader",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "gulp bundle",
|
||||
"clean": "gulp clean",
|
||||
"test": "gulp test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/sp-core-library": "1.7.0",
|
||||
"@microsoft/sp-lodash-subset": "1.7.0",
|
||||
"@microsoft/sp-office-ui-fabric-core": "1.7.0",
|
||||
"@microsoft/sp-webpart-base": "1.7.0",
|
||||
"@pnp/common": "1.2.6",
|
||||
"@pnp/logging": "1.2.6",
|
||||
"@pnp/spfx-controls-react": "1.10.0",
|
||||
"@pnp/spfx-property-controls": "1.12.0",
|
||||
"@types/es6-promise": "0.0.33",
|
||||
"@types/handlebars": "4.0.39",
|
||||
"@types/react": "16.4.2",
|
||||
"@types/react-dom": "16.0.5",
|
||||
"@types/webpack-env": "1.13.1",
|
||||
"common-tags": "1.8.0",
|
||||
"handlebars": "4.0.12",
|
||||
"handlebars-helpers": "0.8.4",
|
||||
"moment": "2.22.2",
|
||||
"react": "16.3.2",
|
||||
"react-dom": "16.3.2",
|
||||
"react-moment": "0.8.3",
|
||||
"ts-md5": "1.2.4",
|
||||
"xml2js": "0.4.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@microsoft/sp-build-web": "1.7.0",
|
||||
"@microsoft/sp-module-interfaces": "1.7.0",
|
||||
"@microsoft/sp-tslint-rules": "1.7.0",
|
||||
"@microsoft/sp-webpart-workbench": "1.7.0",
|
||||
"@types/chai": "3.4.34",
|
||||
"@types/mocha": "2.2.38",
|
||||
"ajv": "~5.2.2",
|
||||
"gulp": "~3.9.1",
|
||||
"unlazy-loader": "0.1.3",
|
||||
"webpack-bundle-analyzer": "^3.0.3"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import { IPropertyPaneCustomFieldProps } from '@microsoft/sp-webpart-base';
|
||||
import { IPropertyPaneTextDialogProps } from './IPropertyPaneTextDialogProps';
|
||||
|
||||
export interface IPropertyPaneTextDialogInternalProps extends IPropertyPaneTextDialogProps, IPropertyPaneCustomFieldProps {
|
||||
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import { ITextDialogStrings } from './components/TextDialog/ITextDialogStrings';
|
||||
|
||||
export interface IPropertyPaneTextDialogProps {
|
||||
dialogTextFieldValue?: string;
|
||||
onPropertyChange: (propertyPath: string, text: string) => void;
|
||||
disabled?: boolean;
|
||||
strings: ITextDialogStrings;
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
import * as React from 'react';
|
||||
import * as ReactDom from 'react-dom';
|
||||
import { IPropertyPaneField, PropertyPaneFieldType } from '@microsoft/sp-webpart-base';
|
||||
import { IPropertyPaneTextDialogProps } from './IPropertyPaneTextDialogProps';
|
||||
import { IPropertyPaneTextDialogInternalProps } from './IPropertyPaneTextDialogInternalProps';
|
||||
import { ITextDialogProps } from './components/TextDialog/ITextDialogProps';
|
||||
import { TextDialog } from './components/TextDialog/TextDialog';
|
||||
|
||||
export class PropertyPaneTextDialog implements IPropertyPaneField<IPropertyPaneTextDialogProps> {
|
||||
|
||||
public type: PropertyPaneFieldType = PropertyPaneFieldType.Custom;
|
||||
public targetProperty: string;
|
||||
public properties: IPropertyPaneTextDialogInternalProps;
|
||||
private elem: HTMLElement;
|
||||
|
||||
|
||||
/*****************************************************************************************
|
||||
* Property pane's contructor
|
||||
* @param targetProperty
|
||||
* @param properties
|
||||
*****************************************************************************************/
|
||||
constructor(targetProperty: string, properties: IPropertyPaneTextDialogProps) {
|
||||
this.targetProperty = targetProperty;
|
||||
this.properties = {
|
||||
dialogTextFieldValue: properties.dialogTextFieldValue,
|
||||
onPropertyChange: properties.onPropertyChange,
|
||||
disabled: properties.disabled,
|
||||
strings: properties.strings,
|
||||
onRender: this.onRender.bind(this),
|
||||
key: targetProperty
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/*****************************************************************************************
|
||||
* Renders the QueryFilterPanel property pane
|
||||
*****************************************************************************************/
|
||||
public render(): void {
|
||||
if (!this.elem) {
|
||||
return;
|
||||
}
|
||||
this.onRender(this.elem);
|
||||
}
|
||||
|
||||
|
||||
/*****************************************************************************************
|
||||
* Renders the QueryFilterPanel property pane
|
||||
*****************************************************************************************/
|
||||
private async onRender(elem: HTMLElement): Promise<void> {
|
||||
if (!this.elem) {
|
||||
this.elem = elem;
|
||||
}
|
||||
|
||||
const textDialog: React.ReactElement<ITextDialogProps> = React.createElement(TextDialog, {
|
||||
dialogTextFieldValue: this.properties.dialogTextFieldValue,
|
||||
onChanged: this.onChanged.bind(this),
|
||||
disabled: this.properties.disabled,
|
||||
strings: this.properties.strings,
|
||||
// required to allow the component to be re-rendered by calling this.render() externally
|
||||
stateKey: new Date().toString()
|
||||
});
|
||||
|
||||
ReactDom.render(textDialog, elem);
|
||||
}
|
||||
|
||||
|
||||
/*****************************************************************************************
|
||||
* Call the property pane's onPropertyChange when the TextDialog changes
|
||||
*****************************************************************************************/
|
||||
private onChanged(text: string): void {
|
||||
this.properties.onPropertyChange(this.targetProperty, text);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
.ace_editor.ace_autocomplete {
|
||||
z-index: 2000000 !important;
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import { ITextDialogStrings } from "./ITextDialogStrings";
|
||||
|
||||
export interface ITextDialogProps {
|
||||
dialogTextFieldValue?: string;
|
||||
onChanged?: (text: string) => void;
|
||||
disabled?: boolean;
|
||||
strings: ITextDialogStrings;
|
||||
stateKey?: string;
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export interface ITextDialogState {
|
||||
dialogText: string;
|
||||
showDialog: boolean;
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
export interface ITextDialogStrings {
|
||||
dialogTitle: string;
|
||||
dialogSubText?: string;
|
||||
dialogButtonLabel?: string;
|
||||
dialogButtonText: string;
|
||||
dialogTextBoxPlaceholder?: string;
|
||||
saveButtonText: string;
|
||||
cancelButtonText: string;
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
.textDialog {
|
||||
max-width: 100%;
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
import * as React from 'react';
|
||||
import AceEditor from 'react-ace';
|
||||
import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog';
|
||||
import { Button, ButtonType } from 'office-ui-fabric-react/lib/Button';
|
||||
import { Label } from 'office-ui-fabric-react/lib/Label';
|
||||
import { ITextDialogProps } from './ITextDialogProps';
|
||||
import { ITextDialogState } from './ITextDialogState';
|
||||
import styles from './TextDialog.module.scss';
|
||||
import './AceEditor.module.scss';
|
||||
|
||||
import 'brace';
|
||||
import 'brace/mode/html';
|
||||
import 'brace/theme/monokai';
|
||||
import 'brace/ext/language_tools';
|
||||
|
||||
export class TextDialog extends React.Component<ITextDialogProps, ITextDialogState> {
|
||||
|
||||
/*************************************************************************************
|
||||
* Component's constructor
|
||||
* @param props
|
||||
* @param state
|
||||
*************************************************************************************/
|
||||
constructor(props: ITextDialogProps, state: ITextDialogState) {
|
||||
super(props);
|
||||
this.state = { dialogText: this.props.dialogTextFieldValue, showDialog: false };
|
||||
}
|
||||
|
||||
|
||||
/*************************************************************************************
|
||||
* Shows the dialog
|
||||
*************************************************************************************/
|
||||
private showDialog() {
|
||||
this.setState({ dialogText: this.state.dialogText, showDialog: true });
|
||||
}
|
||||
|
||||
|
||||
/*************************************************************************************
|
||||
* Notifies the parent with the dialog's latest value, then closes the dialog
|
||||
*************************************************************************************/
|
||||
private saveDialog() {
|
||||
this.setState({ dialogText: this.state.dialogText, showDialog: false });
|
||||
|
||||
if(this.props.onChanged) {
|
||||
this.props.onChanged(this.state.dialogText);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*************************************************************************************
|
||||
* Closes the dialog without notifying the parent for any changes
|
||||
*************************************************************************************/
|
||||
private cancelDialog() {
|
||||
this.setState({ dialogText: this.state.dialogText, showDialog: false });
|
||||
}
|
||||
|
||||
|
||||
/*************************************************************************************
|
||||
* Updates the dialog's value each time the textfield changes
|
||||
*************************************************************************************/
|
||||
private onDialogTextChanged(newValue: string) {
|
||||
this.setState({ dialogText: newValue, showDialog: this.state.showDialog });
|
||||
}
|
||||
|
||||
|
||||
/*************************************************************************************
|
||||
* Called immediately after updating occurs
|
||||
*************************************************************************************/
|
||||
public componentDidUpdate(prevProps: ITextDialogProps, prevState: ITextDialogState): void {
|
||||
if (this.props.disabled !== prevProps.disabled || this.props.stateKey !== prevProps.stateKey) {
|
||||
this.setState({ dialogText: this.props.dialogTextFieldValue, showDialog: this.state.showDialog });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*************************************************************************************
|
||||
* Renders the the TextDialog component
|
||||
*************************************************************************************/
|
||||
public render() {
|
||||
return (
|
||||
<div>
|
||||
<Label>{ this.props.strings.dialogButtonLabel }</Label>
|
||||
|
||||
<Button label={ this.props.strings.dialogButtonLabel }
|
||||
onClick={ this.showDialog.bind(this) }
|
||||
disabled={ this.props.disabled }>
|
||||
{ this.props.strings.dialogButtonText }
|
||||
</Button>
|
||||
|
||||
<Dialog type={ DialogType.normal }
|
||||
isOpen={ this.state.showDialog }
|
||||
onDismiss={ this.cancelDialog.bind(this) }
|
||||
title={ this.props.strings.dialogTitle }
|
||||
subText={ this.props.strings.dialogSubText }
|
||||
isBlocking={ true }
|
||||
modalProps={
|
||||
{
|
||||
containerClassName: 'ms-dialogMainOverride ' + styles.textDialog,
|
||||
}
|
||||
}>
|
||||
|
||||
<AceEditor
|
||||
width="600px"
|
||||
mode="html"
|
||||
theme="monokai"
|
||||
enableLiveAutocompletion={ true }
|
||||
showPrintMargin={ false }
|
||||
showGutter= { true }
|
||||
onChange={ this.onDialogTextChanged.bind(this) }
|
||||
value={ this.state.dialogText }
|
||||
name="CodeEditor"
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button buttonType={ ButtonType.primary } onClick={ this.saveDialog.bind(this) }>{ this.props.strings.saveButtonText }</Button>
|
||||
<Button onClick={ this.cancelDialog.bind(this) }>{ this.props.strings.cancelButtonText }</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Helper methods for plain JS DOM manipulations
|
||||
* https://plainjs.com/javascript/
|
||||
*/
|
||||
export class DomHelper {
|
||||
|
||||
/**
|
||||
* Iterates over a list of DOM nodes (https://toddmotto.com/ditch-the-array-foreach-call-nodelist-hack/)
|
||||
* @param array the node list to browse
|
||||
* @param callback the callback function
|
||||
* @param scope the scope
|
||||
*/
|
||||
public static forEach(array, callback, scope?) {
|
||||
for (var i = 0; i < array.length; i++) {
|
||||
callback.call(scope, i, array[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a DOM element after an other
|
||||
* @param el the dom element to insert
|
||||
* @param referenceNode the parent node to insert after
|
||||
*/
|
||||
public static insertAfter(el, referenceNode) {
|
||||
referenceNode.parentNode.insertBefore(el, referenceNode.nextSibling);
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
// A file is required to be in the root of the /src directory by the TypeScript compiler
|
|
@ -0,0 +1,6 @@
|
|||
enum FeedLayoutOption {
|
||||
Default,
|
||||
Custom
|
||||
}
|
||||
|
||||
export default FeedLayoutOption;
|
|
@ -0,0 +1,7 @@
|
|||
enum FeedServiceOption {
|
||||
Default,
|
||||
Feed2Json,
|
||||
Rss2Json
|
||||
}
|
||||
|
||||
export default FeedServiceOption;
|
|
@ -0,0 +1,18 @@
|
|||
import FeedServiceOption from "./FeedServiceOption";
|
||||
|
||||
export interface IRssReaderRequest {
|
||||
url: string;
|
||||
feedService: FeedServiceOption;
|
||||
feedServiceApiKey?: string;
|
||||
feedServiceUrl?: string;
|
||||
|
||||
useCorsProxy?: boolean;
|
||||
corsProxyUrl?: string;
|
||||
disableCorsMode?: boolean;
|
||||
|
||||
maxCount: number;
|
||||
|
||||
useLocalStorage: boolean;
|
||||
useLocalStorageTimeout?: number;
|
||||
useLocalStorageKeyPrefix?: string;
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
export interface IRssReaderResponse {
|
||||
query: IRssQuery;
|
||||
}
|
||||
|
||||
export interface IRssQuery {
|
||||
count: number;
|
||||
created: string;
|
||||
lang: string;
|
||||
meta: IRssQueryMetaData;
|
||||
results?: IRssQueryResults;
|
||||
}
|
||||
|
||||
export interface IRssQueryResults {
|
||||
rss?: IRssResult[];
|
||||
}
|
||||
|
||||
export interface IRssResult {
|
||||
channel: IRssChannel;
|
||||
}
|
||||
|
||||
export interface IRssChannel {
|
||||
item: IRssItem;
|
||||
}
|
||||
|
||||
export interface IRssItem {
|
||||
title: string;
|
||||
link: string;
|
||||
description: string;
|
||||
pubDate: string;
|
||||
guid: IRssGuid;
|
||||
creator: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface IRssGuid {
|
||||
isPermaLink: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface IRssQueryMetaData {
|
||||
url: IRssUrl;
|
||||
}
|
||||
|
||||
export interface IRssUrl {
|
||||
id: string;
|
||||
status: string;
|
||||
headers: IRssHeaders;
|
||||
}
|
||||
|
||||
export interface IRssHeaders {
|
||||
header: IRssHeader[];
|
||||
}
|
||||
|
||||
export interface IRssHeader {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
export { default as FeedLayoutOption } from './FeedLayoutOption';
|
||||
export { default as FeedServiceOption } from './FeedServiceOption';
|
||||
export { IRssReaderRequest } from './IRssReaderRequest';
|
||||
export {
|
||||
IRssReaderResponse,
|
||||
IRssQuery,
|
||||
IRssQueryResults,
|
||||
IRssResult,
|
||||
IRssChannel,
|
||||
IRssItem,
|
||||
IRssGuid,
|
||||
IRssQueryMetaData,
|
||||
IRssUrl,
|
||||
IRssHeaders,
|
||||
IRssHeader } from './IRssReaderResponse';
|
|
@ -0,0 +1,39 @@
|
|||
export interface ILocalStorageKey {
|
||||
/*
|
||||
The object used to create the key, could be anything. If Object, will be strigified. If not string, converted to string
|
||||
*/
|
||||
keyName: any;
|
||||
|
||||
/*
|
||||
a possible key prefix, added to hash key value
|
||||
*/
|
||||
keyPrefix?: string;
|
||||
|
||||
/*
|
||||
a possible value for this particular key, used in "set"
|
||||
*/
|
||||
keyValue?: any;
|
||||
|
||||
/*
|
||||
timeout in minutes, used in "get"
|
||||
*/
|
||||
timeOutInMinutes?: number;
|
||||
}
|
||||
|
||||
export interface ILocalStorageObject {
|
||||
/*
|
||||
A string or object of the data we want to store
|
||||
*/
|
||||
keyValue: any;
|
||||
|
||||
/*
|
||||
The date / time the data was stored
|
||||
*/
|
||||
keyDate: Date;
|
||||
}
|
||||
|
||||
export interface ILocalStorageService {
|
||||
get(keyToken: ILocalStorageKey): Promise<any>;
|
||||
set(keyToken: ILocalStorageKey): Promise<boolean>;
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import { Logger, LogLevel, ConsoleListener } from '@pnp/logging';
|
||||
|
||||
import {
|
||||
ILocalStorageService,
|
||||
ILocalStorageKey,
|
||||
ILocalStorageObject
|
||||
} from './ILocalStorageService';
|
||||
|
||||
import {Md5} from 'ts-md5/dist/md5';
|
||||
|
||||
class LocalStorageService implements ILocalStorageService {
|
||||
public constructor() {
|
||||
// Setup the Logger
|
||||
const consoleListener = new ConsoleListener();
|
||||
Logger.subscribe(consoleListener);
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to get local storage value based on key
|
||||
* @param keyToken the key value used to retrive and verify local storage
|
||||
* @return any - the found and validated local storage value
|
||||
*/
|
||||
public async get(keyToken: ILocalStorageKey): Promise<any> {
|
||||
|
||||
var p = new Promise<any>(async (resolve, reject) => {
|
||||
try {
|
||||
|
||||
var returnValue: any;
|
||||
|
||||
//get the hash of the local storage token based on value
|
||||
//var keyHash: string = md5(keyToken.keyName);
|
||||
//var keyHash: string = ObjectHash.MD5(keyToken.keyName);
|
||||
var keyHash: string | Int32Array = Md5.hashStr(JSON.stringify(keyToken.keyName));
|
||||
console.log("LS get: keyhash - " + keyHash);
|
||||
|
||||
|
||||
//create the corrrect storage key based on keyHash and possible prefix
|
||||
const storageKey: string = (keyToken.keyPrefix ? keyToken.keyPrefix + "_" : "") + keyHash;
|
||||
console.log("LS get: storagekey - " + storageKey);
|
||||
|
||||
//attempt to get the key/value from local storage based on storageKey
|
||||
const keyValue: ILocalStorageObject = JSON.parse(localStorage.getItem(storageKey)) as ILocalStorageObject;
|
||||
|
||||
//with a valid response, we can continue
|
||||
if (keyValue) {
|
||||
|
||||
//check timeout if one provided
|
||||
if (keyToken.timeOutInMinutes > 0) {
|
||||
|
||||
//have to get proper date object
|
||||
const keyDate: Date = new Date(keyValue.keyDate.toString());
|
||||
|
||||
//determine the local time at which this key/value should expire
|
||||
const timeout: Date = new Date(keyDate.getTime() + keyToken.timeOutInMinutes*60000);
|
||||
|
||||
//console.log("LS get: now " + new Date(Date.now()).toString());
|
||||
//console.log("LS get: timeout " + timeout.toString());
|
||||
|
||||
//check to see if the local storage is stale or not
|
||||
if (timeout.getTime() > Date.now()) {
|
||||
|
||||
//still valid, thus return whatever was found in local storage
|
||||
returnValue = keyValue.keyValue;
|
||||
}
|
||||
else {
|
||||
|
||||
//attempt to remove from local storage for garbage collection
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
||||
//no timeout was provided, thus simply return
|
||||
returnValue = keyValue.keyValue;
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
||||
//key was not found in local storage, simply continue
|
||||
}
|
||||
|
||||
//resolve the promise with whatever was found, a valid, or null
|
||||
resolve(returnValue);
|
||||
|
||||
} catch (error) {
|
||||
Logger.write('[LocalStorageService.get()]: Error: ' + error, LogLevel.Error);
|
||||
|
||||
reject(null);
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to set local storage value based on key
|
||||
* @param keyToken the key value used to store to local storage
|
||||
* @return boolean - true upon success
|
||||
*/
|
||||
public async set(keyToken: ILocalStorageKey): Promise<boolean> {
|
||||
|
||||
var p = new Promise<any>(async (resolve, reject) => {
|
||||
try {
|
||||
|
||||
//get the hash of the local storage token based on value
|
||||
//var keyHash: string = md5(keyToken.keyName);
|
||||
//var keyHash: string = ObjectHash.MD5(keyToken.keyName);
|
||||
var keyHash: string | Int32Array = Md5.hashStr(JSON.stringify(keyToken.keyName));
|
||||
console.log("LS set: keyhash - " + keyHash);
|
||||
|
||||
//create the corrrect storage key based on keyHash and possible prefix
|
||||
const storageKey: string = (keyToken.keyPrefix ? keyToken.keyPrefix + "_" : "") + keyHash;
|
||||
console.log("LS set: storagekey - " + storageKey);
|
||||
|
||||
//create a storage object to hold the value and storage date/time "now"
|
||||
const keyValue: ILocalStorageObject = {
|
||||
keyValue: keyToken.keyValue,
|
||||
keyDate: new Date(Date.now())
|
||||
} as ILocalStorageObject;
|
||||
|
||||
//attempt to store to local storage
|
||||
localStorage.setItem(storageKey, JSON.stringify(keyValue));
|
||||
|
||||
resolve(true);
|
||||
} catch (error) {
|
||||
Logger.write('[LocalStorageService.set()]: Error: ' + error, LogLevel.Error);
|
||||
|
||||
reject(false);
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
export default LocalStorageService;
|
|
@ -0,0 +1,2 @@
|
|||
export { ILocalStorageService, ILocalStorageKey } from './ILocalStorageService';
|
||||
export { default as LocalStorageService } from './LocalStorageService';
|
|
@ -0,0 +1,16 @@
|
|||
import { IRssReaderRequest, IRssReaderResponse } from '../../models';
|
||||
|
||||
export interface IRssHttpClientComponentService {
|
||||
|
||||
/**
|
||||
* Get a component service request based on feedRequest
|
||||
* @param feedRequest the rss reader request
|
||||
*/
|
||||
get(feedRequest: IRssReaderRequest): Promise<IRssReaderResponse>;
|
||||
|
||||
/**
|
||||
* normalization method to convert raw rss response to acceptable format
|
||||
* @param feedRequest the rss reader request
|
||||
*/
|
||||
convertRssFeedToRssReaderResponse(input: any, maxCount: number) : IRssReaderResponse;
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export { RssHttpClientService } from './rssHttpClientService';
|
||||
export { RssHttpClientDirectService } from './rssHttpClientDirectService';
|
||||
export { RssHttpClientFeed2JsonService } from './rssHttpClientFeed2JsonService';
|
||||
export { RssHttpClientRss2JsonService } from './rssHttpClientRss2JsonService';
|
||||
export * from './IRssHttpClientComponentService';
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import * as strings from 'RssReaderWebPartStrings';
|
||||
|
||||
import {
|
||||
IRssHttpClientComponentService,
|
||||
RssHttpClientService } from './';
|
||||
import {
|
||||
IRssReaderResponse,
|
||||
IRssQueryResults,
|
||||
IRssResult,
|
||||
IRssChannel,
|
||||
IRssItem,
|
||||
IRssGuid,
|
||||
IRssQueryMetaData,
|
||||
IRssUrl,
|
||||
IRssHeaders,
|
||||
IRssHeader,
|
||||
IRssReaderRequest
|
||||
} from '../../models';
|
||||
import { RssXmlParserService } from '../../services/RssXmlParserService';
|
||||
|
||||
export class RssHttpClientDirectService implements IRssHttpClientComponentService {
|
||||
public async get(feedRequest: IRssReaderRequest): Promise<IRssReaderResponse> {
|
||||
|
||||
var p = new Promise<IRssReaderResponse>(async (resolve, reject) => {
|
||||
|
||||
let rawFeedOutput: any = null;
|
||||
let response: IRssReaderResponse = null;
|
||||
|
||||
try {
|
||||
|
||||
rawFeedOutput = await RssHttpClientService.getRssXml(feedRequest.url, feedRequest.useCorsProxy ? feedRequest.corsProxyUrl : "", feedRequest.disableCorsMode);
|
||||
|
||||
}
|
||||
catch (err) {
|
||||
|
||||
console.log("RssHttpClientDirectService.get: error retrieving feed");
|
||||
console.log(err);
|
||||
|
||||
reject(err + " - " + strings.ErrorPossibleCORSBlock);
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
//at this point, we need to now process the raw resutls and turn them into a valid response
|
||||
if (rawFeedOutput) {
|
||||
|
||||
RssXmlParserService.init();
|
||||
|
||||
try {
|
||||
let feedOutput = await RssXmlParserService.parse(rawFeedOutput);
|
||||
|
||||
if (feedOutput) {
|
||||
response = this.convertRssFeedToRssReaderResponse(feedOutput, feedRequest.maxCount);
|
||||
|
||||
resolve(response);
|
||||
}
|
||||
else {
|
||||
reject(strings.ErrorParsingFeed);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
reject(strings.ErrorCovertFeedInvalidSource);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//if here, an error occurred
|
||||
reject(strings.ErrorPossibleCORBBlock);
|
||||
|
||||
});
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
public convertRssFeedToRssReaderResponse(input: any, maxCount: number) : IRssReaderResponse {
|
||||
var response: IRssReaderResponse = {query: null} as IRssReaderResponse;
|
||||
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
response.query = {
|
||||
|
||||
//set up feed header
|
||||
count: input.items ? input.items.length : 0,
|
||||
created: (new Date()).toDateString(),
|
||||
lang: input.language,
|
||||
meta: {
|
||||
url: {
|
||||
id: input.link,
|
||||
status: input.title,
|
||||
headers: {
|
||||
header: [
|
||||
{
|
||||
name: input.description
|
||||
} as IRssHeader
|
||||
] as IRssHeader[]
|
||||
} as IRssHeaders
|
||||
} as IRssUrl,
|
||||
} as IRssQueryMetaData,
|
||||
results: null
|
||||
};
|
||||
|
||||
//feed items
|
||||
if (input.items) {
|
||||
|
||||
response.query.results = {
|
||||
rss: [] as IRssResult[]
|
||||
} as IRssQueryResults;
|
||||
|
||||
input.items.map((item: any) => {
|
||||
let newItem: IRssResult = {} as IRssResult;
|
||||
|
||||
newItem.channel = {} as IRssChannel;
|
||||
newItem.channel.item = {} as IRssItem;
|
||||
|
||||
newItem.channel.item.title = item.title;
|
||||
newItem.channel.item.link = item.link;
|
||||
newItem.channel.item.description = item.content;
|
||||
newItem.channel.item.pubDate = item.pubDate;
|
||||
newItem.channel.item.creator = item.creator;
|
||||
newItem.channel.item.date = item.isoDate;
|
||||
newItem.channel.item.guid = {
|
||||
isPermaLink: "true",
|
||||
content: item.guid
|
||||
} as IRssGuid;
|
||||
|
||||
response.query.results.rss.push(newItem);
|
||||
});
|
||||
|
||||
//ensure that we only get maxCount records
|
||||
if (response.query.results.rss.length > maxCount) {
|
||||
|
||||
response.query.results.rss = response.query.results.rss.splice(0, maxCount);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,134 @@
|
|||
import * as strings from 'RssReaderWebPartStrings';
|
||||
|
||||
import {
|
||||
IRssHttpClientComponentService,
|
||||
RssHttpClientService } from './';
|
||||
|
||||
import {
|
||||
IRssReaderRequest,
|
||||
IRssReaderResponse,
|
||||
IRssQueryResults,
|
||||
IRssResult,
|
||||
IRssChannel,
|
||||
IRssItem,
|
||||
IRssGuid,
|
||||
IRssQueryMetaData,
|
||||
IRssUrl,
|
||||
IRssHeaders,
|
||||
IRssHeader
|
||||
} from '../../models';
|
||||
|
||||
export class RssHttpClientFeed2JsonService implements IRssHttpClientComponentService {
|
||||
public async get(feedRequest: IRssReaderRequest): Promise<IRssReaderResponse> {
|
||||
|
||||
var p = new Promise<IRssReaderResponse>(async (resolve, reject) => {
|
||||
|
||||
let rawFeedOutput: any = null;
|
||||
let response: IRssReaderResponse = null;
|
||||
|
||||
try {
|
||||
|
||||
//Create the url to the feed2json service url per documentation at: https://feed2json.org/
|
||||
let rssUrl: string = (feedRequest.feedServiceUrl ? feedRequest.feedServiceUrl : "https://feed2json.org/convert") + "?url=" + feedRequest.url;
|
||||
|
||||
rawFeedOutput = await RssHttpClientService.getRssJson(rssUrl, feedRequest.useCorsProxy ? feedRequest.corsProxyUrl : "", feedRequest.disableCorsMode);
|
||||
|
||||
}
|
||||
catch (err) {
|
||||
console.log("RssHttpClientFeed2JsonService.get: error retrieving feed");
|
||||
console.log(err);
|
||||
|
||||
reject(err + " - " + strings.ErrorPossibleCORSBlock);
|
||||
return;
|
||||
}
|
||||
|
||||
//at this point, we need to now process the raw resutls and turn them into a valid response
|
||||
if (rawFeedOutput) {
|
||||
|
||||
try {
|
||||
|
||||
response = this.convertRssFeedToRssReaderResponse(rawFeedOutput, feedRequest.maxCount);
|
||||
|
||||
resolve(response);
|
||||
|
||||
}
|
||||
catch (err) {
|
||||
reject(strings.ErrorCovertFeedInvalidSource);
|
||||
}
|
||||
}
|
||||
|
||||
//if here, an error occurred
|
||||
reject(strings.ErrorPossibleCORBBlock);
|
||||
});
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
public convertRssFeedToRssReaderResponse(input: any, maxCount: number) : IRssReaderResponse {
|
||||
var response: IRssReaderResponse = {query: null} as IRssReaderResponse;
|
||||
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
response.query = {
|
||||
|
||||
//set up feed header
|
||||
count: input.items ? input.items.length : 0,
|
||||
created: (new Date()).toDateString(),
|
||||
lang: "",
|
||||
meta: {
|
||||
url: {
|
||||
id: input.home_page_url,
|
||||
status: input.name,
|
||||
headers: {
|
||||
header: [
|
||||
{
|
||||
name: input.description
|
||||
} as IRssHeader
|
||||
] as IRssHeader[]
|
||||
} as IRssHeaders
|
||||
} as IRssUrl,
|
||||
} as IRssQueryMetaData,
|
||||
results: null
|
||||
};
|
||||
|
||||
//feed items
|
||||
if (input.items) {
|
||||
|
||||
response.query.results = {
|
||||
rss: [] as IRssResult[]
|
||||
} as IRssQueryResults;
|
||||
|
||||
input.items.map((item: any) => {
|
||||
let newItem: IRssResult = {} as IRssResult;
|
||||
|
||||
newItem.channel = {} as IRssChannel;
|
||||
newItem.channel.item = {} as IRssItem;
|
||||
|
||||
newItem.channel.item.title = item.title;
|
||||
newItem.channel.item.link = item.url;
|
||||
newItem.channel.item.description = item.content_html;
|
||||
newItem.channel.item.pubDate = item.date_published;
|
||||
newItem.channel.item.creator = (item.author && item.author.name) ? item.author.name : "";
|
||||
newItem.channel.item.date = item.date_published;
|
||||
newItem.channel.item.guid = {
|
||||
isPermaLink: "true",
|
||||
content: item.guid
|
||||
} as IRssGuid;
|
||||
|
||||
response.query.results.rss.push(newItem);
|
||||
|
||||
});
|
||||
|
||||
//ensure that we only get maxCount records
|
||||
if (response.query.results.rss.length > maxCount) {
|
||||
|
||||
response.query.results.rss = response.query.results.rss.splice(0, maxCount);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,133 @@
|
|||
import * as strings from 'RssReaderWebPartStrings';
|
||||
|
||||
import {
|
||||
IRssHttpClientComponentService,
|
||||
RssHttpClientService } from './';
|
||||
import {
|
||||
IRssReaderRequest,
|
||||
IRssReaderResponse,
|
||||
IRssQueryResults,
|
||||
IRssResult,
|
||||
IRssChannel,
|
||||
IRssItem,
|
||||
IRssGuid,
|
||||
IRssQueryMetaData,
|
||||
IRssUrl,
|
||||
IRssHeaders,
|
||||
IRssHeader
|
||||
} from '../../models';
|
||||
|
||||
export class RssHttpClientRss2JsonService implements IRssHttpClientComponentService {
|
||||
public async get(feedRequest: IRssReaderRequest): Promise<IRssReaderResponse> {
|
||||
|
||||
var p = new Promise<IRssReaderResponse>(async (resolve, reject) => {
|
||||
|
||||
let rawFeedOutput: any = null;
|
||||
let response: IRssReaderResponse = null;
|
||||
|
||||
try {
|
||||
|
||||
//Create the url to the rss2json service per documentation at: https://rss2json.com/docs
|
||||
let rssUrl: string = "https://api.rss2json.com/v1/api.json?rss_url=" + encodeURIComponent(feedRequest.url)
|
||||
+ (feedRequest.feedServiceApiKey ? "&api_key=" + encodeURIComponent(feedRequest.feedServiceApiKey) : "")
|
||||
+ ((feedRequest.maxCount && feedRequest.feedServiceApiKey) ? "&count=" + feedRequest.maxCount : ""); //a valid API key is required to use count
|
||||
|
||||
rawFeedOutput = await RssHttpClientService.getRssJson(rssUrl, feedRequest.useCorsProxy ? feedRequest.corsProxyUrl : "", feedRequest.disableCorsMode);
|
||||
|
||||
}
|
||||
catch (err) {
|
||||
console.log("RssHttpClientRss2JsonService.get: error retrieving feed");
|
||||
console.log(err);
|
||||
|
||||
reject(err + " - " + strings.ErrorPossibleCORSBlock);
|
||||
return;
|
||||
}
|
||||
|
||||
//at this point, we need to now process the raw resutls and turn them into a valid response
|
||||
if (rawFeedOutput) {
|
||||
try {
|
||||
|
||||
response = this.convertRssFeedToRssReaderResponse(rawFeedOutput, feedRequest.maxCount);
|
||||
|
||||
resolve(response);
|
||||
|
||||
}
|
||||
catch (err) {
|
||||
reject(strings.ErrorCovertFeedInvalidSource);
|
||||
}
|
||||
}
|
||||
|
||||
//if here, an error occurred
|
||||
reject(strings.ErrorPossibleCORBBlock);
|
||||
});
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
public convertRssFeedToRssReaderResponse(input: any, maxCount?: number) : IRssReaderResponse {
|
||||
var response: IRssReaderResponse = {query: null} as IRssReaderResponse;
|
||||
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
response.query = {
|
||||
|
||||
//set up feed header
|
||||
count: input.items ? input.items.length : 0,
|
||||
created: (new Date()).toDateString(),
|
||||
lang: "",
|
||||
meta: {
|
||||
url: {
|
||||
id: input.feed.link,
|
||||
status: input.feed.title,
|
||||
headers: {
|
||||
header: [
|
||||
{
|
||||
name: input.feed.description
|
||||
} as IRssHeader
|
||||
] as IRssHeader[]
|
||||
} as IRssHeaders
|
||||
} as IRssUrl,
|
||||
} as IRssQueryMetaData,
|
||||
results: null
|
||||
};
|
||||
|
||||
//feed items
|
||||
if (input.items) {
|
||||
|
||||
response.query.results = {
|
||||
rss: [] as IRssResult[]
|
||||
} as IRssQueryResults;
|
||||
|
||||
input.items.map((item: any) => {
|
||||
let newItem: IRssResult = {} as IRssResult;
|
||||
|
||||
newItem.channel = {} as IRssChannel;
|
||||
newItem.channel.item = {} as IRssItem;
|
||||
|
||||
newItem.channel.item.title = item.title;
|
||||
newItem.channel.item.link = item.link;
|
||||
newItem.channel.item.description = item.content;
|
||||
newItem.channel.item.pubDate = item.pubDate;
|
||||
newItem.channel.item.creator = item.author;
|
||||
newItem.channel.item.date = item.pubDate;
|
||||
newItem.channel.item.guid = {
|
||||
isPermaLink: "true",
|
||||
content: item.guid
|
||||
} as IRssGuid;
|
||||
|
||||
response.query.results.rss.push(newItem);
|
||||
});
|
||||
|
||||
//ensure that we only get maxCount records
|
||||
if (response.query.results.rss.length > maxCount) {
|
||||
|
||||
response.query.results.rss = response.query.results.rss.splice(0, maxCount);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,141 @@
|
|||
/* based on MSGraph class by Mikael Svenson:
|
||||
https://www.techmikael.com/2018/09/example-of-wrapper-to-ease-usage-of.html
|
||||
*/
|
||||
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import {
|
||||
HttpClient,
|
||||
HttpClientResponse,
|
||||
IHttpClientOptions
|
||||
} from '@microsoft/sp-http';
|
||||
|
||||
export class RssHttpClientService {
|
||||
|
||||
private static _httpClient: HttpClient;
|
||||
|
||||
/*
|
||||
initialize the static class
|
||||
*/
|
||||
public static async init(context: WebPartContext) {
|
||||
|
||||
//obtain the httpClient from the webpart context
|
||||
this._httpClient = await context.httpClient;
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
given a url, make a get request to a given url, expecting json in response
|
||||
Will assume response is only text and will be returned as such
|
||||
*/
|
||||
|
||||
public static async getRssJson(url: string, corsProxyUrl: string, disableCors: boolean): Promise<any> {
|
||||
|
||||
var p = new Promise<string>(async (resolve, reject) => {
|
||||
|
||||
let requestHeaders = new Headers();
|
||||
|
||||
//if Cors is disabled, then we must send a simple Accept type
|
||||
if (!disableCors) {
|
||||
requestHeaders.append('Accept', 'application/json');
|
||||
}
|
||||
else {
|
||||
requestHeaders.append('Accept', 'text/plain');
|
||||
}
|
||||
|
||||
//set up get options
|
||||
const requestGetOptions: IHttpClientOptions = {
|
||||
method: "GET",
|
||||
headers: requestHeaders,
|
||||
mode: !disableCors ? "cors" : "no-cors"
|
||||
};
|
||||
|
||||
let query = this._httpClient.fetch(
|
||||
corsProxyUrl ? RssHttpClientService.processCorsProxyUrl(url, corsProxyUrl) : url,
|
||||
HttpClient.configurations.v1,
|
||||
requestGetOptions)
|
||||
.then((response: HttpClientResponse) : Promise<any> => {
|
||||
|
||||
//get the response based on expected type
|
||||
if (!disableCors) {
|
||||
return response.json();
|
||||
}
|
||||
else {
|
||||
return response.text();
|
||||
}
|
||||
|
||||
})
|
||||
.then((data: any) : void => {
|
||||
|
||||
if (!disableCors) {
|
||||
|
||||
resolve(data);
|
||||
|
||||
}
|
||||
else {
|
||||
|
||||
//expected response is actually json, thus attempt to parse response into json
|
||||
resolve(JSON.parse(data));
|
||||
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
/*
|
||||
given a url, make a get request to a given url
|
||||
Will assume response is only text and will be returned as such
|
||||
*/
|
||||
public static async getRssXml(url: string, corsProxyUrl: string, disableCors: boolean): Promise<any> {
|
||||
|
||||
var p = new Promise<string>(async (resolve, reject) => {
|
||||
|
||||
let requestHeaders = new Headers();
|
||||
requestHeaders.append('Accept', 'text/xml; application/xml');
|
||||
|
||||
//set up get options
|
||||
const requestGetOptions: IHttpClientOptions = {
|
||||
method: "GET",
|
||||
headers: requestHeaders,
|
||||
mode: !disableCors ? "cors" : "no-cors"
|
||||
};
|
||||
|
||||
let query = this._httpClient.fetch(
|
||||
corsProxyUrl ? RssHttpClientService.processCorsProxyUrl(url, corsProxyUrl) : url,
|
||||
HttpClient.configurations.v1,
|
||||
requestGetOptions)
|
||||
.then((response: HttpClientResponse) : Promise<any> => {
|
||||
|
||||
return response.text();
|
||||
|
||||
})
|
||||
.then((data: any) : void => {
|
||||
|
||||
resolve(data);
|
||||
|
||||
})
|
||||
.catch(error => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
return p;
|
||||
}
|
||||
|
||||
/*
|
||||
given a feed url and the proxy url, replace proxy url token(s)
|
||||
{0} will be replaced with url
|
||||
*/
|
||||
private static processCorsProxyUrl(url: string, corsProxyUrl: string) : string {
|
||||
if (!url || !corsProxyUrl) {
|
||||
return "";
|
||||
}
|
||||
|
||||
//replace {0} with the feed Url
|
||||
return corsProxyUrl.replace(/\{0\}/ig, url);
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { IRssReaderService, RssReaderService } from './rssReaderService';
|
|
@ -0,0 +1,149 @@
|
|||
import * as strings from 'RssReaderWebPartStrings';
|
||||
|
||||
import {
|
||||
IRssHttpClientComponentService,
|
||||
RssHttpClientDirectService,
|
||||
RssHttpClientFeed2JsonService,
|
||||
RssHttpClientRss2JsonService } from '../RssHttpClientService';
|
||||
import { IRssReaderResponse, IRssReaderRequest, FeedServiceOption } from '../../models';
|
||||
import {
|
||||
ILocalStorageKey,
|
||||
ILocalStorageService,
|
||||
LocalStorageService} from '../../services/LocalStorageService';
|
||||
|
||||
export interface IRssReaderService {
|
||||
getFeed(feedRequest: IRssReaderRequest): Promise<IRssReaderResponse>;
|
||||
}
|
||||
|
||||
export class RssReaderService implements IRssReaderService {
|
||||
private static storageKeyPrefix: string = 'rssFeed';
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
/*
|
||||
given a feedRequest, determine the specific local storage keyname
|
||||
*/
|
||||
public static getFeedStorageKeyName(feedRequest: IRssReaderRequest) : string {
|
||||
const keyName:string = feedRequest.url + "_" + feedRequest.maxCount;
|
||||
|
||||
return keyName;
|
||||
}
|
||||
|
||||
/*
|
||||
given a feedRequest, determine the specific local storage key prefix to be added to the keyname hash
|
||||
*/
|
||||
public static getFeedStorageKeyPrefix(feedRequest: IRssReaderRequest) : string {
|
||||
const keyPrefix:string = ((feedRequest.useLocalStorageKeyPrefix && feedRequest.useLocalStorageKeyPrefix.length > 0) ? feedRequest.useLocalStorageKeyPrefix + "_" : "") + RssReaderService.storageKeyPrefix;
|
||||
|
||||
return keyPrefix;
|
||||
}
|
||||
|
||||
/*
|
||||
given a feedRequest, go and get the particular feed
|
||||
return a resolved IRssReaderResponse or reject message
|
||||
*/
|
||||
public async getFeed(feedRequest: IRssReaderRequest): Promise<IRssReaderResponse> {
|
||||
var p = new Promise<IRssReaderResponse>(async (resolve, reject) => {
|
||||
|
||||
let localStorageService: ILocalStorageService = new LocalStorageService();
|
||||
|
||||
//attempt to get local storage if needed
|
||||
if (feedRequest.useLocalStorage && feedRequest.useLocalStorageTimeout >= 0) {
|
||||
|
||||
//set up the local storage key to search in local storage for valid stored results
|
||||
let localStorageKey:ILocalStorageKey = {
|
||||
keyName: RssReaderService.getFeedStorageKeyName(feedRequest),
|
||||
keyPrefix: RssReaderService.getFeedStorageKeyPrefix(feedRequest),
|
||||
timeOutInMinutes: feedRequest.useLocalStorageTimeout
|
||||
} as ILocalStorageKey;
|
||||
|
||||
try {
|
||||
|
||||
//try and get cached results from local storage
|
||||
let cachedResults: IRssReaderResponse = await localStorageService.get(localStorageKey);
|
||||
|
||||
if (cachedResults) {
|
||||
|
||||
//appear to have valid cached results, resolve these
|
||||
try {
|
||||
|
||||
resolve(cachedResults);
|
||||
return;
|
||||
|
||||
}
|
||||
catch (err) {
|
||||
//we are going to ignore error as we will simply pull feed again
|
||||
console.log("rssReaderService: an error occurred attempting to convert cached results");
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
//we are going to ignore error as we will simply pull feed again
|
||||
console.log("rssReaderService: an error occurred attempting to retrieve cached results");
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//if we are here, we need to retrieve from feed service
|
||||
let response: IRssReaderResponse = null;
|
||||
|
||||
try {
|
||||
//set up the base rssHttpClient object
|
||||
var rssHttpClient: IRssHttpClientComponentService;
|
||||
|
||||
//set up the http client service for each particular feed service
|
||||
if (feedRequest.feedService == FeedServiceOption.Default) {
|
||||
rssHttpClient = new RssHttpClientDirectService();
|
||||
}
|
||||
else if (feedRequest.feedService == FeedServiceOption.Feed2Json) {
|
||||
rssHttpClient = new RssHttpClientFeed2JsonService();
|
||||
}
|
||||
else if (feedRequest.feedService == FeedServiceOption.Rss2Json) {
|
||||
rssHttpClient = new RssHttpClientRss2JsonService();
|
||||
}
|
||||
|
||||
//if we have a valid feed service, and initialized the proper service, attempt to get the feed
|
||||
if (rssHttpClient) {
|
||||
|
||||
response = await rssHttpClient.get(feedRequest);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
catch (err) {
|
||||
console.log("rssReaderService: error retrieving feed from service " + feedRequest.feedService);
|
||||
console.log(err);
|
||||
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
//if we have a valid response, we can attempt to set to local storage as well as return
|
||||
if (response) {
|
||||
|
||||
if (feedRequest.useLocalStorage && feedRequest.useLocalStorageTimeout >= 0) {
|
||||
|
||||
let localStorageKeyValue: ILocalStorageKey = {
|
||||
keyName: RssReaderService.getFeedStorageKeyName(feedRequest),
|
||||
keyPrefix: RssReaderService.getFeedStorageKeyPrefix(feedRequest),
|
||||
keyValue: response
|
||||
} as ILocalStorageKey;
|
||||
|
||||
let storedResult: any = await localStorageService.set(localStorageKeyValue);
|
||||
|
||||
}
|
||||
|
||||
resolve(response);
|
||||
}
|
||||
else {
|
||||
console.log("rssReaderService getFeed: Feed returned no results");
|
||||
reject(strings.ErrorNoResults);
|
||||
}
|
||||
});
|
||||
|
||||
return p;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
export class Fields {
|
||||
public static feed = [
|
||||
['author', 'creator'],
|
||||
['dc:publisher', 'publisher'],
|
||||
['dc:creator', 'creator'],
|
||||
['dc:source', 'source'],
|
||||
['dc:title', 'title'],
|
||||
['dc:type', 'type'],
|
||||
'title',
|
||||
'description',
|
||||
'author',
|
||||
'pubDate',
|
||||
'webMaster',
|
||||
'managingEditor',
|
||||
'generator',
|
||||
'link',
|
||||
'language',
|
||||
'copyright',
|
||||
'lastBuildDate',
|
||||
'docs',
|
||||
'generator',
|
||||
'ttl',
|
||||
'rating',
|
||||
'skipHours',
|
||||
'skipDays',
|
||||
];
|
||||
|
||||
public static item = [
|
||||
['author', 'creator'],
|
||||
['dc:creator', 'creator'],
|
||||
['dc:date', 'date'],
|
||||
['dc:language', 'language'],
|
||||
['dc:rights', 'rights'],
|
||||
['dc:source', 'source'],
|
||||
['dc:title', 'title'],
|
||||
'title',
|
||||
'link',
|
||||
'pubDate',
|
||||
'author',
|
||||
'content:encoded',
|
||||
'enclosure',
|
||||
'dc:creator',
|
||||
'dc:date',
|
||||
'comments',
|
||||
];
|
||||
|
||||
public static mapItunesField(f) {
|
||||
return ['itunes:' + f, f];
|
||||
}
|
||||
|
||||
public static podcastFeed = ([
|
||||
'author',
|
||||
'subtitle',
|
||||
'summary',
|
||||
'explicit'
|
||||
]).map(Fields.mapItunesField);
|
||||
|
||||
public static podcastItem = ([
|
||||
'author',
|
||||
'subtitle',
|
||||
'summary',
|
||||
'explicit',
|
||||
'duration',
|
||||
'image',
|
||||
'episode',
|
||||
'image',
|
||||
'season',
|
||||
'keywords',
|
||||
]).map(Fields.mapItunesField);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { RssXmlParserService } from './rssXmlParserService';
|
|
@ -0,0 +1,284 @@
|
|||
//rssParser inspired by rss-parser node package
|
||||
import { Parser } from 'xml2js';
|
||||
import { Utils } from './utils';
|
||||
import { Fields } from './fields';
|
||||
|
||||
|
||||
export class RssXmlParserService {
|
||||
|
||||
private static DEFAULT_HEADERS = {
|
||||
'User-Agent': 'rss-parser',
|
||||
'Accept': 'application/rss+xml',
|
||||
};
|
||||
private static DEFAULT_MAX_REDIRECTS = 5;
|
||||
private static DEFAULT_TIMEOUT = 60000;
|
||||
|
||||
private static options: any;
|
||||
|
||||
public static async init(options: any = {}) {
|
||||
options.headers = options.headers || {};
|
||||
options.customFields = options.customFields || {};
|
||||
options.customFields.item = options.customFields.item || [];
|
||||
options.customFields.feed = options.customFields.feed || [];
|
||||
|
||||
if (!options.maxRedirects) options.maxRedirects = this.DEFAULT_MAX_REDIRECTS;
|
||||
if (!options.timeout) options.timeout = this.DEFAULT_TIMEOUT;
|
||||
|
||||
this.options = options;
|
||||
//this.xmlParser = new xml2js.Parser(this.options.xml2js);
|
||||
}
|
||||
|
||||
public static async parse(xmlFeed: string, options?: any): Promise<any> {
|
||||
var p = new Promise<string>(async (resolve, reject) => {
|
||||
//ensure that we have some options
|
||||
options = options ? options : {};
|
||||
|
||||
//we want the string items to not have to be an array
|
||||
var xmlParser = new Parser({explicitArray: false});
|
||||
|
||||
//parse the xml
|
||||
xmlParser.parseString(xmlFeed, (err, result) => {
|
||||
//console.log("parser called");
|
||||
//console.log(result);
|
||||
|
||||
if (err) return reject(err);
|
||||
if (!result) {
|
||||
return reject(new Error('Unable to parse XML.'));
|
||||
}
|
||||
|
||||
let feed = null;
|
||||
if (result.feed) {
|
||||
feed = this.buildAtomFeed(result);
|
||||
}
|
||||
else if (result.rss && result.rss.$ && result.rss.$.version && result.rss.$.version.match(/^2/)) {
|
||||
feed = this.buildRSS2(result);
|
||||
}
|
||||
else if (result['rdf:RDF']) {
|
||||
feed = this.buildRSS1(result);
|
||||
}
|
||||
else if (result.rss && result.rss.$ && result.rss.$.version && result.rss.$.version.match(/0\.9/)) {
|
||||
feed = this.buildRSS0_9(result);
|
||||
}
|
||||
else if (result.rss && options.defaultRSS) {
|
||||
switch(options.defaultRSS) {
|
||||
case 0.9:
|
||||
feed = this.buildRSS0_9(result);
|
||||
break;
|
||||
case 1:
|
||||
feed = this.buildRSS1(result);
|
||||
break;
|
||||
case 2:
|
||||
feed = this.buildRSS2(result);
|
||||
break;
|
||||
default:
|
||||
return reject(new Error("default RSS version not recognized."));
|
||||
}
|
||||
}
|
||||
else {
|
||||
return reject(new Error("Feed not recognized as RSS 1 or 2."));
|
||||
}
|
||||
|
||||
resolve(feed);
|
||||
});
|
||||
});
|
||||
return p;
|
||||
}
|
||||
|
||||
private static buildAtomFeed(xmlObj) {
|
||||
let feed: any = {items: []};
|
||||
|
||||
Utils.copyFromXML(xmlObj.feed, feed, this.options.customFields.feed);
|
||||
if (xmlObj.feed.link) {
|
||||
feed.link = Utils.getLink(xmlObj.feed.link, 'alternate', 0);
|
||||
feed.feedUrl = Utils.getLink(xmlObj.feed.link, 'self', 1);
|
||||
}
|
||||
|
||||
if (xmlObj.feed.title) {
|
||||
let title = xmlObj.feed.title[0] || '';
|
||||
if (title._) title = title._;
|
||||
if (title) feed.title = title;
|
||||
}
|
||||
|
||||
if (xmlObj.feed.updated) {
|
||||
feed.lastBuildDate = xmlObj.feed.updated[0];
|
||||
}
|
||||
|
||||
(xmlObj.feed.entry || []).forEach(entry => {
|
||||
let item:any = {};
|
||||
Utils.copyFromXML(entry, item, this.options.customFields.item);
|
||||
|
||||
if (entry.title) {
|
||||
let title = entry.title[0] || '';
|
||||
if (title._) {
|
||||
title = title._;
|
||||
}
|
||||
if (title) {
|
||||
item.title = title;
|
||||
}
|
||||
}
|
||||
if (entry.link && entry.link.length) {
|
||||
item.link = Utils.getLink(entry.link, 'alternate', 0);
|
||||
}
|
||||
|
||||
if (entry.published && entry.published.length && entry.published[0].length) {
|
||||
item.pubDate = new Date(entry.published[0]).toISOString();
|
||||
}
|
||||
if (!item.pubDate && entry.updated && entry.updated.length && entry.updated[0].length) {
|
||||
item.pubDate = new Date(entry.updated[0]).toISOString();
|
||||
}
|
||||
if (entry.author && entry.author.length) {
|
||||
item.author = entry.author[0].name[0];
|
||||
}
|
||||
if (entry.content && entry.content.length) {
|
||||
item.content = Utils.getContent(entry.content[0]);
|
||||
item.contentSnippet = Utils.getSnippet(item.content);
|
||||
}
|
||||
|
||||
if (entry.id) {
|
||||
item.id = entry.id[0];
|
||||
}
|
||||
|
||||
this.setISODate(item);
|
||||
|
||||
feed.items.push(item);
|
||||
});
|
||||
|
||||
return feed;
|
||||
}
|
||||
|
||||
public static buildRSS0_9(xmlObj) {
|
||||
var channel = xmlObj.rss.channel[0];
|
||||
var items = channel.item;
|
||||
return this.buildRSS(channel, items);
|
||||
}
|
||||
|
||||
public static buildRSS1(xmlObj) {
|
||||
xmlObj = xmlObj['rdf:RDF'];
|
||||
let channel = xmlObj.channel[0];
|
||||
let items = xmlObj.item;
|
||||
return this.buildRSS(channel, items);
|
||||
}
|
||||
|
||||
public static buildRSS2(xmlObj) {
|
||||
let channel:any = Array.isArray(xmlObj.rss.channel) ? xmlObj.rss.channel[0] : xmlObj.rss.channel;
|
||||
let items = channel.item;
|
||||
let feed = this.buildRSS(channel, items);
|
||||
if (xmlObj.rss.$ && xmlObj.rss.$['xmlns:itunes']) {
|
||||
this.decorateItunes(feed, channel);
|
||||
}
|
||||
return feed;
|
||||
}
|
||||
|
||||
public static buildRSS(channel, items) {
|
||||
items = items || [];
|
||||
let feed: any = {
|
||||
items: [] as Array<any>
|
||||
};
|
||||
|
||||
//set up lists of fields and items keys
|
||||
let feedFields: any = Fields.feed.concat(this.options.customFields.feed);
|
||||
let itemFields: any = Fields.item.concat(this.options.customFields.item);
|
||||
|
||||
if (channel['atom:link']) feed.feedUrl = channel['atom:link'][0].$.href;
|
||||
|
||||
//if there is an image, then get additional properties
|
||||
if (channel.image && channel.image[0] && channel.image[0].url) {
|
||||
feed.image = {};
|
||||
let image = channel.image[0];
|
||||
if (image.link) feed.image.link = image.link[0];
|
||||
if (image.url) feed.image.url = image.url[0];
|
||||
if (image.title) feed.image.title = image.title[0];
|
||||
if (image.width) feed.image.width = image.width[0];
|
||||
if (image.height) feed.image.height = image.height[0];
|
||||
}
|
||||
|
||||
Utils.copyFromXML(channel, feed, feedFields);
|
||||
|
||||
items.forEach(xmlItem => {
|
||||
let item: any = {};
|
||||
Utils.copyFromXML(xmlItem, item, itemFields);
|
||||
if (xmlItem.enclosure) {
|
||||
item.enclosure = xmlItem.enclosure[0].$;
|
||||
}
|
||||
if (xmlItem.description) {
|
||||
if (Array.isArray(xmlItem.description)) {
|
||||
item.content = Utils.getContent(xmlItem.description[0]);
|
||||
}
|
||||
else {
|
||||
item.content = Utils.getContent(xmlItem.description);
|
||||
}
|
||||
item.contentSnippet = Utils.getSnippet(item.content);
|
||||
}
|
||||
if (xmlItem.guid) {
|
||||
item.guid = Array.isArray(xmlItem.guid) ? xmlItem.guid[0] : xmlItem.guid;
|
||||
if (item.guid._) item.guid = item.guid._;
|
||||
}
|
||||
if (xmlItem.category) item.categories = xmlItem.category;
|
||||
this.setISODate(item);
|
||||
|
||||
feed.items.push(item);
|
||||
});
|
||||
|
||||
return feed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add iTunes specific fields from XML to extracted JSON
|
||||
*
|
||||
* @access public
|
||||
* @param {object} feed extracted
|
||||
* @param {object} channel parsed XML
|
||||
*/
|
||||
public static decorateItunes(feed, channel) {
|
||||
let items:any = channel.item || [];
|
||||
let entry:any = {};
|
||||
|
||||
feed.itunes = {};
|
||||
|
||||
if (channel['itunes:owner']) {
|
||||
let owner:any = {},
|
||||
image;
|
||||
|
||||
if(channel['itunes:owner'][0]['itunes:name']) {
|
||||
owner.name = channel['itunes:owner'][0]['itunes:name'][0];
|
||||
}
|
||||
if(channel['itunes:owner'][0]['itunes:email']) {
|
||||
owner.email = channel['itunes:owner'][0]['itunes:email'][0];
|
||||
}
|
||||
if(channel['itunes:image']) {
|
||||
let hasImageHref = (channel['itunes:image'][0] &&
|
||||
channel['itunes:image'][0].$ &&
|
||||
channel['itunes:image'][0].$.href);
|
||||
image = hasImageHref ? channel['itunes:image'][0].$.href : null;
|
||||
}
|
||||
|
||||
if(image) {
|
||||
feed.itunes.image = image;
|
||||
}
|
||||
feed.itunes.owner = owner;
|
||||
}
|
||||
|
||||
Utils.copyFromXML(channel, feed.itunes, Fields.podcastFeed);
|
||||
|
||||
items.forEach((item, index) => {
|
||||
entry = feed.items[index];
|
||||
entry.itunes = {};
|
||||
Utils.copyFromXML(item, entry.itunes, Fields.podcastItem);
|
||||
let image = item['itunes:image'];
|
||||
if (image && image[0] && image[0].$ && image[0].$.href) {
|
||||
entry.itunes.image = image[0].$.href;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static setISODate(item) {
|
||||
let date = item.pubDate || item.date;
|
||||
if (date) {
|
||||
try {
|
||||
item.isoDate = new Date(date.trim()).toISOString();
|
||||
} catch (e) {
|
||||
// Ignore bad date format
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
//const utils = module.exports = {};
|
||||
//const entities = require('entities');
|
||||
import { Builder } from 'xml2js';
|
||||
|
||||
export class Utils {
|
||||
public static async init() {
|
||||
}
|
||||
|
||||
public static stripHtml(str) {
|
||||
return str.replace(/<(?:.|\n)*?>/gm, '');
|
||||
}
|
||||
|
||||
public static getSnippet(str) {
|
||||
//return entities.decode(this.stripHtml(str)).trim();
|
||||
return this.stripHtml(str).trim();
|
||||
}
|
||||
|
||||
public static getLink (links, rel, fallbackIdx) {
|
||||
if (!links) return;
|
||||
for (let i = 0; i < links.length; ++i) {
|
||||
if (links[i].$.rel === rel) {
|
||||
return links[i].$.href;
|
||||
}
|
||||
}
|
||||
|
||||
if (links[fallbackIdx]) {
|
||||
return links[fallbackIdx].$.href;
|
||||
}
|
||||
}
|
||||
|
||||
public static copyFromXML(xml, dest, fields) {
|
||||
fields.forEach((f) => {
|
||||
let from = f;
|
||||
let to = f;
|
||||
let options = {};
|
||||
if (Array.isArray(f)) {
|
||||
from = f[0];
|
||||
to = f[1];
|
||||
if (f.length > 2) {
|
||||
options = f[2];
|
||||
}
|
||||
}
|
||||
const keepArray = options;
|
||||
if (xml[from] !== undefined) {
|
||||
dest[to] = keepArray ? xml[from] : xml[from][0];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static getContent(content) {
|
||||
if (typeof content._ === 'string') {
|
||||
return content._;
|
||||
}
|
||||
else if (typeof content === 'object') {
|
||||
let builder = new Builder({headless: true, explicitRoot: true, rootName: 'div', renderOpts: {pretty: false}});
|
||||
return builder.buildObject(content);
|
||||
}
|
||||
else {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
utils.maybePromisify = function(callback, promise) {
|
||||
if (!callback) return promise;
|
||||
return promise.then(
|
||||
data => setTimeout(() => callback(null, data)),
|
||||
err => setTimeout(() => callback(err))
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_ENCODING = 'utf8';
|
||||
const ENCODING_REGEX = /(encoding|charset)\s*=\s*(\S+)/;
|
||||
const SUPPORTED_ENCODINGS = ['ascii', 'utf8', 'utf16le', 'ucs2', 'base64', 'latin1', 'binary', 'hex'];
|
||||
const ENCODING_ALIASES = {
|
||||
'utf-8': 'utf8',
|
||||
'iso-8859-1': 'latin1',
|
||||
}
|
||||
|
||||
utils.getEncodingFromContentType = function(contentType) {
|
||||
contentType = contentType || '';
|
||||
let match = contentType.match(ENCODING_REGEX);
|
||||
let encoding = (match || [])[2] || '';
|
||||
encoding = encoding.toLowerCase();
|
||||
encoding = ENCODING_ALIASES[encoding] || encoding;
|
||||
if (!encoding || SUPPORTED_ENCODINGS.indexOf(encoding) === -1) {
|
||||
encoding = DEFAULT_ENCODING;
|
||||
}
|
||||
return encoding;
|
||||
}
|
||||
*/
|
|
@ -0,0 +1,3 @@
|
|||
.hoverIcon {
|
||||
color: '[theme:themeDarker, default:#0078d7]';
|
||||
}
|
|
@ -0,0 +1,334 @@
|
|||
import { html } from 'common-tags';
|
||||
import * as Handlebars from 'handlebars';
|
||||
|
||||
import 'core-js/modules/es7.array.includes.js';
|
||||
import 'core-js/modules/es6.string.includes.js';
|
||||
import 'core-js/modules/es6.number.is-nan.js';
|
||||
|
||||
import templateStyles from './BaseTemplateService.module.scss';
|
||||
|
||||
abstract class BaseTemplateService {
|
||||
private _helper = null;
|
||||
public CurrentLocale = "en";
|
||||
|
||||
constructor() {
|
||||
// Registers all helpers
|
||||
this.registerTemplateServices();
|
||||
}
|
||||
|
||||
private async LoadHandlebarsHelpers() {
|
||||
let component = await import(
|
||||
/* webpackChunkName: 'search-handlebars-helpers' */
|
||||
'handlebars-helpers'
|
||||
);
|
||||
this._helper = component({
|
||||
handlebars: Handlebars
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default Handlebars list item template used in list layout
|
||||
* @returns the template HTML markup
|
||||
*/
|
||||
public static getListDefaultTemplate(): string {
|
||||
return html`
|
||||
<div class="template_root">
|
||||
<div class="template_rss_list">
|
||||
{{#each items as |item|}}
|
||||
<div class="listItem">
|
||||
<div class="itemTitle">
|
||||
<a href="{{channel/item/link}}" target="_blank">{{channel/item/title}}</a>
|
||||
</div>
|
||||
<div class="itemDate">
|
||||
{{getDate channel/item/pubDate "MM/DD/YYYY"}}
|
||||
</div>
|
||||
<div class="itemContent">
|
||||
{{{getShortText channel/item/description 100 true}}}
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default Handlebars custom blank item template
|
||||
* @returns the template HTML markup
|
||||
*/
|
||||
public static getBlankDefaultTemplate(): string {
|
||||
return `
|
||||
<style>
|
||||
/* Insert your CSS here */
|
||||
</style>
|
||||
|
||||
<div class="template_root">
|
||||
<div class="template_rss_tileList">
|
||||
<div class="ms-Grid">
|
||||
<div class="ms-Grid-row">
|
||||
{{#each items as |item|}}
|
||||
<div class="ms-Grid-col ms-sm12 ms-md6 ms-lg6">
|
||||
<div class="singleCard" onClick="window.location = '{{channel/item/link}}'; return false;">
|
||||
<div class="ms-Grid">
|
||||
<div class="ms-Grid-row">
|
||||
<div class="ms-Grid-col ms-sm12">
|
||||
<span class="primaryText"><a href="{{channel/item/link}}">{{channel/item/title}}</a></span>
|
||||
<span class="secondaryText">{{{getShortText channel/item/description 100 true}}}</span>
|
||||
<span class="dateText">{{getDate channel/item/pubDate "MM/DD/YYYY"}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<div class="template_resultCount">
|
||||
<label class="ms-fontWeight-normal">Total items: {{returnedItemCount}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers useful helpers for search results templates
|
||||
*/
|
||||
private registerTemplateServices() {
|
||||
// Return the URL or Title part of a URL automatic managed property
|
||||
// <p>{{getUrlField MyLinkOWSURLH "Title"}}</p>
|
||||
Handlebars.registerHelper("getUrlField", (urlField: string, value: "URL" | "Title") => {
|
||||
let separatorPos = urlField.indexOf(",");
|
||||
if (value === "URL") {
|
||||
return urlField.substr(0, separatorPos);
|
||||
}
|
||||
return urlField.substr(separatorPos + 1).trim();
|
||||
});
|
||||
|
||||
// Return the formatted date according to current locale using moment.js
|
||||
// <p>{{getDate Created "LL"}}</p>
|
||||
Handlebars.registerHelper("getDate", (date: string, format: string) => {
|
||||
try {
|
||||
let d = this._helper.moment(date, format, { lang: this.CurrentLocale, datejs: false });
|
||||
return d;
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Get the first maxLength characters from a string
|
||||
// <p>{{getShortText Description 100}}</p>
|
||||
Handlebars.registerHelper("getShortText", (inputString: string, maxLength: number, ignoreHtml: boolean) => {
|
||||
if (!inputString || inputString.length < 1) {
|
||||
return "";
|
||||
}
|
||||
|
||||
//remove Html tags if necessary
|
||||
if (ignoreHtml) {
|
||||
let div = document.createElement("div");
|
||||
div.innerHTML = inputString;
|
||||
inputString = (div.textContent || div.innerText || "").replace(/\ /ig, "").trim();
|
||||
}
|
||||
|
||||
if (inputString.length < maxLength) {
|
||||
return inputString;
|
||||
}
|
||||
else {
|
||||
return inputString.substr(0, maxLength).trim() + "...";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile the specified Handlebars template with the associated context object¸
|
||||
* @returns the compiled HTML template string
|
||||
*/
|
||||
public async processTemplate(templateContext: any, templateContent: string): Promise<string> {
|
||||
// Process the Handlebars template
|
||||
const handlebarFunctionNames = [
|
||||
"getDate",
|
||||
"after",
|
||||
"arrayify",
|
||||
"before",
|
||||
"eachIndex",
|
||||
"filter",
|
||||
"first",
|
||||
"forEach",
|
||||
"inArray",
|
||||
"isArray",
|
||||
"itemAt",
|
||||
"join",
|
||||
"last",
|
||||
"lengthEqual",
|
||||
"map",
|
||||
"some",
|
||||
"sort",
|
||||
"sortBy",
|
||||
"withAfter",
|
||||
"withBefore",
|
||||
"withFirst",
|
||||
"withGroup",
|
||||
"withLast",
|
||||
"withSort",
|
||||
"embed",
|
||||
"gist",
|
||||
"jsfiddle",
|
||||
"isEmpty",
|
||||
"iterate",
|
||||
"length",
|
||||
"and",
|
||||
"compare",
|
||||
"contains",
|
||||
"gt",
|
||||
"gte",
|
||||
"has",
|
||||
"eq",
|
||||
"ifEven",
|
||||
"ifNth",
|
||||
"ifOdd",
|
||||
"is",
|
||||
"isnt",
|
||||
"lt",
|
||||
"lte",
|
||||
"neither",
|
||||
"or",
|
||||
"unlessEq",
|
||||
"unlessGt",
|
||||
"unlessLt",
|
||||
"unlessGteq",
|
||||
"unlessLteq",
|
||||
"moment",
|
||||
"fileSize",
|
||||
"read",
|
||||
"readdir",
|
||||
"css",
|
||||
"ellipsis",
|
||||
"js",
|
||||
"sanitize",
|
||||
"truncate",
|
||||
"ul",
|
||||
"ol",
|
||||
"thumbnailImage",
|
||||
"i18n",
|
||||
"inflect",
|
||||
"ordinalize",
|
||||
"info",
|
||||
"bold",
|
||||
"warn",
|
||||
"error",
|
||||
"debug",
|
||||
"_inspect",
|
||||
"markdown",
|
||||
"md",
|
||||
"mm",
|
||||
"match",
|
||||
"isMatch",
|
||||
"add",
|
||||
"subtract",
|
||||
"divide",
|
||||
"multiply",
|
||||
"floor",
|
||||
"ceil",
|
||||
"round",
|
||||
"sum",
|
||||
"avg",
|
||||
"default",
|
||||
"option",
|
||||
"noop",
|
||||
"withHash",
|
||||
"addCommas",
|
||||
"phoneNumber",
|
||||
"random",
|
||||
"toAbbr",
|
||||
"toExponential",
|
||||
"toFixed",
|
||||
"toFloat",
|
||||
"toInt",
|
||||
"toPrecision",
|
||||
"extend",
|
||||
"forIn",
|
||||
"forOwn",
|
||||
"toPath",
|
||||
"get",
|
||||
"getObject",
|
||||
"hasOwn",
|
||||
"isObject",
|
||||
"merge",
|
||||
"JSONparse",
|
||||
"parseJSON",
|
||||
"pick",
|
||||
"JSONstringify",
|
||||
"stringify",
|
||||
"absolute",
|
||||
"dirname",
|
||||
"relative",
|
||||
"basename",
|
||||
"stem",
|
||||
"extname",
|
||||
"segments",
|
||||
"camelcase",
|
||||
"capitalize",
|
||||
"capitalizeAll",
|
||||
"center",
|
||||
"chop",
|
||||
"dashcase",
|
||||
"dotcase",
|
||||
"hyphenate",
|
||||
"isString",
|
||||
"lowercase",
|
||||
"occurrences",
|
||||
"pascalcase",
|
||||
"pathcase",
|
||||
"plusify",
|
||||
"reverse",
|
||||
"replace",
|
||||
"sentence",
|
||||
"snakecase",
|
||||
"split",
|
||||
"startsWith",
|
||||
"titleize",
|
||||
"trim",
|
||||
"uppercase",
|
||||
"encodeURI",
|
||||
"decodeURI",
|
||||
"urlResolve",
|
||||
"urlParse",
|
||||
"stripQuerystring",
|
||||
"stripProtocol"
|
||||
];
|
||||
|
||||
for (let i = 0; i < handlebarFunctionNames.length; i++) {
|
||||
const element = handlebarFunctionNames[i];
|
||||
|
||||
let regEx = new RegExp("{{#?.*?" + element + ".*?}}", "m");
|
||||
if (regEx.test(templateContent)) {
|
||||
await this.LoadHandlebarsHelpers();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let template = Handlebars.compile(templateContent);
|
||||
let result = template(templateContext);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies if the template fiel path is correct
|
||||
* @param filePath the file path string
|
||||
*/
|
||||
public static isValidTemplateFile(filePath: string): boolean {
|
||||
|
||||
let path = filePath.toLowerCase().trim();
|
||||
let pathExtension = path.substring(path.lastIndexOf('.'));
|
||||
return (pathExtension == '.htm' || pathExtension == '.html');
|
||||
}
|
||||
|
||||
public abstract getFileContent(fileUrl: string): Promise<string>;
|
||||
|
||||
public abstract ensureFileResolves(fileUrl: string): Promise<void>;
|
||||
}
|
||||
|
||||
export default BaseTemplateService;
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { html } from 'common-tags';
|
||||
import BaseTemplateService from './BaseTemplateService';
|
||||
|
||||
class MockTemplateService extends BaseTemplateService {
|
||||
constructor(locale: string) {
|
||||
super();
|
||||
this.CurrentLocale = locale;
|
||||
}
|
||||
|
||||
private readonly _mockFileContent: string = html`
|
||||
<div class='template_root'>
|
||||
<div class="template_rssCard">
|
||||
<span><strong>Mocked external template</strong></span>
|
||||
|
||||
<div class="ms-Grid">
|
||||
<div class="ms-Grid-row">
|
||||
{{#each items as |item|}}
|
||||
<div class="ms-Grid-col ms-sm12 ms-md6 ms-lg6">
|
||||
<div class="rssSingleCard">
|
||||
<div class="ms-Grid">
|
||||
<div class="ms-Grid-row">
|
||||
<div class="ms-Grid-col ms-sm12 ms-md4">
|
||||
<div class="previewImg">
|
||||
<img class="cardFileIcon" src=""/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-Grid-col ms-sm12 ms-md8">
|
||||
<span class="ms-ListItem-primaryText"><a href="">Title</a></span>
|
||||
<span class="ms-ListItem-secondaryText">short description</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-Grid-row">
|
||||
<div class="ms-Grid-col ms-sm12">
|
||||
<div class="comments">Description</div>
|
||||
<div class="date">Date</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
public getFileContent(fileUrl: string): Promise<string> {
|
||||
|
||||
const p1 = new Promise<string>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(this._mockFileContent);
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
return p1;
|
||||
}
|
||||
|
||||
public ensureFileResolves(fileUrl: string): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
export default MockTemplateService;
|
|
@ -0,0 +1,60 @@
|
|||
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
|
||||
import BaseTemplateService from './BaseTemplateService';
|
||||
|
||||
class TemplateService extends BaseTemplateService {
|
||||
|
||||
private _spHttpClient: SPHttpClient;
|
||||
|
||||
constructor(spHttpClient: SPHttpClient, locale: string) {
|
||||
|
||||
super();
|
||||
this._spHttpClient = spHttpClient;
|
||||
this.CurrentLocale = locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the external file content from the specified URL
|
||||
* @param fileUrl the file URL
|
||||
*/
|
||||
public async getFileContent(fileUrl: string): Promise<string> {
|
||||
|
||||
try {
|
||||
const response: SPHttpClientResponse = await this._spHttpClient.get(fileUrl, SPHttpClient.configurations.v1);
|
||||
if(response.ok) {
|
||||
return await response.text();
|
||||
}
|
||||
else {
|
||||
throw response.statusText;
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the file is accessible trough the specified URL
|
||||
* @param filePath the file URL
|
||||
*/
|
||||
public async ensureFileResolves(fileUrl: string): Promise<void> {
|
||||
|
||||
try {
|
||||
const response: SPHttpClientResponse = await this._spHttpClient.get(fileUrl, SPHttpClient.configurations.v1);
|
||||
if(response.ok) {
|
||||
|
||||
if(response.url.indexOf('AccessDenied.aspx') > -1){
|
||||
throw 'Access Denied';
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
else {
|
||||
throw response.statusText;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TemplateService;
|
|
@ -0,0 +1,3 @@
|
|||
export { default as BaseTemplateService } from './BaseTemplateService';
|
||||
export { default as MockTemplateService } from './MockTemplateService';
|
||||
export { default as TemplateService } from './TemplateService';
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
|
||||
"id": "f489b1fd-98bf-4b41-8db0-85d5018ba484",
|
||||
"alias": "RssReaderWebPart",
|
||||
"componentType": "WebPart",
|
||||
|
||||
// The "*" signifies that the version should be taken from the package.json
|
||||
"version": "*",
|
||||
"manifestVersion": 2,
|
||||
|
||||
// If true, the component can only be installed on sites where Custom Script is allowed.
|
||||
// Components that allow authors to embed arbitrary script code should set this to true.
|
||||
// https://support.office.com/en-us/article/Turn-scripting-capabilities-on-or-off-1f2c515f-5d7e-448a-9fd7-835da935584f
|
||||
"requiresCustomScript": false,
|
||||
|
||||
"preconfiguredEntries": [{
|
||||
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Other
|
||||
"group": { "default": "Social Tools" },
|
||||
"title": { "default": "Rss Reader" },
|
||||
"description": { "default": "Basic Rss Reader utilizing FeedEK" },
|
||||
"officeFabricIconFontName": "InternetSharing",
|
||||
"properties": {
|
||||
"feedUrl": "https://techcommunity.microsoft.com/gxcuf89792/rss/board?board.id=Office365Blog",
|
||||
"feedService": 2,
|
||||
"feedServiceUrl": "",
|
||||
"feedServiceApiKey": "",
|
||||
"useCorsProxy": false,
|
||||
"corsProxyUrl": "https://cors-anywhere.herokuapp.com/{0}",
|
||||
"disableCorsMode": false,
|
||||
"maxCount": 10,
|
||||
"cacheResults": false,
|
||||
"cacheResultsMinutes": 60,
|
||||
"cacheStorageKeyPrefix": "PnPRssReader",
|
||||
"selectedLayout": 0,
|
||||
"externalTemplateUrl": "",
|
||||
"inlineTemplateText": "",
|
||||
"showDesc": true,
|
||||
"showPubDate": true,
|
||||
"descCharacterLimit": 100,
|
||||
"titleLinkTarget": "_blank",
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"backgroundColor": "#ffffff",
|
||||
"fontColor": "#4EBAFF"
|
||||
}
|
||||
}]
|
||||
|
||||
/*
|
||||
possible cors proxies, not for production
|
||||
|
||||
CORS-anywhere
|
||||
proxy: https://cors-anywhere.herokuapp.com/{0}
|
||||
source: https://github.com/Rob--W/cors-anywhere/
|
||||
|
||||
CORS io
|
||||
proxy: https://cors.io/?{0}
|
||||
|
||||
CORS Proxy
|
||||
proxy: https://corsproxy.github.io/{0}
|
||||
|
||||
CORS Proxy by HTML Driven
|
||||
proxy: <<offline>>
|
||||
source: https://github.com/htmldriven/cors-proxy/
|
||||
*/
|
||||
}
|
|
@ -0,0 +1,606 @@
|
|||
/*
|
||||
Template concept from https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-search-refiners
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import * as ReactDom from 'react-dom';
|
||||
import { Version, Text, Environment, EnvironmentType} from '@microsoft/sp-core-library';
|
||||
import {
|
||||
BaseClientSideWebPart,
|
||||
IPropertyPaneConfiguration,
|
||||
IPropertyPaneField,
|
||||
PropertyPaneTextField,
|
||||
PropertyPaneSlider,
|
||||
PropertyPaneToggle,
|
||||
IPropertyPaneChoiceGroupOption,
|
||||
PropertyPaneChoiceGroup,
|
||||
PropertyPaneHorizontalRule,
|
||||
PropertyPaneLabel
|
||||
} from '@microsoft/sp-webpart-base';
|
||||
import { update, isEmpty } from '@microsoft/sp-lodash-subset';
|
||||
|
||||
import {
|
||||
PropertyFieldColorPicker,
|
||||
PropertyFieldColorPickerStyle } from '@pnp/spfx-property-controls/lib/PropertyFieldColorPicker';
|
||||
|
||||
import * as strings from 'RssReaderWebPartStrings';
|
||||
|
||||
import { RssReader, IRssReaderProps } from './components/RssReader';
|
||||
|
||||
import { RssHttpClientService } from '../../services/RssHttpClientService';
|
||||
import {
|
||||
BaseTemplateService,
|
||||
TemplateService,
|
||||
MockTemplateService } from '../../services/TemplateService';
|
||||
import { FeedLayoutOption, FeedServiceOption } from '../../models';
|
||||
|
||||
export interface IRssReaderWebPartProps {
|
||||
title: string;
|
||||
|
||||
//feed settings
|
||||
feedUrl: string;
|
||||
feedService: FeedServiceOption; //fetch, feed2json (service url) - not for production, rss2json (possible api key)
|
||||
feedServiceUrl: string; //used by feed2json - https://github.com/chilts/feed2json
|
||||
feedServiceApiKey: string; //used by rss2json
|
||||
|
||||
disableCorsMode: boolean;
|
||||
useCorsProxy: boolean;
|
||||
corsProxyUrl: string; //possible dev testing suggestions: https://github.com/Rob--W/cors-anywhere/, https://cors.io/, https://corsproxy.github.io/, https://github.com/htmldriven/cors-proxy/
|
||||
|
||||
maxCount: number;
|
||||
|
||||
//caching / local storage
|
||||
cacheResults: boolean;
|
||||
cacheResultsMinutes: number;
|
||||
cacheStorageKeyPrefix: string;
|
||||
|
||||
feedLoadingLabel: string;
|
||||
|
||||
//rendering / layout
|
||||
selectedLayout: FeedLayoutOption;
|
||||
externalTemplateUrl: string;
|
||||
inlineTemplateText: string;
|
||||
|
||||
//default layout settings
|
||||
feedViewAllLink: string;
|
||||
feedViewAllLinkLabel: string;
|
||||
showDesc: boolean;
|
||||
showPubDate: boolean;
|
||||
descCharacterLimit: number;
|
||||
titleLinkTarget: string;
|
||||
dateFormat: string;
|
||||
dateFormatLang: string;
|
||||
backgroundColor: string;
|
||||
fontColor: string;
|
||||
}
|
||||
|
||||
export default class RssReaderWebPart extends BaseClientSideWebPart<IRssReaderWebPartProps> {
|
||||
private _templateService: BaseTemplateService;
|
||||
private _propertyPage = null;
|
||||
|
||||
/**
|
||||
* The template to display at render time
|
||||
*/
|
||||
private _templateContentToDisplay: string;
|
||||
|
||||
public onInit(): Promise<void> {
|
||||
|
||||
//Initialize a redux store that uses our custom Reducer & state
|
||||
RssHttpClientService.init(this.context);
|
||||
|
||||
//set required properties to enforce certainer parameters
|
||||
this.initializeRequiredProperties();
|
||||
|
||||
if (Environment.type === EnvironmentType.Local) {
|
||||
this._templateService = new MockTemplateService(this.context.pageContext.cultureInfo.currentUICultureName);
|
||||
}
|
||||
else {
|
||||
this._templateService = new TemplateService(this.context.spHttpClient, this.context.pageContext.cultureInfo.currentUICultureName);
|
||||
}
|
||||
|
||||
return super.onInit().then();
|
||||
}
|
||||
|
||||
public async render(): Promise<void> {
|
||||
// Determine the template content to display
|
||||
// In the case of an external template is selected, the render is done asynchronously waiting for the content to be fetched
|
||||
await this._getTemplateContent();
|
||||
|
||||
const element: React.ReactElement<IRssReaderProps > = React.createElement(
|
||||
RssReader,
|
||||
{
|
||||
feedUrl: this.properties.feedUrl,
|
||||
feedService: this.properties.feedService,
|
||||
feedServiceUrl: this.properties.feedServiceUrl,
|
||||
feedServiceApiKey: this.properties.feedServiceApiKey,
|
||||
|
||||
useCorsProxy: this.properties.useCorsProxy,
|
||||
corsProxyUrl: this.properties.corsProxyUrl,
|
||||
disableCorsMode: this.properties.disableCorsMode,
|
||||
|
||||
maxCount: this.properties.maxCount,
|
||||
|
||||
cacheResults: this.properties.cacheResults,
|
||||
cacheResultsMinutes: this.properties.cacheResultsMinutes,
|
||||
cacheStorageKeyPrefix: this.properties.cacheStorageKeyPrefix,
|
||||
|
||||
feedLoadingLabel: this.properties.feedLoadingLabel,
|
||||
|
||||
selectedLayout: this.properties.selectedLayout,
|
||||
externalTemplateUrl: this.properties.externalTemplateUrl,
|
||||
inlineTemplateText: this.properties.inlineTemplateText,
|
||||
|
||||
feedViewAllLink: this.properties.feedViewAllLink,
|
||||
feedViewAllLinkLabel: this.properties.feedViewAllLinkLabel,
|
||||
|
||||
showDesc: this.properties.showDesc,
|
||||
showPubDate: this.properties.showPubDate,
|
||||
descCharacterLimit: this.properties.descCharacterLimit,
|
||||
titleLinkTarget: this.properties.titleLinkTarget,
|
||||
dateFormat: this.properties.dateFormat,
|
||||
|
||||
backgroundColor: this.properties.backgroundColor,
|
||||
fontColor: this.properties.fontColor,
|
||||
|
||||
propertyPane: this.context.propertyPane,
|
||||
|
||||
title: this.properties.title,
|
||||
displayMode: this.displayMode,
|
||||
|
||||
templateService: this._templateService,
|
||||
templateContent: this._templateContentToDisplay,
|
||||
|
||||
updateProperty: (value: string) => {
|
||||
this.properties.title = value;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ReactDom.render(element, this.domElement);
|
||||
}
|
||||
|
||||
protected onDispose(): void {
|
||||
ReactDom.unmountComponentAtNode(this.domElement);
|
||||
}
|
||||
|
||||
//return false if property changes should occur upon change, not upon apply
|
||||
protected get disableReactivePropertyChanges(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected get dataVersion(): Version {
|
||||
return Version.parse('1.0');
|
||||
}
|
||||
|
||||
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
|
||||
return {
|
||||
pages: [
|
||||
{
|
||||
header: {
|
||||
description: strings.FeedSettingsPageName
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
groupName: strings.FeedSettingsGroupLabel,
|
||||
groupFields: this._getFeedSettingsFields()
|
||||
},
|
||||
{
|
||||
groupName: strings.CorsSettingsGroupLabel,
|
||||
isCollapsed: true,
|
||||
groupFields: this._getCorsSettingsFields()
|
||||
}
|
||||
],
|
||||
displayGroupsAsAccordion: true
|
||||
},
|
||||
{
|
||||
header: {
|
||||
description: strings.LayoutSettingsPageName
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
groupFields: this._getLayoutSettingsFields()
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
protected async onPropertyPaneFieldChanged(propertyPath: string) {
|
||||
|
||||
if (propertyPath === 'selectedLayout') {
|
||||
|
||||
// Refresh setting the right template for the property pane
|
||||
await this._getTemplateContent();
|
||||
|
||||
}
|
||||
|
||||
// Detect if the layout has been changed to custom...
|
||||
if (propertyPath === 'inlineTemplateText') {
|
||||
|
||||
// Automatically switch the option to 'Custom' if a default template has been edited
|
||||
// (meaning the user started from a the list or tiles template)
|
||||
if (this.properties.inlineTemplateText && this.properties.selectedLayout !== FeedLayoutOption.Custom) {
|
||||
|
||||
this.properties.selectedLayout = FeedLayoutOption.Custom;
|
||||
|
||||
// Reset also the template URL
|
||||
this.properties.externalTemplateUrl = '';
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the Web Part required properties if there are not present in the manifest (i.e. during an update scenario)
|
||||
*/
|
||||
private initializeRequiredProperties() {
|
||||
|
||||
//require an initial feed service
|
||||
this.properties.feedService = this.properties.feedService ? this.properties.feedService : FeedServiceOption.Rss2Json;
|
||||
|
||||
this.properties.useCorsProxy = this.properties.useCorsProxy ? true : false;
|
||||
this.properties.corsProxyUrl = this.properties.corsProxyUrl ? this.properties.corsProxyUrl : "";
|
||||
this.properties.disableCorsMode = this.properties.disableCorsMode ? true : false;
|
||||
|
||||
this.properties.maxCount = this.properties.maxCount ? this.properties.maxCount : 10;
|
||||
|
||||
this.properties.cacheResults = this.properties.cacheResults ? this.properties.cacheResults : false;
|
||||
this.properties.cacheResultsMinutes = this.properties.cacheResultsMinutes ? this.properties.cacheResultsMinutes : 60;
|
||||
|
||||
// Set the default search results layout
|
||||
this.properties.selectedLayout = this.properties.selectedLayout ? this.properties.selectedLayout : FeedLayoutOption.Default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom handler when a custom property pane field is updated
|
||||
* @param propertyPath the name of the updated property
|
||||
* @param newValue the new value for this property
|
||||
*/
|
||||
private async _onCustomPropertyPaneChange(propertyPath: string, newValue: any): Promise<void> {
|
||||
|
||||
// Stores the new value in web part properties
|
||||
update(this.properties, propertyPath, (): any => { return newValue; });
|
||||
|
||||
// Call the default SPFx handler
|
||||
this.onPropertyPaneFieldChanged(propertyPath);
|
||||
|
||||
// Refresh setting the right template for the property pane
|
||||
await this._getTemplateContent();
|
||||
|
||||
// Refreshes the web part manually because custom fields don't update since sp-webpart-base@1.1.1
|
||||
// https://github.com/SharePoint/sp-dev-docs/issues/594
|
||||
if (!this.disableReactivePropertyChanges) {
|
||||
// The render has to be completed before the property pane to refresh to set up the correct property value
|
||||
// so the property pane field will use the correct value for future edit
|
||||
this.render();
|
||||
this.context.propertyPane.refresh();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//concept from https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-search-refiners
|
||||
protected async loadPropertyPaneResources(): Promise<void> {
|
||||
this._propertyPage = await import(
|
||||
/* webpackChunkName: 'search-property-pane' */
|
||||
'../../controls/PropertyPaneTextDialog/PropertyPaneTextDialog'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1: Set general feed properties including feed location, method to retrieve feed, and caching (local storage)
|
||||
*/
|
||||
private _getFeedSettingsFields(): IPropertyPaneField<any>[] {
|
||||
// Options for the feed service options
|
||||
const feedServiceOptions = [
|
||||
{
|
||||
text: strings.DefaultFeedServiceOption,
|
||||
key: FeedServiceOption.Default,
|
||||
checked: this.properties.feedService === FeedServiceOption.Default || !this.properties.feedService
|
||||
},
|
||||
{
|
||||
text: strings.Feed2JsonFeedServiceOption,
|
||||
key: FeedServiceOption.Feed2Json,
|
||||
checked: this.properties.feedService === FeedServiceOption.Feed2Json
|
||||
},
|
||||
{
|
||||
text: strings.Rss2JsonFeedServiceOption,
|
||||
key: FeedServiceOption.Rss2Json,
|
||||
checked: this.properties.feedService === FeedServiceOption.Rss2Json
|
||||
}
|
||||
] as IPropertyPaneChoiceGroupOption[];
|
||||
|
||||
|
||||
// Sets up styling fields
|
||||
let feedFields: IPropertyPaneField<any>[] = [
|
||||
PropertyPaneTextField('feedUrl', {
|
||||
label: strings.FeedUrlLabel
|
||||
}),
|
||||
PropertyPaneChoiceGroup('feedService', {
|
||||
label: strings.FeedServiceLabel,
|
||||
options: feedServiceOptions
|
||||
})
|
||||
];
|
||||
|
||||
if (this.properties.feedService == FeedServiceOption.Feed2Json) {
|
||||
feedFields.push(PropertyPaneTextField('feedServiceUrl', {
|
||||
label: strings.FeedServiceUrlLabel,
|
||||
description: strings.FeedServiceUrlDescription
|
||||
}));
|
||||
}
|
||||
|
||||
if (this.properties.feedService == FeedServiceOption.Rss2Json) {
|
||||
feedFields.push(PropertyPaneTextField('feedServiceApiKey', {
|
||||
label: strings.FeedServiceApiKeyLabel,
|
||||
description: strings.FeedServiceApiKeyDescription
|
||||
}));
|
||||
}
|
||||
|
||||
feedFields.push(PropertyPaneHorizontalRule());
|
||||
|
||||
feedFields.push(PropertyPaneSlider('maxCount', {
|
||||
label: strings.MaxCountLabel,
|
||||
min: 1,
|
||||
max: 100,
|
||||
step: 1
|
||||
}));
|
||||
|
||||
feedFields.push(PropertyPaneHorizontalRule());
|
||||
|
||||
feedFields.push(PropertyPaneToggle('cacheResults', {
|
||||
label: strings.CacheResultsLabel,
|
||||
checked: this.properties.cacheResults,
|
||||
}));
|
||||
|
||||
// if we want to include a search box, more parameters required
|
||||
if (this.properties.cacheResults) {
|
||||
feedFields.push(PropertyPaneSlider('cacheResultsMinutes', {
|
||||
label: strings.CacheResultsMinutesLabel,
|
||||
max: 1440,
|
||||
min: 5,
|
||||
showValue: true,
|
||||
step: 5,
|
||||
value: this.properties.cacheResultsMinutes,
|
||||
}));
|
||||
|
||||
feedFields.push(PropertyPaneTextField('cacheStorageKeyPrefix', {
|
||||
label: strings.CacheStorageKeyPrefixLabel,
|
||||
description: strings.CacheStorageKeyPrefixDescription
|
||||
}));
|
||||
}
|
||||
|
||||
feedFields.push(PropertyPaneHorizontalRule());
|
||||
|
||||
feedFields.push(PropertyPaneTextField('feedLoadingLabel', {
|
||||
label: strings.FeedLoadingLabel,
|
||||
placeholder: strings.DefaultFeedLoadingLabel
|
||||
}));
|
||||
|
||||
return feedFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1B: Set feed cors settings
|
||||
*/
|
||||
private _getCorsSettingsFields(): IPropertyPaneField<any>[] {
|
||||
// Sets up styling fields
|
||||
let feedFields: IPropertyPaneField<any>[] = [
|
||||
PropertyPaneToggle('useCorsProxy', {
|
||||
label: strings.UseCorsProxyLabel,
|
||||
checked: this.properties.useCorsProxy,
|
||||
})
|
||||
];
|
||||
|
||||
if (this.properties.useCorsProxy) {
|
||||
feedFields.push(PropertyPaneTextField('corsProxyUrl', {
|
||||
label: strings.CorsProxyUrlLabel,
|
||||
description: strings.CorsProxyUrlDescription
|
||||
}));
|
||||
}
|
||||
else {
|
||||
feedFields.push(PropertyPaneToggle('disableCorsMode', {
|
||||
label: strings.DisableCorsModeLabel,
|
||||
checked: this.properties.disableCorsMode,
|
||||
}));
|
||||
feedFields.push(PropertyPaneLabel('disableCorsMode', {
|
||||
text: this.properties.disableCorsMode ? strings.DisableCorsModeSelectedDescription : strings.DisableCorsModeDescription
|
||||
}));
|
||||
}
|
||||
|
||||
return feedFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2: Set feed layout settings
|
||||
*/
|
||||
private _getLayoutSettingsFields(): IPropertyPaneField<any>[] {
|
||||
// Options for the search results layout
|
||||
const layoutOptions = [
|
||||
{
|
||||
iconProps: {
|
||||
officeFabricIconFontName: 'List'
|
||||
},
|
||||
text: strings.DefaultFeedLayoutOption,
|
||||
key: FeedLayoutOption.Default,
|
||||
checked: this.properties.selectedLayout === FeedLayoutOption.Default
|
||||
},
|
||||
{
|
||||
iconProps: {
|
||||
officeFabricIconFontName: 'Code'
|
||||
},
|
||||
text: strings.CustomFeedLayoutOption,
|
||||
key: FeedLayoutOption.Custom,
|
||||
checked: this.properties.selectedLayout === FeedLayoutOption.Custom
|
||||
}
|
||||
] as IPropertyPaneChoiceGroupOption[];
|
||||
|
||||
const canEditTemplate = this.properties.externalTemplateUrl && this.properties.selectedLayout === FeedLayoutOption.Custom ? false : true;
|
||||
|
||||
// Sets up styling fields
|
||||
let layoutFields: IPropertyPaneField<any>[] = [
|
||||
PropertyPaneChoiceGroup('selectedLayout', {
|
||||
label: strings.SelectedLayoutLabel,
|
||||
options: layoutOptions
|
||||
})
|
||||
];
|
||||
|
||||
if (this.properties.selectedLayout === FeedLayoutOption.Custom) {
|
||||
layoutFields.push(
|
||||
new this._propertyPage.PropertyPaneTextDialog('inlineTemplateText', {
|
||||
dialogTextFieldValue: this._templateContentToDisplay,
|
||||
onPropertyChange: this._onCustomPropertyPaneChange.bind(this),
|
||||
disabled: !canEditTemplate,
|
||||
strings: {
|
||||
cancelButtonText: strings.CancelButtonText,
|
||||
dialogButtonLabel: strings.DialogButtonLabel,
|
||||
dialogButtonText: strings.DialogButtonText,
|
||||
dialogTitle: strings.DialogTitle,
|
||||
saveButtonText: strings.SaveButtonText
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
layoutFields.push(PropertyPaneTextField('externalTemplateUrl', {
|
||||
label: strings.TemplateUrlLabel,
|
||||
placeholder: strings.TemplateUrlPlaceholder,
|
||||
deferredValidationTime: 500,
|
||||
onGetErrorMessage: this._onTemplateUrlChange.bind(this)
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
//default layout
|
||||
if (this.properties.selectedLayout === FeedLayoutOption.Default) {
|
||||
layoutFields.push(PropertyPaneHorizontalRule());
|
||||
|
||||
layoutFields.push(PropertyPaneTextField('feedViewAllLink', {
|
||||
label: strings.FeedViewAllLinkLabel,
|
||||
placeholder: strings.FeedViewAllLinkPlaceholder
|
||||
}));
|
||||
|
||||
layoutFields.push(PropertyPaneTextField('feedViewAllLinkLabel', {
|
||||
label: strings.FeedViewAllLinkLabelLabel,
|
||||
placeholder: strings.DefaultFeedViewAllLinkLabel
|
||||
}));
|
||||
|
||||
|
||||
layoutFields.push(PropertyPaneToggle('showPubDate', {
|
||||
label: strings.ShowPubDateLabel
|
||||
}));
|
||||
layoutFields.push(PropertyPaneToggle('showDesc', {
|
||||
label: strings.ShowDescLabel
|
||||
}));
|
||||
layoutFields.push(PropertyPaneSlider('descCharacterLimit', {
|
||||
label: strings.DescCharacterLimitLabel,
|
||||
min: 1,
|
||||
max: 500,
|
||||
step: 1
|
||||
}));
|
||||
layoutFields.push(PropertyPaneTextField('titleLinkTarget', {
|
||||
label: strings.TitleLinkTargetLabel
|
||||
}));
|
||||
layoutFields.push(PropertyPaneTextField('dateFormat', {
|
||||
label: strings.DateFormatLabel
|
||||
}));
|
||||
|
||||
layoutFields.push(PropertyPaneHorizontalRule());
|
||||
|
||||
layoutFields.push(PropertyFieldColorPicker('fontColor', {
|
||||
label: strings.FontColorLabel,
|
||||
selectedColor: this.properties.fontColor,
|
||||
onPropertyChange: this.onPropertyPaneFieldChanged,
|
||||
properties: this.properties,
|
||||
disabled: false,
|
||||
alphaSliderHidden: false,
|
||||
style: PropertyFieldColorPickerStyle.Full,
|
||||
iconName: 'Precipitation',
|
||||
key: 'rssReaderFontColorField'
|
||||
}));
|
||||
layoutFields.push(PropertyFieldColorPicker('backgroundColor', {
|
||||
label: strings.BackgroundColorLabel,
|
||||
selectedColor: this.properties.backgroundColor,
|
||||
onPropertyChange: this.onPropertyPaneFieldChanged,
|
||||
properties: this.properties,
|
||||
disabled: false,
|
||||
alphaSliderHidden: false,
|
||||
style: PropertyFieldColorPickerStyle.Full,
|
||||
iconName: 'Precipitation',
|
||||
key: 'rssReaderBgColorField'
|
||||
}));
|
||||
|
||||
/*
|
||||
dateFormatLang: string;
|
||||
*/
|
||||
}
|
||||
|
||||
return layoutFields;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Custom handler when the external template file URL
|
||||
* from https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-search-refiners
|
||||
* @param value the template file URL value
|
||||
*/
|
||||
private async _onTemplateUrlChange(value: string): Promise<String> {
|
||||
|
||||
try {
|
||||
|
||||
if (isEmpty(value)) {
|
||||
|
||||
// Doesn't raise any error if file is empty (otherwise error message will show on initial load...)
|
||||
return '';
|
||||
|
||||
}
|
||||
else if (!TemplateService.isValidTemplateFile(value)) {
|
||||
|
||||
// Resolves an error if the file isn't a valid .htm or .html file
|
||||
return strings.ErrorTemplateExtension;
|
||||
|
||||
}
|
||||
else {
|
||||
|
||||
console.log("attempt to resolve");
|
||||
// Resolves an error if the file doesn't answer a simple head request
|
||||
await this._templateService.ensureFileResolves(value);
|
||||
return '';
|
||||
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
return Text.format(strings.ErrorTemplateResolve, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the correct results template content according to the property pane current configuration
|
||||
* @returns the template content as a string
|
||||
*/
|
||||
private async _getTemplateContent(): Promise<void> {
|
||||
|
||||
let templateContent = null;
|
||||
|
||||
switch (this.properties.selectedLayout) {
|
||||
case FeedLayoutOption.Default:
|
||||
|
||||
templateContent = TemplateService.getListDefaultTemplate();
|
||||
break;
|
||||
|
||||
case FeedLayoutOption.Custom:
|
||||
|
||||
if (this.properties.externalTemplateUrl) {
|
||||
templateContent = await this._templateService.getFileContent(this.properties.externalTemplateUrl);
|
||||
} else {
|
||||
templateContent = this.properties.inlineTemplateText ? this.properties.inlineTemplateText : TemplateService.getBlankDefaultTemplate();
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
this._templateContentToDisplay = templateContent;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { IRssResult } from '../../../../models/IRssReaderResponse';
|
||||
|
||||
|
||||
/**
|
||||
* Handlebars template context for search results
|
||||
*/
|
||||
export interface IRssResultsTemplateContext {
|
||||
items: IRssResult[];
|
||||
strings: IRssReaderWebPartStrings;
|
||||
totalItemCount: number;
|
||||
returnedItemCount: number;
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import { IRssResultsTemplateContext } from './';
|
||||
import { BaseTemplateService } from '../../../../services/TemplateService';
|
||||
|
||||
export interface IRssResultsTemplateProps {
|
||||
|
||||
/**
|
||||
* The template helper instance
|
||||
*/
|
||||
templateService: BaseTemplateService;
|
||||
|
||||
/**
|
||||
* The template context
|
||||
*/
|
||||
templateContext: IRssResultsTemplateContext;
|
||||
|
||||
/**
|
||||
* The Handlebars raw template content for a single item
|
||||
*/
|
||||
templateContent: string;
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
export interface IRssResultsTemplateState {
|
||||
|
||||
/**
|
||||
* The handlebar compiled template
|
||||
*/
|
||||
processedTemplate: string;
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
.templateRoot{
|
||||
display: initial;
|
||||
}
|
||||
|
||||
:global {
|
||||
.template_root {
|
||||
/*include fabric here to surpress warnings*/
|
||||
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/FabricCore.scss';
|
||||
|
||||
@import '~office-ui-fabric/dist/sass/Fabric.scss';
|
||||
@import '~office-ui-fabric/dist/components/Label/Label.scss';
|
||||
@import '~office-ui-fabric/dist/components/List/List.scss';
|
||||
@import '~office-ui-fabric/dist/components/ListItem/ListItem.scss';
|
||||
|
||||
.template_rss_tileList {
|
||||
.singleCard {
|
||||
margin: 0px 0px 15px;
|
||||
padding: 15px;
|
||||
border: 1px solid $ms-color-neutralLighterAlt;
|
||||
|
||||
&:hover {
|
||||
border-color: $ms-color-neutralLight;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.primaryText {
|
||||
display: block;
|
||||
margin: 0px 0px 10px;
|
||||
padding: 0px;
|
||||
top: 0px;
|
||||
color: "[theme: themePrimary, default: #005a9e]";
|
||||
font-size: 15px;
|
||||
line-height: 1.4em;
|
||||
height: 1.4em;
|
||||
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
outline: transparent;
|
||||
|
||||
A {
|
||||
color: $ms-color-themeSecondary;
|
||||
}
|
||||
}
|
||||
|
||||
.secondaryText {
|
||||
display: block;
|
||||
font-size: 1em;
|
||||
line-height: 1.4em;
|
||||
height: 2.8em;
|
||||
top: 0px;
|
||||
padding: 0px;
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dateText {
|
||||
display: block;
|
||||
padding: 10px 15px 5px;
|
||||
margin: 10px -15px 0px;
|
||||
border-top: 1px solid $ms-color-neutralLighterAlt;
|
||||
font-size: 13px;
|
||||
line-height: 1.4em;
|
||||
}
|
||||
}
|
||||
|
||||
.template_rss_list {
|
||||
width:100%;
|
||||
list-style:none outside none;
|
||||
border:0px solid $ms-color-neutralLighterAlt;
|
||||
padding:4px 6px;
|
||||
color:$ms-color-black;
|
||||
|
||||
.listItem {
|
||||
display: block;
|
||||
border-bottom: 1px solid $ms-color-neutralLighterAlt;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.itemTitle {
|
||||
|
||||
A {
|
||||
color: $ms-color-themePrimary;
|
||||
text-decoration:none
|
||||
|
||||
&:hover {
|
||||
text-decoration:underline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.itemDate {
|
||||
font-size:11px;
|
||||
color: $ms-color-neutralDark;
|
||||
}
|
||||
|
||||
.itemContent {
|
||||
display: block;
|
||||
margin: 1em 0;
|
||||
}
|
||||
}
|
||||
|
||||
.template_resultCount {
|
||||
padding-left: 0px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
IRssResultsTemplateProps,
|
||||
IRssResultsTemplateState
|
||||
} from './';
|
||||
|
||||
import styles from './RssResultsTemplate.module.scss';
|
||||
|
||||
import { DomHelper } from '../../../../helpers/DomHelper';
|
||||
|
||||
export default class RssResultsTemplate extends React.Component<IRssResultsTemplateProps, IRssResultsTemplateState> {
|
||||
|
||||
private parentRef: HTMLElement;
|
||||
|
||||
constructor(props: IRssResultsTemplateProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
processedTemplate: null
|
||||
};
|
||||
}
|
||||
|
||||
public render() {
|
||||
const objectNode: any = document.querySelector("object[data='about:blank']");
|
||||
|
||||
if (objectNode) {
|
||||
objectNode.style.display = "none";
|
||||
}
|
||||
return <div className={styles.templateRoot} ref={el => this.parentRef = el}>
|
||||
<div dangerouslySetInnerHTML={{ __html: this.state.processedTemplate }}></div>
|
||||
</div>;
|
||||
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
|
||||
this._updateTemplate(this.props);
|
||||
|
||||
}
|
||||
|
||||
public componentWillReceiveProps(nextProps: IRssResultsTemplateProps) {
|
||||
|
||||
this._updateTemplate(nextProps);
|
||||
|
||||
}
|
||||
|
||||
private async _updateTemplate(props: IRssResultsTemplateProps): Promise<void> {
|
||||
|
||||
let templateContent = props.templateContent;
|
||||
|
||||
// Process the Handlebars template
|
||||
const template = await this.props.templateService.processTemplate(props.templateContext, templateContent);
|
||||
|
||||
this.setState({
|
||||
processedTemplate: template
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
<div class="template_root">
|
||||
<div class="template_rss_list">
|
||||
{{#each items as |item|}}
|
||||
<div class="listItem">
|
||||
<div class="itemTitle">
|
||||
<a href="{{channel/item/link}}" target="_blank">{{channel/item/title}}</a>
|
||||
</div>
|
||||
<div class="itemDate">
|
||||
{{getDate channel/item/pubDate "MM/DD/YYYY"}}
|
||||
</div>
|
||||
<div class="itemContent">
|
||||
{{{getShortText channel/item/description 100 true}}}
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,27 @@
|
|||
<div class="template_root">
|
||||
<div class="template_rss_tileList">
|
||||
<div class="ms-Grid">
|
||||
<div class="ms-Grid-row">
|
||||
{{#each items as |item|}}
|
||||
<div class="ms-Grid-col ms-sm12 ms-md6 ms-lg6">
|
||||
<div class="singleCard" onClick="window.location = '{{channel/item/link}}'; return false;">
|
||||
<div class="ms-Grid">
|
||||
<div class="ms-Grid-row">
|
||||
<div class="ms-Grid-col ms-sm12">
|
||||
<span class="primaryText"><a href="{{channel/item/link}}">{{channel/item/title}}</a></span>
|
||||
<span class="secondaryText">{{{getShortText channel/item/description 100 true}}}</span>
|
||||
<span class="dateText">{{getDate channel/item/pubDate "MM/DD/YYYY"}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<div class="template_resultCount">
|
||||
<label class="ms-fontWeight-normal">Total items: {{returnedItemCount}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,4 @@
|
|||
export * from './IRssResultsTemplateContext';
|
||||
export * from './IRssResultsTemplateProps';
|
||||
export * from './IRssResultsTemplateState';
|
||||
export { default as RssResultsTemplate } from './RssResultsTemplate';
|
|
@ -0,0 +1,58 @@
|
|||
import { IPropertyPaneAccessor } from '@microsoft/sp-webpart-base';
|
||||
import { DisplayMode } from '@microsoft/sp-core-library';
|
||||
|
||||
import { FeedServiceOption, FeedLayoutOption } from '../../../../models';
|
||||
|
||||
import { BaseTemplateService } from '../../../../services/TemplateService';
|
||||
|
||||
export interface IRssReaderProps {
|
||||
feedUrl: string;
|
||||
feedService: FeedServiceOption;
|
||||
feedServiceUrl?: string;
|
||||
feedServiceApiKey?: string;
|
||||
useCorsProxy?: boolean;
|
||||
corsProxyUrl?: string;
|
||||
disableCorsMode?: boolean;
|
||||
maxCount: number;
|
||||
|
||||
cacheResults?: boolean;
|
||||
cacheResultsMinutes?: number;
|
||||
cacheStorageKeyPrefix?: string;
|
||||
|
||||
feedLoadingLabel?: string;
|
||||
|
||||
//rendering / layout
|
||||
selectedLayout: FeedLayoutOption;
|
||||
externalTemplateUrl?: string;
|
||||
inlineTemplateText?: string;
|
||||
|
||||
feedViewAllLink: string;
|
||||
feedViewAllLinkLabel?: string;
|
||||
|
||||
showDesc: boolean;
|
||||
showPubDate: boolean;
|
||||
descCharacterLimit: number;
|
||||
titleLinkTarget: string;
|
||||
dateFormat: string;
|
||||
//dateFormatLang: string;
|
||||
|
||||
backgroundColor: string;
|
||||
fontColor: string;
|
||||
|
||||
propertyPane?: IPropertyPaneAccessor;
|
||||
|
||||
title: string;
|
||||
displayMode: DisplayMode;
|
||||
|
||||
/**
|
||||
* The template helper instance
|
||||
*/
|
||||
templateService: BaseTemplateService;
|
||||
|
||||
/**
|
||||
* The template raw content to display
|
||||
*/
|
||||
templateContent: string;
|
||||
|
||||
updateProperty: (value: string) => void;
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import { IRssReaderResponse } from '../../../../models';
|
||||
|
||||
export interface IRssReaderState {
|
||||
rssFeedReady: boolean;
|
||||
rssFeed: IRssReaderResponse;
|
||||
rssFeedError: string;
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
|
||||
|
||||
.rssReader {
|
||||
.rssReaderHeader {
|
||||
position: relative;
|
||||
|
||||
.rssReaderListViewAll {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 0px;
|
||||
|
||||
padding: 0px;
|
||||
|
||||
A {
|
||||
color: $ms-color-themePrimary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rssReaderList {
|
||||
width: 100%;
|
||||
list-style: none outside none;
|
||||
border: 0px solid $ms-color-neutralLighterAlt;
|
||||
padding: 0px;
|
||||
color: $ms-color-black;
|
||||
|
||||
&.rssReaderListPadding {
|
||||
padding: 10px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
:global {
|
||||
.ms-List-cell {
|
||||
display: block;
|
||||
border-bottom:1px solid $ms-color-neutralLighterAlt;
|
||||
padding: 5px 0px;
|
||||
|
||||
&:last-child{
|
||||
border-bottom:none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.rssReaderListItem {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.itemTitle {
|
||||
|
||||
A {
|
||||
color: $ms-color-themePrimary;
|
||||
text-decoration:none;
|
||||
|
||||
&:hover {
|
||||
text-decoration:underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.itemDate {
|
||||
font-size:11px;
|
||||
color:$ms-color-neutralDark;
|
||||
}
|
||||
|
||||
.itemContent {
|
||||
display: block;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.messageError {
|
||||
text-align: center;
|
||||
}
|
||||
.messageErrorIcon {
|
||||
font-size: 14px;
|
||||
vertical-align: middle;
|
||||
color: $ms-color-themeDark;
|
||||
}
|
||||
.messageErrorLabel {
|
||||
font-size: 14px;
|
||||
display: inline-block;
|
||||
padding: 0px 0px 0px 10px;
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
color: $ms-color-themeDark;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,303 @@
|
|||
import * as React from 'react';
|
||||
import Moment from 'react-moment';
|
||||
|
||||
import { WebPartTitle } from '@pnp/spfx-controls-react/lib/WebPartTitle';
|
||||
import { Placeholder } from '@pnp/spfx-controls-react/lib/Placeholder';
|
||||
import { List } from 'office-ui-fabric-react/lib/List';
|
||||
import { Link } from 'office-ui-fabric-react/lib/Link';
|
||||
import { Label } from 'office-ui-fabric-react/lib/Label';
|
||||
import { Icon } from 'office-ui-fabric-react/lib/Icon';
|
||||
import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner';
|
||||
import { autobind } from 'office-ui-fabric-react/lib/Utilities';
|
||||
|
||||
import * as strings from 'RssReaderWebPartStrings';
|
||||
import styles from './RssReader.module.scss';
|
||||
|
||||
import { IRssReaderProps, IRssReaderState } from './';
|
||||
|
||||
import { RssResultsTemplate } from '../Layouts';
|
||||
import { RssReaderService, IRssReaderService } from '../../../../services/RssReaderService';
|
||||
import {
|
||||
IRssReaderResponse,
|
||||
IRssResult,
|
||||
IRssItem,
|
||||
IRssReaderRequest,
|
||||
FeedLayoutOption } from '../../../../models';
|
||||
|
||||
export default class RssReader extends React.Component<IRssReaderProps, IRssReaderState> {
|
||||
|
||||
private viewAllLinkLabel: string = strings.DefaultFeedViewAllLinkLabel;
|
||||
private feedLoadingLabel: string = strings.DefaultFeedLoadingLabel;
|
||||
|
||||
constructor(props:IRssReaderProps) {
|
||||
super(props);
|
||||
|
||||
//override default label values if needed
|
||||
if (this.props.feedViewAllLinkLabel && this.props.feedViewAllLinkLabel.length > 0) {
|
||||
this.viewAllLinkLabel = this.props.feedViewAllLinkLabel;
|
||||
}
|
||||
if (this.props.feedLoadingLabel && this.props.feedLoadingLabel.length > 0) {
|
||||
this.feedLoadingLabel = this.props.feedLoadingLabel;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
rssFeedReady: false,
|
||||
rssFeed: {} as IRssReaderResponse,
|
||||
rssFeedError: ''
|
||||
};
|
||||
|
||||
//load the rss feed
|
||||
this.loadRssFeed();
|
||||
}
|
||||
|
||||
public render(): React.ReactElement<IRssReaderProps> {
|
||||
return (
|
||||
<div className={ styles.rssReader }>
|
||||
<div className={styles.rssReaderHeader}>
|
||||
<WebPartTitle displayMode={this.props.displayMode}
|
||||
title={this.props.title}
|
||||
updateProperty={this.props.updateProperty} />
|
||||
|
||||
{this.state.rssFeedReady && this.state.rssFeed && this.props.feedViewAllLink && this.props.feedViewAllLink.length > 0 && (
|
||||
<div className={styles.rssReaderListViewAll}>
|
||||
<a href={this.props.feedViewAllLink}>{this.viewAllLinkLabel}</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!this.props.feedUrl || this.props.feedUrl.length < 1 ? (
|
||||
<Placeholder
|
||||
iconName='Edit'
|
||||
iconText='Configure your web part'
|
||||
description='Please configure the web part.'
|
||||
buttonLabel='Configure'
|
||||
onConfigure={this._onConfigure} />
|
||||
) : (
|
||||
<div>
|
||||
{!this.state.rssFeedReady ? (
|
||||
<div>
|
||||
{this.state.rssFeedError ? (
|
||||
<div className={styles.messageError}>
|
||||
<Icon
|
||||
iconName={"Warning"}
|
||||
className={styles.messageErrorIcon}
|
||||
/>
|
||||
<Label className={styles.messageErrorLabel}>{strings.RssLoadError + ' - ' + this.state.rssFeedError}</Label>
|
||||
</div>
|
||||
) : (
|
||||
<Spinner size={SpinnerSize.large} label={this.feedLoadingLabel} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{this.state.rssFeed ? (
|
||||
<div>
|
||||
{this.props.selectedLayout == FeedLayoutOption.Default && (
|
||||
<List
|
||||
className={styles.rssReaderList + (this.props.backgroundColor ? " " + styles.rssReaderListPadding : "")}
|
||||
items={this.state.rssFeed.query.results.rss}
|
||||
onRenderCell={this._onRenderListRow}
|
||||
style={this.props.backgroundColor ? {backgroundColor: this.props.backgroundColor} : {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{this.props.selectedLayout == FeedLayoutOption.Custom && (
|
||||
<div>
|
||||
<RssResultsTemplate
|
||||
templateService={this.props.templateService}
|
||||
templateContent={this.props.templateContent}
|
||||
templateContext={
|
||||
{
|
||||
items: this.state.rssFeed.query.results.rss,
|
||||
totalItemCount: this.state.rssFeedReady ? this.state.rssFeed.query.count : 0,
|
||||
returnedItemCount: this.state.rssFeedReady ? this.state.rssFeed.query.results.rss.length : 0,
|
||||
strings: strings,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Spinner size={SpinnerSize.large} label={strings.NoReturnedFeed} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public componentDidUpdate(nextProps): void {
|
||||
//if specific resources change, we need to reload feed
|
||||
if(this.props.feedUrl != nextProps.feedUrl ||
|
||||
this.props.feedService != nextProps.feedService ||
|
||||
this.props.feedServiceApiKey != nextProps.feedServiceApiKey ||
|
||||
this.props.feedServiceUrl != nextProps.feedServiceUrl ||
|
||||
|
||||
this.props.useCorsProxy != nextProps.useCorsProxy ||
|
||||
this.props.corsProxyUrl != nextProps.corsProxyUrl ||
|
||||
this.props.disableCorsMode != nextProps.disableCorsMode ||
|
||||
|
||||
this.props.maxCount != nextProps.maxCount ||
|
||||
|
||||
this.props.selectedLayout != nextProps.selectedLayout ||
|
||||
this.props.externalTemplateUrl != nextProps.externalTemplateUrl ||
|
||||
this.props.inlineTemplateText != nextProps.inlineTemplateText ||
|
||||
|
||||
this.props.showDesc != nextProps.showDesc ||
|
||||
this.props.showPubDate != nextProps.showPubDate ||
|
||||
this.props.descCharacterLimit != nextProps.descCharacterLimit ||
|
||||
this.props.titleLinkTarget != nextProps.titleLinkTarget ||
|
||||
this.props.dateFormat != nextProps.dateFormat ||
|
||||
this.props.backgroundColor != nextProps.backgroundColor ||
|
||||
this.props.fontColor != nextProps.fontColor
|
||||
) {
|
||||
this.loadRssFeed();
|
||||
}
|
||||
|
||||
if(this.props.feedViewAllLinkLabel != nextProps.feedViewAllLinkLabel) {
|
||||
this.viewAllLinkLabel = this.props.feedViewAllLinkLabel;
|
||||
}
|
||||
if(this.props.feedLoadingLabel != nextProps.feedLoadingLabel) {
|
||||
this.feedLoadingLabel = this.props.feedLoadingLabel;
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
private _onConfigure() {
|
||||
this.props.propertyPane.open();
|
||||
}
|
||||
|
||||
/*
|
||||
_onReaderListRow used by Default feed Layout
|
||||
*/
|
||||
@autobind
|
||||
private _onRenderListRow(item: IRssResult, index: number | undefined): JSX.Element {
|
||||
let thisItem: IRssItem = item.channel.item;
|
||||
|
||||
//may need to strip html from description
|
||||
let displayDesc: string = thisItem.description;
|
||||
let div = document.createElement("div");
|
||||
div.innerHTML = displayDesc;
|
||||
displayDesc = (div.textContent || div.innerText || "").replace(/\ /ig, "").trim();
|
||||
|
||||
return (
|
||||
<div className={styles.rssReaderListItem} data-is-focusable={true}>
|
||||
<div className={styles.itemTitle}>
|
||||
<Link
|
||||
href={thisItem.link}
|
||||
target={this.props.titleLinkTarget ? this.props.titleLinkTarget : "_self"}
|
||||
style={this.props.fontColor ? {color: this.props.fontColor} : {}}
|
||||
>
|
||||
{thisItem.title}
|
||||
</Link>
|
||||
|
||||
</div>
|
||||
|
||||
{this.props.showPubDate && (
|
||||
<div className={styles.itemDate}>
|
||||
{this.props.dateFormat && this.props.dateFormat.length > 0 ? (
|
||||
<Moment
|
||||
format={this.props.dateFormat}
|
||||
date={thisItem.pubDate}
|
||||
/>
|
||||
) : (
|
||||
<div>{(new Date(thisItem.pubDate)).toLocaleDateString()}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{this.props.showDesc && (
|
||||
<div className={styles.itemContent}>
|
||||
{this.props.descCharacterLimit && (displayDesc.length > this.props.descCharacterLimit) ? (
|
||||
<div>
|
||||
{displayDesc.substring(0, this.props.descCharacterLimit) + '...'}
|
||||
</div>
|
||||
) :
|
||||
(
|
||||
<div>
|
||||
{displayDesc}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
load a rss feed based on properties
|
||||
*/
|
||||
private async loadRssFeed(): Promise<void> {
|
||||
|
||||
//require a feed url
|
||||
if (this.props.feedUrl && this.props.feedUrl.length > 0) {
|
||||
|
||||
//always reset set
|
||||
this.setState({rssFeedReady: false, rssFeed: null});
|
||||
|
||||
let rssReaderService: IRssReaderService = new RssReaderService();
|
||||
|
||||
//build the query
|
||||
let feedRequest: IRssReaderRequest = {} as IRssReaderRequest;
|
||||
feedRequest.url = this.props.feedUrl;
|
||||
feedRequest.feedService = this.props.feedService;
|
||||
feedRequest.feedServiceApiKey = this.props.feedServiceApiKey;
|
||||
feedRequest.feedServiceUrl = this.props.feedServiceUrl;
|
||||
|
||||
//cors
|
||||
feedRequest.useCorsProxy = this.props.useCorsProxy;
|
||||
if (this.props.useCorsProxy) {
|
||||
feedRequest.corsProxyUrl = this.props.corsProxyUrl;
|
||||
}
|
||||
feedRequest.disableCorsMode = this.props.disableCorsMode;
|
||||
|
||||
feedRequest.maxCount = this.props.maxCount;
|
||||
|
||||
//local storage / caching
|
||||
feedRequest.useLocalStorage = this.props.cacheResults;
|
||||
if (this.props.cacheResults) {
|
||||
feedRequest.useLocalStorageTimeout = this.props.cacheResultsMinutes;
|
||||
feedRequest.useLocalStorageKeyPrefix = this.props.cacheStorageKeyPrefix;
|
||||
}
|
||||
|
||||
try {
|
||||
//attempt to get feed
|
||||
var rssFeed: IRssReaderResponse = await rssReaderService.getFeed(feedRequest);
|
||||
|
||||
if (rssFeed && rssFeed.query && rssFeed.query.results) {
|
||||
|
||||
this.setState({
|
||||
rssFeedReady: true,
|
||||
rssFeed: rssFeed,
|
||||
rssFeedError: ""
|
||||
});
|
||||
|
||||
}
|
||||
else {
|
||||
|
||||
this.setState({
|
||||
rssFeedReady: true,
|
||||
rssFeed: null,
|
||||
rssFeedError: strings.ErrorNoResults
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
catch (error) {
|
||||
|
||||
this.setState({
|
||||
rssFeedReady: false,
|
||||
rssFeed: null,
|
||||
rssFeedError: error
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export { IRssReaderProps } from './IRssReaderProps';
|
||||
export { IRssReaderState } from './IRssReaderState';
|
||||
export { default as RssReader } from './RssReader';
|
|
@ -0,0 +1,94 @@
|
|||
define([], function() {
|
||||
return {
|
||||
//"PropertyPaneDescription": "Customize your RSS Feed",
|
||||
"FeedSettingsPageName": "Specify Feed Settings",
|
||||
"LayoutSettingsPageName": "Style Feed Results",
|
||||
|
||||
//feed settings
|
||||
"FeedUrlLabel": "Feed Url",
|
||||
"FeedServiceLabel": "Feed Retrieval Service",
|
||||
"FeedServiceUrlLabel": "Feed Service Url",
|
||||
"FeedServiceUrlDescription": "Optional Feed Service for Feed2Json.org. Default url: https://feed2json.org/, is not for production use. For production, use own Feed2Json node service, source code available at: https://github.com/appsattic/feed2json.org",
|
||||
"FeedServiceApiKeyLabel": "Feed Service Api Key",
|
||||
"FeedServiceApiKeyDescription": "Optional Feed Service API Key for Rss2Json.com. Free service has limitations, view pricing plans at https://rss2json.com/plans",
|
||||
|
||||
"UseCorsProxyLabel": "Use a CORS proxy for cross domain requests?",
|
||||
"CorsProxyUrlLabel": "CORS proxy url",
|
||||
"CorsProxyUrlDescription": "{0} token will be replaced with RSS Feed URL",
|
||||
"DisableCorsModeLabel": "Disable CORS mode?",
|
||||
"DisableCorsModeDescription": "Should CORS mode be disabled when getting RSS Feed? If disabled, OPTIONS request will not be sent to feed service. Browser may still reject if response does not include Access-Control-Allow-Origin header.",
|
||||
"DisableCorsModeSelectedDescription": "CORS mode will be disabled, no pre-flight will occur, mode will be set to 'no-cors'",
|
||||
|
||||
"MaxCountLabel": "Max Count",
|
||||
|
||||
"CacheResultsLabel": "Cache Results",
|
||||
"CacheResultsMinutesLabel": "Minutes to cache results",
|
||||
"CacheStorageKeyPrefixLabel": "Cache Storage Key Prefix",
|
||||
"CacheStorageKeyPrefixDescription": "Optional local storage key prefix to use when storing rss results to local storage",
|
||||
|
||||
"FeedLoadingLabel": "Loading Message",
|
||||
"DefaultFeedLoadingLabel": "Loading Rss Feed",
|
||||
|
||||
|
||||
//layout / styling settings
|
||||
"SelectedLayoutLabel": "Results Layout",
|
||||
"TemplateUrlLabel": "External Template Url",
|
||||
"TemplateUrlPlaceholder": "https://myLayout.html",
|
||||
|
||||
"FeedViewAllLinkLabel": "Feed View All link (i.e. https://yourfeed)",
|
||||
"FeedViewAllLinkLabelLabel": "Feed View All link label (i.e. View all posts)",
|
||||
"FeedViewAllLinkPlaceholder": "https://...",
|
||||
"DefaultFeedViewAllLinkLabel": "View All",
|
||||
"ShowPubDateLabel": "Show publication date",
|
||||
"ShowDescLabel": "Show description",
|
||||
"DescCharacterLimitLabel": "Description Characters limit",
|
||||
"TitleLinkTargetLabel": "Link Target",
|
||||
"DateFormatLabel": "Date Format",
|
||||
"BackgroundColorLabel": "Background Color",
|
||||
"FontColorLabel": "Title Color",
|
||||
|
||||
|
||||
//feed service options
|
||||
"DefaultFeedServiceOption" : "Direct request",
|
||||
"Feed2JsonFeedServiceOption" : "Feed2Json.org",
|
||||
"Rss2JsonFeedServiceOption" : "Rss2Json.com",
|
||||
|
||||
|
||||
//feed layout options
|
||||
"DefaultFeedLayoutOption": "Default",
|
||||
"CustomFeedLayoutOption": "Custom",
|
||||
|
||||
|
||||
//template service
|
||||
"ErrorTemplateExtension": "The template must be a valid .htm or .html file",
|
||||
"ErrorTemplateResolve": "Unable to resolve the specified template, check the url, or may be blocked by CORS. Error details: '{0}'",
|
||||
|
||||
//template dialog
|
||||
"CancelButtonText": "Cancel",
|
||||
"DialogButtonLabel": "Template Editor",
|
||||
"DialogButtonText": "Edit template",
|
||||
"DialogTitle": "Edit results template",
|
||||
"SaveButtonText": "Save",
|
||||
|
||||
//additional messages
|
||||
"NoReturnedFeed": "Rss feed did not return any entries",
|
||||
"RssLoadError": "An error occurred attempting to retrieve the feed",
|
||||
|
||||
|
||||
//groups
|
||||
"FeedSettingsGroupLabel": "Feed Settings",
|
||||
"CorsSettingsGroupLabel": "Cross-Origin Resource Sharing (CORS)",
|
||||
|
||||
//Messages
|
||||
"ErrorNoResults": "Feed returned no results",
|
||||
"ErrorPossibleCORSBlock": "Possibly blocked by CORS policy.",
|
||||
"ErrorParsingFeed": "Unable to parse rss feed",
|
||||
"ErrorCovertFeedInvalidSource": "Unable to convert rss feed, source is not valid",
|
||||
"ErrorPossibleCORBBlock": "Unable to retrieve rss feed. The rss feed url is incorrect or Cross-Origin Read Blocking (CORB) blocked cross-origin response by the browser.",
|
||||
|
||||
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
declare interface IRssReaderWebPartStrings {
|
||||
FeedSettingsPageName: string;
|
||||
LayoutSettingsPageName: string;
|
||||
|
||||
//feed settings
|
||||
FeedUrlLabel: string;
|
||||
FeedServiceLabel: string;
|
||||
FeedServiceUrlLabel: string;
|
||||
FeedServiceUrlDescription: string;
|
||||
|
||||
UseCorsProxyLabel: string;
|
||||
CorsProxyUrlLabel: string;
|
||||
CorsProxyUrlDescription: string;
|
||||
DisableCorsModeLabel: string;
|
||||
DisableCorsModeDescription: string;
|
||||
DisableCorsModeSelectedDescription: string;
|
||||
|
||||
MaxCountLabel: string;
|
||||
|
||||
CacheResultsLabel: string;
|
||||
CacheResultsMinutesLabel: string;
|
||||
CacheStorageKeyPrefixLabel: string;
|
||||
CacheStorageKeyPrefixDescription: string;
|
||||
|
||||
FeedLoadingLabel: string;
|
||||
DefaultFeedLoadingLabel: string;
|
||||
|
||||
|
||||
//layout / stlying settings
|
||||
SelectedLayoutLabel: string;
|
||||
TemplateUrlLabel: string;
|
||||
TemplateUrlPlaceholder: string;
|
||||
|
||||
FeedViewAllLinkLabel: string;
|
||||
FeedViewAllLinkLabelLabel: string;
|
||||
FeedViewAllLinkPlaceholder: string;
|
||||
DefaultFeedViewAllLinkLabel: string;
|
||||
ShowPubDateLabel: string;
|
||||
ShowDescLabel: string;
|
||||
DescCharacterLimitLabel: string;
|
||||
TitleLinkTargetLabel: string;
|
||||
DateFormatLabel: string;
|
||||
BackgroundColorLabel: string;
|
||||
FontColorLabel: string;
|
||||
|
||||
|
||||
|
||||
//feed service options
|
||||
DefaultFeedServiceOption: string;
|
||||
Feed2JsonFeedServiceOption: string;
|
||||
Rss2JsonFeedServiceOption: string;
|
||||
FeedServiceApiKeyLabel: string;
|
||||
FeedServiceApiKeyDescription: string;
|
||||
|
||||
//feed layout options
|
||||
DefaultFeedLayoutOption: string;
|
||||
CustomFeedLayoutOption: string;
|
||||
|
||||
|
||||
//Template Service
|
||||
ErrorTemplateExtension: string;
|
||||
ErrorTemplateResolve: string;
|
||||
|
||||
//Template Dialog
|
||||
CancelButtonText: string;
|
||||
DialogButtonLabel: string;
|
||||
DialogButtonText: string;
|
||||
DialogTitle: string;
|
||||
SaveButtonText: string;
|
||||
|
||||
//additional messages
|
||||
NoReturnedFeed: string;
|
||||
RssLoadError: string;
|
||||
|
||||
//groups
|
||||
FeedSettingsGroupLabel: string;
|
||||
CorsSettingsGroupLabel: string;
|
||||
|
||||
//messages
|
||||
ErrorNoResults: string;
|
||||
ErrorPossibleCORSBlock: string;
|
||||
ErrorParsingFeed: string;
|
||||
ErrorCovertFeedInvalidSource: string;
|
||||
ErrorPossibleCORBBlock: string;
|
||||
|
||||
}
|
||||
|
||||
declare module 'RssReaderWebPartStrings' {
|
||||
const strings: IRssReaderWebPartStrings;
|
||||
export = strings;
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.2/MicrosoftTeams.schema.json",
|
||||
"manifestVersion": "1.2",
|
||||
"packageName": "RssReader",
|
||||
"id": "f489b1fd-98bf-4b41-8db0-85d5018ba484",
|
||||
"version": "0.1",
|
||||
"developer": {
|
||||
"name": "SPFx + Teams Dev",
|
||||
"websiteUrl": "https://products.office.com/en-us/sharepoint/collaboration",
|
||||
"privacyUrl": "https://privacy.microsoft.com/en-us/privacystatement",
|
||||
"termsOfUseUrl": "https://www.microsoft.com/en-us/servicesagreement"
|
||||
},
|
||||
"name": {
|
||||
"short": "RssReader"
|
||||
},
|
||||
"description": {
|
||||
"short": "RssReader description",
|
||||
"full": "RssReader description"
|
||||
},
|
||||
"icons": {
|
||||
"outline": "tab20x20.png",
|
||||
"color": "tab96x96.png"
|
||||
},
|
||||
"accentColor": "#004578",
|
||||
"configurableTabs": [
|
||||
{
|
||||
"configurationUrl": "https://{teamSiteDomain}{teamSitePath}/_layouts/15/TeamsLogon.aspx?SPFX=true&dest={teamSitePath}/_layouts/15/teamshostedapp.aspx%3FopenPropertyPane=true%26teams%26componentId=f489b1fd-98bf-4b41-8db0-85d5018ba484",
|
||||
"canUpdateConfiguration": false,
|
||||
"scopes": [
|
||||
"team"
|
||||
]
|
||||
}
|
||||
],
|
||||
"validDomains": [
|
||||
"*.login.microsoftonline.com",
|
||||
"*.sharepoint.com",
|
||||
"*.sharepoint-df.com",
|
||||
"spoppe-a.akamaihd.net",
|
||||
"spoprod-a.akamaihd.net",
|
||||
"resourceseng.blob.core.windows.net",
|
||||
"msft.spoppe.com"
|
||||
],
|
||||
"webApplicationInfo": {
|
||||
"resource": "https://{teamSiteDomain}",
|
||||
"id": "00000003-0000-0ff1-ce00-000000000000"
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 933 B |
Binary file not shown.
After Width: | Height: | Size: 2.5 KiB |
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"jsx": "react",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"experimentalDecorators": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "lib",
|
||||
"typeRoots": [
|
||||
"./node_modules/@types",
|
||||
"./node_modules/@microsoft"
|
||||
],
|
||||
"types": [
|
||||
"es6-promise",
|
||||
"webpack-env"
|
||||
],
|
||||
"lib": [
|
||||
"es5",
|
||||
"dom",
|
||||
"es2015.collection"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"lib"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"extends": "@microsoft/sp-tslint-rules/base-tslint.json",
|
||||
"rules": {
|
||||
"class-name": false,
|
||||
"export-name": false,
|
||||
"forin": false,
|
||||
"label-position": false,
|
||||
"member-access": true,
|
||||
"no-arg": false,
|
||||
"no-console": false,
|
||||
"no-construct": false,
|
||||
"no-duplicate-variable": true,
|
||||
"no-eval": false,
|
||||
"no-function-expression": true,
|
||||
"no-internal-module": true,
|
||||
"no-shadowed-variable": true,
|
||||
"no-switch-case-fall-through": true,
|
||||
"no-unnecessary-semicolons": true,
|
||||
"no-unused-expression": true,
|
||||
"no-use-before-declare": true,
|
||||
"no-with-statement": true,
|
||||
"semicolon": true,
|
||||
"trailing-comma": false,
|
||||
"typedef": false,
|
||||
"typedef-whitespace": false,
|
||||
"use-named-parameter": true,
|
||||
"variable-name": false,
|
||||
"whitespace": false
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue