diff --git a/src/parser/classes/misc/AccessibilityData.ts b/src/parser/classes/misc/AccessibilityData.ts new file mode 100644 index 00000000..bb5608d2 --- /dev/null +++ b/src/parser/classes/misc/AccessibilityData.ts @@ -0,0 +1,131 @@ +import type { RawNode } from '../../types/index.js'; + +export interface AccessibilityId { + accessibility_id_type?: + | 'UNKNOWN' + | 'MENU_ADD_TO_WATCH_LATER' + | 'MENU_REMOVE_FROM_WATCH_LATER' + | 'MENU_ADD_TO_PLAYLIST' + | 'MENU_REMOVE_FROM_PLAYLIST' + | 'MENU_SHARE_VIDEO' + | 'MENU_SHARE_PLAYLIST' + | 'MENU_OFFLINE_VIDEO' + | 'MENU_OFFLINE_PLAYLIST' + | 'MENU_DELETE_VIDEO' + | 'MENU_DELETE_PLAYLIST' + | 'MENU_EDIT_VIDEO_METADATA' + | 'MENU_HIDE' + | 'MENU_REMOVE_FROM_HISTORY' + | 'MENU_LIKE' + | 'MENU_INFO' + | 'MENU_ADD_TO_REMOTE_QUEUE' + | 'MENU_REMOVE_FROM_REMOTE_QUEUE' + | 'MENU_CREATE_PLAYLIST' + | 'MENU_SETTINGS' + | 'MENU_PRIVACY' + | 'MENU_FEEDBACK' + | 'MENU_HELP' + | 'MENU_DELETE_CHANNEL_POST' + | 'MENU_PLAYLIST_JOIN_COLLABORATION' + | 'MENU_EDIT_PLAYLIST' + | 'MENU_OFFLINE_REMOVE' + | 'MENU_OFFLINE_PAUSE' + | 'MENU_OFFLINE_RESUME' + | 'MENU_UNSUBSCRIBE' + | 'MENU_GET_ALL_UPDATES' + | 'MENU_DISMISS' + | 'MENU_CANCEL_UPLOAD' + | 'MENU_TAKE_PHOTO' + | 'MENU_CHOOSE_PHOTO' + | 'MENU_CHOOSE_FROM_CHANNEL_ART_GALLERY' + | 'MENU_FILTER_VIDEOS_ONLY' + | 'MENU_FILTER_VIDEOS_AND_POSTS' + | 'MENU_WATCH_ON_TV' + | 'MENU_INSERT_IN_REMOTE_QUEUE' + | 'MENU_ADD_UPCOMING_EVENT_REMINDER' + | 'MENU_REMOVE_UPCOMING_EVENT_REMINDER' + | 'MENU_TOGGLE_DENSITY_MODE' + | 'MENU_OFFLINE_UPSELL' + | 'MENU_MORE_LIKE_THIS' + | 'MENU_CREATE_VIDEO' + | 'MENU_CREATE_LIVE_STREAM' + | 'MENU_CREATE_REEL_ITEM' + | 'MENU_CREATE_POST' + | 'MENU_LESS_LIKE_THIS' + | 'MENU_REEL_OVERFLOW' + | 'MENU_DELETE_REEL' + | 'MENU_EDIT_REEL' + | 'MENU_REMOVE_FROM_QUEUE' + | 'MENU_REEL_SHELF_OVERFLOW' + | 'MENU_REEL_SHELF_DISMISS' + | 'MENU_SHARE_ARTIST' + | 'MENU_ABOUT_RECOMMENDATION' + | 'MENU_REPORT' + | 'EXPLORE_DESTINATION_TRENDING' + | 'EXPLORE_DESTINATION_MUSIC' + | 'EXPLORE_DESTINATION_GAMING' + | 'EXPLORE_DESTINATION_NEWS' + | 'EXPLORE_DESTINATION_MOVIES' + | 'EXPLORE_DESTINATION_FASHION' + | 'EXPLORE_DESTINATION_LEARNING' + | 'EXPLORE_DESTINATION_STAY_HOME' + | 'MENU_ABOUT_GAMING_RECOMMENDATAION' + | 'EXPLORE_DESTINATION_LIVE' + | 'MENU_QUALITY' + | 'MENU_CAPTIONS' + | 'MENU_PLAYBACK_SPEED' + | 'MENU_SHARE_PLAYLIST_UNAVAILABLE' + | 'MENU_INFO_CARD' + | 'EXPLORE_DESTINATION_SPORTS' + | 'MENU_SINGLE_LOOP' + | 'MENU_HIDE_VIDEO' + | 'MENU_CLEAR_QUEUE' + | 'EXPLORE_DESTINATION_SHOPPING' + | 'MENU_PLAY_NEXT_IN_QUEUE' + | 'MENU_PLAY_LAST_IN_QUEUE' + | 'MENU_GO_TO_CHANNEL' + | 'EXPLORE_DESTINATION_PODCASTS' + | 'MEDIA_GENERATOR_PROMPT_INPUT' + | 'MEDIA_GENERATOR_STYLE_SHELF' + | 'MEDIA_GENERATOR_STYLE_ITEM' + | 'MEDIA_GENERATOR_CREATE_BUTTON' + | 'MEDIA_GENERATOR_T2V_ENTRYPOINT' + | 'MEDIA_GENERATOR_T2I_ENTRYPOINT' + | 'MEDIA_GENERATOR_T2M_ENTRYPOINT' + | 'MEDIA_GENERATOR_BACK_BUTTON' + | 'MEDIA_GENERATOR_HEADER' + | 'MEDIA_GENERATOR_LOADING_PROGRESS' + | 'MEDIA_GENERATOR_CANCEL_BUTTON' + | 'MEDIA_GENERATOR_IMAGE_PREVIEW' + | 'MEDIA_GENERATOR_VIDEO_PREVIEW' + | 'MEDIA_GENERATOR_DONE_BUTTON' + | 'MEDIA_GENERATOR_IMAGE_SELECTION' + | 'MEDIA_GENERATOR_SOUND_METADATA' + | 'MEDIA_GENERATOR_AUDIO_SELECT_BUTTON' + | 'MEDIA_GENERATOR_T2I2V_ENTRYPOINT' + | 'MENU_SAVE_QUEUE_TO_PLAYLIST' + | 'MEDIA_GENERATOR_ANIMATE_BUTTON' + | 'MEDIA_GENERATOR_SEGMENT_IMPORT_ENTRYPOINT'; +} + +export default class AccessibilityData { + public accessibility_identifier?: string; + public identifier?: AccessibilityId; + public label?: string; + + constructor(data: RawNode) { + if ('accessibilityIdentifier' in data) { + this.accessibility_identifier = data.accessibilityIdentifier; + } + + if ('identifier' in data) { + this.identifier = { + accessibility_id_type: data.identifier.accessibilityIdType + }; + } + + if ('label' in data) { + this.label = data.label; + } + } +} \ No newline at end of file diff --git a/src/parser/classes/misc/EmojiRun.ts b/src/parser/classes/misc/EmojiRun.ts index 23c053e9..0a0fcaa9 100644 --- a/src/parser/classes/misc/EmojiRun.ts +++ b/src/parser/classes/misc/EmojiRun.ts @@ -2,15 +2,17 @@ import type { RawNode } from '../../index.js'; import { escape, type Run } from './Text.js'; import Thumbnail from './Thumbnail.js'; +export interface Emoji { + emoji_id: string; + shortcuts: string[]; + search_terms: string[]; + image: Thumbnail[]; + is_custom: boolean; +} + export default class EmojiRun implements Run { - text: string; - emoji: { - emoji_id: string; - shortcuts: string[]; - search_terms: string[]; - image: Thumbnail[]; - is_custom: boolean; - }; + public text: string; + public emoji: Emoji; constructor(data: RawNode) { this.text = diff --git a/src/parser/classes/misc/Text.ts b/src/parser/classes/misc/Text.ts index ca226067..639b0037 100644 --- a/src/parser/classes/misc/Text.ts +++ b/src/parser/classes/misc/Text.ts @@ -3,13 +3,20 @@ import type { RawNode } from '../../index.js'; import NavigationEndpoint from '../NavigationEndpoint.js'; import EmojiRun from './EmojiRun.js'; import TextRun from './TextRun.js'; +import AccessibilityData from './AccessibilityData.js'; export interface Run { text: string; + toString(): string; + toHTML(): string; } +export interface FormattedStringSupportedAccessibilityDatas { + accessibility_data: AccessibilityData; +} + export function escape(text: string) { return text .replace(/&/g, '&') @@ -19,36 +26,78 @@ export function escape(text: string) { .replace(/'/g, '''); } -// Place this here, instead of in a private static property, -// To avoid the performance penalty of the private field polyfill const TAG = 'Text'; +/** + * Represents text content that may include formatting, emojis, and navigation endpoints. + */ export default class Text { - text?: string; - runs?: (EmojiRun | TextRun)[]; - endpoint?: NavigationEndpoint; + /** + * The plain text content. + */ + public text?: string; + + /** + * Individual text segments with their formatting. + */ + public runs?: (EmojiRun | TextRun)[]; + + /** + * Navigation endpoint associated with this text. + */ + public endpoint?: NavigationEndpoint; + + /** + * Accessibility data associated with this text. + */ + public accessibility?: FormattedStringSupportedAccessibilityDatas; + + /** + * Indicates if the text is right-to-left. + */ + public rtl: boolean; constructor(data: RawNode) { - if (typeof data === 'object' && data !== null && Reflect.has(data, 'runs') && Array.isArray(data.runs)) { - this.runs = data.runs.map((run: RawNode) => run.emoji ? - new EmojiRun(run) : - new TextRun(run) + if (this.isRunsData(data)) { + this.runs = data.runs.map((run: RawNode) => + run.emoji ? new EmojiRun(run) : new TextRun(run) ); this.text = this.runs.map((run) => run.text).join(''); } else { this.text = data?.simpleText; } - if (typeof data === 'object' && data !== null && Reflect.has(data, 'navigationEndpoint')) { + + if (this.isObject(data) && 'accessibility' in data + && 'accessibilityData' in data.accessibility) { + this.accessibility = { + accessibility_data: new AccessibilityData(data.accessibility.accessibilityData) + }; + } + + this.rtl = !!data?.rtl; + + this.parseEndpoint(data); + } + + private isRunsData(data: RawNode): data is { runs: RawNode[] } { + return this.isObject(data) && + Reflect.has(data, 'runs') && + Array.isArray(data.runs); + } + + private parseEndpoint(data: RawNode): void { + if (!this.isObject(data)) return; + if ('navigationEndpoint' in data) { this.endpoint = new NavigationEndpoint(data.navigationEndpoint); - } - if (typeof data === 'object' && data !== null && Reflect.has(data, 'titleNavigationEndpoint')) { + } else if ('titleNavigationEndpoint' in data) { this.endpoint = new NavigationEndpoint(data.titleNavigationEndpoint); + } else if ((this.runs?.[0] as TextRun)?.endpoint) { + this.endpoint = (this.runs?.[0] as TextRun).endpoint; } - if (!this.endpoint) { - if ((this.runs?.[0] as TextRun)?.endpoint) { - this.endpoint = (this.runs?.[0] as TextRun)?.endpoint; - } - } + } + + private isObject(data: RawNode): boolean { + return typeof data === 'object' && data !== null; } static fromAttributed(data: AttributedText) { @@ -75,120 +124,127 @@ export default class Text { length: run.length ?? content.length }) as StyleRun & ResponseRun); - if (style_runs || command_runs || attachment_runs) { - if (style_runs) { - for (const style_run of style_runs) { - if ( - style_run.italic || - style_run.strikethrough === 'LINE_STYLE_SINGLE' || - style_run.weightLabel === 'FONT_WEIGHT_MEDIUM' || - style_run.weightLabel === 'FONT_WEIGHT_BOLD' - ) { - const matching_run = findMatchingRun(runs, style_run); + if (style_runs?.length) + this.processStyleRuns(runs, style_runs, data); - if (!matching_run) { - Log.warn(TAG, 'Unable to find matching run for style run. Skipping...', { - style_run, - input_data: data, - // For performance reasons, web browser consoles only expand an object, when the user clicks on it, - // So if we log the original runs object, it might have changed by the time the user looks at it. - // Deep clone, so that we log the exact state of the runs at this point. - parsed_runs: JSON.parse(JSON.stringify(runs)) - }); + if (command_runs?.length) + this.processCommandRuns(runs, command_runs, data); - continue; - } + if (attachment_runs?.length) + this.processAttachmentRuns(runs, attachment_runs, data); - // Comments use MEDIUM for bold text and video descriptions use BOLD for bold text - insertSubRun(runs, matching_run, style_run, { - bold: style_run.weightLabel === 'FONT_WEIGHT_MEDIUM' || style_run.weightLabel === 'FONT_WEIGHT_BOLD', - italics: style_run.italic, - strikethrough: style_run.strikethrough === 'LINE_STYLE_SINGLE' - }); - } else { - Log.debug(TAG, 'Skipping style run as it is doesn\'t have any information that we parse.', { - style_run, - input_data: data - }); - } + return new Text({ runs }); + } + + private static processStyleRuns(runs: RawRun[], style_runs: (StyleRun & ResponseRun)[], data: AttributedText) { + for (const style_run of style_runs) { + if ( + style_run.italic || + style_run.strikethrough === 'LINE_STYLE_SINGLE' || + style_run.weightLabel === 'FONT_WEIGHT_MEDIUM' || + style_run.weightLabel === 'FONT_WEIGHT_BOLD' + ) { + const matching_run = findMatchingRun(runs, style_run); + + if (!matching_run) { + Log.warn(TAG, 'Unable to find matching run for style run. Skipping...', { + style_run, + input_data: data, + // For performance reasons, web browser consoles only expand an object, when the user clicks on it, + // So if we log the original runs object, it might have changed by the time the user looks at it. + // Deep clone, so that we log the exact state of the runs at this point. + parsed_runs: JSON.parse(JSON.stringify(runs)) + }); + + continue; } + + // Comments use MEDIUM for bold text and video descriptions use BOLD for bold text + insertSubRun(runs, matching_run, style_run, { + bold: style_run.weightLabel === 'FONT_WEIGHT_MEDIUM' || style_run.weightLabel === 'FONT_WEIGHT_BOLD', + italics: style_run.italic, + strikethrough: style_run.strikethrough === 'LINE_STYLE_SINGLE' + }); + } else { + Log.debug(TAG, 'Skipping style run as it is doesn\'t have any information that we parse.', { + style_run, + input_data: data + }); + } + } + } + + private static processCommandRuns(runs: RawRun[], command_runs: CommandRun[], data: AttributedText) { + for (const command_run of command_runs) { + if (command_run.onTap) { + const matching_run = findMatchingRun(runs, command_run); + + if (!matching_run) { + Log.warn(TAG, 'Unable to find matching run for command run. Skipping...', { + command_run, + input_data: data, + // For performance reasons, web browser consoles only expand an object, when the user clicks on it, + // So if we log the original runs object, it might have changed by the time the user looks at it. + // Deep clone, so that we log the exact state of the runs at this point. + parsed_runs: JSON.parse(JSON.stringify(runs)) + }); + + continue; + } + + insertSubRun(runs, matching_run, command_run, { + navigationEndpoint: command_run.onTap + }); + } else { + Log.debug(TAG, 'Skipping command run as it is missing the "doTap" property.', { + command_run, + input_data: data + }); + } + } + } + + private static processAttachmentRuns(runs: RawRun[], attachment_runs: AttachmentRun[], data: AttributedText) { + for (const attachment_run of attachment_runs) { + const matching_run = findMatchingRun(runs, attachment_run); + + if (!matching_run) { + Log.warn(TAG, 'Unable to find matching run for attachment run. Skipping...', { + attachment_run, + input_data: data, + // For performance reasons, web browser consoles only expand an object, when the user clicks on it, + // So if we log the original runs object, it might have changed by the time the user looks at it. + // Deep clone, so that we log the exact state of the runs at this point. + parsed_runs: JSON.parse(JSON.stringify(runs)) + }); + + continue; } - if (command_runs) { - for (const command_run of command_runs) { - if (command_run.onTap) { - const matching_run = findMatchingRun(runs, command_run); + if (attachment_run.length === 0) { + matching_run.attachment = attachment_run; + } else { + const offset_start_index = attachment_run.startIndex - matching_run.startIndex; - if (!matching_run) { - Log.warn(TAG, 'Unable to find matching run for command run. Skipping...', { - command_run, - input_data: data, - // For performance reasons, web browser consoles only expand an object, when the user clicks on it, - // So if we log the original runs object, it might have changed by the time the user looks at it. - // Deep clone, so that we log the exact state of the runs at this point. - parsed_runs: JSON.parse(JSON.stringify(runs)) - }); + const text = matching_run.text.substring(offset_start_index, offset_start_index + attachment_run.length); - continue; - } + const is_custom_emoji = (/^:[^:]+:$/).test(text); - insertSubRun(runs, matching_run, command_run, { - navigationEndpoint: command_run.onTap - }); - } else { - Log.debug(TAG, 'Skipping command run as it is missing the "doTap" property.', { - command_run, - input_data: data - }); - } - } - } + if (attachment_run.element?.type?.imageType?.image && (is_custom_emoji || (/^(?:\p{Emoji}|\u200d)+$/u).test(text))) { + const emoji = { + image: attachment_run.element.type.imageType.image, + isCustomEmoji: is_custom_emoji, + shortcuts: is_custom_emoji ? [ text ] : undefined + }; - if (attachment_runs) { - for (const attachment_run of attachment_runs) { - const matching_run = findMatchingRun(runs, attachment_run); - - if (!matching_run) { - Log.warn(TAG, 'Unable to find matching run for attachment run. Skipping...', { - attachment_run, - input_data: data, - // For performance reasons, web browser consoles only expand an object, when the user clicks on it, - // So if we log the original runs object, it might have changed by the time the user looks at it. - // Deep clone, so that we log the exact state of the runs at this point. - parsed_runs: JSON.parse(JSON.stringify(runs)) - }); - - continue; - } - - if (attachment_run.length === 0) { - matching_run.attachment = attachment_run; - } else { - const offset_start_index = attachment_run.startIndex - matching_run.startIndex; - - const text = matching_run.text.substring(offset_start_index, offset_start_index + attachment_run.length); - - const is_custom_emoji = (/^:[^:]+:$/).test(text); - - if (attachment_run.element?.type?.imageType?.image && (is_custom_emoji || (/^(?:\p{Emoji}|\u200d)+$/u).test(text))) { - const emoji = { - image: attachment_run.element.type.imageType.image, - isCustomEmoji: is_custom_emoji, - shortcuts: is_custom_emoji ? [ text ] : undefined - }; - - insertSubRun(runs, matching_run, attachment_run, { emoji }); - } else { - insertSubRun(runs, matching_run, attachment_run, { - attachment: attachment_run - }); - } - } + insertSubRun(runs, matching_run, attachment_run, { emoji }); + } else { + insertSubRun(runs, matching_run, attachment_run, { + attachment: attachment_run + }); } } } - - return new Text({ runs }); } /** @@ -292,7 +348,7 @@ interface StyleRun extends Partial { value: number }[] } - } + }; } interface CommandRun extends ResponseRun { diff --git a/src/parser/classes/misc/TextRun.ts b/src/parser/classes/misc/TextRun.ts index c4516379..ef827a1c 100644 --- a/src/parser/classes/misc/TextRun.ts +++ b/src/parser/classes/misc/TextRun.ts @@ -3,25 +3,58 @@ import { escape, type Run } from './Text.js'; import type { RawNode } from '../../index.js'; export default class TextRun implements Run { - text: string; - endpoint?: NavigationEndpoint; - bold: boolean; - italics: boolean; - strikethrough: boolean; - deemphasize: boolean; - attachment; + public text: string; + public text_color?: number; + public endpoint?: NavigationEndpoint; + public bold: boolean; + public bracket: boolean; + public dark_mode_text_color?: number; + public deemphasize: boolean; + public italics: boolean; + public strikethrough: boolean; + public error_underline: boolean; + public underline: boolean; + public font_face?: + | 'FONT_FACE_UNKNOWN' + | 'FONT_FACE_YT_SANS_MEDIUM' + | 'FONT_FACE_ROBOTO_MEDIUM' + | 'FONT_FACE_YOUTUBE_SANS_LIGHT' + | 'FONT_FACE_YOUTUBE_SANS_REGULAR' + | 'FONT_FACE_YOUTUBE_SANS_MEDIUM' + | 'FONT_FACE_YOUTUBE_SANS_SEMIBOLD' + | 'FONT_FACE_YOUTUBE_SANS_BOLD' + | 'FONT_FACE_YOUTUBE_SANS_EXTRABOLD' + | 'FONT_FACE_YOUTUBE_SANS_BLACK' + | 'FONT_FACE_YT_SANS_BOLD' + | 'FONT_FACE_ROBOTO_REGULAR'; + public attachment; constructor(data: RawNode) { this.text = data.text; this.bold = Boolean(data.bold); + this.bracket = Boolean(data.bracket); this.italics = Boolean(data.italics); this.strikethrough = Boolean(data.strikethrough); + this.error_underline = Boolean(data.error_underline); + this.underline = Boolean(data.underline); this.deemphasize = Boolean(data.deemphasize); - if (Reflect.has(data, 'navigationEndpoint')) { + if ('textColor' in data) { + this.text_color = data.textColor; + } + + if ('navigationEndpoint' in data) { this.endpoint = new NavigationEndpoint(data.navigationEndpoint); } + if ('darkModeTextColor' in data) { + this.dark_mode_text_color = data.darkModeTextColor; + } + + if ('fontFace' in data) { + this.font_face = data.fontFace; + } + this.attachment = data.attachment; } @@ -36,7 +69,9 @@ export default class TextRun implements Run { if (this.italics) tags.push('i'); if (this.strikethrough) tags.push('s'); if (this.deemphasize) tags.push('small'); - + if (this.underline) tags.push('u'); + if (this.error_underline) tags.push('u'); + if (!this.text?.length) return ''; @@ -48,13 +83,13 @@ export default class TextRun implements Run { if (this.attachment.element.type.imageType.image.sources.length) { if (this.endpoint) { const { url } = this.attachment.element.type.imageType.image.sources[0]; - + let image_el = ''; - + if (url) { image_el = ``; } - + const nav_url = this.endpoint.toURL(); if (nav_url) return `${image_el}${wrapped_text}`; } diff --git a/src/parser/misc.ts b/src/parser/misc.ts index 4d18e614..99cb8791 100644 --- a/src/parser/misc.ts +++ b/src/parser/misc.ts @@ -2,6 +2,7 @@ // See ./scripts/build-parser-map.js export { default as AccessibilityContext } from './classes/misc/AccessibilityContext.js'; +export { default as AccessibilityData } from './classes/misc/AccessibilityData.js'; export { default as Author } from './classes/misc/Author.js'; export { default as ChildElement } from './classes/misc/ChildElement.js'; export { default as CommandContext } from './classes/misc/CommandContext.js';