Skip to content

Commit

Permalink
Merge pull request Expensify#703 from dominictb/fix/app-41952
Browse files Browse the repository at this point in the history
fix: add rules for video conversion
  • Loading branch information
tgolen authored Jun 17, 2024
2 parents c6d1c00 + cb401e0 commit 9f12ae6
Show file tree
Hide file tree
Showing 5 changed files with 138 additions and 4 deletions.
39 changes: 39 additions & 0 deletions __tests__/ExpensiMark-HTML-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<video data-expensify-source="https://example.com/video.mp4" >test</video>';
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: <video data-expensify-source="https://example.com/banana.mp4" >banana</video> a video without name: <video data-expensify-source="https://example.com/developer.mp4" ></video>';
expect(parser.replace(testString)).toBe(resultString);
});

test('Video with raw data attributes', () => {
const testString = '![test](https://example.com/video.mp4)';
const resultString = '<video data-expensify-source="https://example.com/video.mp4" data-raw-href="https://example.com/video.mp4" data-link-variant="labeled" >test</video>';
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 = '<video data-expensify-source="https://example.com/video.mp4" data-expensify-height="100" data-expensify-width="100">test</video>';
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: <img src="https://example.com/banana.png" alt="banana" /> and a video of a banana: <video data-expensify-source="https://example.com/banana.mp4" >banana</video>';
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)';
Expand Down
25 changes: 25 additions & 0 deletions __tests__/ExpensiMark-Markdown-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<video data-expensify-source="https://example.com/video.mp4">video</video>';
const resultString = '![video](https://example.com/video.mp4)';
expect(parser.htmlToMarkdown(testString)).toBe(resultString);
})

test('Video without name', () => {
const testString = '<video data-expensify-source="https://example.com/video.mp4"></video>';
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 = '<video data-expensify-source="https://example.com/video.mp4" data-expensify-width="100" data-expensify-height="500" data-expensify-thumbnail-url="https://image.com/img.jpg">video</video>';
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"')
})
})
1 change: 1 addition & 0 deletions lib/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down
8 changes: 8 additions & 0 deletions lib/ExpensiMark.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,13 @@ declare type Rule = {
declare type ExtrasObject = {
reportIDToName?: Record<string, string>;
accountIDToName?: Record<string, string>;
cacheVideoAttributes?: (vidSource: string, attrs: string) => void;
};

declare type ExtraParamsForReplaceFunc = {
videoAttributeCache?: Record<string, string>;
};

export default class ExpensiMark {
static Log: Logger;
static setLogger(logger: Logger): void;
Expand All @@ -66,11 +72,13 @@ export default class ExpensiMark {
filterRules,
shouldEscapeText,
shouldKeepRawInput,
extras,
}?: {
filterRules?: Name[];
disabledRules?: Name[];
shouldEscapeText?: boolean;
shouldKeepRawInput?: boolean;
extras?: ExtraParamsForReplaceFunc;
},
): string;
/**
Expand Down
69 changes: 65 additions & 4 deletions lib/ExpensiMark.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<span class="c-mrkdwn__br" data-stringify-type="paragraph-break" style="box-sizing: inherit; display: block; height: unset;"></span>';

export default class ExpensiMark {
Expand Down Expand Up @@ -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 `<video data-expensify-source="${Str.sanitizeURL(videoSource)}" ${extraAttrs || ''}>${videoName ? `${videoName}` : ''}</video>`;
},
rawInputReplacement: (match, videoName, videoSource, ...args) => {
const extras = args[args.length - 1];
const extraAttrs = extras && extras.videoAttributeCache && extras.videoAttributeCache[videoSource];
return `<video data-expensify-source="${Str.sanitizeURL(videoSource)}" data-raw-href="${videoSource}" data-link-variant="${typeof videoName === 'string' ? 'labeled' : 'auto'}" ${extraAttrs || ''}>${videoName ? `${videoName}` : ''}</video>`;
},
},

/**
* 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
Expand Down Expand Up @@ -487,6 +519,7 @@ export default class ExpensiMark {
return `[${g4}](${email || g3})`;
},
},

{
name: 'image',
regex: /<img[^><]*src\s*=\s*(['"])(.*?)\1(?:[^><]*alt\s*=\s*(['"])(.*?)\3)?[^><]*>*(?![^<][\s\S]*?(<\/pre>|<\/code>))/gi,
Expand All @@ -498,6 +531,32 @@ export default class ExpensiMark {
return `!(${g2})`;
},
},

{
name: 'video',
regex: /<video[^><]*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: /<mention-report reportID="(\d+)" *\/>/gi,
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down

0 comments on commit 9f12ae6

Please sign in to comment.