diff --git a/src/components/change-view.jsx b/src/components/change-view.jsx index 0c4453a5..a8b160cc 100644 --- a/src/components/change-view.jsx +++ b/src/components/change-view.jsx @@ -8,6 +8,13 @@ import SelectDiffType from './select-diff-type'; import SelectVersion from './select-version'; import Loading from './loading'; import VersionistaInfo from './versionista-info'; +import {diffTypesFor} from '../constants/diff-types'; +import { + htmlType, + mediaTypeForExtension, + parseMediaType, + unknownType +} from '../scripts/media-type'; const collapsedViewStorage = 'WebMonitoring.ChangeView.collapsedView'; @@ -46,6 +53,10 @@ export default class ChangeView extends React.Component { const page = this.props.page; if (page.versions && page.versions.length > 1) { this.state.diffType = 'SIDE_BY_SIDE_RENDERED'; + const relevantTypes = relevantDiffTypes(this.props.from, this.props.to); + if (!relevantTypes.find(type => type.value === this.state.diffType)) { + this.state.diffType = relevantTypes[0].value; + } } if ('sessionStorage' in window) { @@ -130,7 +141,11 @@ export default class ChangeView extends React.Component { Comparison: - + @@ -341,3 +356,30 @@ function changeMatches (change, other) { function isDisabled (element) { return element.disabled || element.classList.contains('disabled'); } + +function relevantDiffTypes (versionA, versionB) { + let typeA = mediaTypeForVersion(versionA); + + if (typeA.equals(mediaTypeForVersion(versionB))) { + return diffTypesFor(typeA); + } + + // If we have differing types of content consider it an 'unkown' type. + return diffTypesFor(unknownType); +} + +function mediaTypeForVersion (version) { + const contentType = version.content_type + || version.source_metadata.content_type; + + if (contentType) { + return parseMediaType(contentType); + } + + if (version.uri) { + const extension = version.uri.match(/^([^:]+:\/\/)?.*\/[^/]*(\.[^/]+)$/); + return mediaTypeForExtension[extension && extension[2]] || htmlType; + } + + return htmlType; +} diff --git a/src/components/diff-view.jsx b/src/components/diff-view.jsx index 7b244cb4..0910aa97 100644 --- a/src/components/diff-view.jsx +++ b/src/components/diff-view.jsx @@ -8,6 +8,8 @@ import HighlightedTextDiff from './highlighted-text-diff'; import InlineRenderedDiff from './inline-rendered-diff'; import SideBySideRenderedDiff from './side-by-side-rendered-diff'; import ChangesOnlyDiff from './changes-only-diff'; +import RawVersion from './raw-version'; +import SideBySideRawVersions from './side-by-side-raw-versions'; /** * @typedef DiffViewProps @@ -60,20 +62,44 @@ export default class DiffView extends React.Component { return ( - {this.renderNoChangeMessage()} + {this.renderNoChangeMessage() || this.renderUndiffableMessage()} {this.renderDiff()} ); } renderNoChangeMessage () { - if (this.state.diffData.change_count === 0) { - return - There were NO changes for this diff type.; + const sameContent = this.props.a + && this.props.b + && this.props.a.version_hash === this.props.b.version_hash; + + const className = 'diff-view__alert alert alert-warning'; + + if (sameContent) { + return + These two versions are exactly the same. + ; } - else { - return null; + else if (this.state.diffData.change_count === 0) { + return + There were no changes for this diff type. (Other diff + types may show changes.) + ; + } + + return null; + } + + renderUndiffableMessage () { + if (this.state.diffData.raw) { + return ( + + We can’t compare the selected versions of page; you are viewing the + content without deletions and insertions highlighted. + + ); } + return null; } renderDiff () { @@ -81,6 +107,18 @@ export default class DiffView extends React.Component { // in the future (e.g. inline vs. side-by-side text), we need a better // way to ensure we use the correct rendering and avoid race conditions switch (this.props.diffType) { + case diffTypes.RAW_SIDE_BY_SIDE.value: + return ( + + ); + case diffTypes.RAW_FROM_CONTENT.value: + return ( + + ); + case diffTypes.RAW_TO_CONTENT.value: + return ( + + ); case diffTypes.HIGHLIGHTED_RENDERED.value: return ( @@ -146,6 +184,18 @@ export default class DiffView extends React.Component { // (page: Page) => page.uuid === pageId); // Promise.resolve(fromList || this.context.api.getDiff(pageId, aId, bId, changeDiffTypes[diffType])) this.setState({diffData: null}); + if (!diffTypes[diffType].diffService) { + return Promise.all([ + fetch(this.props.a.uri, {mode: 'cors'}), + fetch(this.props.b.uri, {mode: 'cors'}) + ]) + .then(([rawA, rawB]) => { + return {raw: true, rawA, rawB}; + }) + .catch(error => error) + .then(data => this.setState({diffData: data})); + } + this.context.api.getDiff(pageId, aId, bId, diffTypes[diffType].diffService, diffTypes[diffType].options) .catch(error => { return error; diff --git a/src/components/raw-version.jsx b/src/components/raw-version.jsx new file mode 100644 index 00000000..1b898ee8 --- /dev/null +++ b/src/components/raw-version.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import SandboxedHtml from './sandboxed-html'; + +/** + * @typedef {Object} RawVersionProps + * @property {Page} page The page this diff pertains to + * @property {Version} version + * @property {string} content + */ + +/** + * Display the raw content of a version. + * + * @class RawVersion + * @extends {React.Component} + * @param {RawVersionProps} props + */ +export default class RawVersion extends React.Component { + render () { + if (this.props.content && /^[\s\n\r]* + + + ); + } + + return ( + + + + ); + } +} diff --git a/src/components/select-diff-type.jsx b/src/components/select-diff-type.jsx index 0a2894a0..f030bc02 100644 --- a/src/components/select-diff-type.jsx +++ b/src/components/select-diff-type.jsx @@ -1,5 +1,4 @@ import React from 'react'; -import {diffTypes} from '../constants/diff-types'; /** * A dropdown select box for types of diffs @@ -10,6 +9,7 @@ import {diffTypes} from '../constants/diff-types'; * @param {string} value Identifier for the selected diff type * @param {Function} onChange Callback when a new value is selected. Signature: * `string => void` + * @param {DiffType[]} types */ export default class SelectDiffType extends React.Component { render () { @@ -20,11 +20,12 @@ export default class SelectDiffType extends React.Component { return ( none - {Object.keys(diffTypes).map(key => { - const diffType = diffTypes[key]; - return {diffType.description}; - })} + {this.props.types.map(this._renderOption)} ); } + + _renderOption (diffType) { + return {diffType.description}; + } } diff --git a/src/components/side-by-side-raw-versions.jsx b/src/components/side-by-side-raw-versions.jsx new file mode 100644 index 00000000..cd020891 --- /dev/null +++ b/src/components/side-by-side-raw-versions.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import SandboxedHtml from './sandboxed-html'; + +/** + * @typedef {Object} SideBySideRawVersionsProps + * @property {DiffData} diffData Object containing diff to render and its metadata + * @property {Page} page The page this diff pertains to + * @property {Version} a + * @property {Version} b + */ + +/** + * Display two versions of a page, side-by-side. + * + * @class SideBySideRawVersions + * @extends {React.Component} + * @param {SideBySideRawVersionsProps} props + */ +export default class SideBySideRawVersions extends React.Component { + render () { + return ( + + {renderVersion(this.props.page, this.props.a, this.props.diffData.rawA)} + {renderVersion(this.props.page, this.props.b, this.props.diffData.rawB)} + + ); + } +} + +function renderVersion (page, version, content) { + if (content && /^[\s\n\r]*; + } + + return ; +} diff --git a/src/constants/diff-types.js b/src/constants/diff-types.js index 3fd05a05..f58cd45a 100644 --- a/src/constants/diff-types.js +++ b/src/constants/diff-types.js @@ -1,4 +1,19 @@ +import { + mediaTypeForExtension, + parseMediaType, + unknownType +} from '../scripts/media-type'; + export const diffTypes = { + RAW_FROM_CONTENT: { + description: '“From” Version', + }, + RAW_TO_CONTENT: { + description: '“To” Version', + }, + RAW_SIDE_BY_SIDE: { + description: 'Side-by-side Content', + }, HIGHLIGHTED_TEXT: { description: 'Highlighted Text', diffService: 'html_text_dmp', @@ -33,3 +48,47 @@ export const diffTypes = { for (let key in diffTypes) { diffTypes[key].value = key; } + +const diffTypesByMediaType = { + 'text/html': [ + diffTypes.HIGHLIGHTED_TEXT, + diffTypes.HIGHLIGHTED_SOURCE, + diffTypes.HIGHLIGHTED_RENDERED, + diffTypes.SIDE_BY_SIDE_RENDERED, + diffTypes.OUTGOING_LINKS, + diffTypes.CHANGES_ONLY_TEXT, + diffTypes.CHANGES_ONLY_SOURCE, + ], + + 'text/*': [ + diffTypes.HIGHLIGHTED_SOURCE, + diffTypes.CHANGES_ONLY_SOURCE, + ], + + '*/*': [ + diffTypes.RAW_SIDE_BY_SIDE, + diffTypes.RAW_FROM_CONTENT, + diffTypes.RAW_TO_CONTENT, + ], +}; + +/** + * Get appropriate diff types for a given kind of content. + * @param {string|MediaType} mediaType The type of content to get. Can be a + * MediaType object, a content type/media type string, or a file extension. + * @returns {Array} + */ +export function diffTypesFor (mediaType) { + let type = null; + if (typeof mediaType === 'string' && mediaType.startsWith('.')) { + type = mediaTypeForExtension(mediaType) || unknownType; + } + else { + type = parseMediaType(mediaType); + } + + return diffTypesByMediaType[type.mediaType] + || diffTypesByMediaType[type.genericType] + || diffTypesByMediaType[unknownType.mediaType] + || []; +} diff --git a/src/scripts/media-type.js b/src/scripts/media-type.js new file mode 100644 index 00000000..43fd0ebb --- /dev/null +++ b/src/scripts/media-type.js @@ -0,0 +1,119 @@ +/** + * Tools for dealing with media types. + */ + +/** + * @typedef MediaType Represents a media type. + * @property {string} mediaType The full media type string, e.g. 'text/plain' + * @property {string} type The primary type, e.g. 'text' in 'text/plain' + * @property {string} subType The sub type, e.g. 'plain' in 'text/plain' + * @property {string} genericType The generic type, e.g. 'text/*' + * @property {(MediaType) => boolean} equals Compare this MediaType with another + */ + +/** + * The "unknown" media type. + * @type {MediaType} + */ +export const unknownType = { + genericType: '*/*', + mediaType: '*/*', + type: '*', + subType: '*', + equals (otherType) { + return !!otherType && this.mediaType === otherType.mediaType; + } +}; + +/** + * The canonical HTML media type. + * @type {MediaType} + */ +export const htmlType = MediaType('text', 'html'); + +/** + * Create an object representing a media type. + * @param {string} [type] + * @param {string} [subtype] + * @returns {MediaType} + */ +export default function MediaType (type, subtype) { + type = type || '*'; + subtype = subtype || '*'; + + return Object.assign(Object.create(unknownType), { + genericType: `${type}/*`, + mediaType: `${type}/${subtype || '*'}`, + type, + subtype, + }); +} + +// TODO: remove this when we have content types for everything in the DB +export const mediaTypeForExtension = { + '.html': htmlType, + '.pdf': MediaType('application', 'pdf'), + '.wsdl': MediaType('application', 'wsdl+xml'), + '.xml': MediaType('application', 'xml'), + '.ksh': MediaType('text', '*'), + '.ics': MediaType('text', 'calendar'), + '.txt': MediaType('text', 'plain'), + '.rss': MediaType('application', 'rss+xml'), + '.jpg': MediaType('image', 'jpeg'), + '.obj': MediaType('application', 'x-tgif'), + '.doc': MediaType('application', 'msword'), + '.zip': MediaType('application', 'zip'), + '.atom': MediaType('application', 'atom+xml'), + '.xlb': MediaType('application', 'excel'), + '.pwz': MediaType('application', 'powerpoint'), + '.gif': MediaType('image', 'gif'), + '.rtf': MediaType('application', 'rtf'), + '.csv': MediaType('text', 'csv'), + '.xls': MediaType('application', 'vnd.ms-excel'), + '.xlsx': MediaType('application', 'vnd.openxmlformats-officedocument.spreadsheetml.sheet'), + '.png': MediaType('image', 'png'), + '.docx': MediaType('application', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'), + '.jpeg': MediaType('image', 'jpeg'), + '.mp3': MediaType('audio', 'mpeg'), + '.ai': MediaType('application', 'postscript'), +}; + +// Maps various media types representing the same thing to a canonical type. +const canonicalTypes = { + 'application/xhtml': htmlType, + 'application/xhtml+xml': htmlType, + 'application/xml+html': htmlType, + 'text/webviewhtml': htmlType, + 'text/x-server-parsed-html': htmlType, + 'text/xhtml': htmlType, +}; + +/** + * Convert a media type string to a MediaType object. + * @param {string|MediaType} mediaType + * @param {boolean} [canonicalize=true] + * @returns {MediaType} + */ +export function parseMediaType (mediaType, canonicalize = true) { + if (mediaType == null) { + mediaType = '*/*'; + } + else if (mediaType.mediaType) { + return mediaType; + } + else if (!(typeof mediaType === 'string')) { + throw new TypeError(`The 'mediaType' argument must be a string, not \`${mediaType}\``); + } + + const parts = mediaType.match(/^([^/;]+)(?:\/([^;]+))?/) || []; + let parsed = MediaType(parts[1], parts[2]); + + if (canonicalize) { + let canonicalType = canonicalTypes[parsed.mediaType]; + if (canonicalType) { + parsed = Object.create(canonicalType, {exactType: {value: parsed}}); + } + } + + return parsed; +}