diff --git a/__tests__/ExpensiMark-HTML-test.js b/__tests__/ExpensiMark-HTML-test.js index 763df5b6..ecafb223 100644 --- a/__tests__/ExpensiMark-HTML-test.js +++ b/__tests__/ExpensiMark-HTML-test.js @@ -1958,6 +1958,45 @@ describe('multi-level blockquote', () => { }); }); +describe('Video markdown conversion to html tag', () => { + test('Single video with source', () => { + const testString = '![test](https://example.com/video.mp4)'; + const resultString = ''; + expect(parser.replace(testString)).toBe(resultString); + }); + + test('Text containing videos', () => { + const testString = 'A video of a banana: ![banana](https://example.com/banana.mp4) a video without name: !(https://example.com/developer.mp4)'; + const resultString = 'A video of a banana: a video without name: '; + expect(parser.replace(testString)).toBe(resultString); + }); + + test('Video with raw data attributes', () => { + const testString = '![test](https://example.com/video.mp4)'; + const resultString = ''; + expect(parser.replace(testString, {shouldKeepRawInput: true})).toBe(resultString); + }) + + test('Single video with extra cached attribues', () => { + const testString = '![test](https://example.com/video.mp4)'; + const resultString = ''; + expect(parser.replace(testString, { + extras: { + videoAttributeCache: { + 'https://example.com/video.mp4': 'data-expensify-height="100" data-expensify-width="100"' + } + } + })).toBe(resultString); + }) + + test('Text containing image and video', () => { + const testString = 'An image of a banana: ![banana](https://example.com/banana.png) and a video of a banana: ![banana](https://example.com/banana.mp4)'; + const resultString = 'An image of a banana: banana and a video of a banana: '; + expect(parser.replace(testString)).toBe(resultString); + }); + +}) + describe('Image markdown conversion to html tag', () => { test('Single image with alt text', () => { const testString = '![test](https://example.com/image.png)'; diff --git a/__tests__/ExpensiMark-Markdown-test.js b/__tests__/ExpensiMark-Markdown-test.js index 093f5ca5..bb1baf1f 100644 --- a/__tests__/ExpensiMark-Markdown-test.js +++ b/__tests__/ExpensiMark-Markdown-test.js @@ -848,3 +848,28 @@ describe('Image tag conversion to markdown', () => { expect(parser.htmlToMarkdown(testString)).toBe(resultString); }); }); + +describe('Video tag conversion to markdown', () => { + test('Video with name', () => { + const testString = ''; + const resultString = '![video](https://example.com/video.mp4)'; + expect(parser.htmlToMarkdown(testString)).toBe(resultString); + }) + + test('Video without name', () => { + const testString = ''; + const resultString = '!(https://example.com/video.mp4)'; + expect(parser.htmlToMarkdown(testString)).toBe(resultString); + }) + + test('While convert video, cache some extra attributes from the video tag', () => { + const cacheVideoAttributes = jest.fn(); + const testString = ''; + const resultString = '![video](https://example.com/video.mp4)'; + const extras = { + cacheVideoAttributes, + }; + expect(parser.htmlToMarkdown(testString, extras)).toBe(resultString); + expect(cacheVideoAttributes).toHaveBeenCalledWith("https://example.com/video.mp4", ' data-expensify-width="100" data-expensify-height="500" data-expensify-thumbnail-url="https://image.com/img.jpg"') + }) +}) diff --git a/lib/CONST.ts b/lib/CONST.ts index 5c62fd97..0d80b0b9 100644 --- a/lib/CONST.ts +++ b/lib/CONST.ts @@ -885,6 +885,7 @@ const CONST = { prompt: 'What software are you moving to and why?', }, }, + VIDEO_EXTENSIONS: ['mp4', 'mov', 'avi', 'wmv', 'flv', 'mkv', 'webm', '3gp', 'm4v', 'mpg', 'mpeg', 'ogv'], } as const; /** diff --git a/lib/ExpensiMark.d.ts b/lib/ExpensiMark.d.ts index a10aa20c..8a67ae27 100644 --- a/lib/ExpensiMark.d.ts +++ b/lib/ExpensiMark.d.ts @@ -39,7 +39,13 @@ declare type Rule = { declare type ExtrasObject = { reportIDToName?: Record; accountIDToName?: Record; + cacheVideoAttributes?: (vidSource: string, attrs: string) => void; }; + +declare type ExtraParamsForReplaceFunc = { + videoAttributeCache?: Record; +}; + export default class ExpensiMark { static Log: Logger; static setLogger(logger: Logger): void; @@ -66,11 +72,13 @@ export default class ExpensiMark { filterRules, shouldEscapeText, shouldKeepRawInput, + extras, }?: { filterRules?: Name[]; disabledRules?: Name[]; shouldEscapeText?: boolean; shouldKeepRawInput?: boolean; + extras?: ExtraParamsForReplaceFunc; }, ): string; /** diff --git a/lib/ExpensiMark.js b/lib/ExpensiMark.js index 6bc9981b..6f42c065 100644 --- a/lib/ExpensiMark.js +++ b/lib/ExpensiMark.js @@ -7,6 +7,11 @@ import * as Utils from './utils'; const MARKDOWN_LINK_REGEX = new RegExp(`\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi'); const MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi'); +const MARKDOWN_VIDEO_REGEX = new RegExp( + `\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(((${UrlPatterns.MARKDOWN_URL_REGEX})\\.(?:${Constants.CONST.VIDEO_EXTENSIONS.join('|')}))\\)(?![^<]*(<\\/pre>|<\\/code>))`, + 'gi', +); + const SLACK_SPAN_NEW_LINE_TAG = ''; export default class ExpensiMark { @@ -121,7 +126,34 @@ export default class ExpensiMark { }, /** - * Converts markdown style images to img tags e.g. ![Expensify](https://www.expensify.com/attachment.png) + * Converts markdown style video to video tags e.g. ![Expensify](https://www.expensify.com/attachment.mp4) + * We need to convert before image rules since they will not try to create a image tag from an existing video URL + * Extras arg could contain the attribute cache for the video tag which is cached during the html-to-markdown conversion + */ + { + name: 'video', + regex: MARKDOWN_VIDEO_REGEX, + /** + * @param {string} match + * @param {string} videoName - The first capture group - video name + * @param {string} videoSource - The second capture group - video URL + * @param {any[]} args - The rest capture groups and `extras` object. args[args.length-1] will the `extras` object + * @return {string} Returns the HTML video tag + */ + replacement: (match, videoName, videoSource, ...args) => { + const extras = args[args.length - 1]; + const extraAttrs = extras && extras.videoAttributeCache && extras.videoAttributeCache[videoSource]; + return ``; + }, + rawInputReplacement: (match, videoName, videoSource, ...args) => { + const extras = args[args.length - 1]; + const extraAttrs = extras && extras.videoAttributeCache && extras.videoAttributeCache[videoSource]; + return ``; + }, + }, + + /** + * Converts markdown style images to image tags e.g. ![Expensify](https://www.expensify.com/attachment.png) * We need to convert before linking rules since they will not try to create a link from an existing img * tag. * Additional sanitization is done to the alt attribute to prevent parsing it further to html by later @@ -487,6 +519,7 @@ export default class ExpensiMark { return `[${g4}](${email || g3})`; }, }, + { name: 'image', regex: /<]*src\s*=\s*(['"])(.*?)\1(?:[^><]*alt\s*=\s*(['"])(.*?)\3)?[^><]*>*(?![^<][\s\S]*?(<\/pre>|<\/code>))/gi, @@ -498,6 +531,32 @@ export default class ExpensiMark { return `!(${g2})`; }, }, + + { + name: 'video', + regex: /<]*data-expensify-source\s*=\s*(['"])(\S*?)\1(.*?)>([^><]*)<\/video>*(?![^<][\s\S]*?(<\/pre>|<\/code>))/gi, + /** + * @param {string} match The full match + * @param {string} g1 {string} The first capture group + * @param {string} videoSource - the second capture group - video source (video URL) + * @param {string} videoAttrs - the third capture group - video attributes (data-expensify-width, data-expensify-height, etc...) + * @param {string} videoName - the fourth capture group will be the video file name (the text between opening and closing video tags) + * @param {any[]} args The rest of the arguments. args[args.length-1] will the `extras` object + * @returns {string} Returns the markdown video tag + */ + replacement: (match, g1, videoSource, videoAttrs, videoName, ...args) => { + const extras = args[args.length - 1]; + if (videoAttrs && extras && extras.cacheVideoAttributes && typeof extras.cacheVideoAttributes === 'function') { + extras.cacheVideoAttributes(videoSource, videoAttrs); + } + if (videoName) { + return `![${videoName}](${videoSource})`; + } + + return `!(${videoSource})`; + }, + }, + { name: 'reportMentions', regex: //gi, @@ -663,7 +722,7 @@ export default class ExpensiMark { * * @returns {String} */ - replace(text, {filterRules = [], shouldEscapeText = true, shouldKeepRawInput = false, disabledRules = []} = {}) { + replace(text, {filterRules = [], shouldEscapeText = true, shouldKeepRawInput = false, disabledRules = [], extras} = {}) { // This ensures that any html the user puts into the comment field shows as raw html let replacedText = shouldEscapeText ? Utils.escape(text) : text; const rules = this.getHtmlRuleset(filterRules, disabledRules, shouldKeepRawInput); @@ -674,10 +733,12 @@ export default class ExpensiMark { replacedText = rule.pre(replacedText); } const replacementFunction = shouldKeepRawInput && rule.rawInputReplacement ? rule.rawInputReplacement : rule.replacement; + const replacementFnWithExtraParams = typeof replacementFunction === 'function' ? (...args) => replacementFunction(...args, extras) : replacementFunction; + if (rule.process) { - replacedText = rule.process(replacedText, replacementFunction, shouldKeepRawInput); + replacedText = rule.process(replacedText, replacementFnWithExtraParams, shouldKeepRawInput); } else { - replacedText = replacedText.replace(rule.regex, replacementFunction); + replacedText = replacedText.replace(rule.regex, replacementFnWithExtraParams); } // Post-process text after applying regex