1212import java .util .Comparator ;
1313import java .util .List ;
1414import java .util .Stack ;
15+ import java .util .function .Function ;
16+ import java .util .regex .Matcher ;
17+ import java .util .regex .Pattern ;
1518
1619import javax .annotation .Nonnull ;
1720import javax .annotation .Nullable ;
@@ -29,6 +32,11 @@ private YoutubeDescriptionHelper() {
2932 public static final String ITALIC_OPEN = "<i>" ;
3033 public static final String ITALIC_CLOSE = "</i>" ;
3134
35+ // special link chips (e.g. for YT videos, YT channels or social media accounts):
36+ // (u00a0) u00a0 u00a0 [/•] u00a0 <link content> u00a0 u00a0
37+ private static final Pattern LINK_CONTENT_CLEANER_REGEX
38+ = Pattern .compile ("(?s)^\u00a0 +[/•]\u00a0 +(.*?)\u00a0 +$" );
39+
3240 /**
3341 * Can be a command run, or a style run.
3442 */
@@ -37,17 +45,30 @@ static final class Run {
3745 @ Nonnull final String close ;
3846 final int pos ;
3947 final boolean isClose ;
48+ @ Nullable final Function <String , String > transformContent ;
49+ int openPosInOutput = -1 ;
4050
4151 Run (
4252 @ Nonnull final String open ,
4353 @ Nonnull final String close ,
4454 final int pos ,
4555 final boolean isClose
56+ ) {
57+ this (open , close , pos , isClose , null );
58+ }
59+
60+ Run (
61+ @ Nonnull final String open ,
62+ @ Nonnull final String close ,
63+ final int pos ,
64+ final boolean isClose ,
65+ @ Nullable final Function <String , String > transformContent
4666 ) {
4767 this .open = open ;
4868 this .close = close ;
4969 this .pos = pos ;
5070 this .isClose = isClose ;
71+ this .transformContent = transformContent ;
5172 }
5273
5374 public boolean sameOpen (@ Nonnull final Run other ) {
@@ -148,12 +169,22 @@ static String runsToHtml(
148169 // condition, because no run will close before being opened, but let's be sure
149170 while (!openRuns .empty ()) {
150171 final Run popped = openRuns .pop ();
151- textBuilder .append (popped .close );
152172 if (popped .sameOpen (closer )) {
173+ // before closing the current run, if the run has a transformContent
174+ // function, use it to transform the content of the current run, based on
175+ // the openPosInOutput set when the current run was opened
176+ if (popped .transformContent != null && popped .openPosInOutput >= 0 ) {
177+ textBuilder .replace (popped .openPosInOutput , textBuilder .length (),
178+ popped .transformContent .apply (
179+ textBuilder .substring (popped .openPosInOutput )));
180+ }
181+ // close the run that we really need to close
182+ textBuilder .append (popped .close );
153183 break ;
154184 }
155185 // we keep popping from openRuns, closing all of the runs we find,
156186 // until we find the run that we really need to close ...
187+ textBuilder .append (popped .close );
157188 tempStack .push (popped );
158189 }
159190 while (!tempStack .empty ()) {
@@ -168,8 +199,10 @@ static String runsToHtml(
168199 } else {
169200 // this will never be reached if openersIndex >= openers.size() because of the
170201 // way minPos is calculated
171- textBuilder .append (openers .get (openersIndex ).open );
172- openRuns .push (openers .get (openersIndex ));
202+ final Run opener = openers .get (openersIndex );
203+ textBuilder .append (opener .open );
204+ opener .openPosInOutput = textBuilder .length (); // save for transforming later
205+ openRuns .push (opener );
173206 ++openersIndex ;
174207 }
175208 }
@@ -180,11 +213,7 @@ static String runsToHtml(
180213 return textBuilder .toString ()
181214 .replace ("\n " , "<br>" )
182215 .replace (" " , " " )
183- // special link chips (e.g. for YT videos, YT channels or social media accounts):
184- // u00a0 u00a0 [/•] u00a0 <link content> u00a0 u00a0
185- .replace ("\" >\u00a0 \u00a0 /\u00a0 " , "\" >" )
186- .replace ("\" >\u00a0 \u00a0 •\u00a0 " , "\" >" )
187- .replace ("\u00a0 \u00a0 </a>" , "</a>" );
216+ .replace ('\u00a0' , ' ' );
188217 }
189218
190219 private static void addAllCommandRuns (
@@ -212,12 +241,44 @@ private static void addAllCommandRuns(
212241 }
213242
214243 final String open = "<a href=\" " + Entities .escape (url ) + "\" >" ;
244+ final Function <String , String > transformContent = getTransformContentFun (run );
215245
216- openers .add (new Run (open , LINK_CLOSE , startIndex , false ));
217- closers .add (new Run (open , LINK_CLOSE , startIndex + length , true ));
246+ openers .add (new Run (open , LINK_CLOSE , startIndex , false ,
247+ transformContent ));
248+ closers .add (new Run (open , LINK_CLOSE , startIndex + length , true ,
249+ transformContent ));
218250 });
219251 }
220252
253+ private static Function <String , String > getTransformContentFun (final JsonObject run ) {
254+ final String accessibilityLabel = run .getObject ("onTapOptions" )
255+ .getObject ("accessibilityInfo" )
256+ .getString ("accessibilityLabel" , "" )
257+ // accessibility labels are e.g. "Instagram Channel Link: instagram_profile_name"
258+ .replaceFirst (" Channel Link" , "" );
259+
260+ final Function <String , String > transformContent ;
261+ if (accessibilityLabel .isEmpty () || accessibilityLabel .startsWith ("YouTube: " )) {
262+ // if there is no accessibility label, or the link points to YouTube, cleanup the link
263+ // text, see LINK_CONTENT_CLEANER_REGEX's documentation for more details
264+ transformContent = (content ) -> {
265+ final Matcher m = LINK_CONTENT_CLEANER_REGEX .matcher (content );
266+ if (m .find ()) {
267+ return m .group (1 );
268+ }
269+ return content ;
270+ };
271+ } else {
272+ // if there is an accessibility label, replace the link text with it, because on the
273+ // YouTube website an ambiguous link text is next to an icon explaining which service it
274+ // belongs to, but since we can't add icons, we instead use the accessibility label
275+ // which contains information about the service
276+ transformContent = (content ) -> accessibilityLabel ;
277+ }
278+
279+ return transformContent ;
280+ }
281+
221282 private static void addAllStyleRuns (
222283 @ Nonnull final JsonObject attributedDescription ,
223284 @ Nonnull final List <Run > openers ,
0 commit comments