Initial addition of React Rss Reader (#764)

This commit is contained in:
Eric Overfield 2019-01-27 00:27:58 -08:00 committed by Vesa Juvonen
parent 3f30d811f5
commit cfe16c9066
73 changed files with 22560 additions and 0 deletions

View File

@ -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

32
samples/react-rss-reader/.gitignore vendored Normal file
View File

@ -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

View File

@ -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"
}
}

View File

@ -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" />

View File

@ -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"
}
}

View File

@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json",
"deployCdnPath": "temp/deploy"
}

View File

@ -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 -->"
}

View File

@ -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"
}
}

View File

@ -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/"
}
}

View File

@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json",
"cdnBasePath": "<!-- PATH TO CDN -->"
}

50
samples/react-rss-reader/gulpfile.js vendored Normal file
View File

@ -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

18342
samples/react-rss-reader/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -0,0 +1,6 @@
import { IPropertyPaneCustomFieldProps } from '@microsoft/sp-webpart-base';
import { IPropertyPaneTextDialogProps } from './IPropertyPaneTextDialogProps';
export interface IPropertyPaneTextDialogInternalProps extends IPropertyPaneTextDialogProps, IPropertyPaneCustomFieldProps {
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -0,0 +1,3 @@
.ace_editor.ace_autocomplete {
z-index: 2000000 !important;
}

View File

@ -0,0 +1,9 @@
import { ITextDialogStrings } from "./ITextDialogStrings";
export interface ITextDialogProps {
dialogTextFieldValue?: string;
onChanged?: (text: string) => void;
disabled?: boolean;
strings: ITextDialogStrings;
stateKey?: string;
}

View File

@ -0,0 +1,4 @@
export interface ITextDialogState {
dialogText: string;
showDialog: boolean;
}

View File

@ -0,0 +1,9 @@
export interface ITextDialogStrings {
dialogTitle: string;
dialogSubText?: string;
dialogButtonLabel?: string;
dialogButtonText: string;
dialogTextBoxPlaceholder?: string;
saveButtonText: string;
cancelButtonText: string;
}

View File

@ -0,0 +1,3 @@
.textDialog {
max-width: 100%;
}

View File

@ -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>
);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1 @@
// A file is required to be in the root of the /src directory by the TypeScript compiler

View File

@ -0,0 +1,6 @@
enum FeedLayoutOption {
Default,
Custom
}
export default FeedLayoutOption;

View File

@ -0,0 +1,7 @@
enum FeedServiceOption {
Default,
Feed2Json,
Rss2Json
}
export default FeedServiceOption;

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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';

View File

@ -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>;
}

View File

@ -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;

View File

@ -0,0 +1,2 @@
export { ILocalStorageService, ILocalStorageKey } from './ILocalStorageService';
export { default as LocalStorageService } from './LocalStorageService';

View File

@ -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;
}

View File

@ -0,0 +1,6 @@
export { RssHttpClientService } from './rssHttpClientService';
export { RssHttpClientDirectService } from './rssHttpClientDirectService';
export { RssHttpClientFeed2JsonService } from './rssHttpClientFeed2JsonService';
export { RssHttpClientRss2JsonService } from './rssHttpClientRss2JsonService';
export * from './IRssHttpClientComponentService';

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1 @@
export { IRssReaderService, RssReaderService } from './rssReaderService';

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -0,0 +1 @@
export { RssXmlParserService } from './rssXmlParserService';

View File

@ -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
}
}
}
}

View File

@ -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;
}
*/

View File

@ -0,0 +1,3 @@
.hoverIcon {
color: '[theme:themeDarker, default:#0078d7]';
}

View File

@ -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(/\&nbsp;/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;

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,3 @@
export { default as BaseTemplateService } from './BaseTemplateService';
export { default as MockTemplateService } from './MockTemplateService';
export { default as TemplateService } from './TemplateService';

View File

@ -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/
*/
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,7 @@
export interface IRssResultsTemplateState {
/**
* The handlebar compiled template
*/
processedTemplate: string;
}

View File

@ -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;
}
}
}

View File

@ -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
});
}
}

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,4 @@
export * from './IRssResultsTemplateContext';
export * from './IRssResultsTemplateProps';
export * from './IRssResultsTemplateState';
export { default as RssResultsTemplate } from './RssResultsTemplate';

View File

@ -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;
}

View File

@ -0,0 +1,7 @@
import { IRssReaderResponse } from '../../../../models';
export interface IRssReaderState {
rssFeedReady: boolean;
rssFeed: IRssReaderResponse;
rssFeedError: string;
}

View File

@ -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;
}
}

View File

@ -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(/\&nbsp;/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
});
}
}
}
}

View File

@ -0,0 +1,3 @@
export { IRssReaderProps } from './IRssReaderProps';
export { IRssReaderState } from './IRssReaderState';
export { default as RssReader } from './RssReader';

View File

@ -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.",
}
});

View File

@ -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;
}

View File

@ -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

View File

@ -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"
]
}

View File

@ -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
}
}